Repository navigation
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
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.armedAlarmEpochand its Kotlin twinwhoop4ArmedAlarmEpochdecode 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:
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:
The readback carries one byte the SET payload does not — a stored flag,
0x01when an alarm is held,0x00when 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 flag00and 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 aMISMATCHtowardalarm.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.lastReportedEpochfrom 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 theflag ∈ {00, 01}shape captured here — so this PR does not explain that reading, and itsReadback 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:
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).readbackReportsNoAlarmgets 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
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.AlarmReadbackDecodeTeston Windows 11, JDK 17, againstmainat51204d7, three times (#1707/#1710 landed while this was being written; the branch is rebased onto7146fc2— 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.ktreverted: 3 failed, each withThe
2389889025the 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.ktis 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.pypasses 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.
AlarmReadbackDecodeTestslives in the app target, whichapp-build.ymlruns 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 forlines — the readback should then echo the sent epoch within the same second. With #1710's button this now also works without arming.Checklist
Packages/**touched); the Swift halves are app-target code.xcodebuild testnot run locally (no Apple machine) —app-build.ymlruns StrandTests on the macOS/Strand leg, which coversAlarmReadbackDecodeTests. Saying it rather than ticking it../gradlew testFullDebugUnitTest, see above)StrandDesigntokens — n/a, no UIdocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated 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.