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—trueonce sensors are upstreaming—enabled/disabledpolling-rate-hz— current sensor poll ratecurrent-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 oldsensitivityfield 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 interrupterror-count,last-error— diagnostic counterswake-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, integerheading-deg— float, medium-EMA-smoothedheading-accuracy— compatibility estimate;180for a rejected headingheading-validandheading-invalid-reason— authoritative usability verdictcalibration-state—uncalibratedorcalibratedheading-field-residual— recent field deviation fractionheading-tilt— angle from levelheading-tilt-comp—trueif 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— fieldstatus. Drives chip profile derivation.power-manager— fieldstate. Hibernation-imminent states route armed → armed-hibernation profile.vehicle, fieldstate: sets the telemetry poll rate on both pollers (5 Hz inparked/ready-to-drive, 1 Hz otherwise).
Pub/Sub Channels¶
motion:sensors(5 Hz inparked/ready-to-drive, 1 Hz otherwise) — JSONSensorReadingwithtimestamp,accel,gyro, optionalmag, each as{x, y, z, magnitude, unit}.motion:heading(5 Hz inparked/ready-to-drive, 1 Hz otherwise) — JSONHeadingReading:{ "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— JSONMotionEvent:typeisedgefor normal motion-engine fires, orwake-hibernationfor the once-per-startup post-resume sentinelengineisany-motion(slope) orslow-motion. Decided by readingINT_STATUS_0before 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:
- Disable poller + watcher (so they don't observe transient INT activity from the bandwidth-settle period).
- Soft-reset accel + gyro; sleep 10 ms.
- Set BMA253 bandwidth.
- Configure the active motion engine (any-motion or slow-motion); disable the other.
- Configure interrupt pin output (active-high, latched).
- 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.
- Map the engine to the configured INT pin(s).
- 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 ofINT_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¶
- Unbind kernel BMX055 driver.
- 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.
- Initialize accel, gyro, mag (with retries on I²C transient errors).
- Load and validate
/data/motion-magnetometer-calibration.json; otherwise remain uncalibrated and fail heading closed. - Start sensor + magnetometer pollers (telemetry).
- Start interrupt poller + watcher (initially disabled).
- Read
INT_STATUS_0BEFORE the firstcontroller.Apply. If a slope or slow-no-motion bit is latched, this is a wake-from-hibernation; record it. - Apply
Idleprofile (always — defends against any half-state from a previous owner). - If wake-from-hibernation was detected:
PUBLISH motion:interrupt {type:"wake-hibernation",...}ANDHSET motion wake-cause <unix-ms>. The hash field is the durable backstop for consumers that started after motion-service published. - Start
Subscriber(HashWatchers on alarm + power-manager,StartWithSyncso the chip syncs to current vehicle state on first dispatch). - Start
CallServer, including calibration capture methods. 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/CallMethodprimitive
Related Documentation¶
- librescoot-alarm — primary consumer of
motion:interruptandmotion:rpc/prepare-hibernation - Redis Operations — full Redis surface
- Service interactions — Redis interaction map
- BMX055 datasheet — chip-level reference