Skip to content

alarm: the 4.0 GET_ALARM_TIME readback carries a stored flag, so its epoch decodes one byte early — a correctly stored alarm reads back as 2045 - #1713

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
Zebsi235:fix/alarm-readback-offset
Aug 28, 2026

Conversation

@Zebsi235

Copy link
Copy Markdown

What this PR does

This is the "decode gets corrected" branch of the plan in #1706: "Once an export carries the bytes, either the decode gets fixed or the gate gets an asymmetric window." My alarm was armed, so my logs already carry the bytes — three of them, from before #1710's button existed.

FrameRouter.armedAlarmEpoch and its Kotlin twin whoop4ArmedAlarmEpoch decode the GET_ALARM_TIME (cmd 67) readback on the assumption that it mirrors the 9-byte SET_ALARM_TIME payload — [form 0x01][u32 LE epoch]…, epoch at offset 1. On fw 41.17.6.0 the readback is an 11-byte payload with the epoch at offset 2, and the offset-1 read returns the epoch's low three bytes shifted up a byte, plus the extra byte itself — wrong by roughly 256×, and free to land anywhere in u32 range. In all three captures it landed on a 2045 date inside the 2017–2100 plausibility window (the same 82-year width flagged in the #1706 review notes), so the gate accepted it.

Three arm → readback pairs from my WHOOP 4.0 (fw 41.17.6.0, NOOP 10.5.0 on iOS 26.6), each pair logged in the same second:

[17:13:49] → Set Alarm Time payload=01d0728e6a00000000
[17:13:49] Alarm: armed for Wed 07:00 CEST — your local wake time (sent as UTC epoch 1787720400)
           Alarm: strap reports armed for Sun 20:03 CEST (epoch 2389889025) [raw 01 01 d0 72 8e 6a 00 00 04 00 20]

[12:13:51] → Set Alarm Time payload=0150c48f6a00000000
[12:13:51] Alarm: armed for Thu 07:00 CEST — your local wake time (sent as UTC epoch 1787806800)
           Alarm: strap reports armed for Thu 20:03 CEST (epoch 2412007425) [raw 01 01 50 c4 8f 6a 00 00 04 00 20]

[23:44:13] → Set Alarm Time payload=01d015916a00000000
[23:44:13] Alarm: armed for Fri 07:00 CEST — your local wake time (sent as UTC epoch 1787893200)
           Alarm: strap reports armed for Mon 19:03 CET (epoch 2434125825) [raw 01 01 d0 15 91 6a 00 00 04 00 20]

In every pair the u32-LE at offset 2 of the raw payload is the sent epoch, byte-exact (d0 72 8e 6a = 1787720400, and so on), and the u32-LE at offset 1 is exactly what the app reported. The reported values step by 22,118,400 = 86400 × 256 — a one-day arm step shifted left one byte. Three independent epochs, zero misses. The strap stored the right time every time; only the read was off.

So the readback shape on this firmware is:

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

The readback carries one byte the SET payload does not — a stored flag, 0x01 when an alarm is held, 0x00 when none is. The corroboration for that reading was already in the tree: the #34 field-report payload pinned in both test files, 01 00 00 00 00 00 00 00 04 00 20, is this exact shape with flag 00 and the epoch zeroed — and an all-zero epoch region is indistinguishable at offsets 1 and 2, which is why the offset stayed invisible until an armed capture existed.

Why this is more than a log line. The misdecoded epoch feeds the #34 sent-vs-reported machinery: it lands in alarm.lastReportedEpoch, always mismatches what was sent, and — attribution permitting (#1707) — counts a MISMATCH toward alarm.rejectStreak, whose warning SmartAlarmView raises at ≥2. A strap whose alarm register works perfectly is reported as refusing the time. It is also exactly the open question in #1706, in its own words: "the code itself lists misdecode as a candidate, and the evidence needed to decide does not survive long enough to reach a debug export." These captures survived, and for fw 41.17.6.0 the answer is: misdecode.

And the misread date was a symptom, never a test. Every epoch decoded from an 11-byte readback through the mirror offset is wrong, not just the ones that printed a far-future date — depending on the epoch's low three bytes, the offset-1 value can also land outside the plausibility window, in which case the old code fell through to the bare-u32 read at offset 0, which mixes different bytes and is differently wrong. So a pre-fix alarm.lastReportedEpoch from a 4.0 readback of this shape cannot be trusted whatever it looks like, sane-looking values included.

Two things I am deliberately not claiming. The #1706 install's own 2045 value (2380672980) has low byte 0xD4, which does not fit the flag ∈ {00, 01} shape captured here — so this PR does not explain that reading, and its Readback frame: line is still the evidence that will. And the 82-year gate stays untouched, exactly per the #1706 note: on firmwares that answer with some other shape, the wide window plus raw-hex fallback is still what gets the bytes captured.

The fix, decode-only, no caller changes, both platforms:

  • Try the 11-byte readback shape first: payload.count == 11 && payload[0] == 0x01 → epoch at offset 2, plausibility-gated. If the epoch is implausible the payload falls to the raw-hex line — never through to the mirror offsets, which are known-wrong for this shape (they fold the flag into the low byte, which is the bug).
  • The existing SET-mirror and bare-u32 shapes are unchanged below it. The length gate makes the 9-byte ci(i18n): audit main pushes, not just PRs into it #535 capture structurally unreachable from the new branch, so its pinned decode cannot change.
  • readbackReportsNoAlarm gets the same shape ordering, so the two functions keep reading the same shapes in the same order — which their doc comments promise. The [master] Alarm not buzzing #34 field-report fixture now matches via the readback shape (epoch field at offset 2 = 0), same verdict as before.

What this does NOT change: the genuine "no alarm stored" findings stand. Four of seven arms in the same logs really did not persist (flag 00, epoch 0) — that is the real #34 signal, and it decodes identically under the corrected offset.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Not on the BLE path: both decode helpers are pure and deliberately CoreBluetooth/Android-free so the golden tests pin them; no bytes change on the wire.

New fixtures on both platforms, in the files that already pin these decoders — all three captured payloads verbatim, the all-three-agree sweep, the 11-byte implausible-epoch fall-through (raw hex, not the mirror read), and the readback-shape mutual-exclusion case.

Ran ./gradlew testFullDebugUnitTest --tests com.noop.ble.AlarmReadbackDecodeTest on Windows 11, JDK 17, against main at 51204d7, three times (#1707/#1710 landed while this was being written; the branch is rebased onto 7146fc2 — their changes touch the caller and UI, not the decode helpers, and the full suite below ran on the rebased tree):

  • with the fix: BUILD SUCCESSFUL, 17 tests, 0 failures — the 12 pre-existing decode tests all unchanged and passing, which is the point of gating the new shape on payload length.

  • with the tests kept and only WhoopBleClient.kt reverted: 3 failed, each with

    java.lang.AssertionError: expected:<1787720400> but was:<2389889025>
    

    The 2389889025 the unfixed decode produces is the exact epoch the app logged on the wrist — the test reproduces the field symptom from the captured bytes, not a synthetic case. The other 14 tests (including all pre-existing ones) still passed, isolating the failure to the offset.

  • fix restored: BUILD SUCCESSFUL, 17 tests, 0 failures (forced re-execution, not a cache hit).

Then the whole suite on the rebased tree, since WhoopBleClient.kt is shared surface: ./gradlew testFullDebugUnitTest --rerun-tasks --no-build-cache — 4,729 tests across 581 classes, 0 failures, 0 errors (6 skipped, pre-existing).

python Tools/doc_comment_lint.py passes with this applied: OK no NEW detached doc comments (1862 files, 25 baselined site(s) remaining).

The compile warnings in that build are pre-existing (RouteExport.kt, BreatheScreen.kt, TodayScreen.kt, WorkoutsScreen.kt, BaselinesTraceTest.kt, HrvCaptureWindowTest.kt), none in the touched files.

The gap I want to state rather than have you find: the Swift half is not run locally — no Apple machine. AlarmReadbackDecodeTests lives in the app target, which app-build.yml runs on the macOS/Strand leg, so CI covers it; but that is CI, not a local run. The Swift change is line-for-line the twin of the Kotlin one that was run.

Hardware cross-check available on request: I can arm the alarm on the same strap after applying this and post the before/after strap reports armed for lines — the readback should then echo the sent epoch within the same second. With #1710's button this now also works without arming.

Checklist

  • Swift package tests — n/a (no Packages/** touched); the Swift halves are app-target code. xcodebuild test not run locally (no Apple machine) — app-build.yml runs StrandTests on the macOS/Strand leg, which covers AlarmReadbackDecodeTests. Saying it rather than ticking it.
  • Android unit tests pass (./gradlew testFullDebugUnitTest, see above)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — n/a, no UI
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders — the new literals are test fixtures (captured payloads) and the decode reads fields, not magic bytes
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Refs #34 (the sent-vs-reported diagnostics this feeds), #401 (the readback close-out that added the decode), #1706 (which asks exactly the misdecode-vs-stale question these captures answer for fw 41.17.6.0).

Not closing any of them: #1706's items are prefs-scoping and diagnostics wording, and #34's genuine non-persisting arms are unaffected by this fix.

The readback is not the SET mirror: on fw 41.17.6.0 it is 11 bytes,
[form 01][stored flag][u32 LE epoch][00 00][04 00 20]. The mirror-offset
read returns the epoch's low three bytes shifted up a byte plus the flag,
which in three captured arm/readback pairs decoded a correctly stored
alarm to a plausible-looking 2045 date and counted a MISMATCH against a
strap whose register is fine. Decode the 11-byte shape at offset 2,
plausibility-gated, falling to raw hex; the 9-byte SET-mirror and bare
u32 shapes are unchanged. Fixtures pin all three captures verbatim.
@ryanbr
ryanbr merged commit eccf107 into ryanbr:main Aug 28, 2026
4 checks passed
@Zebsi235
Zebsi235 deleted the fix/alarm-readback-offset branch September 6, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants