Skip to content
Merged
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
59 changes: 44 additions & 15 deletions lib/ble/ble_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,17 @@ bool burstPacketCountMatches({
}) =>
expectedPacketCount == actualBurstPacketCount + droppedThisBurst;

/// IMU_SET_DATA_STREAM (0x6A) body — gen4 is a bare on/off byte; gen5 requires
/// a leading [revision1] (fw 50.40.1.0 console: `Invalid rev (0) for
/// WSBLE_CMD_IMU_SET_DATA_STREAM` when body is `[0x01]` / `[0x00]` alone).
/// Same revision role as optical. Without this the live IMU stream never arms
/// (gen4: 0x33; gen5 Maverick: 0x2B rec 0x15), so step calibration / live
/// workout steps stay 0.
@visibleForTesting
List<int> imuModePayload(bool on, {required bool isGen5}) => isGen5
? <int>[revision1, on ? 0x01 : 0x00]
: <int>[on ? 0x01 : 0x00];

/// Fired for every LIVE high-rate frame (0x28/0x2B/0x33). These are EPHEMERAL —
/// they are NOT persisted to raw_records (that bloated storage ~50x and stalled
/// derivation). The caller routes them to an in-memory sink for the live UI /
Expand Down Expand Up @@ -1347,8 +1358,14 @@ class BleEngine {
// Re-arm ONLY what the current live mode wants: re-sending the high-rate
// R10/R11 toggle while in HR-only mode (background downgrade) or under the
// marginal-radio fallback would silently undo the downgrade every 30 s.
// gen5: 0x3F is Unknown/Unhandled — re-arm IMU instead when full live.
final isGen5 = _session?.band.isGen5 ?? false;
if (!_liveHrOnly && !state.standardHrFallback) {
_send(Cmd.sendR10R11Realtime, const [0x01]);
if (isGen5) {
_send(Cmd.toggleImuMode, imuModePayload(true, isGen5: true));
} else {
_send(Cmd.sendR10R11Realtime, const [0x01]);
}
}
_send(Cmd.toggleRealtimeHr, const [0x01]);
}
Expand Down Expand Up @@ -3057,6 +3074,7 @@ class BleEngine {
_liveHrOnly = false;
_armTime =
DateTime.now(); // marginal-radio detector measures arm→drop latency
final isGen5 = _session?.band.isGen5 ?? false;
await _send(Cmd.toggleRealtimeHr, const [0x01]);
// MARGINAL-RADIO FALLBACK: a weak radio can't sustain the high-rate R10/R11 +
// IMU + optical flood, so once the detector trips we arm HR only.
Expand All @@ -3065,12 +3083,19 @@ class BleEngine {
return;
}
await Future.delayed(const Duration(milliseconds: 100));
await _send(Cmd.sendR10R11Realtime, const [0x01]);
await Future.delayed(const Duration(milliseconds: 100));
await _send(Cmd.toggleImuMode, const [0x01]);
// gen5 console: 0x3F (R10/R11 realtime) is Unknown/Unhandled — skip it.
// Live steps ride toggleImuMode (gen5: 0x2B rec 0x15; gen4: 0x33).
if (!isGen5) {
await _send(Cmd.sendR10R11Realtime, const [0x01]);
await Future.delayed(const Duration(milliseconds: 100));
}
await _send(Cmd.toggleImuMode, imuModePayload(true, isGen5: isGen5));
await Future.delayed(const Duration(milliseconds: 100));
await _send(Cmd.enableOpticalData, const [revision1, 0x01]);
_log('Live streams enabled (optical: wrist-gated).');
_log(
'Live streams enabled (optical: wrist-gated'
'${isGen5 ? "; gen5 IMU rev1" : ""}).',
);
}

/// Clear the sticky standard-HR fallback and give the full live set another
Expand Down Expand Up @@ -3102,6 +3127,7 @@ class BleEngine {
if (_session?.connected != true) return;
_liveEnabled = true;
_liveHrOnly = true;
final isGen5 = _session?.band.isGen5 ?? false;
await _send(Cmd.toggleRealtimeHr, const [0x01]);
final offOps = <List<dynamic>>[
[
Expand All @@ -3112,13 +3138,14 @@ class BleEngine {
Cmd.enableOpticalData,
[revision1, 0x00],
],
[
Cmd.sendR10R11Realtime,
[0x00],
],
if (!isGen5)
[
Cmd.sendR10R11Realtime,
[0x00],
],
[
Cmd.toggleImuMode,
[0x00],
imuModePayload(false, isGen5: isGen5),
],
];
for (final op in offOps) {
Expand All @@ -3130,6 +3157,7 @@ class BleEngine {

/// Turn everything off. Safe + idempotent. Clears flags back to wrist-gated.
Future<void> disableLiveStreams() async {
final isGen5 = _session?.band.isGen5 ?? false;
final ops = <List<dynamic>>[
[
Cmd.toggleOpticalMode,
Expand All @@ -3139,13 +3167,14 @@ class BleEngine {
Cmd.enableOpticalData,
[revision1, 0x00],
],
[
Cmd.sendR10R11Realtime,
[0x00],
],
if (!isGen5)
[
Cmd.sendR10R11Realtime,
[0x00],
],
[
Cmd.toggleImuMode,
[0x00],
imuModePayload(false, isGen5: isGen5),
],
[
Cmd.toggleRealtimeHr,
Expand Down
73 changes: 73 additions & 0 deletions lib/ble/gen5_live_imu.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Gen5 (WHOOP 5 / Maverick) live IMU decode.
//
// Hardware evidence (fw 50.40.1.0 HCI snoop, 2026-08-05): after toggleImuMode
// with [revision1, 0x01] the strap emits gen5-framed **0x2B** inners of
// **1232 bytes**, not top-level 0x33:
// [0]=0x2B [1]=0x15 … [13]=0x04 [14..15]=100 LE (sample count)
// [16..17]=100 LE (rate) [18..19]=3 LE (axes)
// [20 .. 20+600)=100 planar XYZ int16 LE @ 100 Hz, scale 1/4096 g
//
// Gen4 `frameAccel` only accepts 0x33 (≥84 B / 10 samples) or R10 (rec 0x0A
// @685 B). Every gen5 live IMU frame abstained → step calibration stayed 0
// despite console `IMU data stream enabled`.

import 'dart:math' as math;
import 'dart:typed_data';

import 'package:openstrap_protocol/openstrap_protocol.dart';

/// Gen5 live 0x2B subtype byte[1] seen on every Maverick IMU frame.
const int kGen5LiveImuRec = 0x15;

/// Minimum inner length: header through 100×3×int16 accel planes.
const int kGen5LiveImuMinLen = 620;

/// Samples per gen5 live IMU accel block (matches u16 @ offset 14).
const int kGen5LiveImuSamples = 100;

/// Accel planar XYZ starts here (after 0x04 / count / rate / axes sub-header).
const int kGen5LiveImuAccelOffset = 20;

Uint8List? _bytes(String hex) {
try {
return hexToBytes(hex);
} catch (_) {
return null;
}
}

/// Decode a gen5 Maverick live 0x2B IMU inner, or null if not that layout.
ImuFrame? frameAccelGen5Live(String hex) {
final b = _bytes(hex);
if (b == null || b.length < kGen5LiveImuMinLen) return null;
if (b[0] != PacketType.realtimeRawData || b[1] != kGen5LiveImuRec) {
return null;
}
// Sample-count u16 LE @14 must be 100 — rejects other 0x2B shapes.
final count = b[14] | (b[15] << 8);
if (count != kGen5LiveImuSamples) return null;

final view = b.buffer.asByteData(b.offsetInBytes, b.lengthInBytes);
const n = kGen5LiveImuSamples;
const start = kGen5LiveImuAccelOffset;
final xs = <double>[];
final ys = <double>[];
final zs = <double>[];
final mags = <double>[];
for (var i = 0; i < n; i++) {
final x = view.getInt16(start + 2 * i, Endian.little).toDouble();
final y = view.getInt16(start + 2 * (n + i), Endian.little).toDouble();
final z = view.getInt16(start + 2 * (2 * n + i), Endian.little).toDouble();
xs.add(x);
ys.add(y);
zs.add(z);
mags.add(math.sqrt(x * x + y * y + z * z) / 4096.0);
}
// Already 100 Hz — no upsample. ts=1: gen5 header has no reliable unix@4;
// live ingest uses wall time for coverage; callers reject ts<=0.
return ImuFrame(1, 0, mags, xs, ys, zs);
}

/// Gen5 Maverick live 0x2B first; else gen4 `frameAccel` (0x33 / R10).
ImuFrame? frameAccelForBand(String hex) =>
frameAccelGen5Live(hex) ?? frameAccel(hex);
17 changes: 10 additions & 7 deletions lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import '../ble/accessory_setup.dart';
import '../ble/android_background.dart';
import '../ble/ble_engine.dart';
import '../ble/ble_state.dart' show AlarmConfirmation, AlarmEffect;
import '../ble/gen5_live_imu.dart';
import '../ble/ios_ble_restore.dart';
import '../cloud/companion_client.dart';
import '../compute/derivation_engine.dart';
Expand Down Expand Up @@ -1722,12 +1723,11 @@ class AppState extends ChangeNotifier {
if (breathingActive && (pt == 0x28 || pt == 0x2B)) {
if (_breathingFrames.length < 8000) _breathingFrames.add(hex);
}
// LIVE STEP COUNTER. The dedicated 0x33 IMU stream is the high-rate live
// accel — it arrives ~10 frames/s (10 samples each), so it drives a smooth,
// responsive count. Full R10 (0x2B) is only a fallback when the IMU stream
// isn't flowing (and live 0x2B is often R10-LITE, which carries no accel).
// `frameAccel` returns |a|(g) samples for both; once 0x33 is seen we ignore
// 0x2B to avoid double-counting the same motion from two stream formats.
// LIVE STEP COUNTER. Gen4: dedicated 0x33 IMU (~10 frames/s × 10 samples)
// is preferred; full R10 (0x2B) is only a fallback when 0x33 isn't flowing.
// Gen5 Maverick: live IMU is 0x2B (rec 0x15, 100 Hz planar) — see
// gen5_live_imu.dart. Once gen4 0x33 is seen we ignore 0x2B to avoid
// double-counting the same motion from two stream formats.
if (pt == 0x33) {
_imuStreamSeen = true;
final f = _safeFrameAccel(hex);
Expand All @@ -1736,6 +1736,7 @@ class AppState extends ChangeNotifier {
_trackCoverage(recTs);
}
} else if (pt == 0x2B && !_imuStreamSeen) {
// Gen5 Maverick live IMU is 0x2B (100 Hz planar), not top-level 0x33.
final f = _safeFrameAccel(hex);
if (f != null) {
_ingestLiveMags(f);
Expand All @@ -1746,7 +1747,9 @@ class AppState extends ChangeNotifier {

proto.ImuFrame? _safeFrameAccel(String hex) {
try {
return proto.frameAccel(hex);
// Gen5 Maverick live IMU is 0x2B; gen4 stays on frameAccel (0x33 / R10).
// See gen5_live_imu.dart — gen5 path abstains unless rec=0x15.
return frameAccelForBand(hex);
} catch (_) {
return null;
}
Expand Down
14 changes: 14 additions & 0 deletions test/gen5_imu_mode_payload_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:openstrap_edge/ble/ble_engine.dart';
import 'package:openstrap_protocol/openstrap_protocol.dart';

void main() {
group('imuModePayload', () {
test('gen4 stays bare on/off; gen5 prepends revision1', () {
expect(imuModePayload(true, isGen5: false), [0x01]);
expect(imuModePayload(false, isGen5: false), [0x00]);
expect(imuModePayload(true, isGen5: true), [revision1, 0x01]);
expect(imuModePayload(false, isGen5: true), [revision1, 0x00]);
});
});
}
64 changes: 64 additions & 0 deletions test/gen5_live_imu_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:openstrap_edge/ble/gen5_live_imu.dart';
import 'package:openstrap_protocol/openstrap_protocol.dart';

void main() {
// Real Maverick 0x2B live IMU inner from HCI snoop (fw 50.40.1.0, 2026-08-05).
const captured = '2b15800ccef901a011736a140e046400640003004f00470046004a004600400041004e005500510066005400550053005200450041004200480050004d0044004200550054004f0053005000410034002c003e0047004f004b00470051004a005c004f0044005700530048004300420042004f00520063005200530060005c00510049003a00470052005c003d003b0044004a004b00610062005c0051005a00500048003800460037005300440051004f004d004f00590051005b005200590049004c0041003e004e005d005f005f00500043003a004d0041005900090c0e0c0f0c0f0c170c060cef0bf90bf50bf50bf00bea0bec0bff0b170c150c1e0c130c0d0c0f0c030c000cfc0b0a0c030cef0bf20bf50bf80bf70b010c0b0c120c160c160c1e0c180c070cf20bec0be80be00bf90bf80b070c080c090c070c040c0b0c0e0c080cfe0b070cff0bf80bf70beb0be70b080c180c150c030c140c120c020cef0be80be80bf10bf60b0e0c190c140c0d0c100cf80bf00bf00bf90b090c0e0c080cf90b060cfe0b0d0cff0bfc0bff0b0a0c080c000cf30bef0bf50b030c080c020cf70b350a390a370a400a380a4e0a3c0a340a370a350a330a3c0a440a470a490a3e0a310a430a390a370a2c0a360a3a0a370a370a3b0a280a2b0a290a2a0a3d0a530a3b0a320a3a0a310a3c0a380a330a380a3a0a3c0a410a430a4a0a370a390a2e0a310a2d0a430a3a0a380a420a350a430a500a380a320a300a420a3e0a470a490a3c0a330a220a2a0a3f0a480a420a3c0a4d0a3e0a300a450a350a2e0a450a450a3a0a4f0a430a2e0a390a3e0a320a430a4f0a4d0a340a420a2d0a3a0a3d0a3e0a510a430a420a3f0a6400640005020000000000000600070008000400fafff5fff5fff5fff6fff9ff000008000d001500140012000900060003000100ffff010000000200fdfffdffffff02000200040009000a000d000c000700fcfff2fff0fff3fff6fff9ff00000600090008000b000900030002000000fafffafffbfffcfffeff030003000a000c0013000d000100fbfffafffafffbfffcfffeff00000100040009000b0009000200fefff9fff9fffdff0100020000000000fffffffffbfffdfffdff000004000600060004000000feffffffffff020002000600fefffeff000003000400fefffcff0100fefffefffeff000006000a000c000f000a0008000400040004000300fdfff8fffafffaffffff080009000b000300fdfffbfffdfffcfffcfffbff0000030006000400fefffdff030001000000fefffefff9fffcfffefffcff01000400080007000000f7fffdff04000800fdfff6fffafffffffeffffff040001000200070007000500ffffffff0000ffff0100fdfff9fffcfffdfffcfffdff0000ffff010005000400fdfffbfffcff00000400030007000200fdfffcff040007000500070009000300fdfffaff0000ffff0100030005000700070002000100fcfffafffcfffdff0000020005000500030002000000020000000300090009000700060006000200fefffbffffff03000300060009000800080005000700090006000600070004000100feffffffffff0000ffff05000a000a0005000300060003000000ffff0200050005000700040003000400ffffffff000002000600070005000200040005000400050004000500050005000300040000000200feff0100020003000000ffff';

// Gen4 R10 from protocol decode_parity_cases.json (rec=0x0A).
const gen4R10 = '2b0a29a7f6f001568de201706880545401570000000000000000000031fe00ee0000000000000000000050a1ba3d71a548bfb8bed4bd4811303f0000e44571a548bfb8bed4bd4811303f350266022d03670203016272f3a5f3ccf3b8f3bef311f4ebf3c4f372f34df344f344f339f318f3f1f2f0f217f334f33af363f35cf327f315f32ef361f3a3f3cbf3cdf3baf3baf3d1f3cef3d5f3baf393f39ff395f3ebf281f2f8f2b7f2e9f2d1f36bf3e0f346f45af48af462f450f443f432f4fcf3b7f3a8f3a3f384f343f327f329f307f3fbf203f3daf2b8f2d3f2baf280f288f2aef2d6f2eff205f324f348f392f3f6f328f43af44ff42cf4e5f3bef3a8f38af34ef33df34af357f35df34af336f350f34ef353f349f344f322f366f384f3e2fad5fb48fdfdfd55fed6fe85ff74ff05ffccfe78fe82fed3fe0bff54ffb0ffc3ff2dff83fe46fe85fe2bffc6ffb1ff99fe53fdf5fcadfddafeb1ffd7ff6bffe7fe79fe66fef9fed3ffc8006701170107ffd9fe88fe3dfdd3fc9ffc4bfd1bfe62fe72fe26feeefde5fd9ffd99fd9bfdabfdcdfd4cfee5fe3dff65ffa0ffa3ff78ffc6ffcaff66ff26ff41ff81ffa8ffa0ff4cff7efe5ffd4bfcf2fb36fd6bffc5009b006eff47fe7efd67fdebfd8afed5fec4fe8ffed5fe46ff60ffe9fe72fe84fe82fe44fe6ffdc00a0f0b020b290b180bb90a6e0a6d0a7e0a490a0b0acc095e0951094509660988099b09ac09df09e009a7096e0955098d09190ace0a740bd70be10bae0b570be80a9f0a4a0ae7097a09c40880072607f1078d08d4094c0b810c500d900d700d200d580c8a0bf40a470a260ad909ce09c509b609b509b60987095709110906090d09e908a308670841082f0835087208d0084d09f909e40af10bbf0cfd0cb90c330c770bb40a100aa3097d09700971098109d009210a380a090a080aef09030af209080af709fe090501624c004401f1011602d30172010901a80075007700a200e90047019201b2019e0161011a01e700dd00eb00c8005000abff29ff15ff83ff2100830075000f009bff59ff55ff74ff87ff63fffdfe71fe2efe53fe77fefafeb6ff8c005a01e5010602a101f5006400efffc0ffb8ffc5ffd1ffe9ff2100570070006700480029000600ceff8dff62ff59ff78ffadffdcffeaffd4ffaaff8effa7ff06009e0026014101bd00b8ffb4fe2cfe45fecbfe5fffbcffdaffd9fff1ff1f002c000b00ddffb8ff8fff7cff5fff63ff16000e0012001e001400fefff8ff04000d000300faffedffdcffd3ffd0ffcdffccffcfffceffcfffd4ffd0ffc9ffcaffcdffd2ffdefff2ff08001b002f00400048004c00450032001b00ebff77ff04ffd9fe9bfeb7fe05ff4aff9efff7ff4b009000bf00d700df00e800d700b9009f0087006e0058004d00420035002e003c003f00350028000e00eaffbdff92ff6cff50ff41ff41ff55ff83ffb9ffe2ff0500300051005c005400410028000600edffe1ffe1ffe8ffe4ffe7fff8ff070012001a0016000b0006000400cfffaaffcbff02001e001900260043004f004b003e001c00ffffedffd2ffc1ffc7ffceffcbffc7ffc5ffc7ffd1ffe7fffeff0800f9ffdeffd1ffdbffedfff5fffcff0600080004000100f3ffbaffefff0a00c0ffc0ffb4ff9fff79ff69ff88ffaeffdaff070030004e0063007e009e00b300b1009b008c00840079006d006a0047001200f5ffe9ffcdffacff97ff88ff7dff7bff82ff95ffabffa7ff8fff9dffd9ff1e004c00540047003300230020001f0016000300e2ffd1ffd6ffd9ffcbffb8ffc4ffeaff000100000003040000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000320400000500000100000320000000000002200000000000f9fffffff1fffffffefffffffcfffffffdfffffffffffffffefffffff4fffffff1fffffff0fffffffdfffffff6fffffff9fffffff5fffffff9fffffff8fffffff2fffffff4fffffff7fffffffafffffff0fffffff6fffffffdfffffff2fffffffdfffffffbfffffff3fffffffcfffffffdfffffff6fffffff9fffffffdfffffffafffffffcfffffffdfffffffafffffffcfffffff9fffffffdfffffff1fffffff6fffffff5fffffff9fffffff4fffffff1fffffff4fffffff6fffffff9fffffffefffffff6ffffff03000000f9fffffff8fffffffefffffffaffffff020000000a000000ffffffff010000000400000002000000ffffffff0500000007000000080000000200000004000000fffffffffefffffffffffffffbfffffffdffffff07000000fcfffffffefffffffffffffffbffffff00000000040000000c000000f5ffffff000000000500000009000000faffffff03000000ffffffff0100000003000000ffffffff04000000fcffffff0700000007000000f7ffffff0200000008000000030000000a00000001000000315e0100';

// Minimal valid gen4 0x33 IMU stream (10 samples, ts>0, |a|≈1 g on Z).
const gen4Imu33 = '3300000000f1536500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010001000100010001000100010001000100010';

group('frameAccelGen5Live', () {
test('decodes 0x2B Maverick IMU at 100 Hz near 1 g', () {
expect(captured.length ~/ 2, 1232);
final f = frameAccelGen5Live(captured);
expect(f, isNotNull);
expect(f!.mags, hasLength(kGen5LiveImuSamples));
expect(f.xs, hasLength(kGen5LiveImuSamples));
final avg = f.mags.reduce((a, b) => a + b) / f.mags.length;
expect(avg, greaterThan(0.7));
expect(avg, lessThan(1.4));
});

test('gen4 frameAccel abstains — gen5 path must win', () {
expect(frameAccel(captured), isNull);
final band = frameAccelForBand(captured)!;
final gen5 = frameAccelGen5Live(captured)!;
expect(band.mags, gen5.mags);
});

test('abstains on gen4-shaped short 0x33', () {
expect(frameAccelGen5Live('33${'00' * 80}'), isNull);
});
});

group('WHOOP 4 unchanged via frameAccelForBand', () {
test('gen5 decoder abstains on gen4 0x33; band path == frameAccel', () {
expect(frameAccelGen5Live(gen4Imu33), isNull);
final legacy = frameAccel(gen4Imu33);
final band = frameAccelForBand(gen4Imu33);
expect(legacy, isNotNull);
expect(band, isNotNull);
expect(band!.mags, legacy!.mags);
expect(band.ts, legacy.ts);
expect(band.mags, hasLength(10));
});

test('gen5 decoder abstains on gen4 R10 (rec 0x0A); band path == frameAccel', () {
expect(gen4R10.substring(0, 4).toLowerCase(), '2b0a');
expect(frameAccelGen5Live(gen4R10), isNull,
reason: 'gen4 R10 is rec=0x0A; gen5 gate requires rec=0x15');
final legacy = frameAccel(gen4R10);
final band = frameAccelForBand(gen4R10);
expect(legacy, isNotNull);
expect(band, isNotNull);
expect(band!.mags, legacy!.mags);
expect(band.ts, legacy.ts);
expect(band.mags, hasLength(100));
});
});
}