Repository navigation
Alarm diagnostics compare a 4.0 readback against a 5.0 arm, then blame the strap #1706
Description
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
- Scope the alarm prefs per device, or at minimum record which device each of
lastArmSentEpoch/lastReportedEpochcame from and refuse the comparison when they differ. This is the actual bug; the rest is diagnosis quality. - Persist the raw readback frame alongside
lastReportedEpochso a debug export can settle misdecode-versus-stale without needing the live log to still hold the line. - 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
alarm: attribute the readback to a strap before judging it (#1707)
ryanbr commentedon Aug 28, 2026
Handled in #1707.
Both halves of the comparison now record which strap they came from, and a shared
AlarmReadbackreturnsMATCHES/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 inSmartAlarmViewat ≥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.alarm: let a strap be asked what alarm it has stored (#1710)
ryanbr commentedon Aug 28, 2026
#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_TIMEreadback 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:
2045value 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".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 why2045was 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.Zebsi235 commentedon Aug 28, 2026
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:
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 flag00and 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 theflag ∈ {00, 01}shape above, so I am not claiming #1713 explains the 2045-06-10 reading here. TheReadback frame:line is still the evidence that decides this one — it may be another shape, another firmware, or genuinely stale.alarm: decode the 4.0 GET_ALARM_TIME readback at the offset the firmw…
ryanbr commentedon Aug 28, 2026
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:
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 flag00and 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.lastReportedEpochfrom 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 theflag ∈ {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.
fix(alarm): refuse to judge an alarm readback that answered an earlie…
Alarm readback: refuse to judge one that answered an earlier arm (#2322)