diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 53274245..443216a9 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -4403,19 +4403,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 if the write failed (so the /// caller does not persist a phantom alarm). @@ -4434,11 +4445,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); @@ -4449,12 +4462,14 @@ class BleEngine { haptics: haptics, ); final ok = await _send(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=${ok ? 'ok' : 'FAILED'}', ); return ok ? when : null; @@ -4462,7 +4477,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 1e85d69a..fb4f08d6 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -10,6 +10,10 @@ 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 @@ -1070,10 +1074,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) @@ -1091,7 +1105,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; @@ -1112,7 +1138,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; @@ -1129,11 +1157,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, @@ -1141,15 +1176,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 b5acee9a..a59ab1b2 100644 --- a/test/alarm_test.dart +++ b/test/alarm_test.dart @@ -1,6 +1,9 @@ // Pure-logic 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), and +// - 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). // No BLE / DB — everything here is deterministic. @@ -30,7 +33,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, [ @@ -59,12 +84,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 @@ -116,12 +143,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 d38dc7d2..6a2877be 100644 --- a/test/gen5_wiring_test.dart +++ b/test/gen5_wiring_test.dart @@ -271,9 +271,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', () {