From 89c16b9e290b64b1e7ded837964c0ddd9e37725e Mon Sep 17 00:00:00 2001 From: Pablodvs Date: Wed, 19 Aug 2026 22:32:46 +0000 Subject: [PATCH 1/2] fix(ble): arm gen4 wake alarm with the rev-1 9-byte form the firmware executes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich 20-byte 0x04 form is stored, echoed and confirmed (event 56) by WHOOP 4.0 firmware but never executed: zero STRAP_DRIVEN_ALARM_EXECUTED (57) across 1.07M log lines and 8+ armed alarms on real hardware. The official WHOOP app arms with a rev-1 9-byte form — [0x01][epoch u32 LE][subsec u16][haptic-mode u16=0] — per a btsnoop wire capture (noop PR #535), and an on-device A/B on the affected band proved it: armed rev-1, the band fired autonomously at the armed second (events 60 + 57 + auto-disable 59, ~24 s buzz), while the rich form stayed silent. The trailing haptic-mode u16 is the whole difference from the known-silent 7-byte short form. gen5 keeps its rich 21-byte slot-1 body (#194) — the gen4 findings do not transfer. RTC drift (#122's theory) was ruled out on hardware: raw GET_CLOCK readbacks are wall-correct to the second; the drift shift is kept for genuinely offset RTCs. Full evidence trail in the PR description. The byte layout itself lives in openstrap_protocol (alarmRev1Payload, added alongside cmdSetAlarmRev1 for protocol #32); AlarmPayloads.rev1 is a delegation, so the wire format has exactly one home. Needs the protocol pin bumped to a SHA containing that export — left to the release-time bump. Fixes #119. Related: #186 (gen5), OpenStrap/protocol#32. --- lib/ble/ble_engine.dart | 55 ++++++++++++++++++---------- lib/ble/ble_state.dart | 74 ++++++++++++++++++++++++++++---------- test/alarm_test.dart | 55 ++++++++++++++++++++-------- test/gen5_wiring_test.dart | 8 +++-- 4 files changed, 137 insertions(+), 55 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 24474fe9..85806ea6 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5590,19 +5590,30 @@ class BleEngine { /// frames queued ahead of the response, not to be a plausible steady state. static const Duration _clockReadTimeout = Duration(seconds: 3); - /// On-device wake alarm (SET_ALARM_TIME = 0x42) — the RICH 20-byte form that - /// actually FIRES: + /// On-device wake alarm (SET_ALARM_TIME = 0x42), band-generation aware via + /// [AlarmPayloads.setPayloadForBand]: + /// + /// WHOOP 4 — the REV-1 9-byte form the firmware actually EXECUTES: /// ``` - /// [0] 0x04 rich-form marker - /// [1] u8 index alarm slot (gen4: 0; gen5: 1) - /// [2..6] u32 epoch-sec LE the wake time - /// [6..8] u16 subsec LE (millis % 1000) * 32768 ~/ 1000 (1/32768 s units) - /// [8..20] 12-byte haptic pattern (see [AlarmPayloads.defaultHaptics]) + /// [0] 0x01 rev-1 form marker + /// [1..5] u32 epoch-sec LE the wake time + /// [5..7] u16 subsec LE (millis % 1000) * 32768 ~/ 1000 (1/32768 s units) + /// [7..9] u16 haptic-mode 0 = the stock wake buzz /// ``` - /// WHOOP 5 requires slot index 1: index 0 is - /// rejected with `arm info is invalid, error 0xb`. The short 7-byte - /// time-only form ([setAlarmSimple]) is ACKed but never buzzes. The strap - /// confirms via event 56 and reports firing via 57/58 + 60. + /// This is what the official WHOOP app sends (btsnoop wire capture) and it + /// is proven on our own band (2026-08-19): armed over BLE, the band fired + /// autonomously at the armed second (HAPTICS_FIRED 60 + STRAP_DRIVEN_ALARM_ + /// EXECUTED 57, then auto-disable 59). The rich 0x04 form previously armed + /// here is stored + confirmed (event 56) but NEVER executed on gen4 — the + /// silent-alarm root cause. The 7-byte short form ([setAlarmSimple]) is + /// rev-1 minus the haptic-mode u16: ACKed, never fires. + /// + /// WHOOP 5 — the rich 21-byte slot-1 body, unchanged (#194; index 0 is + /// rejected with `arm info is invalid, error 0xb`). + /// + /// The strap confirms via event 56 and reports firing via 57/58 + 60 — + /// delivered through the band's history stream (typically the NEXT sync), + /// not necessarily live. /// /// Returns the wall-clock instant armed, or null when the strap did not take /// the alarm — so the caller never persists a phantom alarm. Null means one @@ -5637,11 +5648,13 @@ class BleEngine { // on its OWN clock, so if that clock is offset from wall time (SET_CLOCK not // latched / drift) the raw wall epoch fires at the wrong strap-time — or // never (a raw wall epoch is decades ahead of a strap clock still near its - // factory epoch, which is exactly why an immediate RUN_ALARM / Maverick buzz - // works but a scheduled alarm never fires). Shift the target by the - // GET_CLOCK drift; fall back to the raw epoch when we have no correlation - // yet (e.g. just after a reconnect, before this session's GET_CLOCK reply). - // Byte layout + the frame conversion both live in the pure [AlarmPayloads]. + // factory epoch). (Historical note: drift was once blamed for the gen4 + // silent alarm; the real cause was the payload form — see the doc above. + // The shift stays: it is correct for a genuinely offset RTC.) Fall back to + // the raw epoch when we have no correlation yet (e.g. just after a + // reconnect, before this session's GET_CLOCK reply). Frame conversion + + // generation dispatch live in the pure [AlarmPayloads]; the gen4 rev-1 + // byte layout itself is sourced from `openstrap_protocol`. final ref = _clockRef; final driftSec = ref?.driftSec ?? 0; final armWhen = AlarmPayloads.toStrapFrame(when, driftSec); @@ -5652,12 +5665,14 @@ class BleEngine { haptics: haptics, ); final out = await _sendAwaited(Cmd.setAlarmTime, payload); + // rev-1 has no slot byte — payload[1] there is an epoch byte, so only the + // gen5 rich body logs an idx. _log( - 'SET_ALARM_TIME (${isGen5 ? "gen5 rich index1" : "rich"} ${payload.length}B) ' + 'SET_ALARM_TIME (${isGen5 ? "gen5 rich index1" : "rev1"} ${payload.length}B) ' '→ wallSec=${when.millisecondsSinceEpoch ~/ 1000} ' 'strapSec=${armWhen.millisecondsSinceEpoch ~/ 1000} drift=${driftSec}s ' 'correlated=${ref != null} subsec=${AlarmPayloads.subsecOf(armWhen)} ' - 'idx=${payload.length >= 2 ? payload[1] : -1} ' + '${isGen5 && payload.length >= 2 ? 'idx=${payload[1]} ' : ''}' 'write=${out.written ? 'ok' : 'FAILED'}', ); if (!out.written) return null; @@ -5690,7 +5705,9 @@ class BleEngine { /// Time-only alarm (SET_ALARM_TIME = 0x42), SHORT 7-byte form: /// `[0x01][u32 epoch-sec LE][u16 subsec LE]`. Kept for diagnostics/parity — - /// the band ACKs it but never fires it (no haptic waveform). Use [setAlarm]. + /// the band ACKs it but never fires it (it is the rev-1 form minus the + /// trailing haptic-mode u16, and those two bytes are what make the firmware + /// execute the alarm). Use [setAlarm]. Future setAlarmSimple(DateTime when) async { await _send(Cmd.setAlarmTime, AlarmPayloads.simple(when)); _log('SET_ALARM_TIME (simple 7B) → sec=${when.millisecondsSinceEpoch ~/ 1000} ' diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 5c22d3ca..12484e9c 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -11,6 +11,10 @@ import 'dart:async'; import 'dart:math'; +// Pure byte-layer package (zero deps, no I/O) — purity of this file holds. +import 'package:openstrap_protocol/openstrap_protocol.dart' + show alarmRev1Payload; + import '../sync/sync_policy.dart' show isPlausibleUnix; /// The explicit connection state machine. The flutter_blue_plus connection-state @@ -1094,10 +1098,20 @@ class DeriveDebouncer { /// keeping the exact byte layout here makes it unit-testable without a real band. /// /// Alarm opcodes: SET_ALARM_TIME 0x42, GET_ALARM_TIME 0x43, RUN_ALARM 0x44, -/// DISABLE_ALARM 0x45. The RICH SET form (haptic waveform + time) is the one -/// that actually FIRES: WHOOP 4 uses alarm slot index 0; WHOOP 5 uses index 1. -/// The SHORT time-only form is ACKed but never -/// buzzes (no waveform to play). Prefer [setPayloadForBand] for arming. +/// DISABLE_ALARM 0x45. Prefer [setPayloadForBand] for arming. +/// +/// WHICH SET FORM FIRES ON WHOOP 4 — settled on real hardware, 2026-08-19: the +/// REV-1 9-byte form ([rev1]). It is what the official WHOOP app sends +/// (btsnoop wire capture, noop PR #535), and an A/B experiment on our own +/// band armed it and the band fired autonomously at the armed second (events +/// 60 HAPTICS_FIRED + 57 STRAP_DRIVEN_ALARM_EXECUTED + 59 auto-disable, ~24 s +/// buzz). The RICH 20-byte 0x04 form — shipped as "the firing form" until +/// then — is stored, echoed and confirmed (event 56) exactly like a real arm +/// but NEVER executes on gen4: zero event-57s across 1.07M lines of this +/// band's history while it was the shipped form. The SHORT 7-byte form is +/// rev1 minus the trailing haptic-mode u16 (ACKed, never fires) — those two +/// bytes are the whole difference. WHOOP 5 keeps the rich 21-byte slot-1 form +/// (#194, verified by its own users; the gen4 findings do not transfer). class AlarmPayloads { /// The strap's stock 12-byte wake-buzz haptic pattern: /// [0..7] eight waveform-effect slots (two active: 47, 152; six idle) @@ -1115,7 +1129,19 @@ class AlarmPayloads { static int subsecOf(DateTime when) => ((when.millisecondsSinceEpoch % 1000) * 32768) ~/ 1000; - /// RICH 20-byte SET_ALARM_TIME payload — the form that actually fires: + /// REV-1 9-byte SET_ALARM_TIME payload — the form the gen4 firmware EXECUTES + /// (see the class doc for the evidence): + /// `[0x01][u32 epoch-sec LE][u16 subsec LE][u16 haptic-mode LE]`. + /// The byte layout has exactly one home, `openstrap_protocol`'s + /// [alarmRev1Payload]; this is the app-side name for it. Haptic-mode stays + /// at its default 0 (the strap's stock wake buzz) — the only value + /// wire-captured from the official app, so we never send anything else. + static List rev1(DateTime when) => alarmRev1Payload(when); + + /// RICH 20-byte SET_ALARM_TIME payload. On gen4 this is REFERENCE ONLY — the + /// band stores + confirms it (event 56) but never executes it (no event 57, + /// no buzz; this was the silent-alarm root cause). Gen5 arms a 21-byte + /// variant of this shape via [setPayloadForBand]. /// `[0x04][u8 index][u32 epoch-sec LE][u16 subsec LE][12-byte haptic pattern]`. static List rich(DateTime when, {int index = 0, List? haptics}) { final ms = when.millisecondsSinceEpoch; @@ -1136,7 +1162,9 @@ class AlarmPayloads { ]; } - /// SHORT 7-byte time-only SET_ALARM_TIME payload (ACKs but does NOT fire): + /// SHORT 7-byte time-only SET_ALARM_TIME payload (ACKs but does NOT fire — + /// it is [rev1] without the trailing haptic-mode u16, and those two bytes + /// are what separate a silent arm from a firing one). REFERENCE ONLY: /// `[0x01][u32 epoch-sec LE][u16 subsec LE]`. Prefer [setPayloadForBand]. static List simple(DateTime when) { final ms = when.millisecondsSinceEpoch; @@ -1153,11 +1181,18 @@ class AlarmPayloads { ]; } - /// Generation-correct SET_ALARM_TIME body — 20 bytes on gen4, 21 on gen5. + /// Generation-correct SET_ALARM_TIME body — 9 bytes on gen4, 21 on gen5. + /// + /// WHOOP 4: the REV-1 form ([rev1]) — the only form the gen4 firmware + /// actually executes (on-device proof 2026-08-19; the rich slot-0 form this + /// used to build was confirmed-but-never-fired, the silent-alarm root + /// cause). [index]/[haptics]/[crescendo] do not exist in the rev-1 layout + /// and are ignored on gen4. /// - /// WHOOP 4: slot index 0 (HW-verified). WHOOP 5: slot **index 1**. Index 0 is - /// rejected with console `arm info is invalid, error 0xb`. On gen5 the [index] - /// argument is ignored so callers cannot accidentally arm slot 0. + /// WHOOP 5: rich 21-byte body at slot **index 1** (index 0 is rejected with + /// console `arm info is invalid, error 0xb`; the [index] argument is ignored + /// so callers cannot accidentally arm slot 0). Kept exactly as #194 shipped + /// it — verified by gen5 users; the gen4 findings do not transfer. static List setPayloadForBand( DateTime when, { required bool isGen5, @@ -1165,15 +1200,16 @@ class AlarmPayloads { List? haptics, int crescendo = 0, }) => - [ - ...rich(when, index: isGen5 ? gen5Slot : index, haptics: haptics), - // gen5's body carries one byte more than gen4's: a crescendo flag the - // strap validates as 0 or 1 and rejects otherwise, so a 20-byte body - // is refused there. Keep this in step with protocol's cmdSetAlarm, - // which is the reference layout — gen4 stays at the 20 bytes verified - // on hardware. - if (isGen5) crescendo & 0x01, - ]; + isGen5 + ? [ + ...rich(when, index: gen5Slot, haptics: haptics), + // gen5's body carries one byte more than gen4's rich form: a + // crescendo flag the strap validates as 0 or 1 and rejects + // otherwise, so a 20-byte body is refused there. Keep this in + // step with protocol's cmdSetAlarm, the reference layout. + crescendo & 0x01, + ] + : rev1(when); /// The alarm slot WHOOP 5 accepts (index 0 is rejected). static const int gen5Slot = 1; diff --git a/test/alarm_test.dart b/test/alarm_test.dart index 2d2544ae..1e34b83f 100644 --- a/test/alarm_test.dart +++ b/test/alarm_test.dart @@ -1,9 +1,12 @@ // Tests for the on-device wake alarm: -// - the exact SET_ALARM_TIME byte layouts (rich 20-byte firing form + short -// 7-byte time-only form) and the RUN/DISABLE bodies (AlarmPayloads), +// - the exact SET_ALARM_TIME byte layouts: the REV-1 9-byte form (the one +// gen4 firmware executes — pinned against the official app's wire capture +// and our own on-device fire, 2026-08-19), the gen5 rich 21-byte slot-1 +// body, the reference-only rich/short forms, and the RUN/DISABLE bodies +// (AlarmPayloads), // - the strap-event confirmation state machine (AlarmConfirmation), and -// - the arm/run decision made on the correlated reply's alarm-status byte -//, driven over the engine's fake-link seam. +// - the arm/run decision made on the correlated reply's alarm-status byte, +// driven over the engine's fake-link seam. // No radio and no DB — everything here is deterministic. import 'dart:typed_data'; @@ -84,7 +87,29 @@ void main() { (999 * 32768) ~/ 1000); }); - test('rich (20B) = marker + index + u32 sec LE + u16 subsec LE + haptics', () { + test('rev1 (9B, THE gen4 firing form) = 0x01 + u32 sec LE + u16 subsec + u16 mode', () { + final p = AlarmPayloads.rev1(when); + expect(p.length, 9); + expect(p, [ + 0x01, // rev-1 form marker + 0x04, 0x03, 0x02, 0x01, // sec LE + 0x00, 0x40, // subsec LE (16384) + 0x00, 0x00, // haptic-mode (stock wake buzz) + ]); + }); + + test('rev1 matches the official WHOOP app wire capture byte-for-byte', () { + // btsnoop of the official app arming a real WHOOP 4.0 (noop PR #535): + // epoch 1781912880 = 0x6A35D530 → [01, 30, D5, 35, 6A, 00, 00, 00, 00]. + // The same 9-byte shape fired OUR band on-device (2026-08-19 18:55:00: + // events 60+57 stamped at the armed second). Never regress to the 7-byte + // form — dropping the trailing haptic-mode u16 arms silently. + final p = AlarmPayloads.rev1( + DateTime.fromMillisecondsSinceEpoch(1781912880 * 1000, isUtc: true)); + expect(p, [0x01, 0x30, 0xD5, 0x35, 0x6A, 0x00, 0x00, 0x00, 0x00]); + }); + + test('rich (20B, gen4 reference only — confirmed but never executed) layout', () { final p = AlarmPayloads.rich(when); expect(p.length, 20); expect(p, [ @@ -113,12 +138,14 @@ void main() { expect(p, [0x01, 0x04, 0x03, 0x02, 0x01, 0x00, 0x40]); }); - test('setPayloadForBand: gen4 index0 rich, gen5 index1 rich', () { + test('setPayloadForBand: gen4 rev1 (9B), gen5 index1 rich (21B)', () { final g4 = AlarmPayloads.setPayloadForBand(when, isGen5: false); final g5 = AlarmPayloads.setPayloadForBand(when, isGen5: true); - expect(g4.length, 20); - expect(g4[0], 0x04); - expect(g4[1], 0x00); + // gen4 = the rev-1 firing form, byte-identical to AlarmPayloads.rev1 — + // the rich slot-0 body it used to build arms silently (never executes). + expect(g4, AlarmPayloads.rev1(when)); + expect(g4.length, 9); + expect(g4[0], 0x01); expect(g5.length, 21); // gen5 adds the crescendo byte expect(g5[0], 0x04); expect(g5[1], 0x01); // gen5 arms slot 1 @@ -170,12 +197,12 @@ void main() { 1750000000 + 30); }); - test('rich() encodes the strap-frame epoch, not the raw wall epoch', () { + test('rev1() encodes the strap-frame epoch, not the raw wall epoch', () { // On a strap whose RTC is 90s behind, arming the raw wall epoch would fire - // 90s late (or never, for a large offset); the rich payload must carry the - // shifted (wall − drift) seconds. - final p = AlarmPayloads.rich(AlarmPayloads.toStrapFrame(wall, 90)); - final sec = p[2] | (p[3] << 8) | (p[4] << 16) | (p[5] << 24); + // 90s late (or never, for a large offset); the shipped gen4 payload must + // carry the shifted (wall − drift) seconds. Mirrors engine.setAlarm. + final p = AlarmPayloads.rev1(AlarmPayloads.toStrapFrame(wall, 90)); + final sec = p[1] | (p[2] << 8) | (p[3] << 16) | (p[4] << 24); expect(sec, 1750000000 - 90); }); }); diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index c1887943..a8a303bb 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -272,9 +272,11 @@ void main() { expect(AlarmPayloads.disableForBand(isGen5: false), [0x01]); expect(AlarmPayloads.getPayloadForBand(isGen5: false), [0x01]); expect(AlarmPayloads.disable, [0x01]); - // gen4 keeps alarm slot 0. - expect(AlarmPayloads.setPayloadForBand(DateTime.now(), isGen5: false)[1], - 0); + // gen4 arms with the rev-1 9-byte body (the form the firmware executes); + // the exact layout is pinned against the wire capture in alarm_test. + final when = DateTime.now(); + expect(AlarmPayloads.setPayloadForBand(when, isGen5: false), + AlarmPayloads.rev1(when)); }); test('gen5 disable is revision 2 + an alarm id', () { From 58c981c4209933f7c27395f46edbbab43545c3d1 Mon Sep 17 00:00:00 2001 From: Pablodvs Date: Sat, 22 Aug 2026 18:18:05 +0000 Subject: [PATCH 2/2] fix(ble): scope the alarm-form claims to observed firmware; pin protocol at #33's head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs told a mechanism the evidence no longer supports: the rich form executes on at least one other WHOOP 4 (PR #265 review, 2026-08-11 export), and short/rev-1 pad4 to byte-identical frames at haptic-mode 0 — so every behavioural claim now carries its firmware (41.17.4) and provenance, and the "silent-alarm root cause" / "two bytes are the whole difference" wording is gone. pubspec pins protocol #33's rebased head 4ce8f02: the old pin b7990e1 does not export alarmRev1Payload, so a fresh checkout failed analysis (CodeRabbit). kProtocolPin moves with it; kAlgoVersion holds at 76 — the hop is command builders only, no decoder line moves. gen5's composed 21-byte body gets one exact-bytes vector, crescendo=1 included. --- lib/ble/ble_engine.dart | 32 +++++++++-------- lib/ble/ble_state.dart | 57 ++++++++++++++++-------------- lib/compute/derivation_engine.dart | 8 ++++- pubspec.lock | 4 +-- pubspec.yaml | 9 ++++- test/alarm_test.dart | 40 +++++++++++++-------- test/gen5_wiring_test.dart | 2 +- 7 files changed, 93 insertions(+), 59 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 85806ea6..afa7dc1f 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5600,13 +5600,15 @@ class BleEngine { /// [5..7] u16 subsec LE (millis % 1000) * 32768 ~/ 1000 (1/32768 s units) /// [7..9] u16 haptic-mode 0 = the stock wake buzz /// ``` - /// This is what the official WHOOP app sends (btsnoop wire capture) and it - /// is proven on our own band (2026-08-19): armed over BLE, the band fired - /// autonomously at the armed second (HAPTICS_FIRED 60 + STRAP_DRIVEN_ALARM_ - /// EXECUTED 57, then auto-disable 59). The rich 0x04 form previously armed - /// here is stored + confirmed (event 56) but NEVER executed on gen4 — the - /// silent-alarm root cause. The 7-byte short form ([setAlarmSimple]) is - /// rev-1 minus the haptic-mode u16: ACKed, never fires. + /// This is what the official WHOOP app sends (btsnoop wire capture), and on + /// our band (fw 41.17.4, 2026-08-19/20) it fired autonomously at the armed + /// second (HAPTICS_FIRED 60 + STRAP_DRIVEN_ALARM_EXECUTED 57, then + /// auto-disable 59) while the rich 0x04 form previously armed here latched + /// (event 56) without executing. Execution of the rich form is + /// firmware-dependent — at least one other WHOOP 4 executes it (see + /// [AlarmPayloads]). The 7-byte short form ([setAlarmSimple]) is rev-1 + /// minus the haptic-mode u16 — the same bytes on the wire once padded, not + /// a distinct form. /// /// WHOOP 5 — the rich 21-byte slot-1 body, unchanged (#194; index 0 is /// rejected with `arm info is invalid, error 0xb`). @@ -5649,8 +5651,9 @@ class BleEngine { // latched / drift) the raw wall epoch fires at the wrong strap-time — or // never (a raw wall epoch is decades ahead of a strap clock still near its // factory epoch). (Historical note: drift was once blamed for the gen4 - // silent alarm; the real cause was the payload form — see the doc above. - // The shift stays: it is correct for a genuinely offset RTC.) Fall back to + // silent alarm, and later the payload form — which held on fw 41.17.4 but + // is firmware-dependent, see the doc above. The shift stays either way: + // it is correct for a genuinely offset RTC.) Fall back to // the raw epoch when we have no correlation yet (e.g. just after a // reconnect, before this session's GET_CLOCK reply). Frame conversion + // generation dispatch live in the pure [AlarmPayloads]; the gen4 rev-1 @@ -5705,13 +5708,14 @@ class BleEngine { /// Time-only alarm (SET_ALARM_TIME = 0x42), SHORT 7-byte form: /// `[0x01][u32 epoch-sec LE][u16 subsec LE]`. Kept for diagnostics/parity — - /// the band ACKs it but never fires it (it is the rev-1 form minus the - /// trailing haptic-mode u16, and those two bytes are what make the firmware - /// execute the alarm). Use [setAlarm]. + /// it is the rev-1 form minus the trailing haptic-mode u16 and pads to the + /// identical frame when that u16 is 0, so it is not a distinct wire form. + /// Use [setAlarm], which also converts to the strap's RTC frame; this sends + /// the raw epoch. Future setAlarmSimple(DateTime when) async { await _send(Cmd.setAlarmTime, AlarmPayloads.simple(when)); - _log('SET_ALARM_TIME (simple 7B) → sec=${when.millisecondsSinceEpoch ~/ 1000} ' - '(ACKs but will not fire)'); + _log('SET_ALARM_TIME (simple 7B) → ' + 'sec=${when.millisecondsSinceEpoch ~/ 1000}'); } /// Read the armed alarm back. Body is band-specific (see diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 12484e9c..a9149fd5 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -1100,18 +1100,22 @@ class DeriveDebouncer { /// Alarm opcodes: SET_ALARM_TIME 0x42, GET_ALARM_TIME 0x43, RUN_ALARM 0x44, /// DISABLE_ALARM 0x45. Prefer [setPayloadForBand] for arming. /// -/// WHICH SET FORM FIRES ON WHOOP 4 — settled on real hardware, 2026-08-19: the -/// REV-1 9-byte form ([rev1]). It is what the official WHOOP app sends -/// (btsnoop wire capture, noop PR #535), and an A/B experiment on our own -/// band armed it and the band fired autonomously at the armed second (events -/// 60 HAPTICS_FIRED + 57 STRAP_DRIVEN_ALARM_EXECUTED + 59 auto-disable, ~24 s -/// buzz). The RICH 20-byte 0x04 form — shipped as "the firing form" until -/// then — is stored, echoed and confirmed (event 56) exactly like a real arm -/// but NEVER executes on gen4: zero event-57s across 1.07M lines of this -/// band's history while it was the shipped form. The SHORT 7-byte form is -/// rev1 minus the trailing haptic-mode u16 (ACKed, never fires) — those two -/// bytes are the whole difference. WHOOP 5 keeps the rich 21-byte slot-1 form -/// (#194, verified by its own users; the gen4 findings do not transfer). +/// WHICH SET FORM FIRES ON WHOOP 4 — firmware-dependent (evidence: PR #265). +/// On fw 41.17.4 (boot 17.2.2) the REV-1 9-byte form ([rev1]) fired +/// autonomously at the armed second (events 60 HAPTICS_FIRED + 57 +/// STRAP_DRIVEN_ALARM_EXECUTED, then 59 auto-disable), while the RICH +/// 20-byte 0x04 form latched (event 56 + GET_ALARM readback) but never +/// executed — three controlled trials, 2026-08-19/20, plus zero event-57s +/// across 1.07M lines of this band's history while rich was the shipped +/// form. On another WHOOP 4 (fw not yet reported; 2026-08-11 export in the +/// PR review) the RICH form DID execute — the observed discriminator is +/// firmware version, not the form alone. The SHORT 7-byte form is rev1 minus +/// the trailing haptic-mode u16; at haptic-mode 0 the two pad4 to +/// byte-identical BLE frames, so there is no wire distinction between them +/// and no separate short-form behaviour to claim. [rev1] is the arm form: it +/// is what the official WHOOP app sends (btsnoop wire capture, noop PR #535) +/// and no observed firmware fails to execute it. WHOOP 5 keeps the rich +/// 21-byte slot-1 form (#194, verified by its own users). class AlarmPayloads { /// The strap's stock 12-byte wake-buzz haptic pattern: /// [0..7] eight waveform-effect slots (two active: 47, 152; six idle) @@ -1129,8 +1133,8 @@ class AlarmPayloads { static int subsecOf(DateTime when) => ((when.millisecondsSinceEpoch % 1000) * 32768) ~/ 1000; - /// REV-1 9-byte SET_ALARM_TIME payload — the form the gen4 firmware EXECUTES - /// (see the class doc for the evidence): + /// REV-1 9-byte SET_ALARM_TIME payload — the gen4 arm form: the official + /// app's wire form, fired on fw 41.17.4 (class doc for the evidence): /// `[0x01][u32 epoch-sec LE][u16 subsec LE][u16 haptic-mode LE]`. /// The byte layout has exactly one home, `openstrap_protocol`'s /// [alarmRev1Payload]; this is the app-side name for it. Haptic-mode stays @@ -1138,10 +1142,11 @@ class AlarmPayloads { /// wire-captured from the official app, so we never send anything else. static List rev1(DateTime when) => alarmRev1Payload(when); - /// RICH 20-byte SET_ALARM_TIME payload. On gen4 this is REFERENCE ONLY — the - /// band stores + confirms it (event 56) but never executes it (no event 57, - /// no buzz; this was the silent-alarm root cause). Gen5 arms a 21-byte - /// variant of this shape via [setPayloadForBand]. + /// RICH 20-byte SET_ALARM_TIME payload. On gen4 this is REFERENCE ONLY — + /// execution is firmware-dependent: on fw 41.17.4 it latches (event 56) + /// without ever executing, while at least one other firmware executes it + /// (class doc). Gen5 arms a 21-byte variant of this shape via + /// [setPayloadForBand]. /// `[0x04][u8 index][u32 epoch-sec LE][u16 subsec LE][12-byte haptic pattern]`. static List rich(DateTime when, {int index = 0, List? haptics}) { final ms = when.millisecondsSinceEpoch; @@ -1162,9 +1167,9 @@ class AlarmPayloads { ]; } - /// SHORT 7-byte time-only SET_ALARM_TIME payload (ACKs but does NOT fire — - /// it is [rev1] without the trailing haptic-mode u16, and those two bytes - /// are what separate a silent arm from a firing one). REFERENCE ONLY: + /// SHORT 7-byte time-only SET_ALARM_TIME payload — [rev1] without the + /// trailing haptic-mode u16. At haptic-mode 0 the two serialize to the SAME + /// padded frame, so this is not a distinct wire form. REFERENCE ONLY: /// `[0x01][u32 epoch-sec LE][u16 subsec LE]`. Prefer [setPayloadForBand]. static List simple(DateTime when) { final ms = when.millisecondsSinceEpoch; @@ -1183,11 +1188,11 @@ class AlarmPayloads { /// Generation-correct SET_ALARM_TIME body — 9 bytes on gen4, 21 on gen5. /// - /// WHOOP 4: the REV-1 form ([rev1]) — the only form the gen4 firmware - /// actually executes (on-device proof 2026-08-19; the rich slot-0 form this - /// used to build was confirmed-but-never-fired, the silent-alarm root - /// cause). [index]/[haptics]/[crescendo] do not exist in the rev-1 layout - /// and are ignored on gen4. + /// WHOOP 4: the REV-1 form ([rev1]) — the official app's wire form, which + /// fired on fw 41.17.4 where the rich slot-0 body this used to build + /// latched without executing (class doc for the firmware split). + /// [index]/[haptics]/[crescendo] do not exist in the rev-1 layout and are + /// ignored on gen4. /// /// WHOOP 5: rich 21-byte body at slot **index 1** (index 0 is rejected with /// console `arm info is invalid, error 0xb`; the [index] argument is ignored diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 4896f7e0..d1ccd8ea 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1328,8 +1328,14 @@ const int kAlgoVersion = 76; // the same answers. The gen5 records it adds are new: no released build could // decode them, so no stored day at v76 was derived from one, and there is // nothing for a same-version serve to confuse. +// +// The protocol repin to 4ce8f02 (protocol #33 rebased onto b7990e1) holds at +// 76 and is checkable the same way: the whole hop is one commit adding alarm +// COMMAND builders (alarmRev1Payload and friends) plus their exports and +// test. Commands go TO the strap; no decoder line moves, so no stored number +// can. const String kAnalyticsPin = 'd9362a66fbeac326d5d7d7b1fe27b28e41169a79'; -const String kProtocolPin = 'b7990e1499f9ae83dbd4c1fa8481dbe8413e7337'; +const String kProtocolPin = '4ce8f021568a4cd9a1d86c91004f91c6b21980da'; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see diff --git a/pubspec.lock b/pubspec.lock index d0bc5fe6..712b1376 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -933,8 +933,8 @@ packages: dependency: "direct main" description: path: "." - ref: b7990e1499f9ae83dbd4c1fa8481dbe8413e7337 - resolved-ref: b7990e1499f9ae83dbd4c1fa8481dbe8413e7337 + ref: "4ce8f021568a4cd9a1d86c91004f91c6b21980da" + resolved-ref: "4ce8f021568a4cd9a1d86c91004f91c6b21980da" url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 190efac5..0d745879 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,7 +79,14 @@ dependencies: # #31 carries the gen5 hello map, the real clock opcodes and the v18 # record field map this branch's decoders need. main's pre-gen5 pin is # deliberate THERE; this is the branch that wants gen5. - ref: b7990e1499f9ae83dbd4c1fa8481dbe8413e7337 + # + # REPIN (this branch): protocol #33 head @ 4ce8f02, which is b7990e1 + # (everything above) rebased under #33's one commit — alarmRev1Payload, + # the single home of the rev-1 9-byte SET_ALARM_TIME layout this branch + # arms gen4 with. b7990e1 itself does NOT export the symbol, so the old + # pin failed analysis on a fresh checkout. #33 is unmerged, so this is a + # PR-branch-head pin; repin to the main merge commit when it lands. + ref: 4ce8f021568a4cd9a1d86c91004f91c6b21980da openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git diff --git a/test/alarm_test.dart b/test/alarm_test.dart index 1e34b83f..bbef1519 100644 --- a/test/alarm_test.dart +++ b/test/alarm_test.dart @@ -87,7 +87,7 @@ void main() { (999 * 32768) ~/ 1000); }); - test('rev1 (9B, THE gen4 firing form) = 0x01 + u32 sec LE + u16 subsec + u16 mode', () { + test('rev1 (9B, the gen4 arm form) = 0x01 + u32 sec LE + u16 subsec + u16 mode', () { final p = AlarmPayloads.rev1(when); expect(p.length, 9); expect(p, [ @@ -101,15 +101,16 @@ void main() { test('rev1 matches the official WHOOP app wire capture byte-for-byte', () { // btsnoop of the official app arming a real WHOOP 4.0 (noop PR #535): // epoch 1781912880 = 0x6A35D530 → [01, 30, D5, 35, 6A, 00, 00, 00, 00]. - // The same 9-byte shape fired OUR band on-device (2026-08-19 18:55:00: - // events 60+57 stamped at the armed second). Never regress to the 7-byte - // form — dropping the trailing haptic-mode u16 arms silently. + // The same 9-byte shape fired OUR band on-device (fw 41.17.4, + // 2026-08-19 18:55:00: events 60+57 stamped at the armed second). This + // is the app-parity anchor: keep the payload byte-for-byte what the + // official app sends. final p = AlarmPayloads.rev1( DateTime.fromMillisecondsSinceEpoch(1781912880 * 1000, isUtc: true)); expect(p, [0x01, 0x30, 0xD5, 0x35, 0x6A, 0x00, 0x00, 0x00, 0x00]); }); - test('rich (20B, gen4 reference only — confirmed but never executed) layout', () { + test('rich (20B, gen4 reference only — execution is fw-dependent) layout', () { final p = AlarmPayloads.rich(when); expect(p.length, 20); expect(p, [ @@ -132,7 +133,7 @@ void main() { expect(p.sublist(8), custom); }); - test('simple (7B) = 0x01 + u32 sec LE + u16 subsec LE (ACKs, never fires)', () { + test('simple (7B) = rev1 minus the haptic-mode u16 (same frame once padded)', () { final p = AlarmPayloads.simple(when); expect(p.length, 7); expect(p, [0x01, 0x04, 0x03, 0x02, 0x01, 0x00, 0x40]); @@ -140,17 +141,28 @@ void main() { test('setPayloadForBand: gen4 rev1 (9B), gen5 index1 rich (21B)', () { final g4 = AlarmPayloads.setPayloadForBand(when, isGen5: false); - final g5 = AlarmPayloads.setPayloadForBand(when, isGen5: true); - // gen4 = the rev-1 firing form, byte-identical to AlarmPayloads.rev1 — - // the rich slot-0 body it used to build arms silently (never executes). + // gen4 = the rev-1 form, byte-identical to AlarmPayloads.rev1 — the + // official app's wire form (see AlarmPayloads for the firmware + // evidence). expect(g4, AlarmPayloads.rev1(when)); expect(g4.length, 9); expect(g4[0], 0x01); - expect(g5.length, 21); // gen5 adds the crescendo byte - expect(g5[0], 0x04); - expect(g5[1], 0x01); // gen5 arms slot 1 - expect(g5.sublist(8, 20), AlarmPayloads.defaultHaptics); - expect(g5[20], 0, reason: 'crescendo flag, off by default'); + // gen5: the full composed 21-byte body, exact bytes in field order. + expect(AlarmPayloads.setPayloadForBand(when, isGen5: true), [ + 0x04, // rich-form marker + 0x01, // slot 1 (index 0 is rejected on gen5) + 0x04, 0x03, 0x02, 0x01, // sec LE + 0x00, 0x40, // subsec LE (16384) + 47, 152, 0, 0, 0, 0, 0, 0, // 8 waveform effects + 0, 0, // loop control u16 LE + 7, // overall loop + 30, // duration seconds + 0, // crescendo flag, off by default + ]); + expect( + AlarmPayloads.setPayloadForBand(when, isGen5: true, crescendo: 1).last, + 1, + ); // Gen5 ignores a caller-supplied index so slot 0 cannot be armed by accident. expect( AlarmPayloads.setPayloadForBand(when, isGen5: true, index: 0)[1], diff --git a/test/gen5_wiring_test.dart b/test/gen5_wiring_test.dart index a8a303bb..aef2bfc8 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -272,7 +272,7 @@ void main() { expect(AlarmPayloads.disableForBand(isGen5: false), [0x01]); expect(AlarmPayloads.getPayloadForBand(isGen5: false), [0x01]); expect(AlarmPayloads.disable, [0x01]); - // gen4 arms with the rev-1 9-byte body (the form the firmware executes); + // gen4 arms with the rev-1 9-byte body (the official app's wire form); // the exact layout is pinned against the wire capture in alarm_test. final when = DateTime.now(); expect(AlarmPayloads.setPayloadForBand(when, isGen5: false),