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..dc06fb0 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -278,15 +278,26 @@ 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 +// • a 20-byte RICH form ([cmdSetAlarm]) — time + slot + a haptic waveform. +// +// ⚠ 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] — 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]/ @@ -321,9 +332,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 +/// 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) { @@ -332,7 +344,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 +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). /// -/// ⚠ 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. +/// 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); @@ -361,7 +375,72 @@ 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 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]`. +/// - `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 (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. 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 +/// 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: 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. /// /// Payload = 20 bytes: /// ``` @@ -379,18 +458,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 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, 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/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 ca632c6..047e730 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,46 @@ 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 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); }); + 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 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]); + }); + + 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);