Skip to content

alarms: app/cover-button state desync — firmware cancels alarm, app keeps showing it as vibrating #568

Description

@ng

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 handleAlarmalarm[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

  1. Ship option 1 today — closes the worst-case gap (notification persists forever after natural end if the user never opens the app).
  2. Probe option 3 against responseParser.ts — confirm whether DEVICE_STATUS exposes alarm-running state. If yes, layer it on top of option 1.
  3. If option 3 doesn't work, file a follow-up to investigate option 2.

Files likely touched

  • src/hardware/sharedClient.ts / src/hardware/client.tssetAlarm() 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreliabilityReliability and crash resilience

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions