From 9b21e36700d4d730dacc7492501b8e8eae65dac6 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:18:07 +0200 Subject: [PATCH 1/2] fix(gen5): arm wake alarm with official WHOOP slot index 1 WHOOP 5 rejects rich SET_ALARM at slot 0 (`arm info is invalid, 0xb`); HCI capture of the official app uses index 1 and emits event 56. Gen5 test buzz uses Maverick 0x13 (RUN_ALARM is a no-op on-wrist). Gen4 path unchanged. Profile sheet now rebuilds on confirmation flags. --- lib/ble/ble_engine.dart | 82 ++++++++++++++++++++---------- lib/ble/ble_state.dart | 35 +++++++++++-- lib/state/app_state.dart | 16 +++--- lib/ui/profile/profile_screen.dart | 17 +++++-- test/alarm_test.dart | 23 +++++++++ 5 files changed, 129 insertions(+), 44 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 774cb451..eb397aaa 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2924,47 +2924,60 @@ class BleEngine { ); /// On-device wake alarm (SET_ALARM_TIME = 0x42) — the RICH 20-byte form that - /// actually FIRES on WHOOP 4.0: + /// actually FIRES: /// ``` /// [0] 0x04 rich-form marker - /// [1] u8 index alarm slot (default 0) + /// [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]) /// ``` - /// The short 7-byte time-only form ([setAlarmSimple]) is accepted and ACKed by - /// the band but carries no waveform, so the strap never buzzes it — our earlier - /// short-form attempts silently failed for exactly this reason. The strap - /// confirms the alarm latched via event 56 (STRAP_DRIVEN_ALARM_SET) and reports - /// firing via events 57/58 + 60. Byte layout lives in the pure [AlarmPayloads]. - /// Returns whether the arm write actually reached the band, so the caller can - /// avoid persisting / confirming a phantom alarm on a failed write. - Future setAlarm( + /// WHOOP 5 requires slot index 1 (official-app HCI capture): 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. + /// + /// Returns the wall-clock instant armed, or null if the write failed (so the + /// caller does not persist a phantom alarm). + Future setAlarm( DateTime when, { int index = 0, List? haptics, }) async { + final isGen5 = _session?.band.isGen5 ?? false; + if (isGen5) { + // Official WHOOP app SET_CLOCKs before SET_ALARM; refresh RTC drift first. + await setClock(); + await Future.delayed(const Duration(milliseconds: 120)); + } // Arm in the STRAP's RTC frame. The strap fires the wake alarm autonomously // 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 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, 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]. final ref = _clockRef; final driftSec = ref?.driftSec ?? 0; final armWhen = AlarmPayloads.toStrapFrame(when, driftSec); - final ok = await _send( - Cmd.setAlarmTime, - AlarmPayloads.rich(armWhen, index: index, haptics: haptics), + final payload = AlarmPayloads.setPayloadForBand( + armWhen, + isGen5: isGen5, + index: index, + haptics: haptics, ); - _log('SET_ALARM_TIME (rich 20B) → wallSec=${when.millisecondsSinceEpoch ~/ 1000} ' - 'strapSec=${armWhen.millisecondsSinceEpoch ~/ 1000} drift=${driftSec}s ' - 'correlated=${ref != null} subsec=${AlarmPayloads.subsecOf(armWhen)} ' - 'write=${ok ? 'ok' : 'FAILED'}'); - return ok; + final ok = await _send(Cmd.setAlarmTime, payload); + _log( + 'SET_ALARM_TIME (${isGen5 ? "gen5 rich index1" : "rich"} ${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} ' + 'write=${ok ? 'ok' : 'FAILED'}', + ); + return ok ? when : null; } /// Time-only alarm (SET_ALARM_TIME = 0x42), SHORT 7-byte form: @@ -2978,10 +2991,23 @@ class BleEngine { Future getAlarm() => _send(Cmd.getAlarmTime, const [revision1]); - /// Fire the alarm haptics IMMEDIATELY (RUN_ALARM = 0x44), payload `[0x01]`. - /// A "test buzz" so the user can confirm the strap actually fires before - /// trusting the scheduled wake. - Future runAlarm() => _send(Cmd.runAlarm, AlarmPayloads.runNow); + /// Fire the alarm haptics IMMEDIATELY — a "test buzz" so the user can confirm + /// the strap actually fires before trusting the scheduled wake. + /// + /// WHOOP 4: RUN_ALARM (0x44) `[0x01]`. + /// WHOOP 5: RUN_ALARM does not buzz on hardware we tested; use the same + /// Maverick `0x13` short pulse as Find-band. Do NOT STOP_HAPTICS first — + /// on gen5 that can race and swallow the buzz. + Future runAlarm() async { + if (_session?.band.isGen5 ?? false) { + await _send( + Cmd.runHapticPatternMaverick, + AlarmPayloads.gen5MaverickBuzz(), + ); + return; + } + await _send(Cmd.runAlarm, AlarmPayloads.runNow); + } /// Cancel the on-device alarm (DISABLE_ALARM = 0x45), payload `[0x01]`. /// (The earlier `[0x00]` body was ACKed but did not clear the alarm.) @@ -3018,7 +3044,7 @@ class BleEngine { if (_session?.band.isGen5 ?? false) { return _send( Cmd.runHapticPatternMaverick, - const [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 1], + AlarmPayloads.gen5MaverickBuzz(), ); } return _send(Cmd.runHapticsPattern, [pattern, 0, 0, 0, 0]); diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 039dc37c..88f109b2 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -600,9 +600,10 @@ 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 (a haptic waveform + time) is the one -/// that actually FIRES on WHOOP 4.0; the SHORT time-only form is ACKed but never -/// buzzes (no waveform to play). +/// 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 +/// (official-app HCI capture). The SHORT time-only form is ACKed but never +/// buzzes (no waveform to play). Prefer [setPayloadForBand] for arming. class AlarmPayloads { /// The strap's stock 12-byte wake-buzz haptic pattern: /// [0..7] eight waveform-effect slots (two active: 47, 152; six idle) @@ -642,7 +643,7 @@ class AlarmPayloads { } /// SHORT 7-byte time-only SET_ALARM_TIME payload (ACKs but does NOT fire): - /// `[0x01][u32 epoch-sec LE][u16 subsec LE]`. + /// `[0x01][u32 epoch-sec LE][u16 subsec LE]`. Prefer [setPayloadForBand]. static List simple(DateTime when) { final ms = when.millisecondsSinceEpoch; final sec = ms ~/ 1000; @@ -658,6 +659,32 @@ class AlarmPayloads { ]; } + /// Generation-correct SET_ALARM_TIME body (rich 20-byte firing form). + /// + /// WHOOP 4: slot index 0 (HW-verified). WHOOP 5: slot **index 1** — captured + /// from the official WHOOP Android app on fw 50.40.1.0. 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. + static List setPayloadForBand( + DateTime when, { + required bool isGen5, + int index = 0, + List? haptics, + }) => + rich( + when, + index: isGen5 ? 1 : index, + haptics: haptics, + ); + + /// Gen5 Maverick test-buzz body (RUN_HAPTIC_PATTERN_MAVERICK = 0x13). + /// Same `[47, 152]` waveform pair as Find-band. Keep [overallLoop] at 1 for + /// a short pulse — the wake-alarm's loop=7 feels like a stuck vibrate. + static List gen5MaverickBuzz({int overallLoop = 1}) { + final loop = overallLoop.clamp(0, 0xff); + return [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, loop]; + } + /// RUN_ALARM (0x44) body — fire the haptics immediately ("test buzz"). static const List runNow = [0x01]; diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index d4465cd4..2f7b81b3 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2374,20 +2374,18 @@ class AppState extends ChangeNotifier { Future setAlarm(DateTime when) async { if (!isConnected) throw Exception('Connect to your strap first'); - final epoch = - when.millisecondsSinceEpoch ~/ 1000; // local wall-clock → unix // Pass the DateTime through so the engine computes REAL sub-seconds for the // rich 20-byte firing form (a hardcoded 0 subsec would still fire, but the - // engine owns the exact on-wire layout). - final ok = await engine.setAlarm(when); - if (!ok) { - // The arm write never reached the band — do NOT persist or start the - // confirmation machine, or we'd strand a phantom alarm "waiting for the - // strap to confirm" that can never fire. Surface it so the UI reflects - // "couldn't send" (the coach/profile callers snackbar on a throw). + // engine owns the exact on-wire layout). Persist the wall instant the + // engine reports armed (null = write never reached the band). + final armed = await engine.setAlarm(when); + if (armed == null) { + // Do NOT persist or start the confirmation machine, or we'd strand a + // phantom alarm "waiting for the strap to confirm" that can never fire. _log('[alarm] arm write FAILED — not persisting; alarm not set.'); throw Exception('Alarm not sent — the strap did not accept the write'); } + final epoch = armed.millisecondsSinceEpoch ~/ 1000; _savedAlarm = epoch; device.alarmEpoch = epoch; // optimistic display _alarm.set(epoch, DateTime.now().millisecondsSinceEpoch); // await event 56 diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index c80297c9..0bdded69 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -1357,9 +1357,20 @@ class _DeviceSheet extends StatelessWidget { // blanket watch() before; select the fields actually used instead. (Prior // pass here missed `device`/`paired` — re-audited against every `live.` // touchpoint in this class after finding the same gap cost a real bug in - // the main ProfileScreen build above.) - context.select( - (a) => (a.isConnected, a.alarmEpoch, a.strapName, a.device, a.paired), + // the main ProfileScreen build above.) Also select confirmation flags: + // omitting them left the caption stuck on "Setting alarm…" after grace. + context.select( + (a) => ( + a.isConnected, + a.alarmEpoch, + a.strapName, + a.device, + a.paired, + a.alarmConfirmed, + a.alarmPending, + a.alarmUnconfirmed, + ), ); final live = context.read(); final connected = live.isConnected; diff --git a/test/alarm_test.dart b/test/alarm_test.dart index 215ca97b..815fbf84 100644 --- a/test/alarm_test.dart +++ b/test/alarm_test.dart @@ -59,6 +59,29 @@ void main() { expect(p, [0x01, 0x04, 0x03, 0x02, 0x01, 0x00, 0x40]); }); + test('setPayloadForBand: gen4 index0 rich, gen5 index1 rich', () { + 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); + expect(g5.length, 20); + expect(g5[0], 0x04); + expect(g5[1], 0x01); // official WHOOP app slot + expect(g5.sublist(8), AlarmPayloads.defaultHaptics); + // Gen5 ignores a caller-supplied index so slot 0 cannot be armed by accident. + expect( + AlarmPayloads.setPayloadForBand(when, isGen5: true, index: 0)[1], + 0x01, + ); + }); + + test('gen5 Maverick buzz is a short Find-band-style pulse', () { + expect(AlarmPayloads.gen5MaverickBuzz(), + [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 1]); + expect(AlarmPayloads.gen5MaverickBuzz(overallLoop: 7).last, 7); + }); + test('RUN_ALARM + DISABLE_ALARM bodies are both [0x01]', () { expect(AlarmPayloads.runNow, [0x01]); expect(AlarmPayloads.disable, [0x01]); From 584e3a076413bc4086b4fcb572296cc0720833e1 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:22:16 +0200 Subject: [PATCH 2/2] fix(gen5): cast Maverick buzz loop clamp back to int int.clamp returns num; toInt() keeps the List payload type-safe. --- lib/ble/ble_state.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 88f109b2..09072762 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -681,7 +681,8 @@ class AlarmPayloads { /// Same `[47, 152]` waveform pair as Find-band. Keep [overallLoop] at 1 for /// a short pulse — the wake-alarm's loop=7 feels like a stuck vibrate. static List gen5MaverickBuzz({int overallLoop = 1}) { - final loop = overallLoop.clamp(0, 0xff); + // `& 0xff` keeps an int (unlike `clamp`, which widens to num). + final loop = overallLoop.clamp(0, 0xff).toInt(); return [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, loop]; }