Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 54 additions & 28 deletions lib/ble/ble_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> 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<DateTime?> setAlarm(
DateTime when, {
int index = 0,
List<int>? 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:
Expand All @@ -2978,10 +2991,23 @@ class BleEngine {

Future<void> 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<void> 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<void> 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.)
Expand Down Expand Up @@ -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]);
Expand Down
36 changes: 32 additions & 4 deletions lib/ble/ble_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<int> simple(DateTime when) {
final ms = when.millisecondsSinceEpoch;
final sec = ms ~/ 1000;
Expand All @@ -658,6 +659,33 @@ 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<int> setPayloadForBand(
DateTime when, {
required bool isGen5,
int index = 0,
List<int>? 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<int> gen5MaverickBuzz({int overallLoop = 1}) {
// `& 0xff` keeps an int (unlike `clamp`, which widens to num).
final loop = overallLoop.clamp(0, 0xff).toInt();
return <int>[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<int> runNow = <int>[0x01];

Expand Down
16 changes: 7 additions & 9 deletions lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2374,20 +2374,18 @@ class AppState extends ChangeNotifier {

Future<void> 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
Expand Down
17 changes: 14 additions & 3 deletions lib/ui/profile/profile_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState, (bool, int?, String?, dynamic, dynamic)>(
(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<AppState,
(bool, int?, String?, dynamic, dynamic, bool, bool, bool)>(
(a) => (
a.isConnected,
a.alarmEpoch,
a.strapName,
a.device,
a.paired,
a.alarmConfirmed,
a.alarmPending,
a.alarmUnconfirmed,
),
);
final live = context.read<AppState>();
final connected = live.isConnected;
Expand Down
23 changes: 23 additions & 0 deletions test/alarm_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ void main() {
expect(p, <int>[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(),
<int>[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, <int>[0x01]);
expect(AlarmPayloads.disable, <int>[0x01]);
Expand Down