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..774cb45 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -84,6 +84,35 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive); /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); +/// Map a decoded gen5 historical record onto the band-agnostic `Sample` type, +/// or null when this record kind has no `Sample` equivalent (yet). +/// +/// Only `Gen5HistorySample` (v18, the per-second stream) maps today — the +/// deep buffers (`Gen5OpticalBuffer`/`Gen5ImuBuffer`/`Gen5PpgWaveform`, R22 +/// opt-in only) need their own raw-buffer storage, not a 1Hz `Sample`, so +/// they (and a null [g], e.g. an unrecognised version) correctly return null +/// here — the caller archives those, exactly like an undecodable gen4 +/// record. Extracted as a top-level pure function (rather than inlined in +/// `_ingestHistoricalFrame`) so the mapping is unit-testable without a live +/// BLE session — see `gen5_sample_mapping_test.dart`. +@visibleForTesting +Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { + if (g is! Gen5HistorySample) return null; + return Sample( + tsEpoch: g.unix, + counter: g.recordIndex, + hr: g.heartRate, + rrIntervalsMs: List.from(g.rrIntervalsMs), + // Gravity vector is float32 g-units on BOTH generations (unlike skin + // temp / SpO2, which use gen5-specific scales/mechanisms — see + // Gen5HistorySample's field docs) — safe to feed straight into the + // shared ax/ay/az fields analytics already reads band-agnostically. + ax: g.gravityG.isNotEmpty ? g.gravityG[0] : null, + ay: g.gravityG.length > 1 ? g.gravityG[1] : null, + az: g.gravityG.length > 2 ? g.gravityG[2] : null, + ); +} + @visibleForTesting int countHistoricalBurstPackets({ required Map dataPacketCountsByRevision, @@ -269,11 +298,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 +399,15 @@ class BleEngine { final Duration Function() deriveDataStaleness; final bool Function() isForegroundActive; + /// Opt-in: send the gen5 "R22" 16-flag SET_CONFIG enable sequence + /// (`kGen5R22EnableFlags`) before the historical offload on a gen5 link, + /// unlocking the v20 (optical)/v21 (IMU)/v26 (PPG) deep buffers. Defaults to + /// OFF — the official WHOOP app never sends this either, the sequence is + /// UNTESTED on physical hardware, and without it a gen5 strap still serves + /// its always-on v18 per-second stream perfectly well. Wire a caller-owned + /// settings read here to make it a real user-facing toggle. + final bool Function() gen5DeepBuffersEnabled; + BleEngine({ required this.onRecord, required this.onState, @@ -370,8 +424,11 @@ class BleEngine { this.isBackgroundDrainer = false, this.deriveDataStaleness = _defaultDeriveDataStaleness, this.isForegroundActive = _defaultIsForegroundActive, + this.gen5DeepBuffersEnabled = _defaultGen5DeepBuffersDisabled, }); + static bool _defaultGen5DeepBuffersDisabled() => false; + /// True for the headless restore-drain engine (runHeadlessSync). It YIELDS the /// band to a foreground engine rather than fighting it — see [_claimBand]. The /// foreground app engine leaves this false and always wins. @@ -910,7 +967,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 +980,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 +1163,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 +1197,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; } @@ -1343,7 +1423,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 +1441,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 +1646,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 +1670,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 +1845,19 @@ class BleEngine { } else if (pt == PacketType.consoleLogs && _offloadActive) { _drain?.onBurstConsole(); } - final decoded = _maybeAugmentDataRange(frame, decodeFrame(frame)); + final band = _session?.band ?? BandProfile.gen4; + final decoded = _maybeAugmentGen5ClockEpoch( + frame, + _maybeAugmentDataRange(frame, decodeFrame(frame, profile: band)), + ); + // gen5-only, debug-visibility ONLY (never persisted, never gated on): + // log the strap's own console text (now decoded by protocol's + // `parseConsoleLog`, wired into `decodeFrame` above). Genuinely useful + // for diagnosing the untested gen5 handshake/offload on real hardware. + if (band.isGen5 && decoded.kind == 'console_log') { + _log('[CONSOLE gen5] idx=${decoded.fields['record_index']} ' + 'ts=${decoded.fields['ts_epoch']}: ${decoded.fields['text']}'); + } _absorbState(decoded); } @@ -1835,7 +1950,17 @@ class BleEngine { // backfill (all received in one sync) splits into correct per-real-day // buckets instead of collapsing into one "today". Sample? sample; - if (recType == Record.r24 || recType == Record.r12) { + final isGen5 = _session?.band.isGen5 ?? false; + if (isGen5) { + // gen5 (WHOOP 5): `parseGen5Historical` dispatches across all four real + // gen5 historical-record kinds (v18 per-second summary, v20 optical/ + // v21 IMU/v26 PPG deep buffers — R22 opt-in only). Only v18 maps onto + // the band-agnostic `Sample` type today; the deep buffers need their + // own raw-buffer storage (a future db table), not a 1Hz Sample, so they + // fall through to the undecodable archive below — that is honest + // (correctly-identified-but-not-yet-stored), not a decode failure. + sample = sampleFromGen5Historical(parseGen5Historical(frame.inner)); + } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. var decodeTarget = frame.inner; @@ -2055,6 +2180,16 @@ class BleEngine { state.wristOn = h.wristOn ?? state.wristOn; onState(state); } + // gen5's GET_HELLO (opcode 145) response shape is unrelated to gen4's + // HelloInfo — it carries a device_name + a gated fw_version instead + // (parseCommandResponse's gen5 GET_HELLO branch). No confirmed serial/ + // battery/wrist-on offsets for it yet, so — unlike gen4's HELLO above — + // this is diagnostics-only for now (confirms the untested gen5 handshake + // actually got a byte-parseable reply) rather than wired into `state`. + if (d.kind == 'cmd_response' && f.containsKey('device_name')) { + _log('[HELLO gen5] device_name=${f['device_name']} ' + 'fw_version=${f['fw_version']}'); + } if (d.kind == 'realtime_hr') { final hr = f['hr'] as int; if (hr > 0) { @@ -2377,7 +2512,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 +2592,11 @@ class BleEngine { 'last_ack_batches': d.batches, 'strap_history_oldest_ts': _strapHistoryOldestTs, 'strap_history_newest_ts': _strapHistoryNewestTs, + // Which WHOOP generation this batch came from — records/sessions + // vary hugely in richness by generation (and, for gen5, by whether + // the R22 deep-buffer opt-in was sent), so downstream diagnostics + // need this without reaching into the transport layer. + 'band_generation': state.generation, }, )); // Same event, but a REAL per-chunk row keyed by the token — closes out @@ -2515,6 +2656,7 @@ class BleEngine { 'history_completions': _historyCompletions, 'strap_history_oldest_ts': _strapHistoryOldestTs, 'strap_history_newest_ts': _strapHistoryNewestTs, + 'band_generation': state.generation, }, )); _log( @@ -2599,8 +2741,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); @@ -2705,16 +2900,28 @@ class BleEngine { 0, 0, ]; - await _send(Cmd.setClock, payload); - _log('SET_CLOCK → sec=$sec subsec=$subsec (WHOOP-exact 8B).'); + // gen5 ("Maverick") uses a DIFFERENT opcode for SET_CLOCK than gen4 — the + // 8-byte payload shape is unchanged, only the opcode value differs (see + // Cmd.setClockMaverick's doc in protocol/constants.dart). Sending gen4's + // opcode 0x0A to a gen5 strap here would silently fail to latch the RTC, + // which then refuses to serve type-47 history — the exact symptom fixed. + final isGen5 = _session?.band.isGen5 ?? false; + final opcode = isGen5 ? Cmd.setClockMaverick : Cmd.setClock; + await _send(opcode, payload); + _log('SET_CLOCK${isGen5 ? " (gen5 Maverick)" : ""} → sec=$sec ' + 'subsec=$subsec (WHOOP-exact 8B).'); // Read the RTC back so the GET_CLOCK response handler can confirm it latched // (and re-issue SET_CLOCK if the strap clock is still off — see _onDecoded). await getClock(); } /// Read the strap RTC. The response carries `clock_epoch`, handled where we - /// verify drift and re-correlate the strap-RTC ↔ wall clock. - Future getClock() => _send(Cmd.getClock, const []); + /// verify drift and re-correlate the strap-RTC ↔ wall clock. gen5 uses its + /// own GET_CLOCK opcode (147) — see [setClock]. + Future getClock() => _send( + (_session?.band.isGen5 ?? false) ? Cmd.getClockGen5 : Cmd.getClock, + const [], + ); /// On-device wake alarm (SET_ALARM_TIME = 0x42) — the RICH 20-byte form that /// actually FIRES on WHOOP 4.0: @@ -2797,11 +3004,25 @@ class BleEngine { } Future getBattery() => _send(Cmd.getBatteryLevel, const []); - Future getHello() => _send(Cmd.getHelloHarvard, const [0x00]); + Future getHello() => (_session?.band.isGen5 ?? false) + ? _send(Cmd.getHello, const [0x01]) + : _send(Cmd.getHelloHarvard, const [0x00]); Future buzz() => buzzPattern(hapticShortPulse); - Future buzzPattern(int pattern) => - _send(Cmd.runHapticsPattern, [pattern, 0, 0, 0, 0]); + /// Play a haptic buzz. gen5 ("Maverick") has a DIFFERENT buzz opcode and + /// payload shape than gen4 (`Cmd.runHapticPatternMaverick`, 12-byte body — + /// see `cmdBuzzGen5Maverick` in protocol/commands.dart) — [pattern] is + /// honoured only on gen4; a gen5 link always plays the strap's fixed + /// `[47, 152]` waveform pair (the only Maverick buzz byte-verified so far). + Future buzzPattern(int pattern) { + if (_session?.band.isGen5 ?? false) { + return _send( + Cmd.runHapticPatternMaverick, + const [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 1], + ); + } + return _send(Cmd.runHapticsPattern, [pattern, 0, 0, 0, 0]); + } /// Signal strength of the live link, in dBm (negative; closer to zero is /// stronger). Null whenever there is nothing to measure. @@ -3112,6 +3333,33 @@ class BleEngine { fields['history_newest'] = ts.reduce((a, b) => a > b ? a : b); return Decoded(decoded.kind, fields); } + + /// Patch up `COMMAND_RESPONSE` decodes for GET_CLOCK_GEN5 (147) — the one + /// gen5-exclusive opcode `parseCommandResponse` doesn't populate a + /// `clock_epoch` for yet (its GET_HELLO=145 and GET_BATTERY_LEVEL=26 + /// battery-scale handling are already profile-aware natively, via the + /// `profile:` param passed into `decodeFrame` above). Without this, gen5's + /// SET_CLOCK/GET_CLOCK drift-correlation (`ClockRef`, read in + /// `_absorbState`) would never populate on a gen5 link, since + /// `parseCommandResponse` only recognises gen4's `Cmd.getClock` (0x0B) for + /// that field. Same "edge augments a decode protocol hasn't caught up to + /// yet" pattern as [_maybeAugmentDataRange]. + Decoded _maybeAugmentGen5ClockEpoch(Frame frame, Decoded decoded) { + if (decoded.kind != 'cmd_response') return decoded; + if (decoded.fields['opcode'] != Cmd.getClockGen5) return decoded; + final inner = frame.inner; + final payload = + inner.length > 3 ? Uint8List.sublistView(inner, 3) : Uint8List(0); + final wallNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; + for (var o = 0; o + 4 <= payload.length; o++) { + final v = u32(payload, o); + if (isPlausibleUnix(v, wallNow)) { + final fields = {...decoded.fields, 'clock_epoch': v}; + return Decoded(decoded.kind, fields); + } + } + return decoded; + } } /// Per-connection historical-offload helper. Buffers records per ACK boundary and diff --git a/lib/data/models.dart b/lib/data/models.dart index 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/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index f44a335..c80297c 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -587,6 +587,11 @@ class ProfileScreen extends StatelessWidget { : '${d.batteryPct!.round()}%${d.charging == true ? ' ⚡' : ''}', wrist: d.wristOn == null ? '—' : (d.wristOn! ? 'On wrist' : 'Off wrist'), serial: d.serial ?? app.paired?.serial ?? '—', + generation: switch (d.generation) { + 'gen5' => 'WHOOP 5 (experimental)', + 'gen4' => 'WHOOP 4', + _ => null, + }, // Manual pull: anything the strap flashed that we don't hold yet, over // the CURRENT connection (no reconnect). Only offered while connected. onSyncNow: conn == 'connected' ? () => app.forceResync() : null, @@ -847,6 +852,11 @@ class DeviceTile extends StatefulWidget { final VoidCallback? onTap; final Future Function()? onSyncNow; + /// Human label for [DeviceState.generation] ('WHOOP 4' / 'WHOOP 5 + /// (experimental)'), or null before a link has been established this + /// process. Purely informational — never gates any behavior here. + final String? generation; + const DeviceTile({ super.key, required this.name, @@ -857,6 +867,7 @@ class DeviceTile extends StatefulWidget { required this.serial, this.onTap, this.onSyncNow, + this.generation, }); @override @@ -911,7 +922,15 @@ class _DeviceTileState extends State { overflow: TextOverflow.ellipsis, ), const SizedBox(height: Sp.x2), - StatusChip(widget.statusText, tone: widget.statusTone), + Row( + children: [ + StatusChip(widget.statusText, tone: widget.statusTone), + if (widget.generation != null) ...[ + const SizedBox(width: Sp.x2), + StatusChip(widget.generation!, tone: ChipTone.neutral), + ], + ], + ), ], ), ), 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..02b52ad 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 @@ -35,17 +35,26 @@ dependencies: 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/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); + }); + }); +}