diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7fa0a74..2f3ca4f 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 = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + 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 = 52; + CURRENT_PROJECT_VERSION = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + 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 = 52; + CURRENT_PROJECT_VERSION = 54; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.9.21; + 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 = 52; + 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.21; + 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 = 52; + 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.21; + 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 = 52; + 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.21; + 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 = 52; + 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.21; + 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 = 52; + 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.21; + 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 = 52; + 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.21; + MARKETING_VERSION = 0.9.23; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift index e057538..ee7c0a8 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 bbbd94d..d4142c7 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -54,6 +54,7 @@ NSAccessorySetupBluetoothServices 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 + FD4B0001-CCE1-4033-93CE-002D5875F58A NSAccessorySetupKitSupports diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index dbf6dfd..83ec2b2 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -84,6 +84,141 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive); /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); +/// 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, 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 isPlausibleUnix(at7, wallNow) ? 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, int wallNow) { + if (inner.length < kGen5V18MinInnerLen || inner[1] != 18) return null; + 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); + 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, + ); +} + +/// 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). +/// +/// 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, + ); +} + +/// Decode a gen5 historical inner frame to a band-agnostic [Sample], or null. +@visibleForTesting +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, wallNow); + } + return null; +} + @visibleForTesting int countHistoricalBurstPackets({ required Map dataPacketCountsByRevision, @@ -155,6 +290,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 / @@ -269,11 +415,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. @@ -354,6 +516,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, @@ -370,8 +541,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. @@ -910,7 +1084,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) { @@ -920,14 +1097,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'); @@ -1101,15 +1280,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 _failConnect(); 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) { for (final c in svc!.characteristics) { if (c.uuid.str.toLowerCase().startsWith(prefix)) return c; @@ -1117,15 +1314,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 _failConnect(); return false; } @@ -1267,8 +1464,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]); } @@ -1343,7 +1546,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)); @@ -1361,7 +1564,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(); } @@ -1566,11 +1769,22 @@ 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; } - final frame = buildCommand(_seq.nextLive(), opcode, payload); + final frame = buildCommand( + _seq.nextLive(), opcode, payload, _session?.band ?? BandProfile.gen4); final ok = await _write(frame); if (!ok) { _log('WRITE FAILED for opcode 0x${opcode.toRadixString(16)} — ' @@ -1579,6 +1793,18 @@ class BleEngine { return ok; } + // 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, @@ -1742,7 +1968,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); } @@ -1814,6 +2052,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; @@ -1835,7 +2099,33 @@ 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 wallNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final isGen5 = _session?.band.isGen5 ?? false; + if (isGen5) { + // 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 = 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. var decodeTarget = frame.inner; @@ -1874,19 +2164,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). @@ -1898,7 +2180,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, )) { @@ -2055,6 +2337,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) { @@ -2377,7 +2669,8 @@ class BleEngine { ); return; } - 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()}', @@ -2456,6 +2749,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 @@ -2515,6 +2813,7 @@ class BleEngine { 'history_completions': _historyCompletions, 'strap_history_oldest_ts': _strapHistoryOldestTs, 'strap_history_newest_ts': _strapHistoryNewestTs, + 'band_generation': state.generation, }, )); _log( @@ -2599,8 +2898,61 @@ 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; + 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)); + // 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(); + await Future.delayed(const Duration(milliseconds: 120)); + await _sendHistoricalData(); + return; + } _log('Sending 5-packet INIT…'); for (final pkt in initPackets) { await _write(pkt); @@ -2695,69 +3047,98 @@ 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, - ]; - 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, 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 (${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(); } /// 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) 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: + /// 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: @@ -2771,10 +3152,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.) @@ -2797,11 +3191,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, + AlarmPayloads.gen5MaverickBuzz(), + ); + } + 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. @@ -2836,6 +3244,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. @@ -2844,12 +3253,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 @@ -2881,6 +3297,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 = >[ [ @@ -2891,13 +3308,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) { @@ -2909,6 +3327,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, @@ -2918,13 +3337,14 @@ class BleEngine { Cmd.enableOpticalData, [revision1, 0x00], ], - [ - Cmd.sendR10R11Realtime, - [0x00], - ], + if (!isGen5) + [ + Cmd.sendR10R11Realtime, + [0x00], + ], [ Cmd.toggleImuMode, - [0x00], + imuModePayload(false, isGen5: isGen5), ], [ Cmd.toggleRealtimeHr, @@ -3112,6 +3532,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/ble/ble_state.dart b/lib/ble/ble_state.dart index 039dc37..0907276 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,33 @@ class AlarmPayloads { ]; } + /// Generation-correct SET_ALARM_TIME body (rich 20-byte firing form). + /// + /// WHOOP 4: slot index 0 (HW-verified). WHOOP 5: slot **index 1** — captured + /// from the official WHOOP Android app on fw 50.40.1.0. Index 0 is rejected + /// with console `arm info is invalid, error 0xb`. On gen5 the [index] + /// argument is ignored so callers cannot accidentally arm slot 0. + static List 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}) { + // `& 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]; + } + /// RUN_ALARM (0x44) body — fire the haptics immediately ("test buzz"). static const List runNow = [0x01]; diff --git a/lib/ble/gen5_live_imu.dart b/lib/ble/gen5_live_imu.dart new file mode 100644 index 0000000..8c4606c --- /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/compute/substrate.dart b/lib/compute/substrate.dart index 40df505..e9a139f 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/lib/data/db.dart b/lib/data/db.dart index 33d11f7..a7950c5 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,55 @@ 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; + // 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 { - // 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; + bytes = proto.hexToBytes(raw.hex); + } 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) { + return preferred; } + return null; } /// THE orphan guard for an INSERT-OR-REPLACE into `decoded_onehz`. diff --git a/lib/data/models.dart b/lib/data/models.dart index 6f9882e..5f95c50 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/lib/state/app_state.dart b/lib/state/app_state.dart index d4465cd..6e20ad6 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; } @@ -2374,20 +2377,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 f44a335..41ef095 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,22 @@ class _DeviceTileState extends State { overflow: TextOverflow.ellipsis, ), const SizedBox(height: Sp.x2), - StatusChip(widget.statusText, tone: widget.statusTone), + // 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) + StatusChip(widget.generation!, + tone: ChipTone.neutral), + ], + ), ], ), ), @@ -1338,9 +1364,26 @@ 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. + // + // 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( + (a) => ( + a.isConnected, + a.alarmEpoch, + a.strapName, + a.device.serial ?? a.paired?.serial, + a.alarmConfirmed, + a.alarmPending, + a.alarmUnconfirmed, + ), ); final live = context.read(); final connected = live.isConnected; diff --git a/pubspec.lock b/pubspec.lock index bd6ebe1..956060c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -973,8 +973,8 @@ packages: dependency: "direct main" description: path: "." - ref: "5bb8606964f2d94d9a73dc42c57416cb74282d52" - resolved-ref: "5bb8606964f2d94d9a73dc42c57416cb74282d52" + ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d + resolved-ref: c525e29e69ff8b8f0a180b8464d4ccb32ec3e56d url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2b5c900..9ba7b92 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.23+54 environment: sdk: ^3.11.4 @@ -27,25 +27,43 @@ 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. openstrap_protocol: git: url: https://github.com/OpenStrap/protocol.git - # Tip of protocol main — OpenStrap/protocol#20 merged: the reassembler now - # checks the length-field crc8 before trusting it (a corrupted length byte - # used to consume up to 4092 bytes of good stream before this), and - # hexToBytes rejects odd-length hex instead of silently flooring it. This - # PR sat unpinned for a full day after merging — see fix/issues #22 for - # why (edge's own release pipeline shipped the pre-fix decoder). - # NOTE: this ref predates protocol fix/issues#21 (realtimeRr RR-bound, - # historical-family activity/steps_inc null-vs-0) — repin again once #21 - # merges to pick those up too. - # Verified present: `git show :lib/src/framing.dart | grep 'crc8(Uint8List'`. - ref: 5bb8606964f2d94d9a73dc42c57416cb74282d52 + # 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. + # 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 diff --git a/test/alarm_test.dart b/test/alarm_test.dart index 215ca97..815fbf8 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]); diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart new file mode 100644 index 0000000..22af66c --- /dev/null +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -0,0 +1,292 @@ +// 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).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); + } + 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])); + }); + + // 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; + 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 + 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, + 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, + 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); + }); + }); + + // 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/gen5_imu_mode_payload_test.dart b/test/gen5_imu_mode_payload_test.dart new file mode 100644 index 0000000..f9fc49d --- /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 0000000..4a9b62b --- /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)); + }); + }); +} diff --git a/test/gen5_sample_mapping_test.dart b/test/gen5_sample_mapping_test.dart new file mode 100644 index 0000000..37c611a --- /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); + }); + }); +} diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart new file mode 100644 index 0000000..a323f9e --- /dev/null +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -0,0 +1,97 @@ +// 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'; + +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('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' + '040d6003010c020c3100000000000000000000000000000000000000000000011f' + 'bed68080000000a80372c0000000', + ); + + test('strict protocol decode returns null (gravity/dyn gate)', () { + expect(parseGen5Historical(inner), isNull); + }); + + test('unix@7 garbage → abstain (do not invent offset-6 time)', () { + const wallNow = 1785863370; // export capture era + expect(gen5V18UnixFromInner(inner, wallNow), isNull); + expect(sampleFromGen5V18Lenient(inner, wallNow), isNull); + expect(decodeGen5HistoricalSample(inner, wallNow), isNull); + }); + }); + + 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/RR when strict decode fails gravity gate only', () { + 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] = 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); + inner.buffer.asByteData().setFloat32(41, 0.05, Endian.little); + inner.buffer.asByteData().setFloat32(45, 0.05, Endian.little); + + expect(parseGen5Historical(inner), isNull); + const wallNow = unix; // plausible vs the synthetic timestamp + 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); + }); + }); +} diff --git a/test/substrate_accel_absence_test.dart b/test/substrate_accel_absence_test.dart new file mode 100644 index 0000000..e106a15 --- /dev/null +++ b/test/substrate_accel_absence_test.dart @@ -0,0 +1,289 @@ +// 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), + ); + }, + ); + }); + + // 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; +}