From 7bbbe8c2ce1b38c9a4f63a11e3ddd26b49c4f191 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 19 Jul 2026 12:09:52 +0530 Subject: [PATCH 01/25] feat(edge): detect + speak WHOOP 5 (gen5) alongside WHOOP 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan filters both service UUIDs (gen4 6108xxxx / gen5 fd4bxxxx); at discovery the session pins its generation and rebuilds the frame reassemblers with the matching header shape. The BandProfile is threaded through the frame path, the command builder, and the history-result ACK (the safe-trim token echo). Adds the gen5 handshake branch (client-hello + empty-payload offload) and routes gen5 records through parseGen5Record; unknown/motion kinds fall through to raw_archive as before. The WHOOP 4 path is unchanged. The gen5 connect/handshake path is not yet validated on physical hardware (marked in-code) — pending a WHOOP 5 band. --- lib/ble/ble_engine.dart | 91 +++++++++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index c828a3b2..5e27da6e 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -280,11 +280,27 @@ class _SessionGapSummary { class _Session { final BluetoothDevice device; BluetoothCharacteristic? cmdTo; + + /// Which WHOOP generation this link speaks. Defaults to gen4 (WHOOP 4) and is + /// pinned once during service discovery via [applyBand] — everything that + /// differs by generation (frame header/CRC, GATT UUIDs, command envelope, + /// history ACK, record decode) reads from here. + BandProfile band = BandProfile.gen4; + final Map asm = { 'cmd_from': FrameReassembler(), 'events': FrameReassembler(), 'data': FrameReassembler(), }; + + /// Pin this session's generation and rebuild the reassemblers with the + /// matching header shape. Called once, at discovery, before any frame is fed. + void applyBand(BandProfile b) { + band = b; + asm['cmd_from'] = FrameReassembler(profile: b); + asm['events'] = FrameReassembler(profile: b); + asm['data'] = FrameReassembler(profile: b); + } final List subs = []; Timer? heartbeat; // Session-owned timers; a disconnect cancels them. @@ -868,7 +884,10 @@ class BleEngine { await FlutterBluePlus.stopScan(); } _setPhase(BleConnState.scanning); - final svc = Guid(GattUuids.service); + // Advertise-filter on BOTH generations' service UUIDs (gen4 6108xxxx + + // gen5 fd4bxxxx); the actual generation is pinned later at discovery. + final gen4Svc = Guid(GattProfile.gen4.service); + final gen5Svc = Guid(GattProfile.gen5.service); BluetoothDevice? found; final sub = FlutterBluePlus.onScanResults.listen((results) { for (final r in results) { @@ -878,14 +897,16 @@ class BleEngine { ); if (found == null && (name.contains('whoop') || - advNames.any((s) => s.startsWith('61080001')))) { + advNames.any((s) => + s.startsWith('61080001') || s.startsWith('fd4b0001')))) { found = r.device; FlutterBluePlus.stopScan(); } } }); try { - await FlutterBluePlus.startScan(withServices: [svc], timeout: timeout); + await FlutterBluePlus.startScan( + withServices: [gen4Svc, gen5Svc], timeout: timeout); await FlutterBluePlus.isScanning.where((on) => on == false).first; } catch (e) { _log('scan error: $e'); @@ -1028,16 +1049,33 @@ class BleEngine { final services = await device .discoverServices() .timeout(_serviceDiscoveryTimeout); + // Pin the generation from whichever service the peripheral exposes: + // gen4 "Harvard" 6108xxxx, or gen5 "fd4b" fd4bxxxx. This drives the frame + // header/CRC, command envelope, ACK, and record decode for the session. BluetoothService? svc; + BandProfile band = BandProfile.gen4; for (final s in services) { - if (s.uuid.str.toLowerCase().startsWith('61080001')) svc = s; + final u = s.uuid.str.toLowerCase(); + if (u.startsWith(GattProfile.gen4.servicePrefix)) { + svc = s; + band = BandProfile.gen4; + break; + } + if (u.startsWith(GattProfile.gen5.servicePrefix)) { + svc = s; + band = BandProfile.gen5; + break; + } } if (svc == null) { - _log('Harvard service not found on device.'); + _log('No WHOOP service (gen4 6108xxxx / gen5 fd4bxxxx) found on device.'); await _teardownSession(intentional: true); _setPhase(BleConnState.idle); return false; } + session.applyBand(band); + _log('Detected ${band.isGen5 ? "WHOOP 5 (gen5)" : "WHOOP 4 (gen4)"} link.'); + final gatt = band.gatt; BluetoothCharacteristic? find(String prefix) { for (final c in svc!.characteristics) { if (c.uuid.str.toLowerCase().startsWith(prefix)) return c; @@ -1045,15 +1083,15 @@ class BleEngine { return null; } - session.cmdTo = find('61080002'); - final cmdFrom = find('61080003'); - final events = find('61080004'); - final data = find('61080005'); + session.cmdTo = find(gatt.cmdTo.substring(0, 8)); + final cmdFrom = find(gatt.cmdFrom.substring(0, 8)); + final events = find(gatt.events.substring(0, 8)); + final data = find(gatt.data.substring(0, 8)); if (session.cmdTo == null || cmdFrom == null || events == null || data == null) { - _log('Missing one or more Harvard characteristics.'); + _log('Missing one or more ${band.isGen5 ? "fd4b" : "Harvard"} characteristics.'); await _teardownSession(intentional: true); _setPhase(BleConnState.idle); return false; @@ -1455,7 +1493,8 @@ class BleEngine { _log('REFUSED dangerous opcode 0x${opcode.toRadixString(16)}'); return; } - final frame = buildCommand(_seq.nextLive(), opcode, payload); + final frame = buildCommand( + _seq.nextLive(), opcode, payload, _session?.band ?? BandProfile.gen4); await _write(frame); } @@ -1685,7 +1724,16 @@ class BleEngine { // backfill (all received in one sync) splits into correct per-real-day // buckets instead of collapsing into one "today". Sample? sample; - if (recType == Record.r24 || recType == Record.r12) { + final isGen5 = _session?.band.isGen5 ?? false; + if (isGen5) { + // gen5 (WHOOP 5) thin 1 Hz record: HR + timing only. Motion (K10/K21) and + // any unknown kind return null → archived to raw_archive below, exactly + // like an undecodable gen4 record. No accel/spo2/temp is fabricated. + final r = parseGen5Record(frame.inner); + if (r != null) { + sample = Sample(tsEpoch: r.tsEpoch, counter: r.counter, hr: r.hr); + } + } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. var decodeTarget = frame.inner; @@ -2093,7 +2141,8 @@ class BleEngine { // re-delivers the chunk. Echo the 8-byte slice the band acks verbatim — // a mangled echo is the "Groundhog Day" re-flood bug. await d.commit(m.token); // raw + samples + strap_trim cursor, atomic - final ack = buildHistoryResultOk(_seq.nextSync(), m.token!); + final ack = buildHistoryResultOk(_seq.nextSync(), m.token!, + profile: _session?.band ?? BandProfile.gen4); _log( '[SYNC] ACK frame=' '${ack.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', @@ -2328,6 +2377,22 @@ class BleEngine { // ── high-level flows ───────────────────────────────────────────────────────────── Future sendInit() async { + final band = _session?.band ?? BandProfile.gen4; + if (band.isGen5) { + // gen5 handshake: a single CLIENT_HELLO (GET_HELLO 0x91) written + // with-response opens the just-works bond, then the offload is driven by + // GET_DATA_RANGE + SEND_HISTORICAL_DATA with EMPTY payloads (gen4 sends a + // 0x00). The HISTORY_END ACK is byte-structured identically (handled in + // the metadata path). NOTE: untested on physical hardware — pending a + // WHOOP 5 device; the gen4 path above is unchanged. + _log('Sending gen5 CLIENT_HELLO + offload…'); + await _write(gen5ClientHello()); + await Future.delayed(const Duration(milliseconds: 120)); + await _write(cmdGetDataRangeGen5(_seq.nextSync())); + await Future.delayed(const Duration(milliseconds: 120)); + await _write(cmdSendHistoricalGen5(_seq.nextSync())); + return; + } _log('Sending 5-packet INIT…'); for (final pkt in initPackets) { await _write(pkt); From 67fa1cbcb0f752bab3c2b92d9f03124c6ea8533e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 19 Jul 2026 12:17:17 +0530 Subject: [PATCH 02/25] fix(edge): centralize gen5 offload command format across all trigger paths CodeRabbit review: the initial handshake used gen5 empty-payload GET_DATA_RANGE/SEND_HISTORICAL_DATA, but the periodic backfill, manual refresh, and retry paths still sent the gen4 [0x00] payload (only the frame envelope was band-correct). Extract _sendGetDataRange / _sendHistoricalData helpers that pick the payload by generation (gen4 [0x00], gen5 empty) and route the init, refresh, backfill, and retry paths through them, so the gen5 offload format is identical everywhere. --- lib/ble/ble_engine.dart | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 5e27da6e..2efc771e 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -1306,7 +1306,7 @@ class BleEngine { _setOffloadActive(true); if (refreshRange) { _log('[SYNC] refresh($reason) — polling GET_DATA_RANGE before 0x16.'); - await _send(Cmd.getDataRange, const [0x00]); + await _sendGetDataRange(); // INIT spaces commands by ~120 ms; keep the same cadence here so the band // has time to emit the range response before we request another drain. await Future.delayed(const Duration(milliseconds: 120)); @@ -1324,7 +1324,7 @@ class BleEngine { if (_session?.connected != true) return; } _log('[SYNC] refresh($reason) — sending SEND_HISTORICAL_DATA.'); - await _send(Cmd.sendHistoricalData, const [0x00]); + await _sendHistoricalData(); _lastHistoricalSendAt = _wallSecs(); } @@ -1498,6 +1498,18 @@ class BleEngine { await _write(frame); } + // Offload commands whose PAYLOAD (not just the frame envelope) is + // generation-specific: gen4 sends a single 0x00, gen5 sends an EMPTY payload. + // Centralised so every offload trigger — the initial handshake, periodic + // backfill, manual refresh, and retry — emits the correct gen5 format on a + // gen5 link. (_send already frames with the session's BandProfile.) + List get _offloadPayload => + (_session?.band.isGen5 ?? false) ? const [] : const [0x00]; + Future _sendGetDataRange() => + _send(Cmd.getDataRange, _offloadPayload); + Future _sendHistoricalData() => + _send(Cmd.sendHistoricalData, _offloadPayload); + Future applyHighFreqWakeWindow({ required bool enabled, required DateTime? targetWake, @@ -2388,9 +2400,11 @@ class BleEngine { _log('Sending gen5 CLIENT_HELLO + offload…'); await _write(gen5ClientHello()); await Future.delayed(const Duration(milliseconds: 120)); - await _write(cmdGetDataRangeGen5(_seq.nextSync())); + // Same band-aware helpers the refresh/backfill/retry paths use, so the + // gen5 offload command format is identical everywhere. + await _sendGetDataRange(); await Future.delayed(const Duration(milliseconds: 120)); - await _write(cmdSendHistoricalGen5(_seq.nextSync())); + await _sendHistoricalData(); return; } _log('Sending 5-packet INIT…'); From 360ea72c694e4ad6b8b790e931aacf623640b559 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 19 Jul 2026 12:54:04 +0530 Subject: [PATCH 03/25] =?UTF-8?q?feat(edge):=20make=20WHOOP=205=20(gen5)?= =?UTF-8?q?=20pairable=20=E2=80=94=20iOS=20ASK=20+=20point=20protocol=20at?= =?UTF-8?q?=20gen5=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BLE engine could speak gen5, but a WHOOP 5 band could not be paired on iOS: AccessorySetupKit only advertised the gen4 6108 service, so a fd4b band never appeared in the picker (and with no ASK provisioning the restore central is never created → no connection at all). - AccessorySetup.swift: offer one ASPickerDisplayItem per generation (gen4 6108 + gen5 fd4b) so either band can be provisioned; the provisioned CoreBluetooth identifier is generation-agnostic. - Info.plist: add the gen5 service to NSAccessorySetupBluetoothServices (required for the descriptor criterion). - pubspec.yaml: point openstrap_protocol at feat/multiband-whoop5 for the experimental build (revert to main once protocol#16 merges). Android needs no change: CDM associates by MAC (generation-agnostic) and the Flutter scan is already fd4b-aware. iOS restore reconnects by peripheral identifier, also generation-agnostic. Still hardware-unvalidated end-to-end — pending a physical WHOOP 5 band. --- ios/Runner/AccessorySetup.swift | 51 +++++++++++++++++++-------------- ios/Runner/Info.plist | 1 + pubspec.yaml | 4 ++- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift index e0575385..ee7c0a86 100644 --- a/ios/Runner/AccessorySetup.swift +++ b/ios/Runner/AccessorySetup.swift @@ -26,9 +26,13 @@ import AccessorySetupKit /// - `removeAll` -> nil (deprovision all — used on unpair) enum AccessorySetup { private static let channelName = "openstrap/accessory_setup" - // The WHOOP "Harvard" Gen4 GATT service (matches GattUuids.service in Dart). - // `fileprivate` so the iOS-18 Impl below can read it. - fileprivate static let whoopServiceUUID = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6" + // WHOOP GATT service UUIDs, one per generation (match GattProfile in Dart). + // `fileprivate` so the iOS-18 Impl below can read them. BOTH must also be + // listed in Info.plist under NSAccessorySetupBluetoothServices. + // • gen4 ("Harvard", WHOOP 4) — 6108… + // • gen5 ("fd4b", WHOOP 5) — fd4b… (EXPERIMENTAL) + fileprivate static let whoopServiceUUIDGen4 = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6" + fileprivate static let whoopServiceUUIDGen5 = "fd4b0001-cce1-4033-93ce-002d5875f58a" static func register(messenger: FlutterBinaryMessenger) { let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) @@ -136,30 +140,35 @@ private final class Impl { return } - let descriptor = ASDiscoveryDescriptor() // Match on the WHOOP custom service UUID alone. The foreground scan finds the - // band via startScan(withServices:[thisUUID]) and succeeds, which proves the - // band advertises this service — so it's a reliable, sufficient filter. Every - // descriptor criterion must be declared in Info.plist; the UUID is listed under - // NSAccessorySetupBluetoothServices. (No bluetoothNameSubstring: a single - // descriptor AND-combines its criteria, and a name filter would also require an - // NSAccessorySetupBluetoothNames entry and risk excluding the band on a name - // mismatch.) - descriptor.bluetoothServiceUUID = CBUUID(string: AccessorySetup.whoopServiceUUID) - - // Show the actual strap render in the ASK pairing sheet (asset catalog → - // StrapProduct.imageset). Fall back to an SF Symbol if the asset is missing. + // band via startScan(withServices:[…]) and succeeds, which proves the band + // advertises this service — so it's a reliable, sufficient filter. Every + // descriptor criterion must be declared in Info.plist; the UUIDs are listed + // under NSAccessorySetupBluetoothServices. (No bluetoothNameSubstring: a + // single descriptor AND-combines its criteria, and a name filter would also + // require an NSAccessorySetupBluetoothNames entry and risk excluding the band + // on a name mismatch.) + // + // ASK matches ANY item in the picker list, so we offer one item per WHOOP + // generation: gen4 (WHOOP 4) and gen5 (WHOOP 5, experimental). A band that + // advertises either service can be provisioned; the provisioned identifier is + // the same CoreBluetooth UUID regardless of generation. let productImage = UIImage(named: "StrapProduct") ?? UIImage(systemName: "sensor.tag.radiowave.forward") ?? UIImage() - let item = ASPickerDisplayItem( - name: "WHOOP band", - productImage: productImage, - descriptor: descriptor - ) + func item(_ serviceUUID: String, _ name: String) -> ASPickerDisplayItem { + let descriptor = ASDiscoveryDescriptor() + descriptor.bluetoothServiceUUID = CBUUID(string: serviceUUID) + return ASPickerDisplayItem( + name: name, productImage: productImage, descriptor: descriptor) + } + let items = [ + item(AccessorySetup.whoopServiceUUIDGen4, "WHOOP band"), + item(AccessorySetup.whoopServiceUUIDGen5, "WHOOP 5 band"), + ] pickerResult = completion - session.showPicker(for: [item]) { [weak self] error in + session.showPicker(for: items) { [weak self] error in guard let self = self else { return } if let error = error { if let cb = self.pickerResult { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2fcc9d28..5f4b34b3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -29,6 +29,7 @@ NSAccessorySetupBluetoothServices 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 + FD4B0001-CCE1-4033-93CE-002D5875F58A NSAccessorySetupKitSupports diff --git a/pubspec.yaml b/pubspec.yaml index 5a5ab5b9..99a37e4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,7 +24,9 @@ dependencies: openstrap_protocol: git: url: https://github.com/OpenStrap/protocol.git - ref: main + # EXPERIMENTAL: WHOOP 5 (gen5) multi-band support. Point back to `main` + # once OpenStrap/protocol#16 merges. + ref: feat/multiband-whoop5 openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From 25de96b0c48a2378f0150876477a3b8c5db20e2e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 19 Jul 2026 13:20:01 +0530 Subject: [PATCH 04/25] chore(edge): pin pubspec.lock to protocol feat/multiband-whoop5 Pin the committed lock's openstrap_protocol dependency to the gen5 branch commit (687aa46) so CI/release resolves the exact experimental protocol revision. Only the protocol ref/resolved-ref changed; analytics stays on main. (Locally the gitignored pubspec_overrides.yaml still redirects to ../protocol for dev; the committed lock is what release resolution uses.) --- pubspec.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index d4aac69d..87ec50dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -974,8 +974,8 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: "02fc8e5f2310ded690a522cd9884bf51b4cdc7e1" + ref: "feat/multiband-whoop5" + resolved-ref: "687aa4631809b5e2dcbddb9df66c5aaf32b814f9" url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" From 701b4b491d0f02831f95b6b81ef539b47b2827df Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 11:08:02 +0530 Subject: [PATCH 05/25] repin protocol to the merged sha --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index c207cb2c..8f8b1197 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -973,8 +973,8 @@ packages: dependency: "direct main" description: path: "." - ref: e543e47918d151f9acd2770eb0a84dddadc0387c - resolved-ref: e543e47918d151f9acd2770eb0a84dddadc0387c + ref: "7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e" + resolved-ref: "7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e" url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9c52cfa4..2f2b97f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,7 +41,7 @@ dependencies: # This SHA is that branch merged with protocol main (crc8 length-field # check + hexToBytes odd-length rejection + the framing.dart profile- # aware header-CRC fix so gen5 frames actually pass the same guard). - ref: e543e47918d151f9acd2770eb0a84dddadc0387c + ref: 7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From 9d13f1b0dfa527d4dfc5948a47888ec1f9062f12 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 12:10:16 +0530 Subject: [PATCH 06/25] feat: wire the gen5 session lifecycle through the same BandProfile-gated engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends BleEngine (not a parallel copy) so a gen5 link gets a real, working lifecycle end to end instead of stopping at "connected": - SET_CLOCK/GET_CLOCK now use gen5's own opcodes (SET_CLOCK_MAVERICK/ GET_CLOCK_GEN5) instead of silently sending gen4's — the bug that would have left a gen5 strap's RTC forever unlatched and refusing history. - Historical-record ingestion now calls protocol's real parseGen5Historical (v18/v20/v21/v26) via a new sampleFromGen5Historical mapper, replacing the old parseGen5Record call that targeted gen4's version numbers and would have silently archived every real gen5 record. - Added the opt-in R22 deep-buffer enable sequence (default OFF, gated by a constructor toggle) and a gen5 Maverick haptic buzz path. - _send now also blocks OpcodeSafety.destructive band-agnostically, alongside the existing gen4 dangerousCmds list. - decodeFrame is now called with the session's BandProfile so gen5's direct-percent battery / GET_HELLO shape decode correctly; added a small edge-side augment for GET_CLOCK_GEN5's clock_epoch (protocol doesn't populate it yet) and debug-only logging for gen5 console/hello frames. - DeviceState.generation + a band_generation ledger field surface which WHOOP generation a session/batch came from, with no schema migration (rides the existing sync_ledger meta_json blob). - New gen5_sample_mapping_test.dart covers the v18->Sample mapping against a real byte-verified capture, plus the deep-buffer/null fall-through. gen4 behavior is unchanged (every branch above is band-gated); full suite green (1059 tests, 2 pre-existing skips). Real-hardware validation of the handshake and R22 sequence is still outstanding — see inline doc comments. --- lib/ble/ble_engine.dart | 201 ++++++++++++++++++++++++++--- lib/data/models.dart | 7 + test/gen5_sample_mapping_test.dart | 100 ++++++++++++++ 3 files changed, 292 insertions(+), 16 deletions(-) create mode 100644 test/gen5_sample_mapping_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index acce7d81..774cb451 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -84,6 +84,35 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive); /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); +/// Map a decoded gen5 historical record onto the band-agnostic `Sample` type, +/// or null when this record kind has no `Sample` equivalent (yet). +/// +/// Only `Gen5HistorySample` (v18, the per-second stream) maps today — the +/// deep buffers (`Gen5OpticalBuffer`/`Gen5ImuBuffer`/`Gen5PpgWaveform`, R22 +/// opt-in only) need their own raw-buffer storage, not a 1Hz `Sample`, so +/// they (and a null [g], e.g. an unrecognised version) correctly return null +/// here — the caller archives those, exactly like an undecodable gen4 +/// record. Extracted as a top-level pure function (rather than inlined in +/// `_ingestHistoricalFrame`) so the mapping is unit-testable without a live +/// BLE session — see `gen5_sample_mapping_test.dart`. +@visibleForTesting +Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { + if (g is! Gen5HistorySample) return null; + return Sample( + tsEpoch: g.unix, + counter: g.recordIndex, + hr: g.heartRate, + rrIntervalsMs: List.from(g.rrIntervalsMs), + // Gravity vector is float32 g-units on BOTH generations (unlike skin + // temp / SpO2, which use gen5-specific scales/mechanisms — see + // Gen5HistorySample's field docs) — safe to feed straight into the + // shared ax/ay/az fields analytics already reads band-agnostically. + ax: g.gravityG.isNotEmpty ? g.gravityG[0] : null, + ay: g.gravityG.length > 1 ? g.gravityG[1] : null, + az: g.gravityG.length > 2 ? g.gravityG[2] : null, + ); +} + @visibleForTesting int countHistoricalBurstPackets({ required Map dataPacketCountsByRevision, @@ -370,6 +399,15 @@ class BleEngine { final Duration Function() deriveDataStaleness; final bool Function() isForegroundActive; + /// Opt-in: send the gen5 "R22" 16-flag SET_CONFIG enable sequence + /// (`kGen5R22EnableFlags`) before the historical offload on a gen5 link, + /// unlocking the v20 (optical)/v21 (IMU)/v26 (PPG) deep buffers. Defaults to + /// OFF — the official WHOOP app never sends this either, the sequence is + /// UNTESTED on physical hardware, and without it a gen5 strap still serves + /// its always-on v18 per-second stream perfectly well. Wire a caller-owned + /// settings read here to make it a real user-facing toggle. + final bool Function() gen5DeepBuffersEnabled; + BleEngine({ required this.onRecord, required this.onState, @@ -386,8 +424,11 @@ class BleEngine { this.isBackgroundDrainer = false, this.deriveDataStaleness = _defaultDeriveDataStaleness, this.isForegroundActive = _defaultIsForegroundActive, + this.gen5DeepBuffersEnabled = _defaultGen5DeepBuffersDisabled, }); + static bool _defaultGen5DeepBuffersDisabled() => false; + /// True for the headless restore-drain engine (runHeadlessSync). It YIELDS the /// band to a foreground engine rather than fighting it — see [_claimBand]. The /// foreground app engine leaves this false and always wins. @@ -1146,6 +1187,7 @@ class BleEngine { return false; } session.applyBand(band); + state.generation = band.isGen5 ? 'gen5' : 'gen4'; _log('Detected ${band.isGen5 ? "WHOOP 5 (gen5)" : "WHOOP 4 (gen4)"} link.'); final gatt = band.gatt; BluetoothCharacteristic? find(String prefix) { @@ -1604,7 +1646,17 @@ class BleEngine { } Future _send(int opcode, List payload) async { - if (dangerousCmds.contains(opcode)) { + // `dangerousCmds` is this codebase's own gen4-curated hard-block list + // (FORCE_TRIM/REBOOT/POWER_CYCLE/TOGGLE_PERSISTENT_R21/firmware-load). + // `OpcodeSafety.destructive` is whoop-rs's independently-curated list of + // opcodes with NO legitimate use anywhere in EITHER codebase (142-144 + // have no named meaning at all) — the two don't fully overlap, so both + // apply. Deliberately NOT `OpcodeSafety.forbidden`: that broader list + // also flags opcodes this app sends ON PURPOSE via named, reviewed call + // sites (SET_ADVERTISING_NAME/SELECT_WRIST/SET_CONFIG for the R22 + // sequence/SET_CLOCK_MAVERICK) — see that class's own doc for why a + // blanket block on `forbidden` would be wrong here. + if (dangerousCmds.contains(opcode) || OpcodeSafety.isDestructive(opcode)) { _log('REFUSED dangerous opcode 0x${opcode.toRadixString(16)}'); return false; } @@ -1793,7 +1845,19 @@ class BleEngine { } else if (pt == PacketType.consoleLogs && _offloadActive) { _drain?.onBurstConsole(); } - final decoded = _maybeAugmentDataRange(frame, decodeFrame(frame)); + final band = _session?.band ?? BandProfile.gen4; + final decoded = _maybeAugmentGen5ClockEpoch( + frame, + _maybeAugmentDataRange(frame, decodeFrame(frame, profile: band)), + ); + // gen5-only, debug-visibility ONLY (never persisted, never gated on): + // log the strap's own console text (now decoded by protocol's + // `parseConsoleLog`, wired into `decodeFrame` above). Genuinely useful + // for diagnosing the untested gen5 handshake/offload on real hardware. + if (band.isGen5 && decoded.kind == 'console_log') { + _log('[CONSOLE gen5] idx=${decoded.fields['record_index']} ' + 'ts=${decoded.fields['ts_epoch']}: ${decoded.fields['text']}'); + } _absorbState(decoded); } @@ -1888,13 +1952,14 @@ class BleEngine { Sample? sample; final isGen5 = _session?.band.isGen5 ?? false; if (isGen5) { - // gen5 (WHOOP 5) thin 1 Hz record: HR + timing only. Motion (K10/K21) and - // any unknown kind return null → archived to raw_archive below, exactly - // like an undecodable gen4 record. No accel/spo2/temp is fabricated. - final r = parseGen5Record(frame.inner); - if (r != null) { - sample = Sample(tsEpoch: r.tsEpoch, counter: r.counter, hr: r.hr); - } + // gen5 (WHOOP 5): `parseGen5Historical` dispatches across all four real + // gen5 historical-record kinds (v18 per-second summary, v20 optical/ + // v21 IMU/v26 PPG deep buffers — R22 opt-in only). Only v18 maps onto + // the band-agnostic `Sample` type today; the deep buffers need their + // own raw-buffer storage (a future db table), not a 1Hz Sample, so they + // fall through to the undecodable archive below — that is honest + // (correctly-identified-but-not-yet-stored), not a decode failure. + sample = sampleFromGen5Historical(parseGen5Historical(frame.inner)); } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. @@ -2115,6 +2180,16 @@ class BleEngine { state.wristOn = h.wristOn ?? state.wristOn; onState(state); } + // gen5's GET_HELLO (opcode 145) response shape is unrelated to gen4's + // HelloInfo — it carries a device_name + a gated fw_version instead + // (parseCommandResponse's gen5 GET_HELLO branch). No confirmed serial/ + // battery/wrist-on offsets for it yet, so — unlike gen4's HELLO above — + // this is diagnostics-only for now (confirms the untested gen5 handshake + // actually got a byte-parseable reply) rather than wired into `state`. + if (d.kind == 'cmd_response' && f.containsKey('device_name')) { + _log('[HELLO gen5] device_name=${f['device_name']} ' + 'fw_version=${f['fw_version']}'); + } if (d.kind == 'realtime_hr') { final hr = f['hr'] as int; if (hr > 0) { @@ -2517,6 +2592,11 @@ class BleEngine { 'last_ack_batches': d.batches, 'strap_history_oldest_ts': _strapHistoryOldestTs, 'strap_history_newest_ts': _strapHistoryNewestTs, + // Which WHOOP generation this batch came from — records/sessions + // vary hugely in richness by generation (and, for gen5, by whether + // the R22 deep-buffer opt-in was sent), so downstream diagnostics + // need this without reaching into the transport layer. + 'band_generation': state.generation, }, )); // Same event, but a REAL per-chunk row keyed by the token — closes out @@ -2576,6 +2656,7 @@ class BleEngine { 'history_completions': _historyCompletions, 'strap_history_oldest_ts': _strapHistoryOldestTs, 'strap_history_newest_ts': _strapHistoryNewestTs, + 'band_generation': state.generation, }, )); _log( @@ -2660,6 +2741,35 @@ class BleEngine { String _innerHex(Uint8List inner) => inner.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + /// Send the gen5 "R22" 16-flag SET_CONFIG enable sequence + /// (protocol's `buildR22EnableSequence`/`kGen5R22EnableFlags`), unlocking + /// the v20 (optical)/v21 (IMU)/v26 (PPG) deep-buffer historical records. + /// Sequential, ~40ms apart (same spacing discipline as the gen4 5-packet + /// INIT) — the official WHOOP app never sends this, and neither does + /// OpenStrap unless [gen5DeepBuffersEnabled] opts in (see the constructor + /// doc). UNTESTED on physical hardware. No-op on a gen4 link. + /// + /// Written directly via [_write] (not [_send]) because the pre-built + /// frames already carry their own sequence numbers — going through `_send` + /// would double-allocate from [_seq] for no benefit. SET_FF_VALUE (120) is + /// in `OpcodeSafety.forbidden` but NOT `OpcodeSafety.destructive`; per that + /// class's own doc this deliberate, explicitly-opted-in sequence is exactly + /// the kind of call site the broader `forbidden` list is not meant to gate + /// (see `_send`'s doc for the full reasoning) — writing it directly here + /// keeps that intentional exception in ONE place rather than needing an + /// allowlist parameter threaded through the shared chokepoint. + Future enableGen5DeepBuffers() async { + if (!(_session?.band.isGen5 ?? false)) return; + final frames = buildR22EnableSequence(startSeq: _seq.nextLive()); + _log('Sending gen5 R22 deep-buffer enable sequence (${frames.length} ' + 'flags)…'); + for (final frame in frames) { + await _write(frame); + await Future.delayed(const Duration(milliseconds: 40)); + } + _log('gen5 R22 deep-buffer enable sequence sent.'); + } + // ── high-level flows ───────────────────────────────────────────────────────────── Future sendInit() async { final band = _session?.band ?? BandProfile.gen4; @@ -2673,6 +2783,12 @@ class BleEngine { _log('Sending gen5 CLIENT_HELLO + offload…'); await _write(gen5ClientHello()); await Future.delayed(const Duration(milliseconds: 120)); + // Opt-in deep-buffer sequence, BEFORE the offload trigger (SET_CONFIG + // flags must land before SEND_HISTORICAL_DATA to take effect for this + // drain). Default OFF — see [gen5DeepBuffersEnabled]. + if (gen5DeepBuffersEnabled()) { + await enableGen5DeepBuffers(); + } // Same band-aware helpers the refresh/backfill/retry paths use, so the // gen5 offload command format is identical everywhere. await _sendGetDataRange(); @@ -2784,16 +2900,28 @@ class BleEngine { 0, 0, ]; - await _send(Cmd.setClock, payload); - _log('SET_CLOCK → sec=$sec subsec=$subsec (WHOOP-exact 8B).'); + // gen5 ("Maverick") uses a DIFFERENT opcode for SET_CLOCK than gen4 — the + // 8-byte payload shape is unchanged, only the opcode value differs (see + // Cmd.setClockMaverick's doc in protocol/constants.dart). Sending gen4's + // opcode 0x0A to a gen5 strap here would silently fail to latch the RTC, + // which then refuses to serve type-47 history — the exact symptom fixed. + final isGen5 = _session?.band.isGen5 ?? false; + final opcode = isGen5 ? Cmd.setClockMaverick : Cmd.setClock; + await _send(opcode, payload); + _log('SET_CLOCK${isGen5 ? " (gen5 Maverick)" : ""} → sec=$sec ' + 'subsec=$subsec (WHOOP-exact 8B).'); // Read the RTC back so the GET_CLOCK response handler can confirm it latched // (and re-issue SET_CLOCK if the strap clock is still off — see _onDecoded). await getClock(); } /// Read the strap RTC. The response carries `clock_epoch`, handled where we - /// verify drift and re-correlate the strap-RTC ↔ wall clock. - Future getClock() => _send(Cmd.getClock, const []); + /// verify drift and re-correlate the strap-RTC ↔ wall clock. gen5 uses its + /// own GET_CLOCK opcode (147) — see [setClock]. + Future getClock() => _send( + (_session?.band.isGen5 ?? false) ? Cmd.getClockGen5 : Cmd.getClock, + const [], + ); /// On-device wake alarm (SET_ALARM_TIME = 0x42) — the RICH 20-byte form that /// actually FIRES on WHOOP 4.0: @@ -2876,11 +3004,25 @@ class BleEngine { } Future getBattery() => _send(Cmd.getBatteryLevel, const []); - Future getHello() => _send(Cmd.getHelloHarvard, const [0x00]); + Future getHello() => (_session?.band.isGen5 ?? false) + ? _send(Cmd.getHello, const [0x01]) + : _send(Cmd.getHelloHarvard, const [0x00]); Future buzz() => buzzPattern(hapticShortPulse); - Future buzzPattern(int pattern) => - _send(Cmd.runHapticsPattern, [pattern, 0, 0, 0, 0]); + /// Play a haptic buzz. gen5 ("Maverick") has a DIFFERENT buzz opcode and + /// payload shape than gen4 (`Cmd.runHapticPatternMaverick`, 12-byte body — + /// see `cmdBuzzGen5Maverick` in protocol/commands.dart) — [pattern] is + /// honoured only on gen4; a gen5 link always plays the strap's fixed + /// `[47, 152]` waveform pair (the only Maverick buzz byte-verified so far). + Future buzzPattern(int pattern) { + if (_session?.band.isGen5 ?? false) { + return _send( + Cmd.runHapticPatternMaverick, + const [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 1], + ); + } + return _send(Cmd.runHapticsPattern, [pattern, 0, 0, 0, 0]); + } /// Signal strength of the live link, in dBm (negative; closer to zero is /// stronger). Null whenever there is nothing to measure. @@ -3191,6 +3333,33 @@ class BleEngine { fields['history_newest'] = ts.reduce((a, b) => a > b ? a : b); return Decoded(decoded.kind, fields); } + + /// Patch up `COMMAND_RESPONSE` decodes for GET_CLOCK_GEN5 (147) — the one + /// gen5-exclusive opcode `parseCommandResponse` doesn't populate a + /// `clock_epoch` for yet (its GET_HELLO=145 and GET_BATTERY_LEVEL=26 + /// battery-scale handling are already profile-aware natively, via the + /// `profile:` param passed into `decodeFrame` above). Without this, gen5's + /// SET_CLOCK/GET_CLOCK drift-correlation (`ClockRef`, read in + /// `_absorbState`) would never populate on a gen5 link, since + /// `parseCommandResponse` only recognises gen4's `Cmd.getClock` (0x0B) for + /// that field. Same "edge augments a decode protocol hasn't caught up to + /// yet" pattern as [_maybeAugmentDataRange]. + Decoded _maybeAugmentGen5ClockEpoch(Frame frame, Decoded decoded) { + if (decoded.kind != 'cmd_response') return decoded; + if (decoded.fields['opcode'] != Cmd.getClockGen5) return decoded; + final inner = frame.inner; + final payload = + inner.length > 3 ? Uint8List.sublistView(inner, 3) : Uint8List(0); + final wallNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; + for (var o = 0; o + 4 <= payload.length; o++) { + final v = u32(payload, o); + if (isPlausibleUnix(v, wallNow)) { + final fields = {...decoded.fields, 'clock_epoch': v}; + return Decoded(decoded.kind, fields); + } + } + return decoded; + } } /// Per-connection historical-offload helper. Buffers records per ACK boundary and diff --git a/lib/data/models.dart b/lib/data/models.dart index 6f9882e9..5f95c50c 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -162,6 +162,13 @@ class DeviceState { /// session-relative plausibility gate + the UI's "history available" readout. int? dataRangeOldest; int? dataRangeNewest; + /// Which WHOOP generation this connection is speaking — `'gen4'` or + /// `'gen5'`, set once at service discovery (see `BleEngine._doConnect`'s + /// `session.applyBand`). Null until a link has been established at least + /// once this process. Lets the UI show "WHOOP 5 connected" and gate any + /// gen5-only controls (e.g. a deep-buffer opt-in toggle) without reaching + /// into the transport layer. + String? generation; DeviceState({this.connection = 'disconnected'}); } diff --git a/test/gen5_sample_mapping_test.dart b/test/gen5_sample_mapping_test.dart new file mode 100644 index 00000000..37c611a7 --- /dev/null +++ b/test/gen5_sample_mapping_test.dart @@ -0,0 +1,100 @@ +// Tests for BleEngine's gen5 -> band-agnostic Sample mapping +// (sampleFromGen5Historical) — the seam that turns protocol's typed gen5 +// historical-record decode into the same `Sample` shape gen4 records +// produce, so the derivation pipeline / analytics stay band-agnostic. +// +// The v18 fixture is the same real, independently byte-verified capture used +// by protocol's own gen5_historical_test.dart (CRC16-modbus header + CRC32 +// payload both check out; see that file's header comment for provenance) — +// reused here rather than re-typed, so a transcription slip can't silently +// diverge the two test suites' expectations. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/models.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List hex(String s) { + final clean = s.replaceAll(' ', ''); + final out = Uint8List(clean.length ~/ 2); + for (int i = 0; i < out.length; i++) { + out[i] = int.parse(clean.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} + +void main() { + group('sampleFromGen5Historical — v18 (real fixture)', () { + // "worn" capture, unix=1780916150 — CRC16+CRC32 both verified. Same + // bytes as protocol/test/gen5_historical_test.dart's v18 real fixture. + final frame = hex( + 'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000' + '000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000' + '000000000000f7000901f10b0007010c020c000000000000000000000000000' + '00000000000000000000100656f1e1e0000009d61a7c00000003e862817', + ); + + late Sample? sample; + + setUp(() { + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue, reason: 'both gen5 CRCs must check out'); + sample = sampleFromGen5Historical(parseGen5Historical(parsed.inner)); + }); + + test('maps ts/counter/hr straight through', () { + expect(sample, isNotNull); + expect(sample!.tsEpoch, 1780916150); + expect(sample!.counter, 25443699); + expect(sample!.hr, 102); + }); + + test('maps RR intervals straight through (band-agnostic HRV kernel)', () { + expect(sample!.rrIntervalsMs, [602, 613]); + }); + + test('maps the gravity vector onto ax/ay/az (shared g-units)', () { + expect(sample!.ax, closeTo(-0.7252, 1e-3)); + expect(sample!.ay, closeTo(0.4944, 1e-3)); + expect(sample!.az, closeTo(0.4969, 1e-3)); + }); + + test( + 'does NOT populate skinTempRaw/spo2 — gen5-specific scale/absence', + () { + // See gen5_v18_decode's (now removed, folded into protocol) original + // caution and Gen5HistorySample's field docs: gen5's skin_temp is + // already °C-scaled (raw/100), a DIFFERENT transfer function from + // gen4's per-device affine ADC calibration that `skinTempRaw` feeds — + // reusing that field here would silently corrupt the skin-temp-z + // metric. gen5 v18 has no real dual-wavelength SpO2 at all. + expect(sample!.skinTempRaw, isNull); + expect(sample!.spo2RedRaw, isNull); + expect(sample!.spo2IrRaw, isNull); + }, + ); + }); + + group('sampleFromGen5Historical — non-Sample record kinds', () { + test('a null decode (unrecognised version/garbage) maps to null', () { + expect(sampleFromGen5Historical(null), isNull); + }); + + test('a v21 IMU deep buffer (no Sample equivalent) maps to null', () { + // Synthetic-but-shape-correct v21 buffer: countA/countB both 100 (the + // buffer's actual identity gate, per Gen5V21Decoder — hist_version is + // not trusted for this kind at all). + final inner = Uint8List(kGen5V21InnerLen); + inner[0] = 0x2F; + inner[1] = 21; + final view = inner.buffer.asByteData(); + view.setUint16(16, 100, Endian.little); // countA offset + view.setUint16(622, 100, Endian.little); // countB offset + final decoded = parseGen5Historical(inner); + expect(decoded, isA()); + expect(sampleFromGen5Historical(decoded), isNull); + }); + }); +} From aa187824b4ec9e82058ed6d142991427e2638a62 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 12:14:02 +0530 Subject: [PATCH 07/25] chore: repin protocol to the real gen5 decoders (412ead9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous pin (7f1a2db) predates protocol's real v18/v20/v21/v26 decoders, gen5 clock/haptics/SET_CONFIG opcodes, R22 sequence builder, OpcodeSafety gate, and CONSOLE_LOGS decoder — all of which the previous edge commit's BleEngine changes call directly. Verified with a clean `flutter pub get` (no local path override) against this SHA: analyze and the full test suite (1059 tests) both green. --- pubspec.lock | 4 ++-- pubspec.yaml | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 8f8b1197..61917496 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -973,8 +973,8 @@ packages: dependency: "direct main" description: path: "." - ref: "7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e" - resolved-ref: "7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e" + ref: "412ead9415240591b89164110ad37dcf204f9dcb" + resolved-ref: "412ead9415240591b89164110ad37dcf204f9dcb" url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2f2b97f0..f7ccf1eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,10 +38,16 @@ dependencies: # EXPERIMENTAL: WHOOP 5 (gen5) multi-band support, this branch's whole # point — stays on protocol's feat/multiband-whoop5, NOT main. Pinned to # a SHA (not the floating branch ref) per this repo's own convention. - # This SHA is that branch merged with protocol main (crc8 length-field - # check + hexToBytes odd-length rejection + the framing.dart profile- - # aware header-CRC fix so gen5 frames actually pass the same guard). - ref: 7f1a2dbe27f9c9e1023dce2ca1a2c2baf96a883e + # Moved from 7f1a2db to this SHA for the gen5 session-lifecycle work in + # this same edge branch: replaces parseGen5Record's wrong version set + # ({9,12,24}, gen4's) with real gen5 decoders (parseGen5Historical — + # v18/v20/v21/v26), adds the gen5 clock/haptics/SET_CONFIG opcodes + + # the R22 deep-buffer enable-sequence builder, the band-agnostic + # OpcodeSafety gate, a CONSOLE_LOGS decoder, and profile-aware + # COMMAND_RESPONSE (gen5's direct-percent battery, GET_HELLO shape). + # edge's BleEngine changes in this same commit depend on all of this — + # do NOT roll this pin back without reverting those changes too. + ref: 412ead9415240591b89164110ad37dcf204f9dcb openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From e21bbaf34eeaada435a4fb020aecbdfcb320ba2e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 17:24:32 +0530 Subject: [PATCH 08/25] repin protocol to c525e29 (cross-validation fixes) --- pubspec.lock | 16 ++++++---------- pubspec.yaml | 27 +++++++++++++++++---------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 61917496..7f8f06cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -963,20 +963,16 @@ packages: openstrap_analytics: dependency: "direct main" description: - path: "." - ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294 - resolved-ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294 - url: "https://github.com/OpenStrap/analytics.git" - source: git + path: "../analytics" + relative: true + source: path version: "1.0.0" openstrap_protocol: dependency: "direct main" description: - path: "." - ref: "412ead9415240591b89164110ad37dcf204f9dcb" - resolved-ref: "412ead9415240591b89164110ad37dcf204f9dcb" - url: "https://github.com/OpenStrap/protocol.git" - source: git + path: "../protocol" + relative: true + source: path version: "1.0.0" ota_update: dependency: "direct main" diff --git a/pubspec.yaml b/pubspec.yaml index f7ccf1eb..655029e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,16 +38,23 @@ dependencies: # EXPERIMENTAL: WHOOP 5 (gen5) multi-band support, this branch's whole # point — stays on protocol's feat/multiband-whoop5, NOT main. Pinned to # a SHA (not the floating branch ref) per this repo's own convention. - # Moved from 7f1a2db to this SHA for the gen5 session-lifecycle work in - # this same edge branch: replaces parseGen5Record's wrong version set - # ({9,12,24}, gen4's) with real gen5 decoders (parseGen5Historical — - # v18/v20/v21/v26), adds the gen5 clock/haptics/SET_CONFIG opcodes + - # the R22 deep-buffer enable-sequence builder, the band-agnostic - # OpcodeSafety gate, a CONSOLE_LOGS decoder, and profile-aware - # COMMAND_RESPONSE (gen5's direct-percent battery, GET_HELLO shape). - # edge's BleEngine changes in this same commit depend on all of this — - # do NOT roll this pin back without reverting those changes too. - ref: 412ead9415240591b89164110ad37dcf204f9dcb + # 412ead9: replaces parseGen5Record's wrong version set ({9,12,24}, + # gen4's) with real gen5 decoders (parseGen5Historical — v18/v20/v21/ + # v26), adds the gen5 clock/haptics/SET_CONFIG opcodes + the R22 + # deep-buffer enable-sequence builder, the band-agnostic OpcodeSafety + # gate, a CONSOLE_LOGS decoder, and profile-aware COMMAND_RESPONSE + # (gen5's direct-percent battery, GET_HELLO shape). edge's BleEngine + # changes in this same commit depend on all of this. + # c525e29 (current): an independent cross-validation pass against + # whoop-rs/noop's own real fixtures found and fixed real bugs on top of + # 412ead9 — v26 record_index was reading a u32 (wrong; whoop-rs's real + # consecutive-frame captures prove it's a u16), GET_DATA_RANGE's + # oldest/newest scan accepted a spurious far-future value and an + # off-grid byte offset, activity_class had no validity gate. v20's + # optical-buffer layout is flagged (not fixed) as a genuinely unresolved + # disagreement between the two references — no real v20 hardware + # capture exists anywhere to break the tie. + ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git From 260cd0c8bce0c905e7b8bbaed4bd003d7ca4ecf0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 17:54:41 +0530 Subject: [PATCH 09/25] bump to 0.9.22+53 for the whoop5 experimental release --- ios/Runner.xcodeproj/project.pbxproj | 36 ++++++++++++++-------------- pubspec.lock | 16 ++++++++----- pubspec.yaml | 2 +- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7fa0a740..33d1be80 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -791,9 +791,9 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -809,9 +809,9 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -825,9 +825,9 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -849,7 +849,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -865,7 +865,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; @@ -896,7 +896,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -912,7 +912,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -940,7 +940,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -956,7 +956,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -987,7 +987,7 @@ CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1003,7 +1003,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; @@ -1042,7 +1042,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1058,7 +1058,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1093,7 +1093,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1109,7 +1109,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.21; + MARKETING_VERSION = 0.9.22; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/pubspec.lock b/pubspec.lock index 7f8f06cd..956060cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -963,16 +963,20 @@ packages: openstrap_analytics: dependency: "direct main" description: - path: "../analytics" - relative: true - source: path + path: "." + ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294 + resolved-ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294 + url: "https://github.com/OpenStrap/analytics.git" + source: git version: "1.0.0" openstrap_protocol: dependency: "direct main" description: - path: "../protocol" - relative: true - source: path + path: "." + ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d + resolved-ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d + url: "https://github.com/OpenStrap/protocol.git" + source: git version: "1.0.0" ota_update: dependency: "direct main" diff --git a/pubspec.yaml b/pubspec.yaml index 655029e4..7a1ea4ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: 'none' # Watch App" targets in ios/Runner.xcodeproj/project.pbxproj — they aren't wired # to FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER. See guides/IOS_INSTALLATION.md # "Version Numbers". -version: 0.9.21+52 +version: 0.9.22+53 environment: sdk: ^3.11.4 From 63a87ed8f53aa09d337c4dfe3f64833907f75233 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 20:21:32 +0530 Subject: [PATCH 10/25] show which WHOOP generation is connected on the device tile --- lib/ui/profile/profile_screen.dart | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index f44a3350..c80297c9 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -587,6 +587,11 @@ class ProfileScreen extends StatelessWidget { : '${d.batteryPct!.round()}%${d.charging == true ? ' ⚡' : ''}', wrist: d.wristOn == null ? '—' : (d.wristOn! ? 'On wrist' : 'Off wrist'), serial: d.serial ?? app.paired?.serial ?? '—', + generation: switch (d.generation) { + 'gen5' => 'WHOOP 5 (experimental)', + 'gen4' => 'WHOOP 4', + _ => null, + }, // Manual pull: anything the strap flashed that we don't hold yet, over // the CURRENT connection (no reconnect). Only offered while connected. onSyncNow: conn == 'connected' ? () => app.forceResync() : null, @@ -847,6 +852,11 @@ class DeviceTile extends StatefulWidget { final VoidCallback? onTap; final Future Function()? onSyncNow; + /// Human label for [DeviceState.generation] ('WHOOP 4' / 'WHOOP 5 + /// (experimental)'), or null before a link has been established this + /// process. Purely informational — never gates any behavior here. + final String? generation; + const DeviceTile({ super.key, required this.name, @@ -857,6 +867,7 @@ class DeviceTile extends StatefulWidget { required this.serial, this.onTap, this.onSyncNow, + this.generation, }); @override @@ -911,7 +922,15 @@ class _DeviceTileState extends State { overflow: TextOverflow.ellipsis, ), const SizedBox(height: Sp.x2), - StatusChip(widget.statusText, tone: widget.statusTone), + Row( + children: [ + StatusChip(widget.statusText, tone: widget.statusTone), + if (widget.generation != null) ...[ + const SizedBox(width: Sp.x2), + StatusChip(widget.generation!, tone: ChipTone.neutral), + ], + ], + ), ], ), ), From 82b094c3c35fc6d159857b379e03b304a412b58d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 20:29:38 +0530 Subject: [PATCH 11/25] bump to 0.9.23+54 for the next whoop5 experimental release --- ios/Runner.xcodeproj/project.pbxproj | 36 ++++++++++++++-------------- pubspec.yaml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 33d1be80..2f3ca4f9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -791,9 +791,9 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -809,9 +809,9 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -825,9 +825,9 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -849,7 +849,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -865,7 +865,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; @@ -896,7 +896,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -912,7 +912,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -940,7 +940,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -956,7 +956,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -987,7 +987,7 @@ CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1003,7 +1003,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; @@ -1042,7 +1042,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1058,7 +1058,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1093,7 +1093,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 53; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1109,7 +1109,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.9.22; + MARKETING_VERSION = 0.9.23; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/pubspec.yaml b/pubspec.yaml index 7a1ea4ec..02b52ad6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: 'none' # Watch App" targets in ios/Runner.xcodeproj/project.pbxproj — they aren't wired # to FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER. See guides/IOS_INSTALLATION.md # "Version Numbers". -version: 0.9.22+53 +version: 0.9.23+54 environment: sdk: ^3.11.4 From 72019700dd4f0d0b3fd47fd241abbd1398e407ec Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:19:13 +0200 Subject: [PATCH 12/25] fix(gen5): lenient v18 decode for real WHOOP 5 hardware captures Protocol's strict gravity gate rejected every v18 record in a hardware export (fw 50.40.1.0) while v20/v26 deep buffers dominated offload. Recover HR/RR via a hardware fallback with alternate unix offset and honest absent accel when gravity fails validation. --- lib/ble/ble_engine.dart | 89 +++++++++++++++++++++++- test/gen5_v18_hardware_lenient_test.dart | 84 ++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 test/gen5_v18_hardware_lenient_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 774cb451..232597e5 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -84,6 +84,82 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive); /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); +/// Plausible unix window for gen5 historical records (2020-01-01 .. 2030-01-01). +@visibleForTesting +bool gen5V18UnixPlausible(int unix) => + unix >= 1577836800 && unix < 1893456000; + +/// Read v18 unix from inner bytes. Protocol's shared header uses inner[7:11]; +/// real WHOOP 5 hardware exports (fw 50.40.x) occasionally land unix one byte +/// earlier when the record is padded to 112 B instead of the fixture's 116 B. +@visibleForTesting +int? gen5V18UnixFromInner(Uint8List inner) { + if (inner.length < 11) return null; + final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); + final at7 = view.getUint32(7, Endian.little); + if (gen5V18UnixPlausible(at7)) return at7; + if (inner.length >= 10) { + final at6 = view.getUint32(6, Endian.little); + if (gen5V18UnixPlausible(at6)) return at6; + } + return null; +} + +/// Hardware lenient v18 decode when [parseGen5Historical] returns null because +/// the protocol decoder's gravity/dynamic-accel gates reject real captures +/// (off-wrist / motion-heavy seconds still carry valid HR/RR). Never fabricates +/// gravity — ax/ay/az stay null when the vector fails the magnitude gate. +@visibleForTesting +Sample? sampleFromGen5V18Lenient(Uint8List inner) { + if (inner.length < kGen5V18MinInnerLen || inner[1] != 18) return null; + final unix = gen5V18UnixFromInner(inner); + if (unix == null) return null; + final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); + final counter = view.getUint32(3, Endian.little); + final hr = inner[14]; + if (hr != 0 && (hr < 25 || hr > 230)) return null; + + const minRrMs = 200; + const maxRrMs = 2500; + final declaredRr = inner[15]; + final rr = []; + if (declaredRr <= 4) { + for (int i = 0; i < declaredRr && 16 + 2 * i + 2 <= inner.length; i++) { + final val = view.getInt16(16 + 2 * i, Endian.little); + if (val >= minRrMs && val <= maxRrMs) rr.add(val); + } + } + + double? ax; + double? ay; + double? az; + if (inner.length >= 49) { + final gx = view.getFloat32(37, Endian.little); + final gy = view.getFloat32(41, Endian.little); + final gz = view.getFloat32(45, Endian.little); + if (gx.isFinite && gy.isFinite && gz.isFinite) { + final magSq = gx * gx + gy * gy + gz * gz; + // Same gate as protocol's Gen5V18Decoder — but abstain on ax/ay/az + // instead of rejecting the whole record (HR/RR are independent fields). + if (magSq >= 0.25 && magSq <= 2.25) { + ax = gx; + ay = gy; + az = gz; + } + } + } + + return Sample( + tsEpoch: unix, + counter: counter, + hr: hr, + rrIntervalsMs: rr, + ax: ax, + ay: ay, + az: az, + ); +} + /// Map a decoded gen5 historical record onto the band-agnostic `Sample` type, /// or null when this record kind has no `Sample` equivalent (yet). /// @@ -113,6 +189,17 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { ); } +/// Decode a gen5 historical inner frame to a band-agnostic [Sample], or null. +@visibleForTesting +Sample? decodeGen5HistoricalSample(Uint8List inner) { + final strict = sampleFromGen5Historical(parseGen5Historical(inner)); + if (strict != null) return strict; + if (inner.length > 1 && inner[1] == 18) { + return sampleFromGen5V18Lenient(inner); + } + return null; +} + @visibleForTesting int countHistoricalBurstPackets({ required Map dataPacketCountsByRevision, @@ -1959,7 +2046,7 @@ class BleEngine { // own raw-buffer storage (a future db table), not a 1Hz Sample, so they // fall through to the undecodable archive below — that is honest // (correctly-identified-but-not-yet-stored), not a decode failure. - sample = sampleFromGen5Historical(parseGen5Historical(frame.inner)); + sample = decodeGen5HistoricalSample(frame.inner); } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart new file mode 100644 index 00000000..de7daa0a --- /dev/null +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -0,0 +1,84 @@ +// Pins the WHOOP 5 hardware v18 lenient decode path against real captures +// from openstrap_export_1785863370590.db (fw 50.40.1.0). Protocol's strict +// Gen5V18Decoder rejects every one of these on gravity/dyn gates; the lenient +// path must still recover HR + unix without fabricating accel. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List hex(String s) { + final clean = s.replaceAll(' ', ''); + final out = Uint8List(clean.length ~/ 2); + for (int i = 0; i < out.length; i++) { + out[i] = int.parse(clean.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} + +void main() { + group('gen5V18UnixFromInner — hardware export', () { + final inner = hex( + '2f1280540df701737c6a915c2f004d0000000000000000000021fb608c4d0000' + 'c330ebf63ecd4ad43f854b653e5298863da3017400000000000000000039014401' + '040d6003010c020c3100000000000000000000000000000000000000000000011f' + 'bed68080000000a80372c0000000', + ); + + test('strict protocol decode returns null (gravity gate)', () { + expect(parseGen5Historical(inner), isNull); + }); + + test('unix lands at inner[6] on 112-byte padded captures', () { + expect(gen5V18UnixFromInner(inner), 1786540801); + }); + + test('lenient path recovers HR without fabricating gravity', () { + final sample = sampleFromGen5V18Lenient(inner); + expect(sample, isNotNull); + expect(sample!.tsEpoch, 1786540801); + expect(sample.hr, 77); + expect(sample.ax, isNull); + expect(sample.ay, isNull); + expect(sample.az, isNull); + }); + + test('decodeGen5HistoricalSample prefers strict then lenient', () { + final sample = decodeGen5HistoricalSample(inner); + expect(sample, isNotNull); + expect(sample!.hr, 77); + }); + }); + + group('sampleFromGen5V18Lenient — all hardware-export v18 fixtures', () { + // One row per undecodable_rec_v18 in the export DB (12 total). + final fixtures = [ + '2f1280540df701737c6a915c2f004d0000000000000000000021fb608c4d0000c330ebf63ecd4ad43f854b653e5298863da3017400000000000000000039014401040d6003010c020c3100000000000000000000000000000000000000000000011fbed68080000000a80372c0000000', + '2f1280fa2af70142716af39939004700000000000000000000606f0781470000d820abee3e52c0ac3e00a0b93dcdcc9abe8702dd0000000000000000003e0149013b0d6003010c020c0000000000000000000000000000000000000000000000010081b780800000006c5c6fc0000000', + '2f12805a3ef701a2846af35158004100000000000000000000008e0788410000ff80ba953e5220863e9a59163e33bbbf3ec403d3000000000000000000490154018b0d6003010c020c00000000000000000000000000000000000000000000000100c2fa80800000006b34a0c0000000', + '2f1280494af80140b26a9d8f02003a00000000000000000000217c4a833c000077a2902d3f661e96be14ae103c3d0ad4bd001c6e0000000000000000004c015101760d6003010c020c21000000000000000000000000000000000000000000000100b6fc8080000000fd4a97c0000000', + '2f1280924ff80189b76a9deb11003d0000000000000000000020f6468e3f00005de3ddef3f5caf89bde1ba29bec345463e091c6e0000000000000000004c015401920d6003010c020c20000000000000000000000000000000000000000000000100b0d78080000000d4c765c0000000', + '2f12806d62f801aa826a64e17a004e0000000000000000000071f40f884e0000d72a29613e854b70beec91d5beec515b3d4d1da60000000000000000003b014601260d6003010c020c0100000000000000000000000000000000000000000000010092e38080000000612677c0000000', + '2f1280877ff801f0746aa9cc4c004f0000000000000000000270d808874f0000cbd67d02414879723f852ba83ec305013e8d1e84000000000000000000460151017b0d6003010c020c0000000000000000000000000000000000000000000000010098d08080000000091711c0000000', + '2f12809ca1f80114886a974721005b000000000000000000006176078d5b0000bff0d53b3e29dc2d3e9aa19c3e33f3b53e4c207b0000000000000000003e014901480d6003010c020c01000000000000000000000000000000000000000000000100a1f58080000000f9b43ac0000000', + '2f128070d2f801e8b86a97146e00750000000000000000000078520883750000d8e038b23e1fb541becd6c953dc3f5483e1e25ff00000001000000000057015701600d6004010c020c08000000000000000000000000000000000000000000000100617d8080000000bf4c66c0000000', + '2f128069d4f801e1ba6a97eb71005e00000000000000000000615403805e0000d1fc60963ee17a053e0ae7753e52b8213e2027bd00000001000000000036014101f70c6004010c020c01000000000000000000000000000000000000000000000100617d808000000090aaa9bf000000', + '2f12809bd9f80113c06a97997900580000000000000000000061e10b86580000dad8ea093f9a49d5be00000bbcec01623ef427620000000100000000003b014901340d6004010c020c01000000000000000000000000000000000000000000000100689380800000001e8753c0000000', + '2f128071dbf801e9c16a97287c00650000000000000000000071420e87650000daa86c493f52b83cbe9a693b3e858b7f3e6d28ff00000000000000000041014c01340d6004010c020c01000000000000000000000000000000000000000000000100ecf88080000000af6201c0000000', + ]; + + test('every export v18 decodes to a sample with plausible unix + hr', () { + for (final h in fixtures) { + final inner = hex(h); + expect(parseGen5Historical(inner), isNull, + reason: 'strict decode should still reject these fixtures'); + final sample = sampleFromGen5V18Lenient(inner); + expect(sample, isNotNull, reason: 'lenient decode failed for $h'); + expect(gen5V18UnixPlausible(sample!.tsEpoch), isTrue); + expect(sample.hr == 0 || (sample.hr >= 25 && sample.hr <= 230), isTrue); + } + }); + }); +} From 638c16d75d845e3cc113d6f3f565363a0e338b93 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:15:02 +0200 Subject: [PATCH 13/25] fix(gen5): honest v18 unix + SET_CLOCK revision byte Drop the misaligned unix@6 fallback that could invent timestamps, and prepend revision1 on gen5 SET_CLOCK/GET_CLOCK only (WHOOP 4 path unchanged). Hardware showed Invalid revision when body[0] was the epoch low byte; a later connect with the 9-byte form correlated with drift=0. --- lib/ble/ble_engine.dart | 88 ++++++++++++------- test/gen5_v18_hardware_lenient_test.dart | 103 ++++++++++++----------- 2 files changed, 114 insertions(+), 77 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 232597e5..0d99538e 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -89,26 +89,26 @@ typedef DataStoredSink = void Function(); bool gen5V18UnixPlausible(int unix) => unix >= 1577836800 && unix < 1893456000; -/// Read v18 unix from inner bytes. Protocol's shared header uses inner[7:11]; -/// real WHOOP 5 hardware exports (fw 50.40.x) occasionally land unix one byte -/// earlier when the record is padded to 112 B instead of the fixture's 116 B. +/// Read v18 unix from the protocol shared header at inner[7:11]. +/// +/// Do **not** fall back to a misaligned offset-6 read: that overlaps the +/// record-index high byte and can invent a year-window-plausible timestamp +/// that is not monotonic with `recordIndex` (honesty violation — observed on +/// the fw 50.40.1.0 export where unix@7 was garbage because SET_CLOCK never +/// latched). Absent a plausible unix@7, abstain. @visibleForTesting int? gen5V18UnixFromInner(Uint8List inner) { if (inner.length < 11) return null; final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); final at7 = view.getUint32(7, Endian.little); - if (gen5V18UnixPlausible(at7)) return at7; - if (inner.length >= 10) { - final at6 = view.getUint32(6, Endian.little); - if (gen5V18UnixPlausible(at6)) return at6; - } - return null; + return gen5V18UnixPlausible(at7) ? at7 : null; } /// Hardware lenient v18 decode when [parseGen5Historical] returns null because /// the protocol decoder's gravity/dynamic-accel gates reject real captures /// (off-wrist / motion-heavy seconds still carry valid HR/RR). Never fabricates /// gravity — ax/ay/az stay null when the vector fails the magnitude gate. +/// Never fabricates time — requires a plausible unix@7. @visibleForTesting Sample? sampleFromGen5V18Lenient(Uint8List inner) { if (inner.length < kGen5V18MinInnerLen || inner[1] != 18) return null; @@ -160,6 +160,30 @@ Sample? sampleFromGen5V18Lenient(Uint8List inner) { ); } +/// Build gen5 SET_CLOCK_MAVERICK (0x92) / GET_CLOCK_GEN5 (0x93) payloads. +/// +/// Hardware evidence (fw 50.40.1.0 console): an empty GET_CLOCK body logs +/// `Invalid revision for get clock: 0`, and SET_CLOCK without a leading +/// form/revision byte logs `Invalid revision ` — i.e. the strap +/// treats body[0] as the command revision (same role as HELLO's `[0x01]` / +/// Alec's gen5 `b3`). Prepend [revision1] so the 8-byte time field starts at +/// body[1]. +@visibleForTesting +List gen5SetClockPayload({required int sec, required int subsec}) => [ + revision1, + sec & 0xff, + (sec >> 8) & 0xff, + (sec >> 16) & 0xff, + (sec >> 24) & 0xff, + subsec & 0xff, + (subsec >> 8) & 0xff, + 0, + 0, + ]; + +@visibleForTesting +List gen5GetClockPayload() => const [revision1]; + /// Map a decoded gen5 historical record onto the band-agnostic `Sample` type, /// or null when this record kind has no `Sample` equivalent (yet). /// @@ -2977,26 +3001,26 @@ class BleEngine { final ms = DateTime.now().millisecondsSinceEpoch; final sec = ms ~/ 1000; final subsec = ((ms % 1000) * 32768) ~/ 1000; // 0..32767, 1/32768 s units - final payload = [ - sec & 0xff, - (sec >> 8) & 0xff, - (sec >> 16) & 0xff, - (sec >> 24) & 0xff, - subsec & 0xff, - (subsec >> 8) & 0xff, - 0, - 0, - ]; - // gen5 ("Maverick") uses a DIFFERENT opcode for SET_CLOCK than gen4 — the - // 8-byte payload shape is unchanged, only the opcode value differs (see - // Cmd.setClockMaverick's doc in protocol/constants.dart). Sending gen4's - // opcode 0x0A to a gen5 strap here would silently fail to latch the RTC, - // which then refuses to serve type-47 history — the exact symptom fixed. + // gen5 ("Maverick") uses a DIFFERENT opcode for SET_CLOCK than gen4, and + // (per fw 50.40.1.0 console) also needs a leading revision/form byte — see + // [gen5SetClockPayload]. Gen4 keeps the hardware-verified 8-byte body. final isGen5 = _session?.band.isGen5 ?? false; + final payload = isGen5 + ? gen5SetClockPayload(sec: sec, subsec: subsec) + : [ + sec & 0xff, + (sec >> 8) & 0xff, + (sec >> 16) & 0xff, + (sec >> 24) & 0xff, + subsec & 0xff, + (subsec >> 8) & 0xff, + 0, + 0, + ]; final opcode = isGen5 ? Cmd.setClockMaverick : Cmd.setClock; await _send(opcode, payload); _log('SET_CLOCK${isGen5 ? " (gen5 Maverick)" : ""} → sec=$sec ' - 'subsec=$subsec (WHOOP-exact 8B).'); + 'subsec=$subsec (${payload.length}B${isGen5 ? ", rev=$revision1" : ""}).'); // Read the RTC back so the GET_CLOCK response handler can confirm it latched // (and re-issue SET_CLOCK if the strap clock is still off — see _onDecoded). await getClock(); @@ -3004,11 +3028,15 @@ class BleEngine { /// Read the strap RTC. The response carries `clock_epoch`, handled where we /// verify drift and re-correlate the strap-RTC ↔ wall clock. gen5 uses its - /// own GET_CLOCK opcode (147) — see [setClock]. - Future getClock() => _send( - (_session?.band.isGen5 ?? false) ? Cmd.getClockGen5 : Cmd.getClock, - const [], - ); + /// own GET_CLOCK opcode (147) and needs a leading revision byte — see + /// [gen5GetClockPayload] / [setClock]. + Future getClock() { + final isGen5 = _session?.band.isGen5 ?? false; + return _send( + isGen5 ? Cmd.getClockGen5 : Cmd.getClock, + isGen5 ? gen5GetClockPayload() : const [], + ); + } /// On-device wake alarm (SET_ALARM_TIME = 0x42) — the RICH 20-byte form that /// actually FIRES on WHOOP 4.0: diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index de7daa0a..8959b971 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -1,7 +1,5 @@ -// Pins the WHOOP 5 hardware v18 lenient decode path against real captures -// from openstrap_export_1785863370590.db (fw 50.40.1.0). Protocol's strict -// Gen5V18Decoder rejects every one of these on gravity/dyn gates; the lenient -// path must still recover HR + unix without fabricating accel. +// Pins WHOOP 5 hardware v18 + clock payload behaviour against real evidence +// from openstrap_export_1785863370590.db / openstrap_sync.log (fw 50.40.1.0). import 'dart:typed_data'; @@ -19,7 +17,30 @@ Uint8List hex(String s) { } void main() { - group('gen5V18UnixFromInner — hardware export', () { + group('gen5 clock payloads — fw 50.40.1.0 Invalid revision evidence', () { + test('SET_CLOCK prepends revision1 before the 8-byte time', () { + final p = gen5SetClockPayload(sec: 1785710096, subsec: 16351); + expect(p, hasLength(9)); + expect(p[0], revision1); + // Seconds LE start at body[1] — not body[0] (that was read as "revision"). + expect(p[1], 1785710096 & 0xff); + expect(p[2], (1785710096 >> 8) & 0xff); + expect(p[3], (1785710096 >> 16) & 0xff); + expect(p[4], (1785710096 >> 24) & 0xff); + expect(p[5], 16351 & 0xff); + expect(p[6], (16351 >> 8) & 0xff); + expect(p[7], 0); + expect(p[8], 0); + }); + + test('GET_CLOCK sends revision1 (empty body logged revision 0)', () { + expect(gen5GetClockPayload(), [revision1]); + }); + }); + + group('gen5V18UnixFromInner — no fabricated misaligned unix', () { + // Real archive blob #1: unix@7 is garbage; offset-6 is a year-plausible + // false positive that is NOT monotonic with recordIndex. final inner = hex( '2f1280540df701737c6a915c2f004d0000000000000000000021fb608c4d0000' 'c330ebf63ecd4ad43f854b653e5298863da3017400000000000000000039014401' @@ -27,58 +48,46 @@ void main() { 'bed68080000000a80372c0000000', ); - test('strict protocol decode returns null (gravity gate)', () { + test('strict protocol decode returns null (gravity/dyn gate)', () { expect(parseGen5Historical(inner), isNull); }); - test('unix lands at inner[6] on 112-byte padded captures', () { - expect(gen5V18UnixFromInner(inner), 1786540801); + test('unix@7 garbage → abstain (do not invent offset-6 time)', () { + expect(gen5V18UnixFromInner(inner), isNull); + expect(sampleFromGen5V18Lenient(inner), isNull); + expect(decodeGen5HistoricalSample(inner), isNull); }); + }); - test('lenient path recovers HR without fabricating gravity', () { + group('sampleFromGen5V18Lenient — gravity-only abstention needs good unix', () { + // Synthetic: valid shared header unix@7 + HR, gravity out of gate range. + // Layout matches Gen5HistoricalHeader + HR @14. + test('recovers HR when unix@7 plausible and gravity fails gate', () { + final inner = Uint8List(112); + inner[0] = 0x2f; + inner[1] = 18; + inner[2] = 0x80; + // recordIndex = 1 + inner[3] = 1; + // unix = 1785801600 (2026-08-04 00:00:00 UTC) + const unix = 1785801600; + inner.buffer.asByteData().setUint32(7, unix, Endian.little); + inner[14] = 72; // HR + inner[15] = 0; // no RR + // dynAccel = 0.5 (ok), gravity magnitude ~0.1 (fails 0.5..1.5 gate) + inner.buffer.asByteData().setFloat32(33, 0.5, Endian.little); + inner.buffer.asByteData().setFloat32(37, 0.05, Endian.little); + inner.buffer.asByteData().setFloat32(41, 0.05, Endian.little); + inner.buffer.asByteData().setFloat32(45, 0.05, Endian.little); + + expect(parseGen5Historical(inner), isNull); final sample = sampleFromGen5V18Lenient(inner); expect(sample, isNotNull); - expect(sample!.tsEpoch, 1786540801); - expect(sample.hr, 77); + expect(sample!.tsEpoch, unix); + expect(sample.hr, 72); expect(sample.ax, isNull); expect(sample.ay, isNull); expect(sample.az, isNull); }); - - test('decodeGen5HistoricalSample prefers strict then lenient', () { - final sample = decodeGen5HistoricalSample(inner); - expect(sample, isNotNull); - expect(sample!.hr, 77); - }); - }); - - group('sampleFromGen5V18Lenient — all hardware-export v18 fixtures', () { - // One row per undecodable_rec_v18 in the export DB (12 total). - final fixtures = [ - '2f1280540df701737c6a915c2f004d0000000000000000000021fb608c4d0000c330ebf63ecd4ad43f854b653e5298863da3017400000000000000000039014401040d6003010c020c3100000000000000000000000000000000000000000000011fbed68080000000a80372c0000000', - '2f1280fa2af70142716af39939004700000000000000000000606f0781470000d820abee3e52c0ac3e00a0b93dcdcc9abe8702dd0000000000000000003e0149013b0d6003010c020c0000000000000000000000000000000000000000000000010081b780800000006c5c6fc0000000', - '2f12805a3ef701a2846af35158004100000000000000000000008e0788410000ff80ba953e5220863e9a59163e33bbbf3ec403d3000000000000000000490154018b0d6003010c020c00000000000000000000000000000000000000000000000100c2fa80800000006b34a0c0000000', - '2f1280494af80140b26a9d8f02003a00000000000000000000217c4a833c000077a2902d3f661e96be14ae103c3d0ad4bd001c6e0000000000000000004c015101760d6003010c020c21000000000000000000000000000000000000000000000100b6fc8080000000fd4a97c0000000', - '2f1280924ff80189b76a9deb11003d0000000000000000000020f6468e3f00005de3ddef3f5caf89bde1ba29bec345463e091c6e0000000000000000004c015401920d6003010c020c20000000000000000000000000000000000000000000000100b0d78080000000d4c765c0000000', - '2f12806d62f801aa826a64e17a004e0000000000000000000071f40f884e0000d72a29613e854b70beec91d5beec515b3d4d1da60000000000000000003b014601260d6003010c020c0100000000000000000000000000000000000000000000010092e38080000000612677c0000000', - '2f1280877ff801f0746aa9cc4c004f0000000000000000000270d808874f0000cbd67d02414879723f852ba83ec305013e8d1e84000000000000000000460151017b0d6003010c020c0000000000000000000000000000000000000000000000010098d08080000000091711c0000000', - '2f12809ca1f80114886a974721005b000000000000000000006176078d5b0000bff0d53b3e29dc2d3e9aa19c3e33f3b53e4c207b0000000000000000003e014901480d6003010c020c01000000000000000000000000000000000000000000000100a1f58080000000f9b43ac0000000', - '2f128070d2f801e8b86a97146e00750000000000000000000078520883750000d8e038b23e1fb541becd6c953dc3f5483e1e25ff00000001000000000057015701600d6004010c020c08000000000000000000000000000000000000000000000100617d8080000000bf4c66c0000000', - '2f128069d4f801e1ba6a97eb71005e00000000000000000000615403805e0000d1fc60963ee17a053e0ae7753e52b8213e2027bd00000001000000000036014101f70c6004010c020c01000000000000000000000000000000000000000000000100617d808000000090aaa9bf000000', - '2f12809bd9f80113c06a97997900580000000000000000000061e10b86580000dad8ea093f9a49d5be00000bbcec01623ef427620000000100000000003b014901340d6004010c020c01000000000000000000000000000000000000000000000100689380800000001e8753c0000000', - '2f128071dbf801e9c16a97287c00650000000000000000000071420e87650000daa86c493f52b83cbe9a693b3e858b7f3e6d28ff00000000000000000041014c01340d6004010c020c01000000000000000000000000000000000000000000000100ecf88080000000af6201c0000000', - ]; - - test('every export v18 decodes to a sample with plausible unix + hr', () { - for (final h in fixtures) { - final inner = hex(h); - expect(parseGen5Historical(inner), isNull, - reason: 'strict decode should still reject these fixtures'); - final sample = sampleFromGen5V18Lenient(inner); - expect(sample, isNotNull, reason: 'lenient decode failed for $h'); - expect(gen5V18UnixPlausible(sample!.tsEpoch), isTrue); - expect(sample.hr == 0 || (sample.hr >= 25 && sample.hr <= 230), isTrue); - } - }); }); } From 0ab4e4a09e032037a0ff90a74feb100c14264ded Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:58:40 +0200 Subject: [PATCH 14/25] fix(gen5): persist v18 samples to decoded_onehz + align unix gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gen5 v18/lenient samples lack gen4 optics so R24 decode fails and decoded_onehz stayed empty — fall back to the BLE-preferred Sample when tsEpoch > 0, excluding gen4 R10-lite hr-only records. Align gen5V18UnixFromInner with RecordGate's isPlausibleUnix(wallNow) so implausible timestamps archive instead of silently dropping after decode. Restore git-sourced analytics lock (cbbe06a). --- lib/ble/ble_engine.dart | 22 +- lib/data/db.dart | 56 +++-- test/gen5_decoded_onehz_persistence_test.dart | 213 ++++++++++++++++++ test/gen5_v18_hardware_lenient_test.dart | 10 +- 4 files changed, 266 insertions(+), 35 deletions(-) create mode 100644 test/gen5_decoded_onehz_persistence_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 0d99538e..ac378160 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -84,11 +84,6 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive); /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); -/// Plausible unix window for gen5 historical records (2020-01-01 .. 2030-01-01). -@visibleForTesting -bool gen5V18UnixPlausible(int unix) => - unix >= 1577836800 && unix < 1893456000; - /// Read v18 unix from the protocol shared header at inner[7:11]. /// /// Do **not** fall back to a misaligned offset-6 read: that overlaps the @@ -97,11 +92,11 @@ bool gen5V18UnixPlausible(int unix) => /// the fw 50.40.1.0 export where unix@7 was garbage because SET_CLOCK never /// latched). Absent a plausible unix@7, abstain. @visibleForTesting -int? gen5V18UnixFromInner(Uint8List inner) { +int? gen5V18UnixFromInner(Uint8List inner, int wallNow) { if (inner.length < 11) return null; final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); final at7 = view.getUint32(7, Endian.little); - return gen5V18UnixPlausible(at7) ? at7 : null; + return isPlausibleUnix(at7, wallNow) ? at7 : null; } /// Hardware lenient v18 decode when [parseGen5Historical] returns null because @@ -110,9 +105,9 @@ int? gen5V18UnixFromInner(Uint8List inner) { /// gravity — ax/ay/az stay null when the vector fails the magnitude gate. /// Never fabricates time — requires a plausible unix@7. @visibleForTesting -Sample? sampleFromGen5V18Lenient(Uint8List inner) { +Sample? sampleFromGen5V18Lenient(Uint8List inner, int wallNow) { if (inner.length < kGen5V18MinInnerLen || inner[1] != 18) return null; - final unix = gen5V18UnixFromInner(inner); + final unix = gen5V18UnixFromInner(inner, wallNow); if (unix == null) return null; final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); final counter = view.getUint32(3, Endian.little); @@ -215,11 +210,11 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { /// Decode a gen5 historical inner frame to a band-agnostic [Sample], or null. @visibleForTesting -Sample? decodeGen5HistoricalSample(Uint8List inner) { +Sample? decodeGen5HistoricalSample(Uint8List inner, int wallNow) { final strict = sampleFromGen5Historical(parseGen5Historical(inner)); if (strict != null) return strict; if (inner.length > 1 && inner[1] == 18) { - return sampleFromGen5V18Lenient(inner); + return sampleFromGen5V18Lenient(inner, wallNow); } return null; } @@ -2061,6 +2056,7 @@ class BleEngine { // backfill (all received in one sync) splits into correct per-real-day // buckets instead of collapsing into one "today". Sample? sample; + final wallNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; final isGen5 = _session?.band.isGen5 ?? false; if (isGen5) { // gen5 (WHOOP 5): `parseGen5Historical` dispatches across all four real @@ -2070,7 +2066,7 @@ class BleEngine { // own raw-buffer storage (a future db table), not a 1Hz Sample, so they // fall through to the undecodable archive below — that is honest // (correctly-identified-but-not-yet-stored), not a decode failure. - sample = decodeGen5HistoricalSample(frame.inner); + sample = decodeGen5HistoricalSample(frame.inner, wallNow); } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. @@ -2134,7 +2130,7 @@ class BleEngine { // Past this point [sample] is non-null — undecodable records returned above. if (!_recordGate.admit( sample.tsEpoch, - wallNow: DateTime.now().millisecondsSinceEpoch ~/ 1000, + wallNow: wallNow, sessionOldestUnix: _sessionOldestUnix, sessionNewestUnix: _sessionNewestUnix, )) { diff --git a/lib/data/db.dart b/lib/data/db.dart index 33d11f7a..efa76ca1 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11,6 +11,7 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:openstrap_analytics/onehz.dart' as ana; import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; @@ -1856,32 +1857,51 @@ class LocalDb { static String _localDayLabelFromEpoch(int epochSec) => _localDayLabel(DateTime.fromMillisecondsSinceEpoch(epochSec * 1000)); + /// Gen4 historical R10-lite (hr-only, no accel/optical) must stay out of + /// `decoded_onehz` — they belong in the legacy `samples` table only. + static bool _isGen4R10LiteHistorical(Uint8List inner) => + inner.isNotEmpty && + inner[0] == proto.PacketType.historicalData && + inner.length > 1 && + inner[1] == proto.Record.r10; + static Sample? _decodeOneHzSample(RawRecord raw, {Sample? preferred}) { if (preferred != null && preferred.hasDecodedOneHz) return preferred; + Uint8List bytes; + try { + bytes = proto.hexToBytes(raw.hex); + } catch (_) { + return null; + } try { // Legacy decoder first, firmware-fallback chain second — see // FirmwareAwareR24Decoder. This path only runs when no pre-decoded // `preferred` sample was supplied (e.g. a raw-hex import/merge), so a // fresh per-call instance is fine — no session state to preserve. - final r = proto.FirmwareAwareR24Decoder().decode( - proto.hexToBytes(raw.hex), - ); - if (r == null || r.tsEpoch <= 0) return null; - return Sample( - tsEpoch: r.tsEpoch, - counter: r.counter, - hr: r.hr, - rrIntervalsMs: List.from(r.rrIntervalsMs), - ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, - ay: r.accelG.length > 1 ? r.accelG[1] : 0, - az: r.accelG.length > 2 ? r.accelG[2] : 0, - spo2RedRaw: r.spo2RedRaw, - spo2IrRaw: r.spo2IrRaw, - skinTempRaw: r.skinTempRaw, - ); - } catch (_) { - return null; + final r = proto.FirmwareAwareR24Decoder().decode(bytes); + if (r != null && r.tsEpoch > 0) { + return Sample( + tsEpoch: r.tsEpoch, + counter: r.counter, + hr: r.hr, + rrIntervalsMs: List.from(r.rrIntervalsMs), + ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, + ay: r.accelG.length > 1 ? r.accelG[1] : 0, + az: r.accelG.length > 2 ? r.accelG[2] : 0, + spo2RedRaw: r.spo2RedRaw, + spo2IrRaw: r.spo2IrRaw, + skinTempRaw: r.skinTempRaw, + ); + } + } catch (_) {} + // Gen5 v18 / lenient samples carry HR/RR/gravity but lack gen4 optics — + // `hasDecodedOneHz` stays false, yet they are honest 1 Hz substrate rows. + if (preferred != null && + preferred.tsEpoch > 0 && + !_isGen4R10LiteHistorical(bytes)) { + return preferred; } + return null; } /// THE orphan guard for an INSERT-OR-REPLACE into `decoded_onehz`. diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart new file mode 100644 index 00000000..e724007c --- /dev/null +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -0,0 +1,213 @@ +// Gen5 v18 samples must land in `decoded_onehz` via the preferred-sample +// fallback in LocalDb._decodeOneHzSample — they lack gen4 optics so R24 +// decode fails, but they are honest 1 Hz substrate rows. R10-lite hr-only +// records must stay excluded. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/models.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +String _bytesToHex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +Uint8List _buildR10LiteInner({required int ts, required int counter, required int hr}) { + final inner = Uint8List(18); + inner[0] = PacketType.historicalData; + inner[1] = Record.r10; + inner.buffer.asByteData().setUint32(3, counter, Endian.little); + inner.buffer.asByteData().setUint32(7, ts, Endian.little); + inner[17] = hr; + return inner; +} + +/// Synthetic gen5 v18 lenient inner: valid unix@7 + HR, gravity fails gate. +Uint8List _buildGen5V18LenientInner({ + required int unix, + required int counter, + required int hr, + List rrMs = const [], +}) { + final inner = Uint8List(112); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[2] = 0x80; + inner[3] = counter & 0xff; + inner[4] = (counter >> 8) & 0xff; + inner[5] = (counter >> 16) & 0xff; + inner[6] = (counter >> 24) & 0xff; + inner.buffer.asByteData().setUint32(7, unix, Endian.little); + inner[14] = hr; + inner[15] = rrMs.length.clamp(0, 4); + final view = inner.buffer.asByteData(); + for (var i = 0; i < rrMs.length && i < 4; i++) { + view.setInt16(16 + 2 * i, rrMs[i], Endian.little); + } + view.setFloat32(33, 0.5, Endian.little); + view.setFloat32(37, 0.05, Endian.little); + view.setFloat32(41, 0.05, Endian.little); + view.setFloat32(45, 0.05, Endian.little); + return inner; +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_gen5_onehz_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + group('gen5 → decoded_onehz persistence', () { + test('gen5 v18-shaped sample persists via preferred fallback (+ RR)', () async { + // Real fixture inner — same bytes as gen5_sample_mapping_test.dart. + final frameHex = + 'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000' + '000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000' + '000000000000f7000901f10b0007010c020c000000000000000000000000000' + '00000000000000000000100656f1e1e0000009d61a7c00000003e862817'; + final frame = Uint8List.fromList( + List.generate(frameHex.length ~/ 2, (i) { + return int.parse(frameHex.substring(i * 2, i * 2 + 2), radix: 16); + }), + ); + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + final inner = parsed.inner; + final sample = sampleFromGen5Historical(parseGen5Historical(inner)); + expect(sample, isNotNull); + + const recTs = 1780916150; + final raw = RawRecord( + counter: sample!.counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: recTs * 1000, + recTs: recTs, + ); + + await LocalDb.commitSyncBatch([raw], [sample]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [recTs], + ); + expect(rows.length, 1); + expect(rows.first['hr'], 102); + expect(rows.first['counter'], sample.counter); + + final rr = await db.query( + 'decoded_rr', + where: 'counter = ?', + whereArgs: [sample.counter], + ); + expect(rr.length, 2); + expect([for (final r in rr) r['rr_ms']], containsAll([602, 613])); + }); + + test('lenient v18 with null accel still persists (stored as 0)', () async { + const unix = 1785801600; + const counter = 42; + final inner = _buildGen5V18LenientInner(unix: unix, counter: counter, hr: 72); + final sample = sampleFromGen5V18Lenient(inner, unix); + expect(sample, isNotNull); + expect(sample!.ax, isNull); + + final raw = RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: unix * 1000, + recTs: unix, + ); + await LocalDb.commitSyncBatch([raw], [sample]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [unix], + ); + expect(rows.length, 1); + expect(rows.first['hr'], 72); + expect(rows.first['ax'], 0); + expect(rows.first['ay'], 0); + expect(rows.first['az'], 0); + }); + + test('R10-lite hr-only + preferred → no decoded_onehz row', () async { + const ts = 1780000100; + const counter = 99; + final inner = _buildR10LiteInner(ts: ts, counter: counter, hr: 65); + final preferred = Sample(tsEpoch: ts, counter: counter, hr: 65); + final raw = RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: ts * 1000, + recTs: ts, + ); + + await LocalDb.commitSyncBatch([raw], [preferred]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [ts], + ); + expect(rows, isEmpty); + }); + + test('full gen4 R24 sample still persists', () async { + const ts = 1780000200; + const counter = 5001; + final sample = Sample( + tsEpoch: ts, + counter: counter, + hr: 70, + rrIntervalsMs: [800], + ax: 0.1, + ay: -0.2, + az: 0.95, + spo2RedRaw: 100, + spo2IrRaw: 200, + skinTempRaw: 300, + ); + // Minimal non-R10 historical hex — R24 decode won't match, but preferred + // has full gen4 optics so _decodeOneHzSample returns it immediately. + final raw = RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: '2f18' '00' * 20, + capturedAt: ts * 1000, + recTs: ts, + ); + + await LocalDb.commitSyncBatch([raw], [sample]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [ts], + ); + expect(rows.length, 1); + expect(rows.first['hr'], 70); + expect(rows.first['spo2_red_raw'], 100); + }); + }); +} diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index 8959b971..e64cf042 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -53,9 +53,10 @@ void main() { }); test('unix@7 garbage → abstain (do not invent offset-6 time)', () { - expect(gen5V18UnixFromInner(inner), isNull); - expect(sampleFromGen5V18Lenient(inner), isNull); - expect(decodeGen5HistoricalSample(inner), isNull); + const wallNow = 1785863370; // export capture era + expect(gen5V18UnixFromInner(inner, wallNow), isNull); + expect(sampleFromGen5V18Lenient(inner, wallNow), isNull); + expect(decodeGen5HistoricalSample(inner, wallNow), isNull); }); }); @@ -81,7 +82,8 @@ void main() { inner.buffer.asByteData().setFloat32(45, 0.05, Endian.little); expect(parseGen5Historical(inner), isNull); - final sample = sampleFromGen5V18Lenient(inner); + const wallNow = unix; // plausible vs the synthetic timestamp + final sample = sampleFromGen5V18Lenient(inner, wallNow); expect(sample, isNotNull); expect(sample!.tsEpoch, unix); expect(sample.hr, 72); From 1f85b10a3c128113c7afd9ed7361035ae9a85cb9 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:35:33 +0200 Subject: [PATCH 15/25] fix(db): reject R10-lite before preferred hasDecodedOneHz short-circuit Parse raw hex and gate Gen4 R10-lite records before accepting a complete preferred sample into decoded_onehz. Exercise lenient v18 recovery through decodeGen5HistoricalSample with HR+RR in hardware tests. --- lib/data/db.dart | 7 +++---- test/gen5_v18_hardware_lenient_test.dart | 12 +++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index efa76ca1..8bbb84c0 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1866,13 +1866,14 @@ class LocalDb { inner[1] == proto.Record.r10; static Sample? _decodeOneHzSample(RawRecord raw, {Sample? preferred}) { - if (preferred != null && preferred.hasDecodedOneHz) return preferred; Uint8List bytes; try { bytes = proto.hexToBytes(raw.hex); } catch (_) { return null; } + if (_isGen4R10LiteHistorical(bytes)) return null; + if (preferred != null && preferred.hasDecodedOneHz) return preferred; try { // Legacy decoder first, firmware-fallback chain second — see // FirmwareAwareR24Decoder. This path only runs when no pre-decoded @@ -1896,9 +1897,7 @@ class LocalDb { } catch (_) {} // Gen5 v18 / lenient samples carry HR/RR/gravity but lack gen4 optics — // `hasDecodedOneHz` stays false, yet they are honest 1 Hz substrate rows. - if (preferred != null && - preferred.tsEpoch > 0 && - !_isGen4R10LiteHistorical(bytes)) { + if (preferred != null && preferred.tsEpoch > 0) { return preferred; } return null; diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index e64cf042..a323f9ea 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -60,10 +60,10 @@ void main() { }); }); - group('sampleFromGen5V18Lenient — gravity-only abstention needs good unix', () { - // Synthetic: valid shared header unix@7 + HR, gravity out of gate range. + group('decodeGen5HistoricalSample — lenient v18 production path', () { + // Synthetic: valid shared header unix@7 + HR + RR, gravity out of gate range. // Layout matches Gen5HistoricalHeader + HR @14. - test('recovers HR when unix@7 plausible and gravity fails gate', () { + test('recovers HR/RR when strict decode fails gravity gate only', () { final inner = Uint8List(112); inner[0] = 0x2f; inner[1] = 18; @@ -74,7 +74,8 @@ void main() { const unix = 1785801600; inner.buffer.asByteData().setUint32(7, unix, Endian.little); inner[14] = 72; // HR - inner[15] = 0; // no RR + inner[15] = 1; // one RR interval + inner.buffer.asByteData().setInt16(16, 820, Endian.little); // dynAccel = 0.5 (ok), gravity magnitude ~0.1 (fails 0.5..1.5 gate) inner.buffer.asByteData().setFloat32(33, 0.5, Endian.little); inner.buffer.asByteData().setFloat32(37, 0.05, Endian.little); @@ -83,10 +84,11 @@ void main() { expect(parseGen5Historical(inner), isNull); const wallNow = unix; // plausible vs the synthetic timestamp - final sample = sampleFromGen5V18Lenient(inner, wallNow); + final sample = decodeGen5HistoricalSample(inner, wallNow); expect(sample, isNotNull); expect(sample!.tsEpoch, unix); expect(sample.hr, 72); + expect(sample.rrIntervalsMs, [820]); expect(sample.ax, isNull); expect(sample.ay, isNull); expect(sample.az, isNull); From 3a9de5b6ee338b895bb12f7ee949714ec5470f14 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:36:01 +0200 Subject: [PATCH 16/25] test(gen5): add dynamic-gate lenient v18 recovery case Cover strict dynamic-accel rejection with valid gravity through the production decodeGen5HistoricalSample path. --- test/gen5_v18_hardware_lenient_test.dart | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index a323f9ea..f22019b6 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -93,5 +93,33 @@ void main() { expect(sample.ay, isNull); expect(sample.az, isNull); }); + + test('strict dynamic-accel rejection still recovers HR via lenient path', () { + final inner = Uint8List(112); + inner[0] = 0x2f; + inner[1] = 18; + inner[2] = 0x80; + inner[3] = 1; + const unix = 1785801600; + inner.buffer.asByteData().setUint32(7, unix, Endian.little); + inner[14] = 80; + inner[15] = 0; + final view = inner.buffer.asByteData(); + // High dynamic accel fails strict gate; gravity is valid ~1g. + view.setFloat32(33, 5.0, Endian.little); + view.setFloat32(37, 0.0, Endian.little); + view.setFloat32(41, 0.0, Endian.little); + view.setFloat32(45, 1.0, Endian.little); + + expect(parseGen5Historical(inner), isNull); + const wallNow = unix; + final sample = decodeGen5HistoricalSample(inner, wallNow); + expect(sample, isNotNull); + expect(sample!.hr, 80); + // Lenient path keeps gravity when magnitude gate passes. + expect(sample.ax, closeTo(0.0, 1e-6)); + expect(sample.ay, closeTo(0.0, 1e-6)); + expect(sample.az, closeTo(1.0, 1e-6)); + }); }); } From cc53fe1ca85b8fe3e7377a8cd1be7d93ac3386a6 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:36:17 +0200 Subject: [PATCH 17/25] test(gen5): drop inaccurate dynamic-only lenient case Strict parseGen5Historical accepts high dynamic accel when gravity is valid; lenient fallback is exercised via the gravity-gate case only. --- test/gen5_v18_hardware_lenient_test.dart | 28 ------------------------ 1 file changed, 28 deletions(-) diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index f22019b6..a323f9ea 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -93,33 +93,5 @@ void main() { expect(sample.ay, isNull); expect(sample.az, isNull); }); - - test('strict dynamic-accel rejection still recovers HR via lenient path', () { - final inner = Uint8List(112); - inner[0] = 0x2f; - inner[1] = 18; - inner[2] = 0x80; - inner[3] = 1; - const unix = 1785801600; - inner.buffer.asByteData().setUint32(7, unix, Endian.little); - inner[14] = 80; - inner[15] = 0; - final view = inner.buffer.asByteData(); - // High dynamic accel fails strict gate; gravity is valid ~1g. - view.setFloat32(33, 5.0, Endian.little); - view.setFloat32(37, 0.0, Endian.little); - view.setFloat32(41, 0.0, Endian.little); - view.setFloat32(45, 1.0, Endian.little); - - expect(parseGen5Historical(inner), isNull); - const wallNow = unix; - final sample = decodeGen5HistoricalSample(inner, wallNow); - expect(sample, isNotNull); - expect(sample!.hr, 80); - // Lenient path keeps gravity when magnitude gate passes. - expect(sample.ax, closeTo(0.0, 1e-6)); - expect(sample.ay, closeTo(0.0, 1e-6)); - expect(sample.az, closeTo(1.0, 1e-6)); - }); }); } From ab8a386ffb4fb7e325c9bd4e5f0fbcb0b90df2b6 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:53:39 +0200 Subject: [PATCH 18/25] test(gen5): R10-lite regression uses complete preferred Sample Prove _decodeOneHzSample rejects Gen4 R10-lite bytes before the hasDecodedOneHz early return, not only when preferred is HR-only. --- test/gen5_decoded_onehz_persistence_test.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index e724007c..9c24a59d 100644 --- a/test/gen5_decoded_onehz_persistence_test.dart +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -148,11 +148,22 @@ void main() { expect(rows.first['az'], 0); }); - test('R10-lite hr-only + preferred → no decoded_onehz row', () async { + test('R10-lite + complete preferred → no decoded_onehz row', () async { const ts = 1780000100; const counter = 99; final inner = _buildR10LiteInner(ts: ts, counter: counter, hr: 65); - final preferred = Sample(tsEpoch: ts, counter: counter, hr: 65); + final preferred = Sample( + tsEpoch: ts, + counter: counter, + hr: 65, + ax: 0.1, + ay: -0.2, + az: 0.95, + spo2RedRaw: 100, + spo2IrRaw: 200, + skinTempRaw: 300, + ); + expect(preferred.hasDecodedOneHz, isTrue); final raw = RawRecord( counter: counter, packetType: PacketType.historicalData, From 8e36c3ad867e8a6ff13c16667bff5395668cade8 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:27:47 +0200 Subject: [PATCH 19/25] fix(db): keep preferred Sample when raw hex is unparseable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R10-lite reject must parse hex first, but hexToBytes failure should fall through to preferred/hasDecodedOneHz — not return null. Restores decoded_onehz for fixture inserts with placeholder hex. Also fix clamp→int in the gen5 persistence helper. --- lib/data/db.dart | 57 ++++++++++--------- test/gen5_decoded_onehz_persistence_test.dart | 2 +- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 8bbb84c0..a7950c53 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1866,35 +1866,40 @@ class LocalDb { inner[1] == proto.Record.r10; static Sample? _decodeOneHzSample(RawRecord raw, {Sample? preferred}) { - Uint8List bytes; + // Parse hex when possible so Gen4 R10-lite can be rejected even when a + // complete preferred Sample is supplied. Invalid/placeholder hex (test + // fixtures, corrupt imports) must NOT abort before the preferred paths — + // commit 1f85b10 returned null on hexToBytes failure and zeroed + // decoded_onehz for every insertRecord that used non-hex placeholders. + Uint8List? bytes; try { bytes = proto.hexToBytes(raw.hex); - } catch (_) { - return null; - } - if (_isGen4R10LiteHistorical(bytes)) return null; - if (preferred != null && preferred.hasDecodedOneHz) return preferred; - try { - // Legacy decoder first, firmware-fallback chain second — see - // FirmwareAwareR24Decoder. This path only runs when no pre-decoded - // `preferred` sample was supplied (e.g. a raw-hex import/merge), so a - // fresh per-call instance is fine — no session state to preserve. - final r = proto.FirmwareAwareR24Decoder().decode(bytes); - if (r != null && r.tsEpoch > 0) { - return Sample( - tsEpoch: r.tsEpoch, - counter: r.counter, - hr: r.hr, - rrIntervalsMs: List.from(r.rrIntervalsMs), - ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, - ay: r.accelG.length > 1 ? r.accelG[1] : 0, - az: r.accelG.length > 2 ? r.accelG[2] : 0, - spo2RedRaw: r.spo2RedRaw, - spo2IrRaw: r.spo2IrRaw, - skinTempRaw: r.skinTempRaw, - ); - } } catch (_) {} + if (bytes != null && _isGen4R10LiteHistorical(bytes)) return null; + if (preferred != null && preferred.hasDecodedOneHz) return preferred; + if (bytes != null) { + try { + // Legacy decoder first, firmware-fallback chain second — see + // FirmwareAwareR24Decoder. This path only runs when no pre-decoded + // `preferred` sample was supplied (e.g. a raw-hex import/merge), so a + // fresh per-call instance is fine — no session state to preserve. + final r = proto.FirmwareAwareR24Decoder().decode(bytes); + if (r != null && r.tsEpoch > 0) { + return Sample( + tsEpoch: r.tsEpoch, + counter: r.counter, + hr: r.hr, + rrIntervalsMs: List.from(r.rrIntervalsMs), + ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, + ay: r.accelG.length > 1 ? r.accelG[1] : 0, + az: r.accelG.length > 2 ? r.accelG[2] : 0, + spo2RedRaw: r.spo2RedRaw, + spo2IrRaw: r.spo2IrRaw, + skinTempRaw: r.skinTempRaw, + ); + } + } catch (_) {} + } // Gen5 v18 / lenient samples carry HR/RR/gravity but lack gen4 optics — // `hasDecodedOneHz` stays false, yet they are honest 1 Hz substrate rows. if (preferred != null && preferred.tsEpoch > 0) { diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index 9c24a59d..e6226f63 100644 --- a/test/gen5_decoded_onehz_persistence_test.dart +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -43,7 +43,7 @@ Uint8List _buildGen5V18LenientInner({ inner[6] = (counter >> 24) & 0xff; inner.buffer.asByteData().setUint32(7, unix, Endian.little); inner[14] = hr; - inner[15] = rrMs.length.clamp(0, 4); + inner[15] = rrMs.length.clamp(0, 4).toInt(); final view = inner.buffer.asByteData(); for (var i = 0; i < rrMs.length && i < 4; i++) { view.setInt16(16 + 2 * i, rrMs[i], Endian.little); 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 20/25] 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 21/25] 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]; } From c57e6ff6d86befebfaf3152f8c774f8b2b129443 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:16:06 +0200 Subject: [PATCH 22/25] fix(gen5): arm and decode Maverick live IMU for step calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHOOP 5 rejects bare toggleImuMode (Invalid rev 0); arm with [revision1, on] and skip unhandled R10/R11 realtime. Live accel arrives as 0x2B rec 0x15 (100 Hz planar), not gen4 0x33 — decode that layout so calibration counts. Gen4 0x33/R10 still use frameAccel unchanged. --- lib/ble/ble_engine.dart | 59 ++++++++++++++++------ lib/ble/gen5_live_imu.dart | 73 ++++++++++++++++++++++++++++ lib/state/app_state.dart | 17 ++++--- test/gen5_imu_mode_payload_test.dart | 14 ++++++ test/gen5_live_imu_test.dart | 64 ++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 lib/ble/gen5_live_imu.dart create mode 100644 test/gen5_imu_mode_payload_test.dart create mode 100644 test/gen5_live_imu_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 774cb451..d306b636 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -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 imuModePayload(bool on, {required bool isGen5}) => isGen5 + ? [revision1, on ? 0x01 : 0x00] + : [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 / @@ -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]); } @@ -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. @@ -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 @@ -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 = >[ [ @@ -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) { @@ -3130,6 +3157,7 @@ class BleEngine { /// Turn everything off. Safe + idempotent. Clears flags back to wrist-gated. Future disableLiveStreams() async { + final isGen5 = _session?.band.isGen5 ?? false; final ops = >[ [ Cmd.toggleOpticalMode, @@ -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, diff --git a/lib/ble/gen5_live_imu.dart b/lib/ble/gen5_live_imu.dart new file mode 100644 index 00000000..8c4606c8 --- /dev/null +++ b/lib/ble/gen5_live_imu.dart @@ -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 = []; + final ys = []; + final zs = []; + final mags = []; + 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); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index d4465cd4..5000787f 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -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'; @@ -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); @@ -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); @@ -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; } diff --git a/test/gen5_imu_mode_payload_test.dart b/test/gen5_imu_mode_payload_test.dart new file mode 100644 index 00000000..f9fc49df --- /dev/null +++ b/test/gen5_imu_mode_payload_test.dart @@ -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]); + }); + }); +} diff --git a/test/gen5_live_imu_test.dart b/test/gen5_live_imu_test.dart new file mode 100644 index 00000000..5b1ca1a7 --- /dev/null +++ b/test/gen5_live_imu_test.dart @@ -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)); + }); + }); +} From 748f6dcacf58e650ccc65464bcbd7a102cfbcdc9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 7 Aug 2026 00:22:09 +0530 Subject: [PATCH 23/25] gen5: absent gravity must not read back as perfect stillness P0. `sampleFromGen5V18Lenient` correctly abstains on a gravity vector that fails the magnitude gate (ax/ay/az stay null) while keeping HR/RR -- but `decoded_onehz.ax/ay/az` are REAL NOT NULL, so `_queueDecodedOneHz` writes `decoded.ax ?? 0` and the substrate loader reads `?? 0` back. "We did not measure this" becomes "the wrist was at exactly (0,0,0)". That is not an inert default. `zAngle(0,0,0)` is exactly 0.0 in Dart -- atan2(0,0) is 0.0, not NaN -- so a run of absent seconds has a perfectly CONSTANT z-angle, which is the van Hees immobility criterion satisfied maximally. Measured against the pinned analytics: 8 h of (0,0,0) scores 28501 immobile seconds and `vanHeesSleepWindow.present == true`, i.e. a fabricated ~7.9 h night, fully staged, out of data that does not exist. This PR's own commit message notes the strict gate rejected EVERY v18 record on fw 50.40.1.0, so for that firmware it is the ordinary case, not a corner. Exact (0,0,0) is an unambiguous ABSENT marker rather than a reading: gravity always has magnitude ~1 g and every decoder that emits a vector gates on `magSq >= 0.25`. So `Substrate.accelPresentAt` / `accelPresentFraction` can recover the distinction the NOT NULL column erased, with no schema change -- and it heals rows already persisted. TWO OTHER SENTINELS WERE TRIED AND REJECTED, both measured rather than assumed: * NaN fails OPEN. The rule asks "did the angle change by >= threshold", and every comparison against NaN is false, so it never trips -- NaN scores the SAME 28501 immobile seconds as zeros. Pinned in a test so nobody "fixes" this that way later. * Omitting the seconds does not work either: `immobilityMask` is a pure index-wise angle rule with no timestamp/gap awareness (unlike `nap.dart`'s `stillAt`, which does check `absAt(k) - absAt(k-1) == 1`), so it just joins across the hole. Since analytics has no validity input to be told any of this, the honest move at this layer is not to let absent accel ANCHOR a window: below `kMinAccelCoverageForVanHees` the accel-led path is skipped entirely and the day falls through to the EXISTING HR-led fallback, which is already the low-confidence degraded mode for exactly this situation. Half rather than something tiny, because van Hees picks the LONGEST immobile block and absent seconds are maximally immobile, so a mostly-absent window would reliably hand the answer to the missing data. The complete fix belongs upstream -- `immobilityMask` should take an optional validity mask and mark invalid seconds via the `immobileUnknown` machinery it already has. Noted for analytics; this keeps the P0 off the field meanwhile. P1, same commit: lenient records bypassed `raw_archive`. A partial decode is not a full decode -- HR/RR are kept but the gravity bytes the strict gate rejected were discarded the moment we ACK'd the trim, with `raw_records` gone and `decoded_onehz` having nowhere to put a null accel. They are now archived as well as sampled, in the same safe-trim transaction, so a future decoder can recover what this one could not. Extracted `_archiveHistoricalFrame` so both the undecodable and partial paths share one implementation. 7 tests added; the 6 pre-existing failures in notification_dedupe_test also reproduce on origin/main unmodified and are unrelated. --- lib/ble/ble_engine.dart | 55 ++++-- lib/compute/substrate.dart | 75 +++++++- test/gen5_decoded_onehz_persistence_test.dart | 8 + test/substrate_accel_absence_test.dart | 166 ++++++++++++++++++ 4 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 test/substrate_accel_absence_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index ac378160..dbd7fcb7 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2035,6 +2035,32 @@ class BleEngine { /// path is deliberate: the previous duplicate had drifted, silently losing /// the plausibility gate and freezing the frontier the stuck-strap / /// auto-continue policies read. + /// Set a historical frame aside in `raw_archive` — the never-pruned store for + /// bytes this build could not fully turn into a [Sample]. + /// + /// Routed through the drain when one is active so the write lands inside the + /// SAME transaction as the batch commit (safe-trim invariant: nothing the + /// band is told it may trim has been discarded). + void _archiveHistoricalFrame( + Frame frame, + int counter, { + required String reason, + }) { + final archive = ArchiveRecord( + counter: counter, + hex: _innerHex(frame.inner), + packetType: frame.inner.isNotEmpty ? frame.inner[0] : 0, + capturedAt: DateTime.now().millisecondsSinceEpoch, + reason: reason, + ); + final d = _drain; + if (d != null) { + d.onUndecodableRecord(archive); + } else { + unawaited(onArchiveRecord?.call(archive) ?? Future.value()); + } + } + void _ingestHistoricalFrame(Frame frame) { final pt = frame.packetType; if (pt != PacketType.historicalData) return; @@ -2067,6 +2093,21 @@ class BleEngine { // fall through to the undecodable archive below — that is honest // (correctly-identified-but-not-yet-stored), not a decode failure. sample = decodeGen5HistoricalSample(frame.inner, wallNow); + // PARTIAL decode is not a full decode. The lenient v18 path deliberately + // keeps HR/RR while ABSTAINING on a gravity vector that failed the + // magnitude gate — but `raw_records` is gone and `decoded_onehz` has + // nowhere to put a null accel, so those gravity bytes would be discarded + // the moment we ACK the trim. Archive the frame as WELL as keeping the + // sample: same safe-trim transaction, and a future decoder can still + // recover what this one could not. Nothing is double-counted — + // `raw_archive` is a diagnostic store, never a derivation input. + if (sample != null && sample.ax == null) { + _archiveHistoricalFrame( + frame, + counter, + reason: 'partial_decode_v${recType}_no_gravity', + ); + } } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. @@ -2106,19 +2147,11 @@ class BleEngine { // archive rides the SAME commit that runs before the batch-ACK, so nothing the // band trims has been discarded (safe-trim invariant intact). if (sample == null) { - final archive = ArchiveRecord( - counter: counter, - hex: _innerHex(frame.inner), - packetType: frame.inner.isNotEmpty ? frame.inner[0] : 0, - capturedAt: DateTime.now().millisecondsSinceEpoch, + _archiveHistoricalFrame( + frame, + counter, reason: 'undecodable_rec_v$recType', ); - final d = _drain; - if (d != null) { - d.onUndecodableRecord(archive); - } else { - unawaited(onArchiveRecord?.call(archive) ?? Future.value()); - } return; } // PLAUSIBILITY GATE + FRONTIER (RecordGate, shared with the detectors). diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index 40df5050..e9a139f5 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -16,6 +16,16 @@ import 'dart:math' as math; import 'package:openstrap_analytics/onehz.dart' as ana; import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; +/// Minimum fraction of a nocturnal search window that must carry a REAL +/// gravity vector before accel-led (van Hees) sleep detection is trusted. +/// +/// Below this we do not run it at all and fall through to the HR-led window, +/// which is already the honest low-confidence degraded mode. Set at a half +/// rather than something tiny on purpose: van Hees picks the LONGEST immobile +/// block, and absent seconds are maximally "immobile", so a window that is +/// mostly absent would reliably hand the answer to the missing data. +const double kMinAccelCoverageForVanHees = 0.5; + /// The decoded 1 Hz substrate — the only decoded form (ARCHITECTURE_V2). /// /// All HR/accel/ADC arrays are parallel and 1:1 with [tsSec] (one sample per @@ -82,6 +92,36 @@ class Substrate { ana.AccelSample(tsSec[i] * 1000.0, ax[i], ay[i], az[i]) ]; + /// Whether second [i] carries a REAL gravity vector. + /// + /// `decoded_onehz.ax/ay/az` are `REAL NOT NULL`, so a record decoded without + /// a usable gravity vector (the gen5 v18 lenient path, which deliberately + /// abstains on accel while keeping HR/RR) is stored as exact `(0, 0, 0)`. + /// That is not a reading a real device can produce — a gravity vector always + /// has magnitude ~1 g, and every decoder that emits one gates on + /// `magSq >= 0.25` — so exact zero is an unambiguous ABSENT marker rather + /// than a measurement. + /// + /// This matters because absent accel does not merely go unused: a run of + /// `(0, 0, 0)` has a constant z-angle of exactly 0.0°, which the van Hees + /// rule reads as PERFECT IMMOBILITY. Eight hours of missing accel scores + /// 28 501 immobile seconds and yields a fabricated ~7.9 h sleep window, + /// fully staged. Absent input must produce no claim, never a confident one. + bool accelPresentAt(int i) => !(ax[i] == 0 && ay[i] == 0 && az[i] == 0); + + /// Fraction of [lo, hi) seconds carrying a real gravity vector (0..1). + /// Returns 0 for an empty range — no evidence, not "all present". + double accelPresentFraction(int lo, int hi) { + final a = lo < 0 ? 0 : lo; + final b = hi > tsSec.length ? tsSec.length : hi; + if (b <= a) return 0; + var present = 0; + for (var i = a; i < b; i++) { + if (accelPresentAt(i)) present++; + } + return present / (b - a); + } + /// 1 Hz HR as doubles (0 = off-skin). Parallel to [tsSec] / [accelSamples]. List hr1hz() => [for (final h in hr) h.toDouble()]; @@ -531,14 +571,33 @@ List calendarDays( ); src = ov.source; // 'manual' | 'confirmed' } else { - s = ana.segmentSleep( - accelSlice, - hrSlice, - hrBaseline: hrBaseline, - rrMs: rrMsSeg, - rrTsMs: rrTsSeg, - habitualMidsleepSec: habitualMidsleepSec, - ); + // Accel-led detection is only meaningful if we actually HAVE accel. + // Absent gravity is stored as exact (0,0,0) (see `accelPresentAt`) and + // van Hees scores a run of it as perfect immobility, so a night whose + // records all decoded without a gravity vector would otherwise produce + // a confident, fully-staged sleep window built entirely out of missing + // data. `immobilityMask` has no validity input to tell it otherwise — + // it is a pure index-wise angle rule, so neither a NaN sentinel (NaN + // comparisons are false, so the "angle changed" test never trips and + // it reads as immobile) nor omitting the seconds (no gap awareness) + // reaches it. The only honest move at this layer is not to let it + // anchor the window in the first place. + final accelCoverage = sub.accelPresentFraction(loS, hiS); + if (accelCoverage >= kMinAccelCoverageForVanHees) { + s = ana.segmentSleep( + accelSlice, + hrSlice, + hrBaseline: hrBaseline, + rrMs: rrMsSeg, + rrTsMs: rrTsSeg, + habitualMidsleepSec: habitualMidsleepSec, + ); + } else { + // Not an error and not "no sleep" — just no accel evidence. Fall + // through to the HR-led path below, which is exactly the degraded + // mode for this and is already marked low-confidence. + s = ana.SleepSegmentation.absent; + } src = 'auto'; if (!s.present) { // Approach 2: accel-led detection found nothing → HR-led fallback. diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index e6226f63..f0d06464 100644 --- a/test/gen5_decoded_onehz_persistence_test.dart +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -118,6 +118,14 @@ void main() { expect([for (final r in rr) r['rr_ms']], containsAll([602, 613])); }); + // NOTE what this pins, and what it does NOT. `decoded_onehz.ax/ay/az` are + // REAL NOT NULL, so absent gravity has to be STORED as 0 — that is a schema + // constraint, not a claim about the wrist. Exact (0,0,0) is therefore the + // ABSENT marker (no real gravity vector has zero magnitude, and every decoder + // that emits one gates on magSq >= 0.25); `Substrate.accelPresentAt` is what + // stops it being read back as a measurement. See + // substrate_accel_absence_test.dart — without that, a night of these scores + // as perfect immobility and fabricates a fully-staged sleep window. test('lenient v18 with null accel still persists (stored as 0)', () async { const unix = 1785801600; const counter = 42; diff --git a/test/substrate_accel_absence_test.dart b/test/substrate_accel_absence_test.dart new file mode 100644 index 00000000..14c70282 --- /dev/null +++ b/test/substrate_accel_absence_test.dart @@ -0,0 +1,166 @@ +// P0 REGRESSION — absent gravity must not be read back as perfect stillness. +// +// `sampleFromGen5V18Lenient` correctly ABSTAINS on a gravity vector that fails +// the magnitude gate (ax/ay/az stay null) while keeping HR/RR. But +// `decoded_onehz.ax/ay/az` are REAL NOT NULL, so the persistence layer writes +// `decoded.ax ?? 0` and the substrate loader reads `?? 0` back — turning +// "we did not measure this" into "the wrist was at exactly (0,0,0)". +// +// That is not an inert default. zAngle(0,0,0) is exactly 0.0 in Dart (atan2 +// (0,0) == 0.0, NOT NaN), so a run of absent seconds has a PERFECTLY CONSTANT +// z-angle, which is the van Hees immobility criterion satisfied maximally. +// Measured against the pinned analytics: 8 h of (0,0,0) yields 28 501 immobile +// seconds and `vanHeesSleepWindow.present == true` — a fabricated ~7.9 h night, +// fully staged, from data that does not exist. The PR's own commit message +// notes the strict gate rejected EVERY v18 record on fw 50.40.1.0, so this is +// the ordinary case for that firmware, not a corner. +// +// Two sentinels were tried and REJECTED, both verified against the pinned +// analytics rather than assumed: +// * NaN — fails OPEN. The rule tests "did the angle change by >= thr", and +// every comparison against NaN is false, so it never trips: NaN scores the +// SAME 28 501 immobile seconds as zeros. +// * dropping the seconds — `immobilityMask` is a pure index-wise angle rule +// with no timestamp/gap awareness (unlike `nap.dart`'s `stillAt`), so it +// simply joins across the hole. +// Hence the gate below: absent accel must not be allowed to ANCHOR a window. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_analytics/onehz.dart' as ana; +import 'package:openstrap_edge/compute/substrate.dart'; + +Substrate _sub({ + required int n, + required bool accelPresent, + int startSec = 1750000000, +}) { + final ts = []; + final hr = []; + final ax = []; + final ay = []; + final az = []; + for (var i = 0; i < n; i++) { + ts.add(startSec + i); + hr.add(58); + if (accelPresent) { + // A real, still-ish wrist: unit-magnitude gravity with a slight drift. + final rad = (i % 3) * 0.5 * math.pi / 180.0; + ax.add(math.sin(rad)); + ay.add(0.0); + az.add(math.cos(rad)); + } else { + // What the NOT NULL column forces for an abstaining decoder. + ax.add(0.0); + ay.add(0.0); + az.add(0.0); + } + } + return Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: ax, + ay: ay, + az: az, + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + ); +} + +void main() { + group('the hazard this guards against is real', () { + test( + 'zAngle(0,0,0) is 0.0, not NaN — so absent accel is maximally "still"', + () { + expect(ana.zAngle(0.0, 0.0, 0.0), 0.0); + }, + ); + + test( + '8 h of absent accel scores as an almost entirely immobile night', + () { + const n = 8 * 3600; + final s = _sub(n: n, accelPresent: false); + final m = ana.vanHeesSleepWindow(s.accelSamples()); + final win = m.value!; + final immobile = win.immobile.where((b) => b).length; + expect( + immobile, + greaterThan((n * 0.95).round()), + reason: 'this is why the gate exists — missing data reads as sleep', + ); + expect(m.present, isTrue); + }, + ); + + test( + 'a NaN sentinel would NOT have fixed it — comparisons against NaN are ' + 'false, so the "angle changed" test never trips', + () { + const n = 8 * 3600; + final base = 1750000000000.0; + final nan = [ + for (var i = 0; i < n; i++) + ana.AccelSample( + base + i * 1000, double.nan, double.nan, double.nan), + ]; + final immobile = + ana.vanHeesSleepWindow(nan).value!.immobile.where((b) => b).length; + expect( + immobile, + greaterThan((n * 0.95).round()), + reason: 'NaN fails OPEN here; documented so nobody "fixes" it that way', + ); + }, + ); + }); + + group('Substrate.accelPresentAt / accelPresentFraction', () { + test('exact (0,0,0) is absent; a real vector is present', () { + final absent = _sub(n: 10, accelPresent: false); + final present = _sub(n: 10, accelPresent: true); + expect(absent.accelPresentAt(0), isFalse); + expect(present.accelPresentAt(0), isTrue); + expect(absent.accelPresentFraction(0, 10), 0.0); + expect(present.accelPresentFraction(0, 10), 1.0); + }); + + test('an empty range reports 0 — no evidence, not "all present"', () { + final s = _sub(n: 10, accelPresent: true); + expect(s.accelPresentFraction(5, 5), 0.0); + expect(Substrate.empty.accelPresentFraction(0, 100), 0.0); + }); + + test('a mixed window reports the real fraction', () { + final s = _sub(n: 100, accelPresent: true); + for (var i = 0; i < 40; i++) { + s.ax[i] = 0.0; + s.ay[i] = 0.0; + s.az[i] = 0.0; + } + expect(s.accelPresentFraction(0, 100), closeTo(0.60, 1e-9)); + }); + + test( + 'the van Hees coverage floor rejects an all-absent night and accepts a ' + 'fully-measured one', + () { + final absent = _sub(n: 8 * 3600, accelPresent: false); + final present = _sub(n: 8 * 3600, accelPresent: true); + expect( + absent.accelPresentFraction(0, absent.length), + lessThan(kMinAccelCoverageForVanHees), + ); + expect( + present.accelPresentFraction(0, present.length), + greaterThanOrEqualTo(kMinAccelCoverageForVanHees), + ); + }, + ); + }); +} From da95af76e42378d54acb9c470778e7580aa9c547 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 7 Aug 2026 23:26:46 +0530 Subject: [PATCH 24/25] test(gen5): use interpolation so `flutter analyze` stays clean `'33' + ('00' * 80)` trips prefer_interpolation_to_compose_strings. That is only an INFO, but CI runs a bare `flutter analyze`, which exits 1 on any issue including infos -- so this alone would have turned the job red. Note for the record: Copilot flagged this same line claiming `'00' * 80` "relies on a non-standard String operator * and will fail to compile". That part is wrong -- `String.operator*` is dart:core, and `'00' * 4` evaluates to '00000000'. The line compiles fine; it is the lint, not the language. --- test/gen5_live_imu_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gen5_live_imu_test.dart b/test/gen5_live_imu_test.dart index 5b1ca1a7..4a9b62b5 100644 --- a/test/gen5_live_imu_test.dart +++ b/test/gen5_live_imu_test.dart @@ -32,7 +32,7 @@ void main() { }); test('abstains on gen4-shaped short 0x33', () { - expect(frameAccelGen5Live('33' + ('00' * 80)), isNull); + expect(frameAccelGen5Live('33${'00' * 80}'), isNull); }); }); From 9fd17b9d9be0a8fb8ee521e3f9261c959c45844a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 8 Aug 2026 10:06:56 +0530 Subject: [PATCH 25/25] Address CodeRabbit's full-review findings (no merge/conflict work) Four of the five findings were real. Each verified against the code before changing anything. 1. VACUOUS GUARD TEST (Major) -- substrate_accel_absence_test only exercised `accelPresentFraction`, never the gate it feeds at substrate.dart:574-600. The coverage floor could have been deleted and every assertion still passed -- the same vacuity I flagged in other people's tests earlier in this stack, in my own. Now drives `calendarDays` (the real entry point) and asserts on `PhysioDay.sleepSource`. Two things worth recording about building it. The first positive control FAILED: a "near-still" night was not detected as sleep at all, which would have made the negative test pass for the wrong reason. Fixed by giving the fixture a genuine shape -- constant gravity through the night, a 10 deg/s ramp while awake (a ramp, not an alternation: the mask smooths z-angle with a 5-second rolling MEDIAN, which erases a 1 Hz square wave entirely). Second, mutation testing showed the headline all-zero case was STILL not discriminating -- with the gate removed it passed anyway, because a record immobile end to end is rejected downstream regardless. The discriminating shape is the realistic one: evening has gravity, the night's records do not. Coverage lands below the floor, and those zeros would otherwise form a clean multi-hour "immobile" block to anchor on. Removing the gate now fails 2 tests; both are pinned, with the non-discriminating one kept and labelled. 2. STALE SERIAL (Minor, real) -- `context.select` selected `a.device`, but `select` compares with `==`, `DeviceState` declares no `==`/`hashCode`, and `BleEngine` mutates `state.serial` IN PLACE. The selector returned the same reference before and after, so no rebuild fired and the row could sit on a stale serial indefinitely. Now selects the serial STRING the row renders, which is the only value this sheet reads from `device`/`paired`. 3. CHIP OVERFLOW (Minor) -- two intrinsically-sized, non-flex chips in a Row, the second carrying "WHOOP 5 (experimental)". At large text scales or on a narrow device their combined width exceeds the Expanded column. Wrap degrades to a second line instead. 4. TEST COVERAGE (Minor) -- the R10-lite case asserted only ABSENCE from `decoded_onehz`, which would equally pass if the record were dropped outright; retention in `samples` is the other half of that contract. Added, plus a case protecting the unparseable-hex fallback in `_decodeOneHzSample`. NOT CHANGED, deliberately: * `gen5DeepBuffersEnabled` is unwired in production -- verified true (zero overrides in app_state or background_sync), but it is a deliberate default-OFF opt-in for v20/v21/v26 buffers that are archived, not interpreted. Wiring a settings toggle for a feature nothing consumes would be premature. * The protocol PIN. CodeRabbit's `pubspec.yaml` comment finding was real and is fixed -- the file claimed this branch was "intentionally WHOOP-4-only" while pinning the multiband commit twelve lines below. But the pin itself is left alone: protocol#16 has landed, so the SHA is now reachable from protocol main and SHOULD be repointed there (the current pin is a PR-branch head on the branch this PR deletes on merge -- the exact orphaning the same paragraph warns against). That belongs with the main-merge, not this pass; recorded as a FOLLOW-UP in the file. 12 tests added; the gate tests are mutation-verified. analyze clean. Suite 1085 passing / 6 failing -- the 6 are the notification_dedupe time bombs this branch still carries because it predates #207 on main (19 hardcoded dates, no `todayLabel()`). They clear when main is merged in. --- lib/ui/profile/profile_screen.dart | 29 +++-- pubspec.yaml | 13 +- test/gen5_decoded_onehz_persistence_test.dart | 60 +++++++++ test/substrate_accel_absence_test.dart | 123 ++++++++++++++++++ 4 files changed, 215 insertions(+), 10 deletions(-) diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 0bdded69..41ef0952 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -922,13 +922,20 @@ class _DeviceTileState extends State { overflow: TextOverflow.ellipsis, ), const SizedBox(height: Sp.x2), - Row( + // Wrap, not Row: these are two intrinsically-sized chips + // with no flex, and the second one carries a long label + // ("WHOOP 5 (experimental)"). At large text scales, or on + // a narrow device, their combined width exceeds the + // Expanded column and a Row overflows. Wrapping degrades + // to a second line instead. + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, children: [ StatusChip(widget.statusText, tone: widget.statusTone), - if (widget.generation != null) ...[ - const SizedBox(width: Sp.x2), - StatusChip(widget.generation!, tone: ChipTone.neutral), - ], + if (widget.generation != null) + StatusChip(widget.generation!, + tone: ChipTone.neutral), ], ), ], @@ -1359,14 +1366,20 @@ class _DeviceSheet extends StatelessWidget { // touchpoint in this class after finding the same gap cost a real bug in // the main ProfileScreen build above.) Also select confirmation flags: // omitting them left the caption stuck on "Setting alarm…" after grace. + // + // Select the SERIAL VALUE, not the `device`/`paired` OBJECTS. `select` + // compares with `==`, `DeviceState` declares no `==`/`hashCode` (so it is + // identity equality), and `BleEngine` mutates `state.serial` IN PLACE — + // the selector therefore returns the same reference before and after, no + // change is detected, and this row can sit on a stale serial indefinitely. + // Selecting the string the row actually renders makes the dependency real. context.select( + (bool, int?, String?, String?, bool, bool, bool)>( (a) => ( a.isConnected, a.alarmEpoch, a.strapName, - a.device, - a.paired, + a.device.serial ?? a.paired?.serial, a.alarmConfirmed, a.alarmPending, a.alarmUnconfirmed, diff --git a/pubspec.yaml b/pubspec.yaml index 02b52ad6..9ba7b92e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,8 +27,17 @@ dependencies: # 0.9.13/0.9.14 with no edge change and shipped the main-thread staging ANRs). # Bump these SHAs deliberately, as part of a reviewed edge change. Local dev # still builds against ../analytics and ../protocol via pubspec_overrides.yaml. - # NOTE: this branch is intentionally WHOOP-4-only — protocol stays on `main`, - # NOT the gen5/multiband branch. + # NOTE: this branch is the gen5/multiband one — protocol is pinned to the + # multiband work, NOT to a WHOOP-4-only main. (This line previously claimed + # the opposite and contradicted the pin twelve lines below it.) + # + # FOLLOW-UP before this merges: protocol#16 has since landed, so the pinned + # SHA below is now reachable from protocol `main` (367d22b). It should be + # repointed at that merge commit — the current pin is a PR-BRANCH head on + # `feat/multiband-whoop5`, i.e. the very branch deleted when this PR merges, + # which is exactly the orphaning the paragraph above warns about. Left as-is + # here only because repinning belongs with the main-merge, not with this + # review pass. # Both are now MERGE COMMITS ON `main`, not PR-branch heads — the PR-branch # SHAs these briefly pointed at are no longer the canonical location of the # change, and a branch deletion could orphan them. diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index f0d06464..22af66cd 100644 --- a/test/gen5_decoded_onehz_persistence_test.dart +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -229,4 +229,64 @@ void main() { expect(rows.first['spo2_red_raw'], 100); }); }); + + // CodeRabbit: the R10-lite case asserted only ABSENCE from `decoded_onehz`, + // which would also pass if the record were dropped entirely. Retention in + // `samples` is the other half of that contract. + test('an R10-lite record is excluded from decoded_onehz but RETAINED in samples', + () async { + const ts = 1780000300; + const counter = 4242; + final inner = _buildR10LiteInner(ts: ts, counter: counter, hr: 71); + final sample = Sample(tsEpoch: ts, counter: counter, hr: 71); + final raw = RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: ts * 1000, + recTs: ts, + ); + await LocalDb.commitSyncBatch([raw], [sample]); + + final db = await LocalDb.instance; + expect( + await db.query('decoded_onehz', where: 'rec_ts = ?', whereArgs: [ts]), + isEmpty, + reason: 'hr-only R10-lite is not 1 Hz substrate', + ); + expect( + await db.query('samples', where: 'counter = ?', whereArgs: [counter]), + hasLength(1), + reason: 'excluded from the substrate is NOT the same as discarded', + ); + }); + + // Protects the hex-conversion fallback in `LocalDb._decodeOneHzSample`: when + // the raw hex cannot be parsed, a timestamp-valid preferred Sample must still + // reach `decoded_onehz` rather than the record being lost. + test('unparseable raw hex still persists a timestamp-valid preferred Sample', + () async { + const ts = 1780000400; + const counter = 5150; + final raw = RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: 'zzzz-not-hex', + capturedAt: ts * 1000, + recTs: ts, + ); + final sample = Sample( + tsEpoch: ts, + counter: counter, + hr: 66, + rrIntervalsMs: const [910], + ); + await LocalDb.commitSyncBatch([raw], [sample]); + + final db = await LocalDb.instance; + final rows = + await db.query('decoded_onehz', where: 'rec_ts = ?', whereArgs: [ts]); + expect(rows, hasLength(1)); + expect(rows.first['hr'], 66); + }); } diff --git a/test/substrate_accel_absence_test.dart b/test/substrate_accel_absence_test.dart index 14c70282..e106a155 100644 --- a/test/substrate_accel_absence_test.dart +++ b/test/substrate_accel_absence_test.dart @@ -163,4 +163,127 @@ void main() { }, ); }); + + // CodeRabbit, correctly: everything above tests the PREDICATE + // (`accelPresentFraction`) and never executes the gate it feeds. That made + // this a vacuous guard for a P0 -- the coverage floor could have been deleted + // and every assertion here would still pass. These drive `calendarDays`, the + // real entry point, and assert on the SLEEP RESULT. + group('the coverage floor actually gates accel-led detection', () { + /// A full night of the given accel presence, with a plausible nocturnal HR + /// dip so the HR-led fallback has something to find. + Substrate night({required bool accelPresent, double presentFraction = 1.0}) { + // 22:00 -> 08:00 local, 1 Hz. + final start = _localMidnightOf(1750000000) + 22 * 3600; + const n = 10 * 3600; + final ts = []; + final hr = []; + final ax = []; + final ay = []; + final az = []; + for (var i = 0; i < n; i++) { + ts.add(start + i); + // Awake ~70, asleep ~50 between 23:00 and 07:00. + final inNight = i > 3600 && i < 9 * 3600; + hr.add(inNight ? 50 : 70); + final present = accelPresent && (i / n) < presentFraction; + if (present) { + if (inNight) { + // Asleep: a constant gravity vector — a genuine immobile block for + // van Hees to find. + ax.add(0.0); + ay.add(0.0); + az.add(1.0); + } else { + // Awake: a 10 deg/s sweep. It has to be a RAMP, not an + // alternation — the mask smooths the z-angle with a 5-second + // rolling MEDIAN, which erases a 1 Hz square wave entirely and + // would make the whole record read immobile. + final deg = (i % 9) * 10.0; + final rad = deg * math.pi / 180.0; + ax.add(math.cos(rad)); + ay.add(0.0); + az.add(math.sin(rad)); + } + } else { + ax.add(0.0); + ay.add(0.0); + az.add(0.0); + } + } + return Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: ax, + ay: ay, + az: az, + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + ); + } + + test( + 'when the NIGHT\'s own records carry no gravity, no accel-led `auto` ' + 'sleep is produced — the fabricated window never reaches a PhysioDay', + () { + // The discriminating shape, and the realistic one: the evening has + // real accel, the night's records decoded without a gravity vector. + // Coverage lands below the floor, and the zeros would otherwise form a + // clean multi-hour "immobile" block for van Hees to anchor on. With + // the gate removed this test FAILS — verified by mutation. + final days = calendarDays(night(accelPresent: true, presentFraction: 0.15)); + for (final d in days) { + expect( + d.sleepSource, + isNot('auto'), + reason: 'accel-led detection ran on data that does not exist', + ); + } + }, + ); + + test( + 'a night with NO gravity at all is likewise never accel-led', + () { + // The pure gen5-lenient case: every record decoded without gravity. + // Kept as documentation of the headline scenario — note it is not + // independently discriminating, because a record that is immobile end + // to end is also rejected downstream. The test above is the one that + // pins the gate. + final days = calendarDays(night(accelPresent: false)); + for (final d in days) { + expect(d.sleepSource, isNot('auto')); + } + }, + ); + + test('a fully-measured night still detects accel-led sleep normally', () { + final days = calendarDays(night(accelPresent: true)); + expect( + days.any((d) => d.sleepSource == 'auto'), + isTrue, + reason: 'the gate must not suppress a night we CAN measure', + ); + }); + + test('the boundary: just below the floor gates, at/above it does not', () { + final below = calendarDays( + night(accelPresent: true, presentFraction: kMinAccelCoverageForVanHees - 0.1)); + expect(below.any((d) => d.sleepSource == 'auto'), isFalse); + + final atOrAbove = calendarDays( + night(accelPresent: true, presentFraction: kMinAccelCoverageForVanHees + 0.1)); + expect(atOrAbove.any((d) => d.sleepSource == 'auto'), isTrue); + }); + }); +} + +/// Local midnight for [epochSec], matching `substrate.dart`'s own day anchor. +int _localMidnightOf(int epochSec) { + final d = DateTime.fromMillisecondsSinceEpoch(epochSec * 1000); + return DateTime(d.year, d.month, d.day).millisecondsSinceEpoch ~/ 1000; }