Skip to content

Alarm diagnostics compare a 4.0 readback against a 5.0 arm, then blame the strap #1706

Open

Description

@ryanbr

Found reading a strap log from a phone with two straps registered — a WHOOP 4.0 and an active WHOOP 5.0/MG. The Alarm block reads:

Alarm
Enabled: no · set 07:00
Model: WHOOP 5.0 / MG · experimental: on
Strap clock: OK
Last arm: sent 2026-08-26 06:30 · 3d ago · HR 80 bpm at arm
Strap reports: 2045-06-10 14:03  ⚠️ MISMATCH — strap didn't accept the time
Last fired: never observed

1. The MISMATCH verdict compares two different straps

alarm.lastReportedEpoch is written in exactly one place, WhoopBleClient.kt:6462, and that branch is guarded:

if (connectedFamily == DeviceFamily.WHOOP4 && respCmd?.startsWith("GET_ALARM_TIME") == true) {

So the Strap reports: value can only ever come from a 4.0. But the block above it prints Model: WHOOP 5.0 / MG, and the log's own alarm traffic is the 5/MG path (Alarm: disarmed (5/MG rev2)).

None of the thirteen alarm.* keys is device-scoped — they are flat NoopPrefs entries:

alarm.enabled  alarm.lastArmAt  alarm.lastArmConnected  alarm.lastArmHeartRate
alarm.lastArmSentEpoch  alarm.lastFiredAt  alarm.lastReportedAt
alarm.lastReportedEpoch  alarm.scheduledDeadlineMs  …

So on any multi-strap install, AndroidDiagnostics.kt:386 compares an arm sent to one strap against a readback from another and concludes "strap didn't accept the time". That conclusion is unsound in this configuration no matter what the values are, and it points the reader at the wrong strap.

2. The reported value is not a plausible armed time

2045-06-10 14:03 local is epoch 2,380,672,980, which is 19 years ahead and — worth noting — above 2³¹, while the value actually sent (1,787,682,600) sits comfortably below it.

The decode already anticipates this. WhoopBleClient.kt:6454:

a plausible epoch that never matches what we armed, the corrupted-register signature — needs the raw frame to tell a genuinely-stored stale alarm from a misdecode of a fixed response field

That is exactly the question here, and the capture built to answer it (whoop4AlarmReadbackPayloadHex, logged as [raw …]) had already rolled out of the retained window by the time the log was exported. I am not claiming it is a misdecode — only that the value is implausible, the code itself lists misdecode as a candidate, and the evidence needed to decide does not survive long enough to reach a debug export.

3. Minor: two unrelated lines read as contradicting each other

Strap clock: OK comes from strap.newestRecordTs versus wall clock — it is about record timestamps. It then sits two lines above an alarm readback claiming 2045. Both look like statements about the strap's clock. They are not measuring the same thing, but nothing in the output says so.

Suggested order

  1. Scope the alarm prefs per device, or at minimum record which device each of lastArmSentEpoch / lastReportedEpoch came from and refuse the comparison when they differ. This is the actual bug; the rest is diagnosis quality.
  2. Persist the raw readback frame alongside lastReportedEpoch so a debug export can settle misdecode-versus-stale without needing the live log to still hold the line.
  3. Reword so the clock line cannot be read as covering the alarm readback.

No user reported this — I found it reading the log, and the alarm is disabled on that install, so nothing is currently broken for them.

Activity

  1. ryanbr commented on Aug 28, 2026

    @ryanbr
    OwnerAuthor

    Handled in #1707.

    Both halves of the comparison now record which strap they came from, and a shared AlarmReadback returns MATCHES / MISMATCH / DIFFERENT_STRAP / UNATTRIBUTED. Only a proven same-strap disagreement means the strap refused, so the export can no longer name a device that was never asked.

    One thing was worse than this issue described. The same unscoped comparison also drives alarm.rejectStreak, which raises a warning in SmartAlarmView at ≥2 — so a cross-strap reading could climb that streak indefinitely. That was not diagnostic text; it was user-visible. It now takes the verdict, and does nothing on a cross-strap reading: advancing would warn about a strap that was never asked, clearing would hide a real refusal, and it is evidence of neither.

    The 2045 value is still unexplained, deliberately. The raw readback frame is now persisted beside the epoch and printed as Readback frame:. That is what separates a genuinely stored stale alarm from a misdecode of a fixed response field, and the capture built for exactly that question had rolled out of the live log before the export was taken.

    Reviewing also turned up why it was displayed at all: the plausibility gate is 1_500_000_000..4_102_444_800 — 82 years wide — against its own stated intent that "a real armed alarm is near-now". A value 19 years out sails through. I left that alone on purpose: narrowing it would suppress the symptom before anyone knows the cause, and would stop the raw frame from ever being captured. Once an export carries the bytes, either the decode gets fixed or the gate gets an asymmetric window — generous into the past, where a stale stored alarm is real and diagnostic, tight into the future, where an armed alarm cannot be years ahead. That belongs in its own issue at that point.

    Keeping this open until an export with Readback frame: shows what the strap actually returns.

  2. ryanbr commented on Aug 28, 2026

    @ryanbr
    OwnerAuthor

    #1710 has landed, which makes the remaining question here answerable.

    Both platforms now have a "Check what the strap has stored" button on the strap firmware-alarm card. Until now the GET_ALARM_TIME readback was reachable only by arming, so an install with its alarm switched off — the state of the account in this issue — had no way to produce the frame that would explain its reported time.

    What to do when there is a build:

    1. Connect the WHOOP 4.0. The button lives in the 4.0 branch of the card, and the 2045 value came from the 4.0, so a 5/MG connection will not show it. This is "connect the 4.0, then press", not just "press".
    2. Press Check what the strap has stored.
    3. Export a strap log.

    The Alarm block should then carry a Readback frame: line with the raw bytes. That is what separates a genuinely stored stale alarm from a misdecode of a fixed response field — the distinction the decode's own comment names and the one this issue could not settle, because the capture had rolled out of the live log before the export was taken.

    Also worth recording, found while reviewing: the plausibility gate on that epoch is 1_500_000_000..4_102_444_800 — 82 years wide — against its own stated intent that "a real armed alarm is near-now". That is why 2045 was accepted and printed as a date at all. Still deliberately untouched: narrowing it now would suppress the symptom before the cause is known, and would stop the raw frame from ever being captured. Once a frame exists, either the decode gets corrected or the gate gets an asymmetric window — generous into the past, where a stale stored alarm is real and diagnostic, tight into the future, where an armed alarm cannot be years ahead.

  3. Zebsi235 commented on Aug 28, 2026

    @Zebsi235

    The misdecode-vs-stale question is answerable for fw 41.17.6.0 without waiting on a #1710 export — my alarm was armed, so my strap logs already carry the raw frames the log rotation ate on the install here. Three arm → readback pairs, and in every one the u32 at offset 2 of the readback payload is byte-exactly the epoch the arm sent, while the u32 at offset 1 is exactly the 2045-ish value the app logged:

    sent 1787720400 → raw 01 01 d0 72 8e 6a 00 00 04 00 20 → reported 2389889025
    sent 1787806800 → raw 01 01 50 c4 8f 6a 00 00 04 00 20 → reported 2412007425
    sent 1787893200 → raw 01 01 d0 15 91 6a 00 00 04 00 20 → reported 2434125825
    

    The reported values step by exactly 86400 × 256 while the arms step by 86400. So on this firmware the readback is not the SET mirror: it is [form 01][stored flag][u32 LE epoch][00 00][04 00 20], and the decode reads it one byte early, folding the stored flag into the epoch's low byte. The strap stored the right time every time. The #34 field-report payload (01 00 00 00 ... 04 00 20) turns out to be this same 11-byte shape with flag 00 and the epoch zeroed, which is why the offset was invisible until an armed capture existed — zeros read the same at both offsets.

    Fix with the captures as fixtures on both platforms: #1713.

    One caution about this install's value specifically: 2380672980 has low byte 0xD4, which does not fit the flag ∈ {00, 01} shape above, so I am not claiming #1713 explains the 2045-06-10 reading here. The Readback frame: line is still the evidence that decides this one — it may be another shape, another firmware, or genuinely stale.

  4. ryanbr commented on Aug 28, 2026

    @ryanbr
    OwnerAuthor

    Answered — for fw 41.17.6.0 at least — and the answer is misdecode. @Zebsi235 had the bytes already, from arms made before #1710's button existed, and landed the correction in #1713.

    The GET readback is 11 bytes and carries a stored flag the 9-byte SET payload does not, so the epoch sits one byte further along:

    SET  (9):  [form 01][u32 LE epoch][00 00 00 00]
    GET (11):  [form 01][stored flag][u32 LE epoch][00 00][04 00 20]
    

    Reading it at the SET-mirror offset returned the epoch's low three bytes shifted up a byte, plus the flag — wrong by roughly 256×, and free to land anywhere in u32 range. In all three of their captures it landed on a 2045 date inside the 2017–2100 window, so the gate accepted it.

    I re-derived every number from the raw payloads before merging. The u32-LE at offset 2 is the sent epoch byte-exact in all three; the u32-LE at offset 1 is exactly what the app logged in all three; and the misreads step by 22,118,400 = 86400 × 256 — one day of arm, shifted a byte. Three independent epochs, no misses.

    Why it hid for so long. The only fixture in the tree was the #34 field report 01 00 00 00 00 00 00 00 04 00 20 — the same 11-byte shape, but with the flag 00 and the epoch zeroed. A zeroed epoch region decodes identically at offsets 1 and 2, so the existing test could not have caught this and did not. It took an armed capture to make the offset visible.

    What this means for the earlier verdicts. Straps of this firmware were reported as refusing a time they had stored perfectly. #1707 stopped the cross-strap comparison from accusing the wrong device; #1713 stops the same-strap one from being wrong as well. And a pre-fix alarm.lastReportedEpoch from a readback of this shape should not be trusted whatever it looked like — an offset-1 read that fell outside the window dropped through to a bare-u32 read of different bytes, so sane-looking values were not safe either.

    This issue is not finished

    The 2045 value from this install is still unexplained. Its low byte is 0xD4, which cannot be the flag ∈ {00, 01} this shape requires — so it is a different shape, and #1713 deliberately does not claim it. @Zebsi235 was careful to say so, and they were right to.

    What settles it is still the same thing: connect the 4.0, press Check what the strap has stored (#1710, in the strap-alarm card), and export a strap log. The Readback frame: line will carry the bytes.

    The 82-year plausibility window also stays as it is, deliberately — on firmwares answering with some other shape, the wide window plus the raw-hex fallback is exactly what gets those bytes captured in the first place. Narrowing it now would close the door this issue is still waiting to walk through.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions