librescoot-alarm (alarm-service)¶
Description¶
Security alarm for the scooter, driven by motion, button presses, the handlebar sensors and unauthorized seatbox openings. alarm-service is a pure FSM/policy layer — as of the motion-service refactor, it does not touch the BMX055 directly. It publishes its FSM state (alarm.status) so motion-service can reactively configure the chip; consumes motion:interrupt events; and uses one synchronous motion:rpc/prepare-hibernation Call to gate pm-service's hibernation entry.
Version¶
Librescoot alarm-service v0.10+ (Phase 4 of the motion-service refactor).
Command-Line Options¶
--redis=localhost:6379 Redis address
--log-level=info Log level (debug, info, warn, error)
--alarm-enabled=true Enable alarm system (writes to Redis on startup)
--alarm-duration=30 Alarm duration in seconds
--horn-enabled=false Enable horn during alarm (overrides Redis setting)
--seatbox-trigger=true Trigger alarm on unauthorized seatbox opening
--hair-trigger=false Enable hair trigger mode (immediate short alarm on first motion)
--hair-trigger-duration=3 Hair trigger alarm duration in seconds
--l1-cooldown=15 Level 1 cooldown duration in seconds
--version Print version and exit
--i2c-bus, --evdev-device, --evdev-keycode, --poller-interval-ms are gone. motion-service owns the chip.
These flags only take effect when explicitly passed; each one then writes its value to the settings hash on startup. The production unit passes none of them, so the settings-service schema defaults apply; the compiled-in flag defaults now match the schema (alarm.duration 30, alarm.l1-cooldown 15).
Redis Operations¶
Hash: alarm¶
Fields written:
status— current alarm status:disabled— alarm system is disableddisarmed— alarm enabled but not armed (vehicle not in stand-by)delay-armed— 5 s arming delayarmed— armed and monitoringlevel-1-triggered— Level 1 (cooldown + verification window)level-2-triggered— Level 2 (horn + hazards)seatbox-access— temporarily disabled while authorized seatbox opening is activetrigger:source- the input that last set the alarm off:motion,seatbox,handlebar_position,handlebar_lock,brake_left,brake_right,horn_button,seatbox_buttontrigger:timestamp- when that trigger fired, RFC3339 UTC
Both trigger fields are written together in one round trip with a single notification, so a consumer watching the hash never sees a source paired with the previous timestamp.
Only a trigger that actually moved the state machine is recorded. An edge dropped by the settling window, the position dwell or a disabled source never claims the field. Neither field is cleared on disarm: they name the most recent trigger, not the current state, which is what makes them useful for working out after the fact why a parked scooter sounded its horn.
Published channel: alarm
motion-service watches this hash. A change on alarm.status triggers a profile re-derivation in motion-service via (alarm.status, power-manager.state). See librescoot-motion for the full mapping table.
Hash: settings¶
Fields read:
alarm.enabled,alarm.honk,alarm.duration,alarm.seatbox-triggeralarm.hairtrigger,alarm.hairtrigger-duration,alarm.l1-cooldownalarm.trigger.motion,alarm.trigger.buttons,alarm.trigger.handlebar(per-source trigger gates, see Trigger Sources)
Fields written (if CLI flags set): same fields, overrides Redis values on startup.
Subscribed channels: settings
Hash: power-manager¶
Fields read: state. Hibernation-imminent transitions trigger the synchronous prepare-hibernation Call.
Subscribed channels: power-manager
Hash: usb¶
Fields read: mode. A value of ums or ums-by-dbc suppresses the alarm for the duration of the mass-storage session (see UMS Suppression).
Subscribed channels: usb
Hash: motion¶
Fields read on startup: wake-cause (deleted after consumption). Durable backstop for the wake-from-hibernation indicator across the startup-ordering race with motion-service's pub/sub.
Pub/Sub Channels¶
Subscribes to:
vehicle— state, seatbox:opened event, seatbox:lock, handlebar:lock-sensor, handlebar:positionsettings— alarm.* changespower-manager— state fieldusb— mode fieldmotion:interrupt— JSON envelope{type, timestamp, engine}from motion-service.typebecomesBMXInterruptEvent.Data(the FSM discriminateswake-hibernationfrom regular edges).buttons: raw input edges from vehicle-service (brake:left:{on,off},brake:right:{on,off},horn:{on,off},seatbox:{on,off}). Blinker edges share the channel and are ignored.
RPC Calls (synchronous, via redis-ipc CallMethod)¶
motion:rpc/prepare-hibernation— called whenpower-manager.stateflips tohibernating-imminentwhile inStateArmed, OR on entry toStateArmedifhibernationImminentis already true. Blocks until motion-service confirms the chip is inarmed-hibernationprofile. ~180 ms round-trip. On failure: alarm-service holds the suspend inhibitor and pm-service stays blocked rather than hibernating with an unverified chip profile.
Lists Consumed (BRPOP)¶
scooter:alarm:enable,disable— setsettings alarm.enabledarm,disarm— runtime override (doesn't changesettings.alarm.enabled)start:<seconds>— manual alarm triggerstop— stop alarm immediately
Lists Produced (LPUSH)¶
scooter:horn—on,offscooter:blinker—both,offscooter:power(hibernate-manual, sent 5 minutes after a wake from hibernation if nothing triggers again)
Trigger Sources¶
Four inputs escalate the alarm out of armed. Each has its own settings gate.
| Setting | Source | Default |
|---|---|---|
alarm.trigger.motion |
motion:interrupt events from motion-service |
true |
alarm.trigger.buttons |
brake left/right, horn and seatbox button presses on the buttons channel |
true |
alarm.trigger.handlebar |
vehicle[handlebar:lock-sensor] going unlocked, vehicle[handlebar:position] going off-place |
false |
alarm.seatbox-trigger |
unauthorized vehicle[seatbox:lock] = open |
true |
The defaults are the settings-service schema values, which settings-service
writes into the settings hash at startup. Without settings-service the
compiled-in fallbacks apply: true for alarm.trigger.motion and
alarm.trigger.buttons, false for alarm.trigger.handlebar.
Button and handlebar edges are filtered in the Redis subscriber, so a source that is switched off never reaches the FSM. Motion is filtered in the FSM instead: a motion event also carries the wake-from-hibernation stamp that the re-hibernate bookkeeping depends on, and the stamp has to survive the drop.
Switching a source off suppresses the alarm, not the wake. The accelerometer
still asserts its interrupt and the nRF52 still wakes the MDB;
alarm.trigger.motion=false only means the resulting event is dropped instead
of escalated.
Only the pressed edge of a button counts, so one press is one trigger. Throttle is not a source: it exists only as an ECU CAN payload and the ECU is powered down in stand-by.
Handlebar Baseline¶
The two handlebar fields count only on a genuine safe-to-unsafe transition,
locked -> unlocked and on-place -> off-place. The first value each field
delivers after startup is recorded as a baseline and triggers nothing. Plenty of
scooters park with the handlebar lock never engaged and report unlocked as
their resting value, which the initial hash sync would otherwise turn into an
alarm on every service restart.
Handlebar Settling Window¶
Both handlebar sources stay muted for 90 seconds after every entry into
armed (handlebarSettleDelay in internal/fsm/state_machine.go). Locking the
vehicle is itself a handlebar event: vehicle-service drives the lock solenoid
for 1.1 s with up to 3 retries, and a rider who still has to swing the bars into
place gets a 60 s positioning window that pulses again at the end of it. Either
can bounce the lock sensor or move the position sensor while the alarm has
already armed.
Arming starts ~5 s after the vehicle reaches stand-by, so a 60 s window here would expire just as vehicle-service's own closes, and the retry pulses of a late positioning would land outside it entirely. 90 s clears both with ~30 s to spare.
Edges inside the window are dropped rather than queued. Replaying a stale edge
once the window closes would sound the alarm for something that finished a
minute ago. The timer keeps running when armed is left, so an escalation
cannot strand the sources muted, and a disarm/rearm cycle opens a fresh window.
Motion, buttons and the seatbox are never muted, so the vehicle keeps a live
tamper path throughout.
Handlebar Position Dwell¶
vehicle[handlebar:position] must read off-place continuously for
1 second (defaultHandlebarPositionDwell in internal/redis/subscribers.go)
before it counts as tampering. The off-place edge starts a timer instead of
firing; returning to on-place cancels it, and a fresh off-place edge restarts
it, so a chattering sensor never accumulates a full window.
handlebar:position and handlebar:lock-sensor are separate sensors, and a
parked vehicle can report a brief off-place without the bars having been
turned. The dwell exists to filter those. A real tamper leaves the bars
off-place, so the only cost is delaying the alarm by that second.
The dwell deliberately does not consult handlebar:lock-state. A locked
reading is not proof the bars are held: a forced or broken lock pin can leave
that sensor reading locked while the bars turn freely, which is a hallmark of
a theft attempt rather than a reason to suppress the trigger.
Note the dwell only filters brief excursions. If the bars come to rest outside
the on-place zone, the reading stays off-place, the dwell expires and the
trigger stands, which is correct: at that point it is indistinguishable from
bars that someone has actually turned.
Suppressed excursions are logged at info with how long they lasted
(off_place_ms), so the constant can be retuned from what the sensor and the
weather actually do rather than guessed at again.
The lock sensor has no dwell. locked -> unlocked is unambiguous and fires as
soon as the baseline and settling-window guards allow.
Alarm State Machine¶
init → waiting_enabled → disarmed → delay_armed (5s) → armed
↑ ↓ motion detected (motion:interrupt)
| trigger_level_1_wait (configurable cooldown)
| ↓
| trigger_level_1 (5s check)
| ↓ major movement
| trigger_level_2 (alarm.duration, max 6 cycles)
|________________|
Plus seatbox_access (transient state during authorized seatbox openings) and waiting_movement (between L2 cycles).
State Descriptions¶
- init: Initial state on startup
- waiting_enabled: Alarm disabled, waiting for enable command
- disarmed: Alarm enabled but vehicle not in stand-by
- delay_armed: 5-second delay before arming (allows user to leave)
- armed: Armed and monitoring the enabled trigger sources (motion, buttons, handlebar, seatbox)
- trigger_level_1_wait: Cooldown after a trigger.
alarm.l1-cooldown, default 15s. With hair trigger: immediate short alarm. - trigger_level_1: 5-second check for continued movement
- trigger_level_2: Full alarm activated (horn + hazards). Duration is
alarm.duration, default 30s. Capped at 6 cycles. - seatbox_access: Authorized seatbox opening — alarm temporarily inhibited
- waiting_movement: Between L2 cycles, waiting for re-trigger
Arming¶
- Alarm enabled (
settings alarm.enabled = true) - Vehicle enters
stand-by - 5-second
delay_armedcountdown (skipped on startup if already in stand-by) - Transition to
armed
Triggering¶
- An enabled trigger source fires while in
armed: amotion:interrupt, a button press, or a handlebar edge past the settling window armed→trigger_level_1_wait- Hazards blink once
- If hair trigger enabled: short horn pulse (skipped if this edge was the wake-from-hibernation edge —
wakeFromHibernationflag gates it) - Cooldown (default 15s, configurable) →
trigger_level_1 - 5s verification window → if another trigger arrives:
trigger_level_2; else: back throughdelay_armedtoarmed
Disarming¶
- Vehicle leaves
stand-by(user unlocks) LPUSH scooter:alarm disable(persistent — setssettings.alarm.enabled=false)LPUSH scooter:alarm disarm(runtime — re-arms on next stand-by)LPUSH scooter:alarm stop(immediate)
UMS Suppression¶
Entering USB mass-storage mode unloads g_ether and loads g_mass_storage, after which the rider plugs and unplugs a cable at the MDB. That reliably trips the any-motion engine, so an armed scooter would escalate to level 2 and sound the horn during authorized work.
While usb.mode reads ums or ums-by-dbc:
- Any transition target other than
disarmedorwaiting_enabledis redirected todisarmed, so nothing arms and no trigger escalates. - An alarm already sounding is stopped by the normal exit handler of the triggered state.
- The 5-minute post-alarm cooldown timer is not started, so it cannot re-arm mid-session.
Because the published alarm.status is disarmed, motion-service also drops the BMX055 to the idle profile and stops raising interrupts.
When usb.mode returns to normal, an enabled alarm on a scooter in stand-by re-arms through the usual 5-second delay_armed window. ums-service seeds usb.mode = normal on startup, so a reboot mid-session clears the suppression.
Hibernation Handshake¶
When pm-service publishes power-manager.state = hibernating-imminent AND alarm-service is in StateArmed:
- alarm-service sees the state change (HashWatcher).
- FSM dispatches
confirmHibernationProfile(ctx). ipc.CallMethod(rpcClient, "motion:rpc", "prepare-hibernation", ...)— synchronous, 1.5 s timeout.- motion-service applies armed-hibernation profile to the chip; replies.
- On success: alarm-service logs "motion-service confirmed armed-hibernation profile". pm-service can now suspend; the chip is guaranteed to be in the right profile.
- On failure: alarm-service acquires its
power:inhibitsblock inhibitor and holds it — pm-service stays blocked rather than hibernating with an unverified chip profile.
This is the only synchronous call alarm-service makes. All other chip-config flows reactively through the alarm hash that motion-service watches.
Wake-from-Hibernation Detection¶
When motion-service starts up after a hibernation wake AND finds INT_STATUS_0 already latched, it publishes a one-shot motion:interrupt {type: "wake-hibernation", ...} AND writes motion.wake-cause = <unix-ms> to its hash. The hash field is durable across the startup-ordering race.
alarm-service on startup:
- Calls
MotionClient.ConsumeWakeCause(ctx)— HGET + HDEL onmotion.wake-cause. If the timestamp is within 30 s of now, returns true. - If true: sends
BMXInterruptEvent{Data:"wake-hibernation"}to the FSM atStateInit. SetswakeFromHibernation=true. - FSM init checks the flag at
InitCompleteEvent: if alarm-enabled + standby + wakeFromHibernation, transitions directly toStateTriggerLevel1Wait(treats the wake-edge as the L1 trigger). Hair trigger is skipped on this entry — the wake-edge isn't a tampering event by itself.
Suspend Inhibitor Management¶
A block inhibitor in pm-service's power:inhibits hash (id alarm-active, who librescoot-alarm, what power-state-change) keeps pm-service from suspending or hibernating the MDB while the alarm is delaying or triggered. pm-service's suspend gate reads its own registry (power:inhibits + the /tmp/suspend_inhibitor socket). The who is deliberately not librescoot-modem, so pm-service's hasOnlyModemBlockingInhibitors does not let it suspend through. alarm-service clears any stale alarm-active entry on startup so a crash mid-alarm can't wedge power management.
Held in:
- delay_armed: held during 5-s arming delay
- trigger_level_1_wait / trigger_level_1: held during cooldown + verification
- trigger_level_2 / waiting_movement: held during full alarm
- seatbox_access: held during authorized opening
- prepare-hibernation failure (rare): held indefinitely until alarm-service is restarted or the chip-config issue is resolved
armed state intentionally does NOT hold the inhibitor — that's the state where hibernation IS allowed.
Configuration¶
Settings via Redis¶
# Enable alarm system
redis-cli HSET settings alarm.enabled true
redis-cli PUBLISH settings alarm.enabled
# Enable horn during alarm
redis-cli HSET settings alarm.honk true
redis-cli PUBLISH settings alarm.honk
# Enable hair trigger
redis-cli HSET settings alarm.hairtrigger true
redis-cli PUBLISH settings alarm.hairtrigger
# Switch off one trigger source
redis-cli HSET settings alarm.trigger.handlebar false
redis-cli PUBLISH settings alarm.trigger.handlebar
Settings via Command Line¶
CLI flags override Redis settings when explicitly provided:
Testing¶
Drive a complete trigger cycle¶
# Make sure motion-service is up
redis-cli HGET motion current-profile # -> armed-awake when alarm armed
# Synthetic motion edge
redis-cli PUBLISH motion:interrupt "{\"type\":\"edge\",\"timestamp\":$(date +%s%3N),\"engine\":\"any-motion\"}"
# Watch alarm transitions
redis-cli SUBSCRIBE alarm
Manual trigger¶
Dependencies¶
- motion-service — owns the BMX055; provides motion events + the prepare-hibernation RPC
- redis-ipc v0.13.0+ — for
CallMethod - Redis — for hashes, pub/sub, command queues
- vehicle-service — provides vehicle state for arm/disarm gating
- ums-service — owns
usb.mode; its mass-storage sessions suppress the alarm - horn / blinker — typically provided by vehicle-service via
scooter:horn/scooter:blinkerqueues
systemd¶
librescoot-alarm.service:
[Unit]
After=valkey.service librescoot-vehicle.service librescoot-settings.service librescoot-motion.service
Wants=valkey.service librescoot-motion.service
[Service]
ExecStart=/usr/bin/alarm-service \
--redis=localhost:6379 \
--log-level=info
The Wants=/After=librescoot-motion.service ordering ensures the chip is in a known state before alarm-service starts watching the alarm hash.
Related Documentation¶
- librescoot-motion — chip owner; provider of
motion:interrupt+motion:rpc/prepare-hibernation - Vehicle states — how vehicle state affects alarm arming
- Librescoot Services — complete service overview