Problem
The cover buttons (top / middle / bottom on the side that's alarming) physically stop a running alarm — handled entirely inside frankenfirmware at Sensor.cpp:1453 handleAlarm → alarm[left] off. The pod's app/server never sees this event, so the in-app "alarm vibrating" notification continues to display until the original alarm's duration window elapses (or the user manually taps Stop in the app).
UX symptom: cover buzz stops, app still says it's buzzing.
Verified live on Pod 5 J55 (192.168.1.88), 2026-05-12:
07:16:42 dac_loop command: 5 payload: … ← app fired ALARM_LEFT
07:16:42 sparkAlarmL[alarm] vib. left: time …, power 100, pattern double, dur 60
07:16:42 alarm[left] start: power 100, pattern 0, dur 60000 ms
07:16:42 [tca8418L] gpi press 98 ← user pressed middle button
07:16:42 [TTC] left middle button clicked 1 times
07:16:42 Sensor.cpp:1453 handleAlarm[TTC] left alarm
07:16:42 alarm[left] off ← firmware cancelled it
07:16:42 alarm[left] start: power 25, pattern 7, dur 500 ms ← haptic confirmation buzz
At this point the app is still inside its 60-second isAlarmVibrating: true broadcast window.
State flow today
device.setAlarm tRPC ──► HardwareClient.setAlarm
│
├─► encodeAlarmPayload (CBOR)
├─► dacTransport.sendCommand(ALARM_LEFT, …)
└─► broadcastMutationStatus(side, { isAlarmVibrating: true })
frank ── alarm[left] off ──► (nowhere — log-only on the pod)
src/streaming/broadcastMutationStatus.ts flips isAlarmVibrating to true and there is no event source that flips it back when the firmware ends the alarm.
Proposed approaches (pick one or stack)
1. Client-side end-of-duration timer (cheap, partial)
setAlarm already knows duration. Schedule a setTimeout(duration * 1000 + slack) that calls broadcastMutationStatus(side, { isAlarmVibrating: false }). Implement in HardwareClient.setAlarm (or wherever isAlarmVibrating: true is set today) so it stays in one place.
- Pros: one-line fix, no firmware reverse-engineering, handles the natural end-of-alarm path correctly.
- Cons: button cancels are still misrepresented for
duration - cancel_time seconds (worst case ~60s for a long alarm). Most users cancel within a few seconds → UX gap is large.
2. Subscribe to frank's alarm[X] off event over the DAC socket (better)
src/hardware/dacMonitor.instance.ts already keeps a long-lived connection to /persistent/deviceinfo/dac.sock. Investigate whether frank pushes alarm-state messages back through this socket or only logs them via syslog.
- If DAC pushes events: parse
alarm[left] off / alarm[right] off frames, broadcast isAlarmVibrating: false.
- If only logged: needs an out-of-band stream. Options:
- Tail
journalctl -u frank over a unix socket / named pipe set up at install time.
- A tiny systemd-side helper that watches
journalctl -u frank and writes the relevant lines to a socket the app subscribes to.
This catches button cancels in near real time, but requires verifying which channel actually carries the event.
3. Poll DEVICE_STATUS while an alarm is running (middle ground)
HardwareCommand.DEVICE_STATUS (cmd 14) returns the full device state. If the response includes a live alarm-running field, poll every ~2s for the duration of the alarm; flip isAlarmVibrating: false the first time the poll says it's off.
- Pros: framework-friendly, no log parsing.
- Cons: one extra DAC roundtrip every 2s for up to
duration seconds; needs verification that the device-status payload actually reflects firmware-cancel state.
Suggested ordering
- Ship option 1 today — closes the worst-case gap (notification persists forever after natural end if the user never opens the app).
- Probe option 3 against
responseParser.ts — confirm whether DEVICE_STATUS exposes alarm-running state. If yes, layer it on top of option 1.
- If option 3 doesn't work, file a follow-up to investigate option 2.
Files likely touched
src/hardware/sharedClient.ts / src/hardware/client.ts — setAlarm() is the natural place to schedule the end-of-duration broadcast.
src/streaming/broadcastMutationStatus.ts — already publishes isAlarmVibrating; may need a paired clearIsAlarmVibrating(side) helper.
src/hardware/snoozeManager.ts — already handles a similar "scheduled clear" lifecycle; check whether the new timer should be unified with snooze tracking.
src/hardware/responseParser.ts / src/hardware/types.ts — for option 3, check whether rawDeviceDataSchema includes alarm state.
src/hardware/dacMonitor.instance.ts — for option 2, the natural place to subscribe to firmware events.
Acceptance
- After a cover-button cancel on Pod 5 J55, the app's
isAlarmVibrating: true broadcast clears within N seconds (definition of N depends on chosen approach).
- After a natural end-of-duration alarm, the broadcast clears within
duration + 5s.
- New tests in
src/hardware/tests/ cover both the natural-end path and (for whichever approach catches it) the button-cancel path.
Refs
docs/hardware/alarms.md — DAC wire format and cover-vibration routing
sleepypod-core-49 (ygg) — separate scheduler-race bug where a reload inside the fire window jumps the alarm a full week
sleepypod-core-50 (ygg, closed by 412fb18) — UI hides intensity/pattern since cover MCU clamps both
- frank logs cited above:
journalctl -u frank on 192.168.1.88, 2026-05-12 07:16
Problem
The cover buttons (top / middle / bottom on the side that's alarming) physically stop a running alarm — handled entirely inside frankenfirmware at
Sensor.cpp:1453 handleAlarm→alarm[left] off. The pod's app/server never sees this event, so the in-app "alarm vibrating" notification continues to display until the original alarm'sdurationwindow elapses (or the user manually taps Stop in the app).UX symptom: cover buzz stops, app still says it's buzzing.
Verified live on Pod 5 J55 (192.168.1.88), 2026-05-12:
At this point the app is still inside its 60-second
isAlarmVibrating: truebroadcast window.State flow today
src/streaming/broadcastMutationStatus.tsflipsisAlarmVibratingtotrueand there is no event source that flips it back when the firmware ends the alarm.Proposed approaches (pick one or stack)
1. Client-side end-of-duration timer (cheap, partial)
setAlarmalready knowsduration. Schedule asetTimeout(duration * 1000 + slack)that callsbroadcastMutationStatus(side, { isAlarmVibrating: false }). Implement inHardwareClient.setAlarm(or whereverisAlarmVibrating: trueis set today) so it stays in one place.duration - cancel_timeseconds (worst case ~60s for a long alarm). Most users cancel within a few seconds → UX gap is large.2. Subscribe to frank's
alarm[X] offevent over the DAC socket (better)src/hardware/dacMonitor.instance.tsalready keeps a long-lived connection to/persistent/deviceinfo/dac.sock. Investigate whether frank pushes alarm-state messages back through this socket or only logs them via syslog.alarm[left] off/alarm[right] offframes, broadcastisAlarmVibrating: false.journalctl -u frankover a unix socket / named pipe set up at install time.journalctl -u frankand writes the relevant lines to a socket the app subscribes to.This catches button cancels in near real time, but requires verifying which channel actually carries the event.
3. Poll
DEVICE_STATUSwhile an alarm is running (middle ground)HardwareCommand.DEVICE_STATUS(cmd 14) returns the full device state. If the response includes a live alarm-running field, poll every ~2s for the duration of the alarm; flipisAlarmVibrating: falsethe first time the poll says it's off.durationseconds; needs verification that the device-status payload actually reflects firmware-cancel state.Suggested ordering
responseParser.ts— confirm whetherDEVICE_STATUSexposes alarm-running state. If yes, layer it on top of option 1.Files likely touched
src/hardware/sharedClient.ts/src/hardware/client.ts—setAlarm()is the natural place to schedule the end-of-duration broadcast.src/streaming/broadcastMutationStatus.ts— already publishesisAlarmVibrating; may need a pairedclearIsAlarmVibrating(side)helper.src/hardware/snoozeManager.ts— already handles a similar "scheduled clear" lifecycle; check whether the new timer should be unified with snooze tracking.src/hardware/responseParser.ts/src/hardware/types.ts— for option 3, check whetherrawDeviceDataSchemaincludes alarm state.src/hardware/dacMonitor.instance.ts— for option 2, the natural place to subscribe to firmware events.Acceptance
isAlarmVibrating: truebroadcast clears within N seconds (definition of N depends on chosen approach).duration + 5s.src/hardware/tests/cover both the natural-end path and (for whichever approach catches it) the button-cancel path.Refs
docs/hardware/alarms.md— DAC wire format and cover-vibration routingsleepypod-core-49(ygg) — separate scheduler-race bug where a reload inside the fire window jumps the alarm a full weeksleepypod-core-50(ygg, closed by 412fb18) — UI hides intensity/pattern since cover MCU clamps bothjournalctl -u frankon 192.168.1.88, 2026-05-12 07:16