From 4ce8f021568a4cd9a1d86c91004f91c6b21980da Mon Sep 17 00:00:00 2001 From: Pablodvs Date: Thu, 20 Aug 2026 11:29:55 +0000 Subject: [PATCH 1/2] =?UTF-8?q?gen4=20SET=5FALARM=5FTIME:=20the=20rich=20b?= =?UTF-8?q?ody=20never=20executes=20=E2=80=94=20add=20the=20rev-1=20firing?= =?UTF-8?q?=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a real WHOOP 4.0 the 20-byte 0x04 rich body is stored, echoed by GET_ALARM_TIME and confirmed with STRAP_DRIVEN_ALARM_SET (56) exactly like a live arm, but the scheduler never executes it: months of sync logs with 8+ armed alarms show zero STRAP_DRIVEN_ALARM_EXECUTED (57) and no haptics at any target. The official app arms with a rev-1 9-byte body — [0x01][epoch u32 LE][subsec u16][haptic-mode u16=0] — and an A/B on the same band proved it: armed rev-1, the band fired autonomously at the armed second (events 60 + 57 + auto-disable 59, ~24 s buzz, HAPTICS_TERMINATED 100 at +24 s). The trailing haptic-mode u16 is the whole difference from the known-silent 7-byte short form. Add cmdSetAlarmRev1 with the official-app wire capture pinned in the tests, and correct the docs that recommended cmdSetAlarm as the firing form: on gen4 a GET_ALARM readback match or an event 56 only proves a body was stored, never that it will fire. gen5 keeps the rich 21-byte body as its only known arm form, still unverified for actually waking. Closes #32. --- lib/openstrap_protocol.dart | 2 + lib/src/commands.dart | 126 ++++++++++++++++++++++------ test/gen5_command_surface_test.dart | 39 ++++++++- 3 files changed, 138 insertions(+), 29 deletions(-) diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 035e558..9aadde6 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -109,6 +109,8 @@ export 'src/commands.dart' cmdEnableOptical, cmdBuzz, cmdSetAlarm, + cmdSetAlarmRev1, + alarmRev1Payload, cmdSetAlarmSimple, cmdRunAlarm, cmdDisableAlarm, diff --git a/lib/src/commands.dart b/lib/src/commands.dart index aa46d90..2ffffbf 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -278,15 +278,25 @@ Uint8List cmdBuzz(int seq, [int pattern = hapticShortPulse]) { // split into whole seconds + a 1/32768-s sub-second remainder, exactly like // SET_CLOCK (0x0A) — the strap's RTC ticks at 32768 Hz. // -// The alarm has TWO on-wire forms, both hardware-verified from our own device -// captures: -// • a SHORT form ([cmdSetAlarmSimple]) that carries only the time, and -// • a RICH form ([cmdSetAlarm]) that carries the time PLUS a haptic waveform -// pattern. -// On real hardware only the RICH form actually makes the strap buzz: a short -// "time only" write is accepted and ACK'd but the strap never fires it (there -// is no waveform to play). Our earlier 8-byte `[u32 epoch][u32 pad]` attempt -// silently failed for exactly this reason. Prefer [cmdSetAlarm]. +// The alarm has THREE known on-wire forms: +// • a 7-byte SHORT form ([cmdSetAlarmSimple]) — time only, no haptic-mode; +// • a 9-byte REV-1 form ([cmdSetAlarmRev1]) — time + a haptic-mode u16. +// This is what the official WHOOP app sends (btsnoop capture), and the +// only form observed to actually EXECUTE on a real WHOOP 4.0; and +// • a 20-byte RICH form ([cmdSetAlarm]) — time + slot + a haptic waveform. +// +// ⚠ On gen4 the RICH form is a trap: the firmware stores it, echoes it back +// from GET_ALARM_TIME, and confirms it with STRAP_DRIVEN_ALARM_SET (56) — +// but its scheduler never executes it. Extended observation of a real 4.0 +// (months of sync logs, 8+ arms) showed zero STRAP_DRIVEN_ALARM_EXECUTED +// (57) and no haptics at any armed target, while the same band armed with +// the REV-1 form fired autonomously at the armed second (events 60 + 57 + +// the one-shot auto-disable 59). Full evidence: issue #32. A GET_ALARM +// readback match therefore proves only that a body was STORED, not that it +// will fire. The SHORT form fails for the same reason it always did — it is +// the REV-1 form minus the trailing haptic-mode u16. +// +// For a gen4 wake alarm, use [cmdSetAlarmRev1]. // // gen5: SET_ALARM_TIME(66)/DISABLE_ALARM(69) are opcode-identical across // generations (§1.4), so [cmdSetAlarm]/[cmdSetAlarmSimple]/[cmdDisableAlarm]/ @@ -321,9 +331,10 @@ int _alarmSubsec(DateTime when) => /// to slot 0 there can never be fired on demand or cancelled afterwards — the /// usable range is 1..6. /// -/// gen4 is NOT the same and must not inherit that rule: slot 0 is the slot a -/// real WHOOP 4 fires from, verified on hardware. Rejecting it here would break -/// the one alarm path we have actually seen work. +/// gen4 is NOT the same and must not inherit that rule: 0 is the default slot +/// the rich form encodes there, and the firmware accepts it. (Note the gen4 +/// form that verifiably FIRES — [cmdSetAlarmRev1] — carries no slot byte at +/// all; slot ids only exist in the rich/gen5 encodings.) int _checkAlarmId(int id, String name, BandProfile profile) { final lo = profile.isGen5 ? 1 : 0; if (id < lo || id > 6) { @@ -332,7 +343,7 @@ int _checkAlarmId(int id, String name, BandProfile profile) { return id; } -/// Default alarm slot for [profile] — gen4 fires from slot 0, gen5 from 1. +/// Default alarm slot for [profile] — 0 on gen4 (rich form), 1 on gen5. int _defaultAlarmId(BandProfile profile) => profile.isGen5 ? 1 : 0; /// SHORT alarm form (SET_ALARM_TIME = 0x42). @@ -342,9 +353,11 @@ int _defaultAlarmId(BandProfile profile) => profile.isGen5 ? 1 : 0; /// - epoch seconds — `when` as a unix epoch, u32 LE. /// - sub-seconds — `(millis % 1000) * 32768 ~/ 1000`, u16 LE (1/32768 s units). /// -/// ⚠ This form sets the alarm TIME but ships no haptic waveform, so on real -/// hardware the strap ACKs it yet never buzzes. Use [cmdSetAlarm] to actually -/// arm a firing alarm; this is kept for parity / diagnostics only. +/// ⚠ On real hardware the strap ACKs this form yet never buzzes. It is +/// [cmdSetAlarmRev1] minus the trailing haptic-mode u16, and that missing +/// field — not the missing waveform pattern — is why it arms silently. Use +/// [cmdSetAlarmRev1] to arm a firing gen4 alarm; this is kept for parity / +/// diagnostics only. Uint8List cmdSetAlarmSimple(int seq, DateTime when, {BandProfile profile = BandProfile.gen4}) { final sec = _alarmEpochSec(when); @@ -361,7 +374,70 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when, return buildCommand(seq, Cmd.setAlarmTime, p, profile); } -/// RICH alarm form (SET_ALARM_TIME = 0x42) — THE form that actually fires. +/// REV-1 alarm form (SET_ALARM_TIME = 0x42) — the form a WHOOP 4.0 actually +/// EXECUTES, and the one the official app sends (btsnoop-captured; the wire +/// vector is pinned in the tests). +/// +/// Payload = 9 bytes: +/// `[0x01][u32 epoch-seconds LE][u16 sub-seconds LE][u16 haptic-mode LE]`. +/// - `0x01` — the rev-1 form marker, as in [cmdSetAlarmSimple]. +/// - epoch / sub-seconds — as everywhere else (1/32768-s units). +/// - haptic-mode — buzz selector. The official app sends 0, the stock wake +/// buzz (observed ~24 s, ended by HAPTICS_TERMINATED event 100). Non-zero +/// modes are accepted on the wire but unexplored — keep the default unless +/// you are experimenting. +/// +/// Hardware-verified on a real 4.0: armed with this form the band fired +/// autonomously at the armed second — HAPTICS_FIRED (60), +/// STRAP_DRIVEN_ALARM_EXECUTED (57), then the one-shot auto-disable (59), all +/// stamped at the target epoch — with no phone connected. The 20-byte rich +/// form ([cmdSetAlarm]) latches and confirms identically but was never seen +/// to execute there; see issue #32 for the full evidence. +/// +/// Note the confirmation lifecycle: ALARM_SET (56) and the fired events +/// arrive via the band's HISTORY stream on the next sync, not live, and 56 +/// only proves the body latched — not that it will fire. +/// +/// gen5 straps take the same opcode but a different body (see [cmdSetAlarm]); +/// whether this rev-1 body means anything to a gen5 is untested. +Uint8List cmdSetAlarmRev1(int seq, DateTime when, + {int hapticMode = 0, BandProfile profile = BandProfile.gen4}) => + buildCommand( + seq, Cmd.setAlarmTime, alarmRev1Payload(when, hapticMode: hapticMode), + profile); + +/// The bare 9-byte payload of the REV-1 alarm form (see [cmdSetAlarmRev1]). +/// +/// Exposed separately from the framed command so an app layer that runs its +/// own sequence counter and framing can still source the byte layout from +/// this package instead of duplicating it — the layout has exactly one home. +List alarmRev1Payload(DateTime when, {int hapticMode = 0}) { + if (hapticMode < 0 || hapticMode > 0xffff) { + throw ArgumentError.value( + hapticMode, 'hapticMode', 'haptic mode must fit in a u16'); + } + final sec = _alarmEpochSec(when); + final subsec = _alarmSubsec(when); + return [ + 0x01, + sec & 0xff, + (sec >> 8) & 0xff, + (sec >> 16) & 0xff, + (sec >> 24) & 0xff, + subsec & 0xff, + (subsec >> 8) & 0xff, + hapticMode & 0xff, + (hapticMode >> 8) & 0xff, + ]; +} + +/// RICH alarm form (SET_ALARM_TIME = 0x42). +/// +/// ⚠ gen4: this form is STORED but NEVER EXECUTED — the band echoes it from +/// GET_ALARM_TIME and confirms it with event 56 exactly like a live arm, yet +/// the scheduler never fires it (issue #32). For a gen4 wake alarm use +/// [cmdSetAlarmRev1]. On gen5 this rich body (plus the crescendo byte) is the +/// only known arm form, still hardware-unverified for actually waking. /// /// Payload = 20 bytes: /// ``` @@ -379,18 +455,18 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when, /// [u8 durationSeconds] max time to keep buzzing /// ``` /// -/// A haptic pattern is REQUIRED for the alarm to actually buzz — the time-only -/// [cmdSetAlarmSimple] form ACKs without firing. [hapticPattern] defaults to -/// [kDefaultAlarmHaptics] (the strap's stock wake buzz); pass your own 12 bytes +/// [hapticPattern] defaults to [kDefaultAlarmHaptics]; pass your own 12 bytes /// to customise. The strap confirms the alarm latched via the /// STRAP_DRIVEN_ALARM_SET (56) event and its firing via -/// STRAP_DRIVEN_ALARM_EXECUTED (57) / HAPTICS_FIRED (60). +/// STRAP_DRIVEN_ALARM_EXECUTED (57) / HAPTICS_FIRED (60) — but on gen4 event +/// 56 is emitted for this form even though it never fires (see above): only +/// 57/60 prove execution. /// /// [index] is the alarm slot, and the usable range is per-generation (see -/// [_checkAlarmId]): gen4 is 0..6 and DEFAULTS to 0, the slot a real WHOOP 4 -/// was verified to fire from; gen5 is 1..6, because there RUN_ALARM and -/// DISABLE_ALARM reject 0 and an alarm in slot 0 is un-runnable and -/// un-cancellable. Omit [index] to get the right default for the profile. +/// [_checkAlarmId]): gen4 is 0..6 and DEFAULTS to 0; gen5 is 1..6, because +/// there RUN_ALARM and DISABLE_ALARM reject 0 and an alarm in slot 0 is +/// un-runnable and un-cancellable. Omit [index] to get the right default for +/// the profile. /// /// GENERATION DIFFERENCE — the trailing byte: /// • gen4 reads 12 haptic bytes (payload 20). Hardware-verified; unchanged. diff --git a/test/gen5_command_surface_test.dart b/test/gen5_command_surface_test.dart index ca632c6..8dc5f9b 100644 --- a/test/gen5_command_surface_test.dart +++ b/test/gen5_command_surface_test.dart @@ -199,7 +199,7 @@ void main() { group('alarms', () { final when = DateTime.fromMillisecondsSinceEpoch(0x12345678 * 1000); - test('gen4 keeps the hardware-verified 12-byte haptic block (20-byte body)', + test('the gen4 rich body keeps its 12-byte haptic block (20-byte body)', () { final body = _body(cmdSetAlarm(1, when)); // [0x04][index][u32 sec][u16 subsec][12 haptic] = 20, padded to 21. @@ -241,14 +241,45 @@ void main() { expect(_body(cmdSetAlarm(1, when, profile: gen5), profile: gen5)[1], 1); }); - test('gen4 keeps slot 0 — it is the slot a WHOOP 4 fires from', () { - // Verified on hardware. gen5's 1..6 rule must not be applied here: it - // would reject the only alarm path we have seen actually work. + test('gen4 rich-form slots stay 0..6 with 0 the default', () { + // gen5's 1..6 rule must not leak here — the gen4 firmware accepts and + // stores slot 0. (The form that verifiably FIRES on gen4 is the rev-1 + // body below, which has no slot byte at all.) expect(_body(cmdSetAlarm(1, when, index: 0))[1], 0); expect(_body(cmdSetAlarm(1, when))[1], 0); expect(() => cmdSetAlarm(1, when, index: 7), throwsArgumentError); }); + test('the rev-1 body is 9 bytes: time + the haptic-mode u16', () { + // [0x01][u32 sec LE][u16 subsec][u16 haptic-mode]; inner is /4-aligned + // already, so no pad byte reaches the body. + final body = _body(cmdSetAlarmRev1(1, when)); + expect(body, [0x01, 0x78, 0x56, 0x34, 0x12, 0, 0, 0, 0]); + // The framed command carries exactly the exported bare payload — app + // layers with their own framing consume [alarmRev1Payload] directly. + expect(body, alarmRev1Payload(when)); + // Sub-seconds are 1/32768ths, exactly like SET_CLOCK: 500 ms = 0x4000. + final half = when.add(const Duration(milliseconds: 500)); + expect(_body(cmdSetAlarmRev1(1, half)).sublist(5, 7), [0x00, 0x40]); + }); + + test('rev-1 pins the official app\'s wire capture (issue #32)', () { + // btsnoop of the official app arming a real WHOOP 4.0: epoch 1781912880 + // (0x6A35D530), subsec 0, haptic-mode 0 — the form the band executes. + final capture = DateTime.fromMillisecondsSinceEpoch(1781912880 * 1000); + expect(_body(cmdSetAlarmRev1(1, capture)), + [0x01, 0x30, 0xD5, 0x35, 0x6A, 0x00, 0x00, 0x00, 0x00]); + }); + + test('rev-1 haptic-mode is a u16 and encodes LE', () { + expect(() => cmdSetAlarmRev1(1, when, hapticMode: -1), + throwsArgumentError); + expect(() => cmdSetAlarmRev1(1, when, hapticMode: 0x10000), + throwsArgumentError); + expect(_body(cmdSetAlarmRev1(1, when, hapticMode: 0x0102)).sublist(7), + [0x02, 0x01]); + }); + test('gen5 RUN_ALARM needs revision 2 plus an id', () { expect(() => cmdRunAlarm(1, profile: gen5), throwsArgumentError); expect(() => cmdRunAlarm(1, mode: 0, profile: gen5), throwsArgumentError); From cc757dd1cdb5a121ee199f26d8e8660d098c952d Mon Sep 17 00:00:00 2001 From: Pablodvs Date: Sat, 22 Aug 2026 18:40:58 +0000 Subject: [PATCH 2/2] alarm docs: scope execution claims to observed firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-band evidence (issue #32 + OpenStrap/edge#265) splits by firmware: on fw 41.17.4 the rich body latches without executing and rev-1 fires; at least one other 4.0 executes rich. So "never executes", "the short form fails", and "(months of sync logs, 8+ arms)" overclaimed — every claim now names its firmware, and the short form is stated for what it is: the rev-1 frame byte-for-byte once padded, at haptic-mode 0. The event-id doc loses "NEVER EMITTED (58/59)" and "only 56 and 57 arrive": 58 follows RUN_ALARM and 59/60 arrived with every observed firing. --- lib/src/commands.dart | 71 +++++++++++++++-------------- lib/src/constants.dart | 12 ++--- test/gen5_command_surface_test.dart | 7 +-- 3 files changed, 47 insertions(+), 43 deletions(-) diff --git a/lib/src/commands.dart b/lib/src/commands.dart index 2ffffbf..dc06fb0 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -281,22 +281,23 @@ Uint8List cmdBuzz(int seq, [int pattern = hapticShortPulse]) { // The alarm has THREE known on-wire forms: // • a 7-byte SHORT form ([cmdSetAlarmSimple]) — time only, no haptic-mode; // • a 9-byte REV-1 form ([cmdSetAlarmRev1]) — time + a haptic-mode u16. -// This is what the official WHOOP app sends (btsnoop capture), and the -// only form observed to actually EXECUTE on a real WHOOP 4.0; and +// This is what the official WHOOP app sends (btsnoop capture); and // • a 20-byte RICH form ([cmdSetAlarm]) — time + slot + a haptic waveform. // -// ⚠ On gen4 the RICH form is a trap: the firmware stores it, echoes it back -// from GET_ALARM_TIME, and confirms it with STRAP_DRIVEN_ALARM_SET (56) — -// but its scheduler never executes it. Extended observation of a real 4.0 -// (months of sync logs, 8+ arms) showed zero STRAP_DRIVEN_ALARM_EXECUTED -// (57) and no haptics at any armed target, while the same band armed with -// the REV-1 form fired autonomously at the armed second (events 60 + 57 + -// the one-shot auto-disable 59). Full evidence: issue #32. A GET_ALARM -// readback match therefore proves only that a body was STORED, not that it -// will fire. The SHORT form fails for the same reason it always did — it is -// the REV-1 form minus the trailing haptic-mode u16. +// ⚠ Which form a WHOOP 4 EXECUTES is firmware-dependent (evidence: issue +// #32 + OpenStrap/edge#265). On fw 41.17.4 the RICH form latches — event 56 +// STRAP_DRIVEN_ALARM_SET, armed GET_ALARM readback — but the scheduler never +// executes it (three controlled arms, zero event 57), while the REV-1 form +// fired autonomously at the armed second (events 60 + 57 + the one-shot +// auto-disable 59). At least one other 4.0 (fw not yet reported) DOES +// execute the rich form. A latch confirmation therefore proves only that a +// body was STORED, not that it will fire. The SHORT form is the REV-1 form +// minus the trailing haptic-mode u16 — at haptic-mode 0 the two pad to +// byte-identical frames, so there is no separate short-form behaviour on +// the wire. // -// For a gen4 wake alarm, use [cmdSetAlarmRev1]. +// For a gen4 wake alarm, use [cmdSetAlarmRev1] — the official app's form, +// not observed to fail on any firmware. // // gen5: SET_ALARM_TIME(66)/DISABLE_ALARM(69) are opcode-identical across // generations (§1.4), so [cmdSetAlarm]/[cmdSetAlarmSimple]/[cmdDisableAlarm]/ @@ -333,8 +334,8 @@ int _alarmSubsec(DateTime when) => /// /// gen4 is NOT the same and must not inherit that rule: 0 is the default slot /// the rich form encodes there, and the firmware accepts it. (Note the gen4 -/// form that verifiably FIRES — [cmdSetAlarmRev1] — carries no slot byte at -/// all; slot ids only exist in the rich/gen5 encodings.) +/// arm form — [cmdSetAlarmRev1] — carries no slot byte at all; slot ids only +/// exist in the rich/gen5 encodings.) int _checkAlarmId(int id, String name, BandProfile profile) { final lo = profile.isGen5 ? 1 : 0; if (id < lo || id > 6) { @@ -353,11 +354,11 @@ int _defaultAlarmId(BandProfile profile) => profile.isGen5 ? 1 : 0; /// - epoch seconds — `when` as a unix epoch, u32 LE. /// - sub-seconds — `(millis % 1000) * 32768 ~/ 1000`, u16 LE (1/32768 s units). /// -/// ⚠ On real hardware the strap ACKs this form yet never buzzes. It is -/// [cmdSetAlarmRev1] minus the trailing haptic-mode u16, and that missing -/// field — not the missing waveform pattern — is why it arms silently. Use -/// [cmdSetAlarmRev1] to arm a firing gen4 alarm; this is kept for parity / -/// diagnostics only. +/// It is [cmdSetAlarmRev1] minus the trailing haptic-mode u16 — and frames +/// are zero-padded to a multiple of 4, so at haptic-mode 0 the two build +/// byte-identical wire frames: there is no separate short-form behaviour to +/// claim. Prefer [cmdSetAlarmRev1], which carries the field explicitly; this +/// is kept for parity / diagnostics only. Uint8List cmdSetAlarmSimple(int seq, DateTime when, {BandProfile profile = BandProfile.gen4}) { final sec = _alarmEpochSec(when); @@ -374,9 +375,9 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when, return buildCommand(seq, Cmd.setAlarmTime, p, profile); } -/// REV-1 alarm form (SET_ALARM_TIME = 0x42) — the form a WHOOP 4.0 actually -/// EXECUTES, and the one the official app sends (btsnoop-captured; the wire -/// vector is pinned in the tests). +/// REV-1 alarm form (SET_ALARM_TIME = 0x42) — the official app's arm form +/// (btsnoop-captured; the wire vector is pinned in the tests), verified to +/// fire on fw 41.17.4. /// /// Payload = 9 bytes: /// `[0x01][u32 epoch-seconds LE][u16 sub-seconds LE][u16 haptic-mode LE]`. @@ -387,12 +388,13 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when, /// modes are accepted on the wire but unexplored — keep the default unless /// you are experimenting. /// -/// Hardware-verified on a real 4.0: armed with this form the band fired -/// autonomously at the armed second — HAPTICS_FIRED (60), +/// Hardware-verified on a real 4.0 (fw 41.17.4): armed with this form the +/// band fired autonomously at the armed second — HAPTICS_FIRED (60), /// STRAP_DRIVEN_ALARM_EXECUTED (57), then the one-shot auto-disable (59), all -/// stamped at the target epoch — with no phone connected. The 20-byte rich -/// form ([cmdSetAlarm]) latches and confirms identically but was never seen -/// to execute there; see issue #32 for the full evidence. +/// stamped at the target epoch — with no phone connected. On that firmware +/// the 20-byte rich form ([cmdSetAlarm]) latches and confirms identically +/// but never executed, while at least one other 4.0 does execute it; see +/// issue #32 and OpenStrap/edge#265 for the evidence. /// /// Note the confirmation lifecycle: ALARM_SET (56) and the fired events /// arrive via the band's HISTORY stream on the next sync, not live, and 56 @@ -433,9 +435,10 @@ List alarmRev1Payload(DateTime when, {int hapticMode = 0}) { /// RICH alarm form (SET_ALARM_TIME = 0x42). /// -/// ⚠ gen4: this form is STORED but NEVER EXECUTED — the band echoes it from -/// GET_ALARM_TIME and confirms it with event 56 exactly like a live arm, yet -/// the scheduler never fires it (issue #32). For a gen4 wake alarm use +/// ⚠ gen4: whether this form EXECUTES is firmware-dependent — on fw 41.17.4 +/// it is stored and confirmed (event 56) exactly like a live arm yet the +/// scheduler never fires it, while at least one other 4.0 executes it +/// (issue #32, OpenStrap/edge#265). For a gen4 wake alarm use /// [cmdSetAlarmRev1]. On gen5 this rich body (plus the crescendo byte) is the /// only known arm form, still hardware-unverified for actually waking. /// @@ -458,9 +461,9 @@ List alarmRev1Payload(DateTime when, {int hapticMode = 0}) { /// [hapticPattern] defaults to [kDefaultAlarmHaptics]; pass your own 12 bytes /// to customise. The strap confirms the alarm latched via the /// STRAP_DRIVEN_ALARM_SET (56) event and its firing via -/// STRAP_DRIVEN_ALARM_EXECUTED (57) / HAPTICS_FIRED (60) — but on gen4 event -/// 56 is emitted for this form even though it never fires (see above): only -/// 57/60 prove execution. +/// STRAP_DRIVEN_ALARM_EXECUTED (57) / HAPTICS_FIRED (60) — but event 56 is +/// emitted at latch time whether or not the firmware will execute the form +/// (see above): only 57/60 prove execution. /// /// [index] is the alarm slot, and the usable range is per-generation (see /// [_checkAlarmId]): gen4 is 0..6 and DEFAULTS to 0; gen5 is 1..6, because diff --git a/lib/src/constants.dart b/lib/src/constants.dart index e38263f..71c9487 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -417,14 +417,14 @@ class EventId { static const int rawDataCollectionOff = 47; // Alarm lifecycle events — how the strap tells us the alarm latched and fired. // "strap-driven" = the strap's own scheduled alarm; "app-driven" = one we - // triggered over BLE. These 56–59 events are the confirmation that our - // SET_ALARM_TIME write actually took (the older short form never emitted them). - // Only 56 and 57 actually arrive. + // triggered over BLE. 56 confirms a SET_ALARM_TIME write latched (latch + // only — not that the body will execute, see cmdSetAlarm); 57 + 60 with + // the one-shot auto-disable 59 report a scheduled alarm firing, and 58 + // follows an app-triggered RUN_ALARM — all observed on fw 41.17.4 (issue + // #32). They arrive via the history stream on the next sync, not live, so + // nothing may block waiting on any of them. static const int strapDrivenAlarmSet = 56; static const int strapDrivenAlarmExecuted = 57; - // ⚠ NEVER EMITTED (58/59). An app-triggered alarm run and an alarm being - // disabled are not reported as events — confirm those from the command - // response instead. Nothing may block waiting on either of these. static const int appDrivenAlarmExecuted = 58; static const int strapDrivenAlarmDisabled = 59; static const int hapticsFired = 60; diff --git a/test/gen5_command_surface_test.dart b/test/gen5_command_surface_test.dart index 8dc5f9b..047e730 100644 --- a/test/gen5_command_surface_test.dart +++ b/test/gen5_command_surface_test.dart @@ -243,8 +243,8 @@ void main() { test('gen4 rich-form slots stay 0..6 with 0 the default', () { // gen5's 1..6 rule must not leak here — the gen4 firmware accepts and - // stores slot 0. (The form that verifiably FIRES on gen4 is the rev-1 - // body below, which has no slot byte at all.) + // stores slot 0. (The gen4 arm form — the rev-1 body below — has no + // slot byte at all.) expect(_body(cmdSetAlarm(1, when, index: 0))[1], 0); expect(_body(cmdSetAlarm(1, when))[1], 0); expect(() => cmdSetAlarm(1, when, index: 7), throwsArgumentError); @@ -265,7 +265,8 @@ void main() { test('rev-1 pins the official app\'s wire capture (issue #32)', () { // btsnoop of the official app arming a real WHOOP 4.0: epoch 1781912880 - // (0x6A35D530), subsec 0, haptic-mode 0 — the form the band executes. + // (0x6A35D530), subsec 0, haptic-mode 0. The same shape fired on fw + // 41.17.4 (issue #32). final capture = DateTime.fromMillisecondsSinceEpoch(1781912880 * 1000); expect(_body(cmdSetAlarmRev1(1, capture)), [0x01, 0x30, 0xD5, 0x35, 0x6A, 0x00, 0x00, 0x00, 0x00]);