Skip to content

librescoot-motion (motion-service)

Description

The motion service is the sole owner of the BMX055 9-axis IMU on the MDB. It exposes raw sensor telemetry, tilt-compensated magnetic heading, and motion-engine events (any-motion + slow/no-motion). Steady-state chip configuration is reactive: motion-service watches the alarm and power-manager hashes and re-derives the chip profile from (alarm.status, power-manager.state), so consumers (alarm-service primarily) never write registers themselves. A small synchronous RPC surface (motion:rpc) covers the cases where a consumer needs a confirmed-applied answer — most importantly the hibernation handshake.

The repo is at github.com/librescoot/motion-service. Public; CC-BY-NC-SA.

Version

Librescoot motion-service v0.1+ (semver via git tags; PV from git describe).

Command-Line Options

--i2c-bus=/dev/i2c-3                                    I2C bus device path for BMX055
--redis=localhost:6379                                  Redis address
--polling-rate=5                                        Sensor polling rate (Hz)
--evdev-device=/dev/input/by-path/platform-gpio-keys-event   gpio-keys input for the BMX INT line (empty -> poller-only)
--evdev-keycode=43                                      Keycode for the BMX INT line on the gpio-keys device
--log-level=info                                        Log level (debug, info, warn, error)
--version                                               Print version and exit

Hardware

The BMX055 is a single package with three independently I²C-addressable devices on /dev/i2c-3:

  • 0x18 — BMA253 accelerometer (data + slope/any-motion + slow/no-motion engines + INT pins)
  • 0x68 — BMG160 gyroscope (3-axis rate)
  • 0x10 — BMM150 magnetometer (3-axis field, with on-chip trim compensation)

motion-service unbinds the kernel driver at startup and drives all three over /dev/i2c-3 directly. The chip is mounted on the underside of the MDB; magnetometer readings are remapped (axis order + signs) into a vehicle NED frame and tilt-compensated using the accelerometer.

Redis Operations

Hash: motion

Fields written:

  • initialized — true once sensors are up
  • streaming — enabled/disabled
  • polling-rate-hz — current sensor poll rate
  • current-profile — profile name applied to chip (idle, armed-awake, armed-hibernation, level1, waiting)
  • interrupt, pin, mode, bandwidth, threshold, duration — motion-engine config as programmed. Written by the profile controller on every apply, so they describe the chip rather than the last manual command. The old sensitivity field is gone (it is a property of the profile); motion-service deletes it at startup on upgraded units.
  • last-interrupt-timestamp — unix-ms of last interrupt
  • error-count, last-error — diagnostic counters
  • wake-cause — unix-ms timestamp written once on startup if INT_STATUS_0 had a latched bit at boot. Durable backstop for the wake-from-hibernation indicator. alarm-service reads + deletes this on its own startup.
  • Heading fields (refreshed from each magnetometer publish):
  • heading — 0–359 degrees, integer
  • heading-deg — float, medium-EMA-smoothed
  • heading-accuracy — compatibility estimate; 180 for a rejected heading
  • heading-valid and heading-invalid-reason — authoritative usability verdict
  • calibration-state — uncalibrated or calibrated
  • heading-field-residual — recent field deviation fraction
  • heading-tilt — angle from level
  • heading-tilt-comp — true if accel-based tilt compensation was applied to this sample

Pub/sub on hash writes: none. Every motion hash write goes out with NoPublish, so consumers must poll the hash; live data streams on the motion:* channels below.

Subscribed Hashes (HashWatcher with 50 ms debounce + StartWithSync)

  • alarm — field status. Drives chip profile derivation.
  • power-manager — field state. Hibernation-imminent states route armed → armed-hibernation profile.
  • vehicle, field state: sets the telemetry poll rate on both pollers (5 Hz in parked / ready-to-drive, 1 Hz otherwise).

Pub/Sub Channels

  • motion:sensors (5 Hz in parked / ready-to-drive, 1 Hz otherwise) — JSON SensorReading with timestamp, accel, gyro, optional mag, each as {x, y, z, magnitude, unit}.
  • motion:heading (5 Hz in parked / ready-to-drive, 1 Hz otherwise) — JSON HeadingReading:
    {
      "timestamp": 1777996408778,
      "heading_deg": 192.5,        // medium EMA, canonical "smoothed" value
      "heading_raw_deg": 191.7,    // unsmoothed, this sample only
      "heading_fast_deg": 192.1,   // fast EMA, τ ≈ 0.3 s
      "heading_slow_deg": 193.4,   // slow EMA, τ ≈ 3.9 s
      "accuracy_deg": 17.2,
      "heading_valid": true,
      "calibration_state": "calibrated",
      "tilt_compensated": true,
      "tilt_deg": 3.2,
      "mag_strength_ut": 48.6,
      "horizontal_field_ut": 20.1,
      "field_residual": 0.03,
      "heading_dispersion_deg": 1.4,
      "excess_g": 0.018,
      "yaw_rate_dps": 1.4,
      "data_ready": true
    }
    
  • motion:interrupt — JSON MotionEvent:
    {"type": "edge", "timestamp": 1777996408778, "engine": "any-motion"}
    
  • type is edge for normal motion-engine fires, or wake-hibernation for the once-per-startup post-resume sentinel
  • engine is any-motion (slope) or slow-motion. Decided by reading INT_STATUS_0 before clearing the latch.
  • motion:ready — fired once on first successful profile-apply at startup. Payload: unix-ms timestamp.

RPC Channel: motion:rpc

motion-service hosts a single redis-ipc CallServer on this channel, dispatching by method name. One BRPOP loop regardless of method count.

Method Request Response Use
prepare-hibernation {profile: "armed-hibernation"} {programmed: bool, profile: string} Synchronous chip-config confirmation. alarm-service Calls this before releasing pm-service's suspend inhibitor. ~180 ms round-trip on the bench.
get-calibration {} {hard_iron_offset, soft_iron_xy, axis_order, axis_sign, yaw_offset_deg, state} Returns the active pipeline calibration.
calibration-start {} capture status Starts a bounded coherent in-process capture.
calibration-status {} capture status Reports sample count, angle bins, spans, readiness, and fit metadata.
calibration-finish {} capture status Fits, validates, atomically saves, and applies the capture. Failure preserves the previous model.
calibration-cancel {} capture status Discards the active capture without changing calibration.
calibration-reset {} capture status Deletes calibration and immediately disables heading.
clear-latch {} {ok: bool} Clears the BMX055 latched INT bits. Useful for support if the chip is stuck asserted.
soft-reset {} {ok: bool} Soft-resets accel + gyro, then reprograms the current profile. It deliberately does not leave the chip at register defaults: a reset wipes the motion engine, and on an armed scooter that means no motion detection.
set-polling {rate_hz: 1..100} {ok: bool} Overrides the telemetry poll rate on both pollers. An override, not a setting — the vehicle-state watcher re-derives the rate on the next vehicle.state change. Replaces LPUSH scooter:motion polling:N.
set-streaming {enabled: bool} {ok: bool} Gates the motion:sensors stream. Replaces LPUSH scooter:motion streaming:enable\|disable.

Chip Profile Derivation

motion-service watches alarm.status and power-manager.state. The mapping table:

alarm.status power-manager.state Profile Engine BW Threshold N INT pin
disabled / disarmed / delay-armed / seatbox-access / unknown * idle slow-motion 7.81 Hz 0x14 (~78 mg) 3 none, interrupt off
armed not hibernating-imminent armed-awake any-motion (slope) 31.25 Hz 0x06 (~23 mg) 4 both, interrupt on
armed hibernating-*-imminent / hibernating-* armed-hibernation any-motion (slope) 31.25 Hz 0x08 (~31 mg) 4 both, interrupt on
level-1-triggered * level1 slow-motion 15.63 Hz 0x08 (~31 mg) 4 both, interrupt on
level-2-triggered * waiting slow-motion 7.81 Hz 0x06 (~23 mg) 4 INT1, interrupt on

Profile values match alarm-service's pre-Phase-4 sensor configs verbatim — the production tunings carry over.

Apply Sequence

controller.Apply(profile) is idempotent (re-applying the current profile is a no-op). Sequence on a real change:

  1. Disable poller + watcher (so they don't observe transient INT activity from the bandwidth-settle period).
  2. Soft-reset accel + gyro; sleep 10 ms.
  3. Set BMA253 bandwidth.
  4. Configure the active motion engine (any-motion or slow-motion); disable the other.
  5. Configure interrupt pin output (active-high, latched).
  6. Double-clear the latch with a 100 ms settle window in between — load-bearing. The bandwidth change kicks off a low-pass filter settle that can produce a transient slope large enough to set the status bit. If the pin mapping is in place during that transient, INT spikes from a stale status bit and the gpio-keys edge fires before the status is cleared. Clear → 100 ms → clear → only then add the engine to the pin map.
  7. Map the engine to the configured INT pin(s).
  8. Re-arm poller + watcher.

Total elapsed ~150–180 ms on the bench. With the 50 ms HashWatcher debounce, total propagation from alarm.status write to chip-in-new-profile is ~200–230 ms.

Interrupt Path

Two redundant sources, one envelope shape:

  • Fast path: InterruptWatcher — reads the gpio-keys input device for the BMX055 INT line (/dev/input/by-path/platform-gpio-keys-event, keycode 43 by default). Wakes within milliseconds of the GPIO edge.
  • Watchdog path: InterruptPoller — 1 s polling of INT_STATUS_0. Catches anything the watcher missed (e.g. evdev unavailable, kernel tick delays).

Both publish identical MotionEvent JSON envelopes on motion:interrupt and clear the chip latch. They're enabled in lockstep with the profile (gated by controller.Apply).

Startup Invariants

  1. Unbind kernel BMX055 driver.
  2. Connect the redis-ipc client. One client serves the publisher, the hash watchers and the RPC server, so it comes up before anything that needs to write Redis.
  3. Initialize accel, gyro, mag (with retries on I²C transient errors).
  4. Load and validate /data/motion-magnetometer-calibration.json; otherwise remain uncalibrated and fail heading closed.
  5. Start sensor + magnetometer pollers (telemetry).
  6. Start interrupt poller + watcher (initially disabled).
  7. Read INT_STATUS_0 BEFORE the first controller.Apply. If a slope or slow-no-motion bit is latched, this is a wake-from-hibernation; record it.
  8. Apply Idle profile (always — defends against any half-state from a previous owner).
  9. If wake-from-hibernation was detected: PUBLISH motion:interrupt {type:"wake-hibernation",...} AND HSET motion wake-cause <unix-ms>. The hash field is the durable backstop for consumers that started after motion-service published.
  10. Start Subscriber (HashWatchers on alarm + power-manager, StartWithSync so the chip syncs to current vehicle state on first dispatch).
  11. Start CallServer, including calibration capture methods.
  12. PUBLISH motion:ready.

Magnetometer Calibration

Firmware contains only the mechanical sensor-to-vehicle axis mapping. It ships no vehicle-specific hard-iron, soft-iron, or empirical yaw values. Without a valid persisted model, every magnetic heading has heading_valid=false, reason uncalibrated, and compatibility accuracy 180.

Use the in-process workflow so motion-service remains the sole I²C owner and alarm detection continues:

lsc motion calibrate start
# Walk the upright scooter through slow clockwise and counter-clockwise circles.
lsc motion calibrate status
lsc motion calibrate finish

Collect away from vehicles, steel structures, cables, and motor current. Readiness requires 180 accepted low-dynamic samples, 30/36 angular bins, and at least 300 compensated LSB span on both axes. finish fits a positive planar ellipse, rejects condition above 4 or normalized radial RMS above 10%, writes a diagnostic CSV, and atomically replaces /data/motion-magnetometer-calibration.json. Failed captures preserve the active model. cancel discards only the capture; reset deletes the model and disables heading.

The model corrects X/Y hard iron and planar soft iron before the fixed NED axis transform. Planar calibration cannot identify Z hard iron, so headings over 10° vehicle tilt are invalid. Heading validity also checks warm-up, magnetic field range and short-term residual, horizontal field, dynamic acceleration, and stationary dispersion.

Magnetometer Operating Mode

BMM150 in normal mode at ODR 10 Hz with the regular REPXY/REPZ presets (9/15 reps), polled by the host at the sensor-poller rate (5 Hz in parked / ready-to-drive, 1 Hz otherwise). The trim compensation is the full Bosch BMM050 reference port (int64 to avoid the integer-truncation pitfall).

systemd Unit

librescoot-motion.service:

[Unit]
Description=Librescoot Motion Service (BMX055 IMU)
After=valkey.service
Wants=valkey.service

[Service]
ExecStart=/usr/bin/motion-service \
    --i2c-bus=/dev/i2c-3 \
    --redis=localhost:6379 \
    --polling-rate=5 \
    --log-level=info
Restart=always
RestartSec=5

Auto-enabled (SYSTEMD_AUTO_ENABLE = "enable" in the meta-librescoot recipe). alarm-service's unit declares Wants=/After=librescoot-motion.service so the chip is in a known state before alarm-service starts driving it.

Diagnostic Tool

The separate motion-calibrate binary remains for offline diagnostics. It records coherent raw and factory-trim-compensated values but requires exclusive IMU ownership. Prefer the live RPC workflow for normal calibration.

Testing

Inspect live state

# Current chip profile
redis-cli HGET motion current-profile

# Live heading at 5 Hz
redis-cli SUBSCRIBE motion:heading

# Live IMU stream (5 Hz parked / ready-to-drive, 1 Hz otherwise)
redis-cli SUBSCRIBE motion:sensors

# Watch motion edges
redis-cli SUBSCRIBE motion:interrupt

Drive a profile change manually

# Force armed-hibernation profile
redis-cli HSET alarm status armed
redis-cli PUBLISH alarm status
redis-cli HSET power-manager state hibernating-imminent
redis-cli PUBLISH power-manager state

# Verify
redis-cli HGET motion current-profile  # -> armed-hibernation

RPC call (manual one-liner)

CHANNEL=motion:rpc
REPLY=$CHANNEL:reply:test
DEADLINE=$(($(date +%s%3N) + 2000))
ENV="{\"id\":\"test\",\"method\":\"get-calibration\",\"reply_channel\":\"$REPLY\",\"deadline\":$DEADLINE,\"payload\":{}}"

( timeout 3 redis-cli SUBSCRIBE "$REPLY" > /tmp/reply 2>&1 ) &
sleep 0.3
redis-cli LPUSH "$CHANNEL" "$ENV"
wait
cat /tmp/reply

Dependencies

  • BMX055 — accessible via I²C on /dev/i2c-3
  • gpio-keys evdev at /dev/input/by-path/platform-gpio-keys-event (graceful fallback to poller-only)
  • Redis — for state hashes, pub/sub, RPC
  • redis-ipc v0.13.0+ — CallServer / RegisterCall / CallMethod primitive