From 89d0f782adb1d94ace39b6ed4172021833fc4bc3 Mon Sep 17 00:00:00 2001 From: dev-noaman Date: Wed, 12 Aug 2026 15:08:19 +0300 Subject: [PATCH 1/6] feat(ios): discover WHOOP 5.0 / MG in the AccessorySetupKit pairing sheet WHOOP 5.0 / MG bands never appear in the ASK pairing sheet. The discovery descriptor and NSAccessorySetupBluetoothServices both declared only the WHOOP 4.0 service UUID (61080001-...), so the sheet had nothing to match and reported "No Accessory Found" even with the band in pairing mode, flashing blue, and visible in system Bluetooth. On iOS 18+ there is no fallback either: the pairing screen returns at the ASK step before the service-filtered scan is reached. Widen discovery without touching the 4.0 path: - Info.plist declares the candidate gen5 service UUID (0xFD4B expanded against the Bluetooth Base UUID) and a "WHOOP" name substring. - The picker is built from three ASPickerDisplayItems -- gen4 by service, gen5 by service, and a name-substring net -- because a single ASDiscoveryDescriptor AND-combines its criteria. - If iOS rejects the widened list, the picker retries once with the 4.0-only item, so the experiment can never break WHOOP 4.0 pairing. - The Dart scan filter (Android / iOS < 18) accepts both families; withServices is OR-combined, so 4.0 discovery is unchanged. - Failed discovery and failed post-connect service discovery now log what was actually seen, and the pairing screen gains an opt-in diagnostics probe that dumps raw advertisements to the shareable log. It appears only after discovery has already failed: running it automatically would trigger the CoreBluetooth permission prompt the ASK flow deliberately avoids. This does NOT make gen5 work. There is no gen5 transport -- a gen5 band that now reaches the connect step still fails at service discovery, by design, logging its real GATT tree. The goal is to turn a silent dead end into a reportable capture. The gen5 service UUID is a candidate from community reports, not from hardware any maintainer owns. It is kept local to the app rather than promoted into openstrap_protocol until a real capture confirms it. Also corrects README, which claimed gen5 bands are "detected and spoken to" -- before this change there was no gen5 code in the tree at all. Testing: flutter analyze clean on all touched Dart; ble_engine_test.dart passes. The iOS native changes are UNBUILT (authored on Windows, no Xcode) and need someone with a Mac to compile and a 4.0 owner to confirm no regression. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NPm2gRRVfrTWeq71Ke6x3Q --- README.md | 10 +- ios/Runner/AccessorySetup.swift | 99 +++++++++++++++----- ios/Runner/Info.plist | 19 ++++ lib/ble/ble_engine.dart | 156 ++++++++++++++++++++++++++++++-- lib/state/app_state.dart | 13 +++ lib/ui/pairing_screen.dart | 78 ++++++++++++++++ 6 files changed, 345 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5681a140..12c44cf4 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,13 @@ shortcuts, a smart alarm that buzzes the band. against a lab, don't treat any of it as a diagnosis. - Not on the App Store or Play Store yet. iOS is a public TestFlight beta, which is a normal install but still a beta; Android is an APK straight off Releases. -- WHOOP 5.0 / MG support is in progress and **experimental** — the band is detected and - spoken to, but it hasn't been validated against real 5.0 hardware. WHOOP 4.0 is the - only one that's actually tested. +- WHOOP 5.0 / MG support is **experimental and discovery-only**. Pairing now looks for + a gen5 band (by its reported service UUID and by name) instead of silently ignoring + it, and a Diagnostics button on the pairing screen captures what your phone can + actually see. But there is no gen5 transport: a 5.0 / MG band that connects will + still fail at service discovery, on purpose, logging its real GATT tree. Nothing here + has been validated against 5.0 hardware — no maintainer owns one, so those captures + are how it gets fixed. WHOOP 4.0 is the only family that actually works. ## Run it diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift index e0575385..a889c850 100644 --- a/ios/Runner/AccessorySetup.swift +++ b/ios/Runner/AccessorySetup.swift @@ -28,7 +28,18 @@ 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" + fileprivate static let gen4ServiceUUID = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6" + + // EXPERIMENTAL — WHOOP 5.0 / MG. The 16-bit member service 0xFD4B expanded against + // the Bluetooth Base UUID. NOT validated against hardware: no maintainer owns a gen5 + // strap, so this is a community-reported candidate. If a capture proves it wrong, + // this is the one line to change (plus its twin in Info.plist and ble_engine.dart). + fileprivate static let gen5ServiceUUID = "0000FD4B-0000-1000-8000-00805F9B34FB" + + // EXPERIMENTAL — the net that catches a gen5 band whose service UUID we have wrong. + // Matches the advertised local name: "WHOOP MGB…", "WHOOP 5…", "WHOOP 4…". + // Must stay in sync with NSAccessorySetupBluetoothNames in Info.plist. + fileprivate static let nameSubstring = "WHOOP" static func register(messenger: FlutterBinaryMessenger) { let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) @@ -136,39 +147,85 @@ 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. let productImage = UIImage(named: "StrapProduct") ?? UIImage(systemName: "sensor.tag.radiowave.forward") ?? UIImage() - let item = ASPickerDisplayItem( - name: "WHOOP band", - productImage: productImage, - descriptor: descriptor - ) + + // ONE ITEM PER MATCH STRATEGY. A single ASDiscoveryDescriptor AND-combines its + // criteria, so a descriptor carrying the 4.0 service AND the gen5 service AND a + // name substring would match nothing at all. showPicker(for:) takes an array + // precisely so alternative accessories can each bring their own descriptor; the + // sheet de-duplicates by peripheral, so a 4.0 band matching two items shows once. + // + // Every criterion used below is declared in Info.plist (NSAccessorySetupBluetooth- + // Services / …Names) — an undeclared criterion is silently ignored by the system, + // which is exactly how gen5 bands ended up invisible here (no gen5 UUID declared, + // no name fallback ⇒ "No Accessory Found" no matter what the band was doing). + func makeItem(_ label: String, + _ configure: (ASDiscoveryDescriptor) -> Void) -> ASPickerDisplayItem { + let descriptor = ASDiscoveryDescriptor() + configure(descriptor) + return ASPickerDisplayItem(name: label, productImage: productImage, + descriptor: descriptor) + } + + let items: [ASPickerDisplayItem] = [ + // WHOOP 4.0 — the proven path, byte-identical to what shipped before. + makeItem("WHOOP band") { + $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.gen4ServiceUUID) + }, + // EXPERIMENTAL — WHOOP 5.0 / MG by its (community-reported) service UUID. + makeItem("WHOOP 5.0 / MG") { + $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.gen5ServiceUUID) + }, + // EXPERIMENTAL — WHOOP 5.0 / MG by advertised name, for when the UUID above is + // wrong or iOS won't surface it. Drop this item once a real capture confirms + // the gen5 service UUID. + makeItem("WHOOP band") { + $0.bluetoothNameSubstring = AccessorySetup.nameSubstring + }, + ] pickerResult = completion - session.showPicker(for: [item]) { [weak self] error in + present(items, allowGen4Retry: true) + } + + /// Presents the picker and resolves `pickerResult`. + /// + /// SAFETY NET for the experimental gen5 items: if the system rejects the descriptor + /// list outright (e.g. it won't accept a name-only descriptor), we must not take + /// WHOOP 4.0 pairing down with it — so one retry falls back to the 4.0-only item + /// that shipped before gen5 support existed. Fail closed on the experiment, never + /// on the path that works. + private func present(_ items: [ASPickerDisplayItem], allowGen4Retry: Bool) { + session.showPicker(for: items) { [weak self] error in guard let self = self else { return } if let error = error { - if let cb = self.pickerResult { - self.pickerResult = nil - cb(.failure(PickerError(message: error.localizedDescription))) + // `pickerResult == nil` means .pickerDidDismiss already resolved this as a + // user cancel, so there is nothing to retry or report. + guard let cb = self.pickerResult else { return } + let message = error.localizedDescription + let looksCancelled = message.lowercased().contains("cancel") + if allowGen4Retry, !looksCancelled, items.count > 1 { + NSLog("[ASK] picker rejected the %d-item descriptor list (%@) — " + + "retrying with the WHOOP 4.0 item only.", items.count, message) + self.present([items[0]], allowGen4Retry: false) + return } + self.pickerResult = nil + cb(.failure(PickerError(message: message))) return } // Picker succeeded — read the newly provisioned accessory's identifier. + // Log every provisioned accessory first: gen5 triage happens entirely from + // user-submitted logs, and "what name did the sheet actually show?" is the + // question that keeps coming back. + for acc in self.session.accessories { + NSLog("[ASK] provisioned name=%@ id=%@", acc.displayName, + acc.bluetoothIdentifier?.uuidString ?? "nil") + } let id = self.session.accessories .compactMap { $0.bluetoothIdentifier } .first?.uuidString.uppercased() diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 75b3d356..79b9d8f6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -51,9 +51,28 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAccessorySetupBluetoothServices + 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 + + 0000FD4B-0000-1000-8000-00805F9B34FB + + + NSAccessorySetupBluetoothNames + + WHOOP NSAccessorySetupKitSupports diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index e0fa3ef4..04dc09b2 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -53,6 +53,30 @@ import 'ble_state.dart'; int u32(Uint8List b, int o) => b.buffer.asByteData(b.offsetInBytes, b.length).getUint32(o, Endian.little); +// ── WHOOP 5.0 / MG (gen5) — EXPERIMENTAL ──────────────────────────────────── +// The 16-bit member service 0xFD4B expanded against the Bluetooth Base UUID. +// UNCONFIRMED against hardware: no maintainer owns a gen5 strap, so this is a +// community-reported candidate used for DISCOVERY ONLY. There is no gen5 +// transport — a gen5 band that connects still fails at service discovery below, +// deliberately and with a log line describing its real GATT tree. +// +// Deliberately local to the app rather than added to package:openstrap_protocol +// (separate repo, pinned by hash in pubspec.lock): an unverified guess should not +// become a protocol constant. Promote it there once a capture confirms it, and +// keep it in sync with gen5ServiceUUID in ios/Runner/AccessorySetup.swift and +// NSAccessorySetupBluetoothServices in ios/Runner/Info.plist. +const String kGen5ServiceUuid = '0000fd4b-0000-1000-8000-00805f9b34fb'; + +/// True for either WHOOP family's advertised service UUID. +/// +/// Platforms disagree on spelling: iOS reports 16-bit UUIDs in short form +/// ("fd4b") while Android reports the full 128-bit Base-UUID expansion, so both +/// have to be accepted. Gen4 is matched on its 32-bit prefix exactly as before. +bool isWhoopServiceUuid(String raw) { + final u = raw.toLowerCase(); + return u.startsWith('61080001') || u == 'fd4b' || u.startsWith('0000fd4b'); +} + typedef SampleSink = Future Function(Sample? sample, RawRecord raw); typedef StateSink = void Function(DeviceState state); typedef LogSink = void Function(String line); @@ -1197,24 +1221,37 @@ class BleEngine { await FlutterBluePlus.stopScan(); } _setPhase(BleConnState.scanning); - final svc = Guid(GattUuids.service); + final gen4 = Guid(GattUuids.service); + // EXPERIMENTAL gen5. `withServices` is OR-combined on both platforms (one + // ScanFilter per service on Android, CBCentralManager's service array on iOS), + // so adding this strictly widens discovery — the 4.0 path cannot be narrowed. + final gen5 = Guid(kGen5ServiceUuid); BluetoothDevice? found; + // Everything the scan saw, for the log line below. A silent "no WHOOP found" is + // useless to a gen5 owner filing a report; the candidate list is the evidence. + final seen = {}; final sub = FlutterBluePlus.onScanResults.listen((results) { for (final r in results) { final name = r.device.platformName.toLowerCase(); - final advNames = r.advertisementData.serviceUuids.map( + final advUuids = r.advertisementData.serviceUuids.map( (g) => g.str.toLowerCase(), ); + seen.add( + '${r.device.platformName.isEmpty ? "(no name)" : r.device.platformName}' + ' rssi=${r.rssi} svc=[${advUuids.join(",")}]', + ); if (found == null && - (name.contains('whoop') || - advNames.any((s) => s.startsWith('61080001')))) { + (name.contains('whoop') || advUuids.any(isWhoopServiceUuid))) { found = r.device; FlutterBluePlus.stopScan(); } } }); try { - await FlutterBluePlus.startScan(withServices: [svc], timeout: timeout); + await FlutterBluePlus.startScan( + withServices: [gen4, gen5], + timeout: timeout, + ); await FlutterBluePlus.isScanning.where((on) => on == false).first; } catch (e) { _log('scan error: $e'); @@ -1224,10 +1261,104 @@ class BleEngine { if (found == null) { _setPhase(BleConnState.idle); _log('No WHOOP found (force-quit the official app; band must be free).'); + // WHOOP 5.0 / MG is experimental: if the band advertises a service we don't + // know about, this list is what identifies it. Empty means nothing matching + // either family's service UUID was in range at all. + _log( + seen.isEmpty + ? 'Scan saw no advertisers matching either WHOOP service UUID.' + : 'Scan candidates: ${seen.join(" | ")}', + ); } return found; } + /// UNFILTERED discovery probe — a diagnostic, never part of the pairing path. + /// + /// WHY THIS EXISTS: WHOOP 5.0 / MG support is experimental and no maintainer owns + /// gen5 hardware, so every gen5 fix depends on a capture from someone who does. + /// This dumps every BLE advertiser in range with its local name, RSSI, advertised + /// service UUIDs and manufacturer data — which is exactly what identifies the gen5 + /// service UUID that [kGen5ServiceUuid] is currently only guessing at. + /// + /// PLATFORM NOTE: Android returns the complete advertisement, so an Android capture + /// is strictly more informative. iOS surfaces 16-bit service UUIDs normally but + /// hashes 128-bit UUIDs that land in the scan response's overflow area, so an iOS + /// capture can show a band by name while still hiding a custom 128-bit service. + /// + /// COST: this needs Bluetooth permission. On iOS the pairing flow deliberately + /// avoids touching CoreBluetooth before an accessory is provisioned (the adapter + /// reports as unauthorized until then), so callers MUST keep this behind an + /// explicit user action rather than running it automatically. + /// + /// Returns a human-readable, paste-into-an-issue report. Never throws. + Future discoveryProbe({ + Duration timeout = const Duration(seconds: 10), + }) async { + final lines = []; + final byId = {}; // remoteId → newest formatted line + StreamSubscription? sub; + try { + if (FlutterBluePlus.isScanningNow) { + await FlutterBluePlus.stopScan(); + } + sub = FlutterBluePlus.onScanResults.listen((results) { + for (final r in results) { + final adv = r.advertisementData; + final name = r.device.platformName.isNotEmpty + ? r.device.platformName + : (adv.advName.isNotEmpty ? adv.advName : '(no name)'); + final services = adv.serviceUuids.map((g) => g.str).join(','); + final mfg = adv.manufacturerData.entries + .map( + (e) => + '0x${e.key.toRadixString(16).padLeft(4, "0")}:' + '${e.value.map((b) => b.toRadixString(16).padLeft(2, "0")).join()}', + ) + .join(' '); + final svcData = adv.serviceData.entries + .map( + (e) => + '${e.key.str}:' + '${e.value.map((b) => b.toRadixString(16).padLeft(2, "0")).join()}', + ) + .join(' '); + byId[r.device.remoteId.str] = + '$name id=${r.device.remoteId.str} rssi=${r.rssi} ' + 'connectable=${adv.connectable} ' + 'services=[$services] mfg=[$mfg] svcData=[$svcData]'; + } + }); + // No `withServices` — that is the entire point of the probe. + await FlutterBluePlus.startScan(timeout: timeout); + await FlutterBluePlus.isScanning.where((on) => on == false).first; + } catch (e) { + lines.add('probe error: $e'); + } finally { + await sub?.cancel(); + } + lines.addAll(byId.values); + final report = StringBuffer() + ..writeln('── OpenStrap discovery probe ──') + ..writeln('platform: ${Platform.operatingSystem} ' + '${Platform.operatingSystemVersion}') + ..writeln('gen4 service: ${GattUuids.service}') + ..writeln('gen5 service (EXPERIMENTAL, unverified): $kGen5ServiceUuid') + ..writeln('advertisers seen: ${byId.length}'); + if (lines.isEmpty) { + report.writeln( + '(nothing seen — is Bluetooth on and permission granted?)', + ); + } else { + for (final l in lines) { + report.writeln(l); + } + } + final text = report.toString(); + _log(text); + return text; + } + /// Reconnect to a previously-paired device by its persisted remote id. Future connectToRemoteId(String remoteId) => connect(BluetoothDevice.fromId(remoteId)); @@ -1394,7 +1525,20 @@ class BleEngine { if (s.uuid.str.toLowerCase().startsWith('61080001')) svc = s; } if (svc == null) { - _log('Harvard service not found on device.'); + // Still fatal: there is no gen5 transport to fall through to, only the 4.0 + // (Harvard) framing. But dump what the band DOES expose before giving up — + // for a WHOOP 5.0 / MG this log is the entire basis on which a gen5 + // transport could eventually be written, and it costs one line here. + final tree = services + .map( + (s) => + '${s.uuid.str}[${s.characteristics.map((c) => c.uuid.str).join(",")}]', + ) + .join(' '); + _log( + 'Harvard (WHOOP 4.0) service not found on device. ' + 'Discovered services: ${tree.isEmpty ? "(none)" : tree}', + ); await _failConnect(); return false; } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 288ff2b9..c3af529d 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2908,6 +2908,19 @@ class AppState extends ChangeNotifier { // ── pairing (LOCAL only) ──────────────────────────────────────────────────── Future scanForBand() => engine.scan(); + /// Unfiltered BLE discovery dump for gen5 (WHOOP 5.0 / MG) triage. Returns a + /// paste-into-an-issue report and — via the engine's log sink — appends it to the + /// shareable [FileLog], whose path [diagnosticsLogPath] returns. + /// + /// MUST stay behind an explicit user action: on iOS this is the first thing that + /// touches CoreBluetooth, and the pairing screen deliberately avoids that before an + /// accessory is provisioned (the adapter reports unauthorized until then, so an + /// automatic call here would regress pairing for WHOOP 4.0 users too). + Future runDiscoveryProbe() => engine.discoveryProbe(); + + /// Where [runDiscoveryProbe]'s output can be found on disk, for sharing. + Future diagnosticsLogPath() => FileLog.path(); + /// True on iOS 18+, where pairing must go through the AccessorySetupKit picker so /// the band is provisioned for iOS-26 background relaunch (TN3115). False on Android /// and iOS < 18 — those use the service-filtered scan flow ([scanForBand]/[pairWith]). diff --git a/lib/ui/pairing_screen.dart b/lib/ui/pairing_screen.dart index a83ac900..d9c8a621 100644 --- a/lib/ui/pairing_screen.dart +++ b/lib/ui/pairing_screen.dart @@ -172,6 +172,14 @@ class _StrapPlaceholder extends StatelessWidget { /// Public so the pure [PairingStateView] can be rendered per-state in tests. enum PairPhase { scanning, found, notFound, pairing, askReady, bluetoothOff } +/// Shown alongside a failed pick. WHOOP 5.0 / MG discovery is experimental and the +/// most common way it fails is the system sheet simply staying empty — which reads +/// as "my band is broken" unless we say otherwise. +const String kGen5PairingHint = + 'Have a WHOOP 5.0 or MG? Support for those is experimental and your band ' + 'may not be discoverable yet. Tap Diagnostics to capture what your phone ' + 'can actually see — that capture is what makes a fix possible.'; + /// Turns whatever a BLE plugin throws into something a normal person can act /// on. flutter_blue_plus/AccessorySetupKit exceptions come through as raw /// PlatformException text — nobody should ever see that on screen. @@ -202,6 +210,9 @@ class _ScanStepState extends State<_ScanStep> { PairPhase _phase = PairPhase.scanning; BluetoothDevice? _device; String? _error; + // Only offered after discovery has actually failed once — see _runDiagnostics. + bool _offerDiagnostics = false; + bool _diagnosticsRunning = false; @override void initState() { @@ -248,10 +259,51 @@ class _ScanStepState extends State<_ScanStep> { setState(() { _error = humanizePairError(e); _phase = PairPhase.askReady; + // The sheet came back empty-handed at least once — offer the gen5 capture + // route from here on. Not offered up front: it costs a Bluetooth permission + // prompt, and on iOS the ASK flow deliberately avoids CoreBluetooth until an + // accessory exists. + _offerDiagnostics = true; }); } } + /// Runs the unfiltered discovery probe and shows the report. This is the artifact + /// a WHOOP 5.0 / MG owner attaches to an issue — no maintainer owns gen5 hardware, + /// so it is the only way gen5 discovery gets fixed. + Future _runDiagnostics() async { + final app = context.read(); // capture before async gaps + setState(() => _diagnosticsRunning = true); + String report; + String? path; + try { + report = await app.runDiscoveryProbe(); + path = await app.diagnosticsLogPath(); + } catch (e) { + report = 'Diagnostics failed: $e'; + } + if (!mounted) return; + setState(() => _diagnosticsRunning = false); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Discovery diagnostics'), + content: SingleChildScrollView( + child: SelectableText( + path == null ? report : '$report\nSaved to:\n$path', + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + Future _scan() async { final app = context.read(); // capture before async gaps // Bluetooth being off is the #1 reason a scan silently finds nothing — @@ -277,12 +329,14 @@ class _ScanStepState extends State<_ScanStep> { setState(() { _device = d; _phase = d == null ? PairPhase.notFound : PairPhase.found; + if (d == null) _offerDiagnostics = true; }); } catch (e) { if (!mounted) return; setState(() { _error = humanizePairError(e); _phase = PairPhase.notFound; + _offerDiagnostics = true; }); } } @@ -310,6 +364,9 @@ class _ScanStepState extends State<_ScanStep> { @override Widget build(BuildContext context) { + final failed = + _phase == PairPhase.notFound || + (_phase == PairPhase.askReady && _error != null); return PairingStateView( phase: _phase, deviceName: _device == null ? null : _name(_device!), @@ -317,6 +374,8 @@ class _ScanStepState extends State<_ScanStep> { onBack: widget.onBack, onPair: _phase == PairPhase.askReady ? _pairViaAsk : _pair, onRetry: _scan, + onDiagnostics: _offerDiagnostics && failed ? _runDiagnostics : null, + diagnosticsRunning: _diagnosticsRunning, ); } } @@ -331,6 +390,12 @@ class PairingStateView extends StatelessWidget { final VoidCallback onPair; final VoidCallback onRetry; + /// Non-null once discovery has failed at least once: reveals the WHOOP 5.0 / MG + /// hint and the capture button. Null (the default) keeps the view exactly as it + /// was for every state that hasn't failed yet. + final VoidCallback? onDiagnostics; + final bool diagnosticsRunning; + const PairingStateView({ super.key, required this.phase, @@ -339,6 +404,8 @@ class PairingStateView extends StatelessWidget { required this.onBack, required this.onPair, required this.onRetry, + this.onDiagnostics, + this.diagnosticsRunning = false, }); @override @@ -399,7 +466,18 @@ class PairingStateView extends StatelessWidget { ]), const SizedBox(height: Sp.x4), ], + if (onDiagnostics != null) ...[ + Text(kGen5PairingHint, style: AppText.caption), + const SizedBox(height: Sp.x3), + ], _actions(), + if (onDiagnostics != null) + TextButton( + onPressed: diagnosticsRunning ? null : onDiagnostics, + child: Text( + diagnosticsRunning ? 'Capturing…' : 'Diagnostics', + ), + ), ], ), ), From b24324b48ec06e2aa41292652f7ff6289c5379a4 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 14 Aug 2026 15:50:37 +0300 Subject: [PATCH 2/6] Add WHOOP 5.0 / MG transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gen5 turns out not to be a separate protocol. Comparing this package's own gen4 work against two independent public gen5 clients (b-nnett/goose and its Kotlin port dsp515/GooseAndroid) shows the packet types, command opcodes and record header are identical across generations — including the per-version HR offset table {7:27, 9:17, 12:17, 18:14, 24:17}, which both projects derived separately from different hardware and got the same answer for. Only two things actually differ, and this commit implements both: 1. The GATT service prefix: fd4b0001-cce1-4033-93ce-002d5875f58a, with the characteristics numbered off it exactly as gen4 numbers off 61080001. The previous value (0000FD4B-0000-1000-8000-00805F9B34FB, the 16-bit member UUID expanded against the Bluetooth base UUID) is a different UUID that no band advertises. AccessorySetupKit matches the declared service byte-for-byte, so that one wrong constant is why the iOS pairing sheet could only ever report "No Accessory Found" for a 5.0 / MG. 2. The frame envelope: an 8-byte header with a CRC-16/MODBUS over it, where gen4 uses 4 bytes with a CRC-8 over the length. Everything inside the envelope is unchanged, so gen5 reuses the existing record decoders. That shared-payload property is what keeps this small. Command builders still emit gen4 frames and _write swaps the envelope at the single point where bytes reach the characteristic — one function, one call site, and it is structurally impossible to send a gen5 frame to a 4.0 band. Gen4 reassembly still delegates to the protocol package's own reassembler, so the 4.0 path runs unchanged code. What is NOT decoded, deliberately: accelerometer, RR intervals and SpO2 from gen5 history records. They are not where 4.0 keeps them — measured, not assumed (parseR24 reads a real k18 capture's gravity vector at 0.27 g and correctly refuses it). Scanning that capture for a plausible gravity triple yields 16 candidate offsets, and one frame cannot separate them. Those fields stay null, which archives the record for a future fix; a guessed offset would silently corrupt every metric downstream of it, which is the same mistake as assuming a service UUID. Heart-rate-driven metrics work; motion-driven sleep detail is thinner until someone with hardware supplies captures. Verified by 59 new tests: buildGen5Frame reproduces goose's GET_HELLO vector byte-for-byte, and real WHOOP 5.0 captures decode to their recorded values — 83 bpm realtime through the untouched gen4 parser, and HR 77 at the recorded timestamp from a k18 history frame, including through chunked reassembly. Full suite: 1528 pass. The 42 failures are pre-existing phosphor_flutter / Flutter SDK incompatibilities, confirmed unchanged against a clean tree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RrVjCqVDMANK5sa5eyjATw --- README.md | 31 ++- ios/Runner/AccessorySetup.swift | 39 +++- ios/Runner/Info.plist | 20 +- lib/ble/ble_engine.dart | 197 ++++++++++++++---- lib/ble/gen5_framing.dart | 314 +++++++++++++++++++++++++++++ lib/ble/gen5_records.dart | 172 ++++++++++++++++ test/ble_engine_test.dart | 56 ++++++ test/gen5_framing_test.dart | 341 ++++++++++++++++++++++++++++++++ test/gen5_records_test.dart | 147 ++++++++++++++ 9 files changed, 1255 insertions(+), 62 deletions(-) create mode 100644 lib/ble/gen5_framing.dart create mode 100644 lib/ble/gen5_records.dart create mode 100644 test/gen5_framing_test.dart create mode 100644 test/gen5_records_test.dart diff --git a/README.md b/README.md index 12c44cf4..581173b4 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,9 @@ drawer-bracelet problem can use it, or go dig through the code themselves. ## Checklist -- **WHOOP 4.0 only.** Haven't touched a WHOOP 5, don't know if it even shares a protocol. +- **WHOOP 4.0 is the validated path.** WHOOP 5.0 / MG shares the protocol above the + frame envelope and is now implemented, but nobody here owns one to confirm it — see + the gen5 note further down for exactly what works and what is still missing. - Not affiliated with WHOOP, doesn't talk to their servers. - Not a clone of their algorithms — different math, published methods, cited in the analytics repo. Don't expect identical numbers to what their app shows. @@ -143,13 +145,26 @@ shortcuts, a smart alarm that buzzes the band. against a lab, don't treat any of it as a diagnosis. - Not on the App Store or Play Store yet. iOS is a public TestFlight beta, which is a normal install but still a beta; Android is an APK straight off Releases. -- WHOOP 5.0 / MG support is **experimental and discovery-only**. Pairing now looks for - a gen5 band (by its reported service UUID and by name) instead of silently ignoring - it, and a Diagnostics button on the pairing screen captures what your phone can - actually see. But there is no gen5 transport: a 5.0 / MG band that connects will - still fail at service discovery, on purpose, logging its real GATT tree. Nothing here - has been validated against 5.0 hardware — no maintainer owns one, so those captures - are how it gets fixed. WHOOP 4.0 is the only family that actually works. +- WHOOP 5.0 / MG support is **experimental — implemented, not yet hardware-confirmed.** + Gen5 turns out not to be a separate protocol: the packet types, command opcodes and + record header are the same ones 4.0 uses, and only two things differ — the GATT UUID + prefix (`fd4b0001…` instead of `61080001…`) and the frame envelope (an 8-byte header + with a CRC-16/MODBUS over it, instead of 4 bytes with a CRC-8). Both are implemented + and unit-tested against real 5.0 captures and against two independent open-source + gen5 clients, so pairing, commands, the historical sync handshake and realtime heart + rate should all work. + + Two caveats. **One:** an earlier build declared the wrong gen5 service UUID (the + 16-bit `0xFD4B` expanded against the Bluetooth base UUID, which no band advertises), + which is why the iOS pairing sheet used to say "No Accessory Found"; that is fixed, + but if your band still shows as *Connected* in iOS Settings it is not advertising and + no app can find it — forget the device first. **Two:** gen5's historical records store + heart rate, timestamp, respiratory rate and skin temperature where we can read them, + but **accelerometer, RR intervals and SpO₂ are not decoded** — those fields are not + where 4.0 keeps them, and one capture is not enough to locate them without guessing. + So expect heart-rate-driven metrics to work and motion-driven sleep detail to be + thinner. No maintainer owns a 5.0 or MG, so reports and captures are how this + finishes. WHOOP 4.0 remains the fully validated path. ## Run it diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift index a889c850..f9577424 100644 --- a/ios/Runner/AccessorySetup.swift +++ b/ios/Runner/AccessorySetup.swift @@ -30,14 +30,26 @@ enum AccessorySetup { // `fileprivate` so the iOS-18 Impl below can read it. fileprivate static let gen4ServiceUUID = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6" - // EXPERIMENTAL — WHOOP 5.0 / MG. The 16-bit member service 0xFD4B expanded against - // the Bluetooth Base UUID. NOT validated against hardware: no maintainer owns a gen5 - // strap, so this is a community-reported candidate. If a capture proves it wrong, - // this is the one line to change (plus its twin in Info.plist and ble_engine.dart). - fileprivate static let gen5ServiceUUID = "0000FD4B-0000-1000-8000-00805F9B34FB" + // WHOOP 5.0 / MG. A full 128-bit VENDOR service, in the same shape as the gen4 + // service above — NOT the 16-bit member UUID 0xFD4B expanded against the Bluetooth + // Base UUID. That earlier guess (0000FD4B-0000-1000-8000-00805F9B34FB) is a + // different UUID that gen5 bands never advertise, and since ASK matches the declared + // service byte-for-byte, it is the reason the picker only ever said "No Accessory + // Found" for a 5.0 / MG. Cross-checked against b-nnett/goose and dsp515/GooseAndroid. + // Keep in sync with kGen5ServiceUuid in lib/ble/ble_engine.dart and Info.plist. + fileprivate static let gen5ServiceUUID = "FD4B0001-CCE1-4033-93CE-002D5875F58A" + + // The 16-bit member UUID as a second descriptor. A 128-bit service UUID often does + // not fit the 31-byte advertisement, and iOS hashes any that spill into the scan + // response's overflow area — so on iOS the short form is sometimes the only service + // the picker can actually see. + fileprivate static let gen5MemberUUID16 = "FD4B" // EXPERIMENTAL — the net that catches a gen5 band whose service UUID we have wrong. - // Matches the advertised local name: "WHOOP MGB…", "WHOOP 5…", "WHOOP 4…". + // Matches the advertised local name. Reported gen5 forms differ by batch/report — + // "WHOOP MGB…" (this project's MG reporter) and "WHOOP 5AM…" / "WHOOP 5AG…" (the + // serial prefixes GenieMax uses to tell MG from 5.0) — plus gen4's "WHOOP 4…". + // The single substring "WHOOP" covers every one of them, which is the point. // Must stay in sync with NSAccessorySetupBluetoothNames in Info.plist. fileprivate static let nameSubstring = "WHOOP" @@ -176,13 +188,20 @@ private final class Impl { makeItem("WHOOP band") { $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.gen4ServiceUUID) }, - // EXPERIMENTAL — WHOOP 5.0 / MG by its (community-reported) service UUID. + // WHOOP 5.0 / MG by its 128-bit vendor service UUID. makeItem("WHOOP 5.0 / MG") { $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.gen5ServiceUUID) }, - // EXPERIMENTAL — WHOOP 5.0 / MG by advertised name, for when the UUID above is - // wrong or iOS won't surface it. Drop this item once a real capture confirms - // the gen5 service UUID. + // WHOOP 5.0 / MG by the 16-bit member UUID. Separate item, not an extra + // criterion on the one above: criteria within a descriptor AND-combine, and a + // band advertising only one of the two forms would then match neither. + makeItem("WHOOP 5.0 / MG") { + $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.gen5MemberUUID16) + }, + // Last net — by advertised name, for firmware that fits neither service UUID + // into the 31-byte advertisement. Keep this even though the UUIDs above are + // now confirmed: it costs nothing and it is the only criterion that survives + // iOS hashing 128-bit UUIDs into the scan response's overflow area. makeItem("WHOOP band") { $0.bluetoothNameSubstring = AccessorySetup.nameSubstring }, diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 79b9d8f6..1b0b3b82 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -59,17 +59,23 @@ 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 - - 0000FD4B-0000-1000-8000-00805F9B34FB + + FD4B0001-CCE1-4033-93CE-002D5875F58A + + FD4B + the advertised local name is not). Reported gen5 names vary — "WHOOP MGB…" + as well as "WHOOP 5AM…" (MG) / "WHOOP 5AG…" (5.0) — and 4.0 is "WHOOP 4…", + so the single substring "WHOOP" covers every family. --> NSAccessorySetupBluetoothNames WHOOP diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 04dc09b2..d8ab6eb6 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -47,34 +47,63 @@ import '../data/models.dart'; import '../sync/paired_device.dart' show cleanDeviceLabel; import '../sync/sync_policy.dart'; import 'ble_state.dart'; +import 'gen5_framing.dart'; +import 'gen5_records.dart'; // Little-endian u32 reader. The package keeps `u32` private, and the engine only // needs it to peek the record-counter / ts out of a raw historical frame header. int u32(Uint8List b, int o) => b.buffer.asByteData(b.offsetInBytes, b.length).getUint32(o, Endian.little); -// ── WHOOP 5.0 / MG (gen5) — EXPERIMENTAL ──────────────────────────────────── -// The 16-bit member service 0xFD4B expanded against the Bluetooth Base UUID. -// UNCONFIRMED against hardware: no maintainer owns a gen5 strap, so this is a -// community-reported candidate used for DISCOVERY ONLY. There is no gen5 -// transport — a gen5 band that connects still fails at service discovery below, -// deliberately and with a log line describing its real GATT tree. +// ── WHOOP 5.0 / MG (gen5) ─────────────────────────────────────────────────── +// The gen5 GATT service. This is a full 128-bit VENDOR UUID in exactly the same +// shape as the gen4 "Harvard" service (61080001-8d6d-…): a `xxxx0001-` base with +// the characteristics numbered 0002..0007 off the same suffix. +// +// IT IS NOT the 16-bit member UUID 0xFD4B expanded against the Bluetooth Base +// UUID. That earlier guess (0000fd4b-0000-1000-8000-00805f9b34fb) is a DIFFERENT +// UUID that gen5 bands never advertise, which is why the AccessorySetupKit sheet +// could only ever report "No Accessory Found" for a WHOOP 5.0 / MG — ASK matches +// the declared service UUID byte-for-byte, so one wrong UUID means zero results. +// +// Cross-checked against two independent public gen5 clients: b-nnett/goose +// (GooseSwift/GooseBLEClient.swift `whoopServices`) and its Kotlin port +// dsp515/GooseAndroid (WhoopUUIDs.SERVICE_PRIMARY), which agree exactly. // // Deliberately local to the app rather than added to package:openstrap_protocol -// (separate repo, pinned by hash in pubspec.lock): an unverified guess should not -// become a protocol constant. Promote it there once a capture confirms it, and -// keep it in sync with gen5ServiceUUID in ios/Runner/AccessorySetup.swift and +// (separate repo, pinned by hash in pubspec.lock): the app can discover and +// identify a gen5 band, but there is still no gen5 TRANSPORT — the V5 framing +// differs from gen4 (8-byte header, crc16-modbus over the header, vs gen4's +// 4-byte header and crc8 over the length), so a gen5 band that connects still +// stops at service discovery below, deliberately and with a log line describing +// its real GATT tree. Promote these to the protocol package with the transport. +// Keep in sync with gen5ServiceUUID in ios/Runner/AccessorySetup.swift and // NSAccessorySetupBluetoothServices in ios/Runner/Info.plist. -const String kGen5ServiceUuid = '0000fd4b-0000-1000-8000-00805f9b34fb'; +const String kGen5ServiceUuid = 'fd4b0001-cce1-4033-93ce-002d5875f58a'; + +/// The gen5 characteristic suffix. Gen5 mirrors gen4's GATT layout one-for-one +/// (0002 = command write, 0003/0004/0005 = notify, 0007 = debug), so only the +/// 32-bit prefix differs between the families. +const String kGen5UuidSuffix = '-cce1-4033-93ce-002d5875f58a'; + +/// The 16-bit Bluetooth SIG member UUID assigned to WHOOP. Some gen5 firmware +/// puts this in the advertisement alongside (or instead of) the 128-bit service +/// above — a 128-bit UUID often does not fit the 31-byte AD and gets dropped or, +/// on iOS, hashed into the unreadable overflow area. Matching it too costs +/// nothing and is sometimes the only thing visible on iOS. +const String kWhoopMemberUuid16 = 'fd4b'; /// True for either WHOOP family's advertised service UUID. /// /// Platforms disagree on spelling: iOS reports 16-bit UUIDs in short form /// ("fd4b") while Android reports the full 128-bit Base-UUID expansion, so both -/// have to be accepted. Gen4 is matched on its 32-bit prefix exactly as before. +/// have to be accepted. Gen4 and gen5 are matched on their 32-bit prefixes. bool isWhoopServiceUuid(String raw) { final u = raw.toLowerCase(); - return u.startsWith('61080001') || u == 'fd4b' || u.startsWith('0000fd4b'); + // WhoopFamily owns the per-generation prefixes — one source of truth, so a + // future family cannot be added to the transport but forgotten by discovery. + if (WhoopFamily.ofServiceUuid(u) != null) return true; + return u == kWhoopMemberUuid16 || u.startsWith('0000fd4b'); } typedef SampleSink = Future Function(Sample? sample, RawRecord raw); @@ -320,11 +349,36 @@ class _SessionGapSummary { class _Session { final BluetoothDevice device; BluetoothCharacteristic? cmdTo; - final Map asm = { - 'cmd_from': FrameReassembler(), - 'events': FrameReassembler(), - 'data': FrameReassembler(), + + /// Which WHOOP generation this link is speaking. + /// + /// Defaults to gen4 and is set for real by [useFamily] once service discovery + /// has seen the band's GATT tree. Defaulting to gen4 keeps the 4.0 path + /// identical if anything ever reads this before discovery: gen5 is only ever + /// entered by positively identifying a gen5 service, never by falling back. + WhoopFamily family = WhoopFamily.gen4; + + /// Per-role frame reassemblers. Replaced wholesale by [useFamily] because the + /// two generations need different frame codecs (see gen5_framing.dart). + Map asm = { + 'cmd_from': WhoopReassembler.of(WhoopFamily.gen4), + 'events': WhoopReassembler.of(WhoopFamily.gen4), + 'data': WhoopReassembler.of(WhoopFamily.gen4), }; + + /// Commit this session to [f], installing that generation's frame codecs. + /// + /// MUST be called before any subscription starts: a reassembler that has + /// already buffered bytes under the wrong header length cannot recover them. + void useFamily(WhoopFamily f) { + family = f; + asm = { + 'cmd_from': WhoopReassembler.of(f), + 'events': WhoopReassembler.of(f), + 'data': WhoopReassembler.of(f), + }; + } + final List subs = []; Timer? heartbeat; // Session-owned timers; a disconnect cancels them. @@ -1222,10 +1276,15 @@ class BleEngine { } _setPhase(BleConnState.scanning); final gen4 = Guid(GattUuids.service); - // EXPERIMENTAL gen5. `withServices` is OR-combined on both platforms (one - // ScanFilter per service on Android, CBCentralManager's service array on iOS), - // so adding this strictly widens discovery — the 4.0 path cannot be narrowed. + // Gen5. `withServices` is OR-combined on both platforms (one ScanFilter per + // service on Android, CBCentralManager's service array on iOS), so adding + // these strictly widens discovery — the 4.0 path cannot be narrowed. final gen5 = Guid(kGen5ServiceUuid); + // …and the 16-bit member UUID as a second net: a 128-bit service UUID often + // does not fit the 31-byte advertisement, so some gen5 firmware advertises + // only the short form. A service-filtered scan matches ONLY what is actually + // in the AD, so missing this is indistinguishable from the band being absent. + final gen5Short = Guid(kWhoopMemberUuid16); BluetoothDevice? found; // Everything the scan saw, for the log line below. A silent "no WHOOP found" is // useless to a gen5 owner filing a report; the candidate list is the evidence. @@ -1249,7 +1308,7 @@ class BleEngine { }); try { await FlutterBluePlus.startScan( - withServices: [gen4, gen5], + withServices: [gen4, gen5, gen5Short], timeout: timeout, ); await FlutterBluePlus.isScanning.where((on) => on == false).first; @@ -1279,7 +1338,9 @@ class BleEngine { /// gen5 hardware, so every gen5 fix depends on a capture from someone who does. /// This dumps every BLE advertiser in range with its local name, RSSI, advertised /// service UUIDs and manufacturer data — which is exactly what identifies the gen5 - /// service UUID that [kGen5ServiceUuid] is currently only guessing at. + /// advertisement a gen5 band actually emits — which is what confirms whether it + /// exposes [kGen5ServiceUuid] in full, only the 16-bit [kWhoopMemberUuid16], or + /// neither (name-only), and therefore which net has to catch it. /// /// PLATFORM NOTE: Android returns the complete advertisement, so an Android capture /// is strictly more informative. iOS surfaces 16-bit service UUIDs normally but @@ -1343,7 +1404,7 @@ class BleEngine { ..writeln('platform: ${Platform.operatingSystem} ' '${Platform.operatingSystemVersion}') ..writeln('gen4 service: ${GattUuids.service}') - ..writeln('gen5 service (EXPERIMENTAL, unverified): $kGen5ServiceUuid') + ..writeln('gen5 service: $kGen5ServiceUuid (16-bit: $kWhoopMemberUuid16)') ..writeln('advertisers seen: ${byId.length}'); if (lines.isEmpty) { report.writeln( @@ -1520,15 +1581,25 @@ class BleEngine { final services = await device .discoverServices() .timeout(_serviceDiscoveryTimeout); + // Pick the family from what the band actually exposes. Gen4 is preferred + // when both are somehow present, so a 4.0 band can never be dragged onto + // the newer, less-proven path by a stray service. BluetoothService? svc; - for (final s in services) { - if (s.uuid.str.toLowerCase().startsWith('61080001')) svc = s; + WhoopFamily? family; + for (final want in WhoopFamily.values) { + for (final s in services) { + if (s.uuid.str.toLowerCase().startsWith(want.servicePrefix)) { + svc = s; + family = want; + break; + } + } + if (svc != null) break; } - if (svc == null) { - // Still fatal: there is no gen5 transport to fall through to, only the 4.0 - // (Harvard) framing. But dump what the band DOES expose before giving up — - // for a WHOOP 5.0 / MG this log is the entire basis on which a gen5 - // transport could eventually be written, and it costs one line here. + if (svc == null || family == null) { + // Dump what the band DOES expose before giving up — for an unrecognised + // strap this log is the entire basis on which support could be written, + // and it costs one line here. final tree = services .map( (s) => @@ -1536,12 +1607,18 @@ class BleEngine { ) .join(' '); _log( - 'Harvard (WHOOP 4.0) service not found on device. ' + 'No WHOOP GATT service found (looked for ' + '${WhoopFamily.values.map((f) => f.servicePrefix).join(" / ")}). ' 'Discovered services: ${tree.isEmpty ? "(none)" : tree}', ); await _failConnect(); return false; } + // Install this generation's frame codecs BEFORE any subscription starts — + // a reassembler that has buffered bytes under the wrong header length + // cannot recover them. + session.useFamily(family); + BluetoothCharacteristic? find(String prefix) { for (final c in svc!.characteristics) { if (c.uuid.str.toLowerCase().startsWith(prefix)) return c; @@ -1549,18 +1626,28 @@ class BleEngine { return null; } - session.cmdTo = find('61080002'); - final cmdFrom = find('61080003'); - final events = find('61080004'); - final data = find('61080005'); + session.cmdTo = find(family.cmdToPrefix); + final cmdFrom = find(family.cmdFromPrefix); + final events = find(family.eventsPrefix); + final data = find(family.dataPrefix); if (session.cmdTo == null || cmdFrom == null || events == null || data == null) { - _log('Missing one or more Harvard characteristics.'); + _log( + 'Missing one or more ${family.label} characteristics on service ' + '${svc.uuid.str}. Found: ' + '${svc.characteristics.map((c) => c.uuid.str).join(",")}', + ); await _failConnect(); return false; } + _log( + 'Link is ${family.label} (service ${svc.uuid.str}).' + '${family == WhoopFamily.gen5 ? " Gen5 is newer than the 4.0 path and " + "has not been confirmed against hardware by a maintainer — " + "please report what works and what does not." : ""}', + ); _setPhase(BleConnState.subscribing); await _subscribe(session, cmdFrom, 'cmd_from'); @@ -2111,9 +2198,23 @@ class BleEngine { _log('write skipped: it belongs to a session that is no longer live.'); return; } + // THE ONE PLACE GENERATIONS DIVERGE ON THE WRITE PATH. Every command + // builder in package:openstrap_protocol emits a gen4-enveloped frame, + // and the inner content is identical across generations, so a gen5 link + // re-wraps here instead of every builder learning about families. Doing + // it at the characteristic also makes it structurally impossible to send + // a gen5-framed command to a 4.0 band: this is the last point at which + // the session — and therefore the family — is known. + final wire = session.family == WhoopFamily.gen5 + ? reframeGen4ToGen5(raw) + : raw; + // The seam observes `wire`, not `raw`, for the same reason the guards + // above run before it: a seam that sees different bytes than the radio + // would makes every test relying on it prove the wrong thing. On a gen4 + // link `wire` IS `raw`, so this is a no-op for every existing test. final hook = debugWriteHook; if (hook != null) { - ok = await hook(raw); + ok = await hook(wire); return; } final cmd = session.cmdTo; @@ -2127,7 +2228,7 @@ class BleEngine { // never rose, and _send would swallow the alarm silently. Long writes are // a no-op for the small (<=20B) frames every other command uses. await cmd - .write(raw, withoutResponse: false, allowLongWrite: true) + .write(wire, withoutResponse: false, allowLongWrite: true) .timeout(_writeTimeout); ok = true; } on TimeoutException { @@ -2464,6 +2565,28 @@ class BleEngine { sample = Sample(tsEpoch: r.tsEpoch, counter: r.counter, hr: r.hr); } } + // GEN5 FALLBACK. Ordered last on purpose: a gen4 link never reaches it, and + // on a gen5 link the richer decoders above still get first refusal, so if a + // gen5 k-domain ever does match the full v24 field map we keep the extra + // fields rather than dropping to this subset. + // + // WHAT THIS DELIBERATELY DOES NOT FILL: accelerometer, RR intervals and + // SpO2. Gen5's k18 body does not follow the gen4 v24 map — measured, not + // assumed (parseR24 reads its gravity vector at 0.27 g and correctly + // refuses) — and one capture cannot tell us where those fields really live. + // Leaving them null costs actigraphy-driven sleep detail; guessing an offset + // would silently corrupt every metric downstream of it. See gen5_records.dart. + if (sample == null && _session?.family == WhoopFamily.gen5) { + final g = decodeGen5History(frame.inner); + if (g != null) { + sample = Sample( + tsEpoch: g.tsEpoch, + counter: g.counter, + hr: g.hr, + skinTempRaw: g.skinTempCentiC, + ); + } + } // FIRMWARE RESILIENCE: a historical record we could NOT decode (unknown/ // unsupported version, or a known version whose decode failed) is ARCHIVED // durably rather than dropped — it used to fall into raw_records with a null diff --git a/lib/ble/gen5_framing.dart b/lib/ble/gen5_framing.dart new file mode 100644 index 00000000..4f0d8c2e --- /dev/null +++ b/lib/ble/gen5_framing.dart @@ -0,0 +1,314 @@ +// Gen5 framing — the WHOOP 5.0 / MG ("V5") frame envelope. +// +// THE ONE THING TO UNDERSTAND ABOUT GEN5: it is not a new protocol. Everything +// above the frame envelope is the protocol WHOOP 4.0 already speaks, byte for +// byte — the same packet-type bytes (0x23 COMMAND … 0x34 HISTORICAL_IMU), the +// same command opcodes (0x0A SET_CLOCK, 0x22 GET_DATA_RANGE, 0x17 the batch +// ACK …), the same record layout inside a data packet (k/version at inner[1], +// counter at inner[3:7], unix seconds at inner[7:11], sub-seconds at +// inner[11:13], HR at inner[17] for k9/k12/k24 and inner[14] for k18). +// +// Only two things actually differ between a 4.0 and a 5.0 band: +// +// 1. the GATT UUID prefix — 61080001… becomes fd4b0001…, with the +// characteristics numbered identically off it (see [WhoopFamily]); +// 2. this file — the outer envelope. +// +// gen4: [0xAA][u16 LE size][crc8(size)] [inner] [u32 LE crc32] +// gen5: [0xAA][0x01][u16 LE size][0x00][0x01] +// [u16 LE crc16-modbus(header[0..6])] [inner] [u32 LE crc32] +// +// 4-byte header vs 8-byte header; crc8 over the length vs crc16-modbus +// over the whole header prefix. `size` counts the padded inner PLUS the +// trailing crc32 in both. Inner is zero-padded to 4 bytes in both, and the +// crc32 is computed over the padded form in both. +// +// So gen5 support is an envelope swap, not a second stack: [Gen5FrameReassembler] +// hands the existing `decodeFrame`/`parseR24` decoders exactly the `Frame` they +// already understand, and [reframeGen4ToGen5] re-wraps the frames the existing +// command builders already produce. That is why the 4.0 path is not touched. +// +// Ported from the two independent public gen5 clients that agree on every field: +// b-nnett/goose (Rust/core/src/protocol.rs — `build_v5_payload_frame`, +// `FrameAccumulator`, `crc16_modbus`) and satayutata/geniemax-core +// (Sources/GenieMax/WhoopFrame.swift). +// +// PURE Dart — no Flutter, no I/O. Kept in the app rather than in +// package:openstrap_protocol so gen5 can be revised against real hardware +// without a protocol-package release; promote it once captures confirm it. + +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// Which WHOOP generation a link is speaking. +/// +/// The two families are distinguished ONLY by their 32-bit UUID prefix; the +/// characteristic numbering off that prefix is identical, which is what lets +/// one set of role lookups serve both. +enum WhoopFamily { + /// WHOOP 4.0 — the "Harvard" service. The long-supported, hardware-tested path. + gen4('61080001', '61080002', '61080003', '61080004', '61080005', 'WHOOP 4.0'), + + /// WHOOP 5.0 / MG. Same GATT shape, different vendor prefix and frame header. + gen5('fd4b0001', 'fd4b0002', 'fd4b0003', 'fd4b0004', 'fd4b0005', 'WHOOP 5.0 / MG'); + + const WhoopFamily( + this.servicePrefix, + this.cmdToPrefix, + this.cmdFromPrefix, + this.eventsPrefix, + this.dataPrefix, + this.label, + ); + + /// 32-bit prefix of the GATT service this family advertises and exposes. + final String servicePrefix; + + /// Command WRITE characteristic (…0002). + final String cmdToPrefix; + + /// Command-response NOTIFY characteristic (…0003). + final String cmdFromPrefix; + + /// Event NOTIFY characteristic (…0004). + final String eventsPrefix; + + /// Bulk-data NOTIFY characteristic (…0005). + final String dataPrefix; + + /// Human-readable name, for logs and pairing UI. + final String label; + + /// The family a discovered service UUID belongs to, or null if it is neither. + static WhoopFamily? ofServiceUuid(String uuid) { + final u = uuid.toLowerCase(); + for (final f in WhoopFamily.values) { + if (u.startsWith(f.servicePrefix)) return f; + } + return null; + } +} + +// ── CRC ─────────────────────────────────────────────────────────────────────── + +/// CRC-16/MODBUS — init 0xFFFF, reflected, poly 0xA001, no final XOR. +/// +/// Gen5 uses this over the 6-byte header prefix in place of gen4's crc8 over the +/// 2-byte length field. It is the gate that tells a real frame boundary from a +/// 0xAA that merely happens to occur inside sensor data. +int crc16Modbus(List data) { + var crc = 0xFFFF; + for (final b in data) { + crc ^= b & 0xFF; + for (var i = 0; i < 8; i++) { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xA001 : crc >> 1; + } + } + return crc & 0xFFFF; +} + +// ── frame envelope ──────────────────────────────────────────────────────────── + +/// Length of the gen5 header, in bytes (gen4's is 4). +const int kGen5HeaderLen = 8; + +/// Upper bound on a single gen5 frame. The largest packet either reference +/// client decodes is the k21 IMU frame at ~1.2 kB and the k20 optical frame at +/// 2132 B of payload; 4096 leaves headroom without letting a corrupted length +/// field make us buffer unboundedly. Mirrors the gen4 reassembler's own cap. +const int kGen5MaxFrameLen = 4096; + +/// Wrap already-built inner content in the gen5 envelope. +/// +/// [inner] is the same `[type][seq][opcode][body…]` byte string gen4 uses — this +/// only changes the wrapper around it. +Uint8List buildGen5Frame(List inner) { + final innerP = pad4(inner); + final declared = innerP.length + 4; // +4 = trailing crc32, as in gen4 + + final header = Uint8List(kGen5HeaderLen); + header[0] = sof; // 0xAA + header[1] = 0x01; // frame version + header[2] = declared & 0xFF; + header[3] = (declared >> 8) & 0xFF; + header[4] = 0x00; + header[5] = 0x01; + // The header CRC covers bytes 0..5 — i.e. everything above, and nothing else. + final hc = crc16Modbus(Uint8List.sublistView(header, 0, 6)); + header[6] = hc & 0xFF; + header[7] = (hc >> 8) & 0xFF; + + final c32 = crc32(innerP); + final tail = Uint8List(4) + ..buffer.asByteData().setUint32(0, c32, Endian.little); + + final out = BytesBuilder() + ..add(header) + ..add(innerP) + ..add(tail); + return out.toBytes(); +} + +/// Parse one complete gen5 frame. Returns null if it is too short or not a frame. +/// +/// Returns the SAME [Frame] type gen4 produces, so every existing decoder +/// (`decodeFrame`, `parseR24`, `parseMetadata`, …) consumes it unchanged. The +/// `crc8Ok` field carries the gen5 HEADER-CRC result: the two families use +/// different header checksums, but both answer the same question — "is this +/// envelope intact?" — and `Frame.valid` already means "both CRCs passed". +Frame? parseGen5Frame(Uint8List raw) { + if (raw.length < kGen5HeaderLen || raw[0] != sof) return null; + final declared = raw[2] | (raw[3] << 8); // u16 LE + // Must at least cover the trailing crc32, or the inner slice below goes + // negative — same guard as the gen4 parser, same reason. + if (declared < 4) return null; + final total = kGen5HeaderLen + declared; + if (raw.length < total) return null; + + final stored = raw[6] | (raw[7] << 8); + final headerOk = crc16Modbus(Uint8List.sublistView(raw, 0, 6)) == stored; + + final inner = Uint8List.fromList( + Uint8List.sublistView(raw, kGen5HeaderLen, kGen5HeaderLen + declared - 4), + ); + final crcBd = raw.buffer.asByteData( + raw.offsetInBytes + kGen5HeaderLen + declared - 4, + 4, + ); + return Frame(inner, headerOk, crcBd.getUint32(0, Endian.little) == crc32(inner)); +} + +/// Re-wrap a gen4-enveloped frame in the gen5 envelope, preserving inner bytes. +/// +/// WHY THIS EXISTS: every command builder in package:openstrap_protocol +/// (`buildCommand`, `buildHistoryResultOk`, `cmdSetAlarm`, `initPackets`, …) +/// emits a complete gen4 frame. Since the inner content is identical across +/// families, translating at the single point where bytes reach the +/// characteristic is both far less code and far less risk than teaching a dozen +/// builders about generations — and it makes it structurally impossible for a +/// gen4 band to receive a gen5-framed command. +/// +/// Returns [gen4Frame] unchanged if it does not parse as a gen4 frame, so a +/// malformed input fails the same way it would have without gen5 support. +Uint8List reframeGen4ToGen5(Uint8List gen4Frame) { + final parsed = parseFrame(gen4Frame); + if (parsed == null) return gen4Frame; + return buildGen5Frame(parsed.inner); +} + +// ── reassembly ──────────────────────────────────────────────────────────────── + +/// One reassembler interface over both families, so a session holds the right +/// codec for its band and the notification path stays generation-agnostic. +abstract class WhoopReassembler { + /// Feed a raw BLE notification chunk; get back every frame now complete. + List feed(List chunk); + + /// Drop all buffered bytes (used when a link drops mid-frame). + void reset(); + + /// How many times the stream had to resynchronise — a degraded-link signal. + int get resyncs; + + /// Build the reassembler for [family]. + factory WhoopReassembler.of(WhoopFamily family) => family == WhoopFamily.gen4 + ? _Gen4Reassembler() + : Gen5FrameReassembler(); +} + +/// Gen4 — delegates to the protocol package's own reassembler so the 4.0 path +/// runs exactly the code it always has, including its crc8 length gate and its +/// inter-record null-padding skip. +class _Gen4Reassembler implements WhoopReassembler { + final FrameReassembler _inner = FrameReassembler(); + + @override + List feed(List chunk) => _inner.feed(chunk); + + @override + void reset() => _inner.reset(); + + @override + int get resyncs => _inner.resyncs; +} + +/// Length-based gen5 reassembler. +/// +/// MUST be length-based, not "reset on 0xAA": sensor payloads are full of 0xAA +/// bytes and BLE notification boundaries land on them. The header crc16 is what +/// separates a real frame start from a coincidental one — it is checked BEFORE +/// the declared length is trusted, because acting on a corrupted length byte +/// would swallow up to 4 kB of good stream, which for historical records is data +/// the band trims from flash and never sends again. +class Gen5FrameReassembler implements WhoopReassembler { + final List _buf = []; + int _resyncs = 0; + + @override + int get resyncs => _resyncs; + + @override + List feed(List chunk) { + final out = []; + _buf.addAll(chunk); + + // Drop to the next plausible frame start after index 0. Returns false when + // no further 0xAA exists, meaning "stop, wait for more bytes". + bool resync() { + _resyncs++; + var next = -1; + for (var i = 1; i < _buf.length; i++) { + if (_buf[i] == sof) { + next = i; + break; + } + } + if (next < 0) { + _buf.clear(); + return false; + } + _buf.removeRange(0, next); + return true; + } + + while (_buf.length >= kGen5HeaderLen) { + if (_buf[0] != sof) { + if (!resync()) break; + continue; + } + final declared = _buf[2] | (_buf[3] << 8); + final total = kGen5HeaderLen + declared; + if (declared < 4 || total > kGen5MaxFrameLen) { + if (!resync()) break; // implausible length ⇒ spurious 0xAA + continue; + } + final storedHeaderCrc = _buf[6] | (_buf[7] << 8); + if (crc16Modbus(_buf.sublist(0, 6)) != storedHeaderCrc) { + if (!resync()) break; // header did not hold up ⇒ not a frame boundary + continue; + } + if (_buf.length < total) break; // wait for the rest of this frame + + final frame = parseGen5Frame(Uint8List.fromList(_buf.sublist(0, total))); + if (frame != null) out.add(frame); + _buf.removeRange(0, total); + + // Skip inter-record zero padding, as the gen4 reassembler does. + var i = 0; + while (i < _buf.length && _buf[i] == 0x00) { + i++; + } + if (i > 0) _buf.removeRange(0, i); + } + + if (_buf.length > 8192) _buf.clear(); // never grow unbounded + return out; + } + + @override + void reset() { + _buf.clear(); + _resyncs = 0; + } +} diff --git a/lib/ble/gen5_records.dart b/lib/ble/gen5_records.dart new file mode 100644 index 00000000..e50ee6c7 --- /dev/null +++ b/lib/ble/gen5_records.dart @@ -0,0 +1,172 @@ +// Gen5 historical records — the fields a WHOOP 5.0 / MG history packet carries +// that we can actually justify reading. +// +// WHAT IS SHARED WITH GEN4, AND WHAT IS NOT +// +// The record HEADER is common to both generations, independently established: +// this package's own gen4 captures and goose's gen5 reverse-engineering agree +// byte-for-byte on the layout AND on the per-version HR offset — +// +// inner[1] layout version / k-domain +// inner[3:7] record counter (u32 LE) +// inner[7:11] unix seconds (u32 LE) +// inner[11:13] sub-seconds (u16 LE) +// HR byte k7→27, k9/k12/k24→17, k18→14 +// +// (compare `_hrOffsetByVersion` in package:openstrap_protocol's records.dart +// with `history_hr_marker_offset` in goose's Rust/core/src/protocol.rs — same +// table, derived from different hardware by different people.) +// +// The rest of the gen4 v24 field map does NOT carry over to gen5's k18. That is +// a measured result, not a guess: running gen4's `parseR24` over a real k18 +// capture reads its gravity vector as 0.27 g, far outside the 0.5–1.8 g +// plausibility gate, so the decode is correctly refused. Accelerometer, RR +// intervals and SpO₂ live somewhere else in a k18 record — or not in it at all. +// +// WHY THIS FILE DOES NOT GUESS THEM +// +// Scanning the one k18 capture we have for a plausible gravity triple yields 16 +// candidate offsets, several of which overlap the timestamp field. One frame +// cannot distinguish them, and shipping a guessed offset is exactly the mistake +// that made gen5 undiscoverable in the first place (a service UUID assumed +// rather than confirmed). So this decoder reads ONLY fields with an independent +// cross-reference, and leaves the rest null — an absent field is honest and +// recoverable; a wrong one silently poisons every downstream metric. +// +// Fields below are cross-referenced against geniemax-core's k18 decode +// (Sources/GenieMax/WhoopDecode.swift) and its recorded golden values. + +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// HR byte offset within a history record inner, keyed by the k-domain at +/// inner[1]. Deliberately duplicated from the gen4 table rather than imported: +/// this is the gen5 evidence (goose's `history_hr_marker_offset`), and if the +/// two ever diverge, that divergence must be visible, not silently resolved. +const Map kGen5HrOffsetByKDomain = { + 7: 27, + 9: 17, + 12: 17, + 18: 14, + 24: 17, +}; + +/// The k-domain gen5 straps use for their 1 Hz history record. +const int kGen5HistoryKDomain = 18; + +/// Byte offset of the respiratory-rate value inside a k18 record. +const int _k18RespOffset = 35; + +/// Byte offset of the skin temperature (int16 LE, centi-degrees C) in a k18. +const int _k18SkinTempOffset = 65; + +/// Minimum length of a k18 record that carries the temperature field. +const int _k18MinLength = 67; + +/// The verified subset of a gen5 history record. +/// +/// Every field here has a recorded golden value from a real strap. Anything not +/// listed is not "zero" — it is unknown, and is represented as null so a +/// consumer can tell the difference. +class Gen5HistoryRecord { + /// Unix seconds from the record itself (inner[7:11]). + final int tsEpoch; + + /// Sub-second counter (inner[11:13]). + final int tsSubsec; + + /// Monotonic record counter (inner[3:7]) — drives band-reboot detection. + final int counter; + + /// Beats per minute. 0 legitimately means off-wrist, as it does for gen4. + final int hr; + + /// The k-domain this record was decoded as (inner[1]). + final int kDomain; + + /// Breaths per minute, k18 only. Null when the record is another k-domain. + final int? respRate; + + /// Skin temperature in hundredths of a degree Celsius, k18 only. + /// + /// UNITS DIFFER FROM GEN4, DELIBERATELY: gen4 stores a raw ADC count here. + /// That is harmless because the metric is only ever consumed as a z-score + /// against the same band's own rolling baseline (`skin_temp_adc` in the + /// derivation engine), and a band never changes generation mid-history. Any + /// consumer that starts treating it as an absolute value must branch on the + /// generation first. + final int? skinTempCentiC; + + const Gen5HistoryRecord({ + required this.tsEpoch, + required this.tsSubsec, + required this.counter, + required this.hr, + required this.kDomain, + this.respRate, + this.skinTempCentiC, + }); + + /// Skin temperature in degrees Celsius, or null if this record has none. + double? get skinTempC => + skinTempCentiC == null ? null : skinTempCentiC! / 100.0; + + @override + String toString() => + 'Gen5HistoryRecord(k$kDomain ts=$tsEpoch counter=$counter hr=$hr ' + 'resp=$respRate tempC=$skinTempC)'; +} + +int _u16(Uint8List b, int o) => b[o] | (b[o + 1] << 8); + +int _u32(Uint8List b, int o) => + b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24); + +int _i16(Uint8List b, int o) { + final v = _u16(b, o); + return v >= 0x8000 ? v - 0x10000 : v; +} + +/// Decode the verified subset of a gen5 history record from a frame's inner. +/// +/// Returns null when [inner] is not a history packet, is in a k-domain we have +/// no HR offset for, is too short, or carries an implausible heart rate. A null +/// return is the caller's signal to ARCHIVE the record rather than drop it — +/// undecodable bytes from real hardware are the raw material for the next fix. +Gen5HistoryRecord? decodeGen5History(Uint8List inner) { + if (inner.length < 13) return null; + if (inner[0] != PacketType.historicalData) return null; + + final k = inner[1]; + final hrOffset = kGen5HrOffsetByKDomain[k]; + if (hrOffset == null) return null; // unknown layout — archive it instead + if (inner.length <= hrOffset) return null; + + final hr = inner[hrOffset]; + // 0 is a real reading (off-wrist), exactly as on gen4. Anything between 0 and + // a live human range is a decode that landed on the wrong byte. + if (hr != 0 && (hr < 25 || hr > 230)) return null; + + int? resp; + int? skinTemp; + if (k == kGen5HistoryKDomain && inner.length >= _k18MinLength) { + final r = inner[_k18RespOffset]; + // Respiration is 4–60 brpm in anything alive; outside that the byte is not + // what we think it is, so report nothing rather than something wrong. + if (r >= 4 && r <= 60) resp = r; + skinTemp = _i16(inner, _k18SkinTempOffset); + // 10–50 °C brackets "on a human wrist" generously in either direction. + if (skinTemp < 1000 || skinTemp > 5000) skinTemp = null; + } + + return Gen5HistoryRecord( + tsEpoch: _u32(inner, 7), + tsSubsec: _u16(inner, 11), + counter: _u32(inner, 3), + hr: hr, + kDomain: k, + respRate: resp, + skinTempCentiC: skinTemp, + ); +} diff --git a/test/ble_engine_test.dart b/test/ble_engine_test.dart index 3234f63a..03526719 100644 --- a/test/ble_engine_test.dart +++ b/test/ble_engine_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/ble/gen5_framing.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; void main() { @@ -240,4 +241,59 @@ void main() { expect(proto.initPackets.last, proto.cmdSendHistorical(4)); }); }); + + // These constants are load-bearing in a way a unit test can't otherwise reach: + // they are copied by hand into ios/Runner/AccessorySetup.swift and the + // NSAccessorySetupBluetoothServices array in ios/Runner/Info.plist. iOS matches + // an AccessorySetupKit descriptor against the advertisement byte-for-byte, so a + // single wrong digit here is not a degraded match — it is "No Accessory Found" + // forever, with no error to debug. That is exactly the bug this group pins shut. + group('WHOOP service UUIDs', () { + test('gen5 is the 128-bit vendor service, not the Base-UUID expansion', () { + expect(kGen5ServiceUuid, 'fd4b0001-cce1-4033-93ce-002d5875f58a'); + // The regression itself: 0xFD4B expanded against the Bluetooth Base UUID is + // a DIFFERENT UUID that no gen5 band advertises. + expect(kGen5ServiceUuid, isNot('0000fd4b-0000-1000-8000-00805f9b34fb')); + }); + + test('gen5 mirrors the gen4 layout — 0001 service, shared suffix', () { + expect(kGen5ServiceUuid, endsWith(kGen5UuidSuffix)); + expect(kGen5ServiceUuid, startsWith('fd4b0001')); + }); + + test('matches both families', () { + expect(isWhoopServiceUuid('61080001-8d6d-82b8-614a-1c8cb0f8dcc6'), isTrue); + expect(isWhoopServiceUuid(kGen5ServiceUuid), isTrue); + }); + + test('matching is case-insensitive (platforms disagree on spelling)', () { + expect(isWhoopServiceUuid('FD4B0001-CCE1-4033-93CE-002D5875F58A'), isTrue); + expect(isWhoopServiceUuid('61080001-8D6D-82B8-614A-1C8CB0F8DCC6'), isTrue); + }); + + test('accepts the 16-bit member UUID in either spelling', () { + // iOS reports 16-bit UUIDs short; Android expands them against the Base UUID. + expect(isWhoopServiceUuid(kWhoopMemberUuid16), isTrue); + expect(isWhoopServiceUuid('FD4B'), isTrue); + expect(isWhoopServiceUuid('0000fd4b-0000-1000-8000-00805f9b34fb'), isTrue); + }); + + test('does not match unrelated services', () { + expect(isWhoopServiceUuid('0000180d-0000-1000-8000-00805f9b34fb'), isFalse); + expect(isWhoopServiceUuid('0000180f-0000-1000-8000-00805f9b34fb'), isFalse); + expect(isWhoopServiceUuid(''), isFalse); + }); + + test('the scan UUID and the transport family agree', () { + // kGen5ServiceUuid drives the scan filter and is hand-copied into iOS; + // WhoopFamily.gen5 drives service discovery and characteristic lookup. + // If these two ever disagree, the band is found and then cannot be talked + // to (or the reverse) — a failure mode with no obvious symptom. + expect(kGen5ServiceUuid, startsWith(WhoopFamily.gen5.servicePrefix)); + expect( + proto.GattUuids.service, + startsWith(WhoopFamily.gen4.servicePrefix), + ); + }); + }); } diff --git a/test/gen5_framing_test.dart b/test/gen5_framing_test.dart new file mode 100644 index 00000000..dfa2a13f --- /dev/null +++ b/test/gen5_framing_test.dart @@ -0,0 +1,341 @@ +// Gen5 (WHOOP 5.0 / MG) framing — validated against REAL DEVICE CAPTURES. +// +// No maintainer owns a gen5 strap, so this suite is the only thing standing +// between the gen5 transport and wishful thinking. Every vector below is either +// a hand-derived frame from an independent implementation or bytes a real WHOOP +// 5.0 actually emitted, with the expected decode recorded alongside by whoever +// captured it. Sources: +// +// • GET_HELLO — b-nnett/goose, Rust/core/tests/protocol_tests.rs. Doubles as a +// BUILDER PARITY check: goose asserts its own builder reproduces this exact +// hex, so if buildGen5Frame matches it, three implementations agree. +// • k2 / k18 / type36 — satayutata/geniemax-core, +// Tests/GenieMaxTests/Fixtures/decode_golden.json, captured from a real +// strap with the decoded values verified against the official app. +// +// The k2 and k18 cases matter most: they are fed to the EXISTING gen4 record +// decoders, unchanged. That is the whole architectural claim of gen5 support — +// only the envelope differs — and these tests are what make it falsifiable. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/gen5_framing.dart'; +import 'package:openstrap_edge/ble/gen5_records.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// GET_HELLO: COMMAND(0x23) seq=1 opcode=145 data=[1]. +const String kGetHelloFrame = 'aa0108000001e67123019101363e5c8d'; + +/// Realtime HR packet (pt 40 / k2) — the strap reported 83 bpm. +const String kK2Frame = + 'aa011800010022e1280273ca246a852b530000000000000000000100c1e0e8ed'; + +/// Historical record (pt 47 / k18) — HR 77 bpm at unix 1577582585. +/// +/// NOTE ON ITS CRC32: this frame's stored payload CRC does NOT match its bytes, +/// and that is a property of the fixture, not a bug here. geniemax-core states +/// its fixtures are "time-shifted, de-identified" — the capture's timestamp was +/// rewritten after the fact without recomputing the trailing CRC32. The payload +/// is otherwise intact and self-consistent (ts at [7:11], HR at [14] and resp at +/// [35] all still read exactly what the fixture records), so it remains a valid +/// test of the header math and the record decode. [kK18FrameCrcFixed] is the +/// same frame with the CRC recomputed, for the full-validation path. +const String kK18Frame = + 'aa01740001003fb12f12800e79a701f9ff075e3d2a004d000000000000000000' + '0070310a00000000ce0030123c52b05cbf3de2ef3ed7b3893e780aff00000000' + '000000000039013e01e60c000c010c020c000000000000000000000000000000' + '000000000000000001008f888080000000f4c238c0000000686c9868'; + +/// [kK18Frame] with its payload CRC32 recomputed over the (unchanged) payload. +const String kK18FrameCrcFixed = + 'aa01740001003fb12f12800e79a701f9ff075e3d2a004d000000000000000000' + '0070310a00000000ce0030123c52b05cbf3de2ef3ed7b3893e780aff00000000' + '000000000039013e01e60c000c010c020c000000000000000000000000000000' + '000000000000000001008f888080000000f4c238c0000000dae5fee1'; + +/// COMMAND_RESPONSE (pt 36) to GET_DATA_RANGE — head 0, watermark 0. +const String kType36Frame = + 'aa01740001003fb1243b91000201000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000' + '00000000000000000000000000000000000000000000000045467b8e'; + +void main() { + group('gen5 frame envelope', () { + test('builder reproduces the goose GET_HELLO vector byte-for-byte', () { + // inner = [COMMAND, seq=1, GET_HELLO=145, 0x01] + final built = buildGen5Frame(const [0x23, 0x01, 0x91, 0x01]); + expect(_hex(built), kGetHelloFrame); + }); + + test('parses the GET_HELLO vector with both CRCs valid', () { + final f = parseGen5Frame(hexToBytes(kGetHelloFrame))!; + expect(f.crc8Ok, isTrue, reason: 'header crc16-modbus'); + expect(f.crc32Ok, isTrue, reason: 'payload crc32'); + expect(f.valid, isTrue); + expect(_hex(f.inner), '23019101'); + expect(f.packetType, PacketType.command); + expect(f.seq, 1); + expect(f.opcode, 145); + }); + + test('round-trips arbitrary inner content through build → parse', () { + // Deliberately not a multiple of 4, to exercise the padding path. + const inner = [0x23, 0x07, 0x22, 0xDE, 0xAD, 0xBE]; + final f = parseGen5Frame(buildGen5Frame(inner))!; + expect(f.valid, isTrue); + expect(f.inner.take(inner.length), inner); + expect(f.inner.length % 4, 0, reason: 'inner is zero-padded to 4 bytes'); + }); + + test('a corrupted payload fails crc32 but leaves the header readable', () { + // Mirrors goose's payload_crc_mismatch_preserves_parseable_header test: + // we must still be able to see WHAT the frame was, to log it usefully. + final raw = hexToBytes(kGetHelloFrame); + raw[raw.length - 1] ^= 0xFF; + final f = parseGen5Frame(raw)!; + expect(f.crc8Ok, isTrue); + expect(f.crc32Ok, isFalse); + expect(f.valid, isFalse); + expect(f.packetType, PacketType.command); + }); + + test('a corrupted header is reported, not silently trusted', () { + final raw = hexToBytes(kGetHelloFrame); + raw[6] ^= 0xFF; // header crc16 low byte + expect(parseGen5Frame(raw)!.crc8Ok, isFalse); + }); + + test('rejects a short buffer and a declared length below the crc32', () { + expect(parseGen5Frame(hexToBytes('aa010800')), isNull); + // declared = 2, which cannot even cover the trailing crc32. + expect(parseGen5Frame(hexToBytes('aa010200000100000000')), isNull); + }); + + test('crc16-modbus matches the reference implementation', () { + // The header prefix of GET_HELLO: aa 01 08 00 00 01 → 0x71e6. + expect(crc16Modbus(const [0xAA, 0x01, 0x08, 0x00, 0x00, 0x01]), 0x71E6); + expect(crc16Modbus(const []), 0xFFFF); + }); + }); + + group('gen5 reassembly', () { + test('reassembles a frame split across BLE notifications', () { + final frame = hexToBytes(kGetHelloFrame); + final asm = Gen5FrameReassembler(); + expect(asm.feed(frame.sublist(0, 5)), isEmpty); + expect(asm.feed(frame.sublist(5, 11)), isEmpty); + final out = asm.feed(frame.sublist(11)); + expect(out, hasLength(1)); + expect(out.single.valid, isTrue); + expect(_hex(out.single.inner), '23019101'); + }); + + test('drops leading noise before the frame start', () { + // goose's deframer test feeds exactly this shape. + final frame = hexToBytes(kGetHelloFrame); + final asm = Gen5FrameReassembler(); + final out = asm.feed([0x00, 0x01, ...frame]); + expect(out, hasLength(1)); + expect(out.single.valid, isTrue); + }); + + test('carves several frames out of one chunk', () { + final asm = Gen5FrameReassembler(); + final out = asm.feed([ + ...hexToBytes(kGetHelloFrame), + ...hexToBytes(kK2Frame), + ...hexToBytes(kGetHelloFrame), + ]); + expect(out, hasLength(3)); + expect(out.every((f) => f.valid), isTrue); + }); + + test('a 0xAA inside sensor data does not desynchronise the stream', () { + // THE regression this design exists to prevent. Sensor payloads are full + // of 0xAA and notification boundaries land on them; a "reset on 0xAA" + // reassembler would lose every record after the first such byte. + final payload = List.filled(32, 0xAA); + final frame = buildGen5Frame([0x2F, 0x18, ...payload]); + final asm = Gen5FrameReassembler(); + final out = asm.feed(frame); + expect(out, hasLength(1)); + expect(out.single.valid, isTrue); + expect(out.single.inner[1], 0x18); + }); + + test('resynchronises past a bad header instead of stalling', () { + final good = hexToBytes(kGetHelloFrame); + final asm = Gen5FrameReassembler(); + // A 0xAA with a header CRC that cannot hold up, then a real frame. + final out = asm.feed([0xAA, 0x01, 0x40, 0x00, 0x00, 0x01, 0x00, 0x00, ...good]); + expect(out, hasLength(1)); + expect(out.single.valid, isTrue); + expect(asm.resyncs, greaterThan(0)); + }); + + test('an implausible declared length does not buffer unboundedly', () { + final asm = Gen5FrameReassembler(); + expect(asm.feed([0xAA, 0x01, 0xFF, 0xFF, 0x00, 0x01, 0x00, 0x00]), isEmpty); + expect(asm.feed(hexToBytes(kGetHelloFrame)), hasLength(1)); + }); + + test('reset clears buffered bytes and the resync counter', () { + final asm = Gen5FrameReassembler(); + final frame = hexToBytes(kGetHelloFrame); + asm.feed(frame.sublist(0, 6)); + asm.reset(); + expect(asm.resyncs, 0); + // The tail of the old frame alone must not produce anything. + expect(asm.feed(frame.sublist(6)), isEmpty); + }); + }); + + // The claim under test: gen5 differs ONLY in the envelope, so real gen5 + // payloads decode through the existing, hardware-tested gen4 decoders. + group('real gen5 captures decode through the gen4 decoders', () { + test('k2 realtime packet yields the captured heart rate', () { + final f = parseGen5Frame(hexToBytes(kK2Frame))!; + expect(f.valid, isTrue, reason: 'real device frame must validate'); + expect(f.packetType, PacketType.realtimeData); + expect(f.inner[1], 2, reason: 'k-domain 2'); + + final hr = parseRealtimeHr(f.inner)!; + expect(hr.hrBpm, 83, reason: 'geniemax recorded hr8 = 83'); + }); + + test('k18 historical record yields the captured HR and timestamp', () { + final f = parseGen5Frame(hexToBytes(kK18Frame))!; + // Header math must hold on the real capture. The payload CRC does not, + // for the de-identification reason documented on the constant. + expect(f.crc8Ok, isTrue, reason: 'real device header must validate'); + expect(f.packetType, PacketType.historicalData); + expect(f.inner[1], 18, reason: 'k-domain 18'); + + // The HEADER decodes through the shared layout both generations agree on. + // The full gen4 v24 field map does NOT apply to a k18 body — see + // gen5_records_test.dart for that boundary and decodeGen5History for the + // fields we can actually justify reading. + final r = decodeGen5History(f.inner)!; + expect(r.hr, 77, reason: 'geniemax recorded hr14 = 77'); + expect(r.tsEpoch, 1577582585, reason: 'geniemax recorded ts7'); + }); + + test('the same k18 frame fully validates once its CRC32 is recomputed', () { + // Proves the CRC mismatch above is the fixture's, not the parser's: the + // payload bytes are untouched, only the trailing CRC differs. + final f = parseGen5Frame(hexToBytes(kK18FrameCrcFixed))!; + expect(f.valid, isTrue); + expect(f.inner, parseGen5Frame(hexToBytes(kK18Frame))!.inner); + expect(decodeGen5History(f.inner)!.hr, 77); + }); + + test('a real k18 frame survives chunked reassembly', () { + // The path that actually runs on-device: bytes arrive split across BLE + // notifications, not as one buffer. + final frame = hexToBytes(kK18FrameCrcFixed); + final asm = Gen5FrameReassembler(); + final out = []; + for (var i = 0; i < frame.length; i += 20) { + out.addAll(asm.feed( + frame.sublist(i, i + 20 > frame.length ? frame.length : i + 20), + )); + } + expect(out, hasLength(1)); + expect(out.single.valid, isTrue); + expect(decodeGen5History(out.single.inner)!.hr, 77); + }); + + test('type36 command response frames validate and route', () { + final f = parseGen5Frame(hexToBytes(kType36Frame))!; + expect(f.valid, isTrue); + expect(f.packetType, PacketType.commandResponse); + }); + + test('decodeFrame routes a real gen5 realtime frame without changes', () { + final f = parseGen5Frame(hexToBytes(kK2Frame))!; + final d = decodeFrame(f); + expect(d.kind, 'realtime_hr'); + expect(d.fields['hr'], 83); + }); + }); + + group('envelope translation', () { + test('reframing a built gen4 command preserves inner bytes exactly', () { + final gen4 = buildCommand(7, Cmd.getClock, const [0x00]); + final gen5 = reframeGen4ToGen5(gen4); + + final a = parseFrame(gen4)!; + final b = parseGen5Frame(gen5)!; + expect(b.inner, a.inner); + expect(b.valid, isTrue); + expect(b.packetType, PacketType.command); + expect(b.opcode, Cmd.getClock); + }); + + test('reframing produces exactly what buildGen5Frame would', () { + final inner = parseFrame(buildCommand(1, 0x91, const [0x01]))!.inner; + expect(reframeGen4ToGen5(buildCommand(1, 0x91, const [0x01])), + buildGen5Frame(inner)); + }); + + test('the batch ACK survives the envelope swap', () { + // The HISTORY_END ACK is the one write where a mistake costs data: the + // band trims flash once it lands. + final token = [1, 2, 3, 4, 5, 6, 7, 8]; + final gen5 = reframeGen4ToGen5(buildHistoryResultOk(3, token)); + final f = parseGen5Frame(gen5)!; + expect(f.valid, isTrue); + expect(f.opcode, Cmd.historicalDataResult); + expect(f.inner.sublist(4, 12), token); + }); + + test('a non-frame input is returned untouched rather than mangled', () { + final junk = hexToBytes('0badc0de'); + expect(reframeGen4ToGen5(junk), junk); + }); + }); + + group('WhoopFamily', () { + test('identifies each family from its service UUID', () { + expect(WhoopFamily.ofServiceUuid(GattUuids.service), WhoopFamily.gen4); + expect(WhoopFamily.ofServiceUuid('fd4b0001-cce1-4033-93ce-002d5875f58a'), + WhoopFamily.gen5); + expect(WhoopFamily.ofServiceUuid('FD4B0001-CCE1-4033-93CE-002D5875F58A'), + WhoopFamily.gen5); + expect(WhoopFamily.ofServiceUuid('0000180d-0000-1000-8000-00805f9b34fb'), + isNull); + }); + + test('characteristic roles are numbered identically off each prefix', () { + // This parallelism is what lets one set of role lookups serve both + // families; if it ever stops holding, the connect path must change. + for (final f in WhoopFamily.values) { + expect(f.cmdToPrefix, '${f.servicePrefix.substring(0, 4)}0002'); + expect(f.cmdFromPrefix, '${f.servicePrefix.substring(0, 4)}0003'); + expect(f.eventsPrefix, '${f.servicePrefix.substring(0, 4)}0004'); + expect(f.dataPrefix, '${f.servicePrefix.substring(0, 4)}0005'); + } + }); + + test('gen4 prefixes still match the protocol package constants', () { + expect(GattUuids.service, startsWith(WhoopFamily.gen4.servicePrefix)); + expect(GattUuids.cmdTo, startsWith(WhoopFamily.gen4.cmdToPrefix)); + expect(GattUuids.cmdFrom, startsWith(WhoopFamily.gen4.cmdFromPrefix)); + expect(GattUuids.events, startsWith(WhoopFamily.gen4.eventsPrefix)); + expect(GattUuids.data, startsWith(WhoopFamily.gen4.dataPrefix)); + }); + + test('WhoopReassembler.of returns the right codec per family', () { + expect(WhoopReassembler.of(WhoopFamily.gen5), isA()); + // Gen4 must keep running the protocol package's own reassembler. + final gen4 = WhoopReassembler.of(WhoopFamily.gen4); + expect(gen4, isNot(isA())); + final out = gen4.feed(buildCommand(1, Cmd.getClock, const [0x00])); + expect(out, hasLength(1)); + expect(out.single.valid, isTrue); + }); + }); +} + +String _hex(List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); diff --git a/test/gen5_records_test.dart b/test/gen5_records_test.dart new file mode 100644 index 00000000..42b7982c --- /dev/null +++ b/test/gen5_records_test.dart @@ -0,0 +1,147 @@ +// Gen5 history-record decode, checked against the golden values recorded +// alongside a real WHOOP 5.0 capture (geniemax-core decode_golden.json). +// +// The point of this suite is as much about what we REFUSE to decode as what we +// decode. A gen5 field we cannot justify must come back null, because null +// archives the record for a future fix while a wrong number silently poisons +// every metric derived from it. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/gen5_framing.dart'; +import 'package:openstrap_edge/ble/gen5_records.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +import 'gen5_framing_test.dart' show kK18Frame, kK2Frame; + +/// Inner payload of the real k18 capture. +Uint8List _k18Inner() => parseGen5Frame(hexToBytes(kK18Frame))!.inner; + +void main() { + group('real k18 capture', () { + test('decodes every field geniemax recorded, and no others', () { + final r = decodeGen5History(_k18Inner())!; + expect(r.kDomain, 18); + expect(r.hr, 77, reason: 'golden hr14'); + expect(r.tsEpoch, 1577582585, reason: 'golden ts7'); + expect(r.respRate, 18, reason: 'golden resp35'); + expect(r.skinTempC, closeTo(33.02, 0.001), reason: 'golden temp_c'); + expect(r.counter, greaterThan(0)); + }); + + test('agrees with the gen4 header decode on counter and timestamp', () { + // The header is the part both generations genuinely share; if this ever + // stops holding, the shared-header premise is wrong and the transport + // needs rethinking, not patching. + final inner = _k18Inner(); + final view = inner.buffer.asByteData(inner.offsetInBytes, inner.length); + final r = decodeGen5History(inner)!; + expect(r.counter, view.getUint32(3, Endian.little)); + expect(r.tsEpoch, view.getUint32(7, Endian.little)); + expect(r.tsSubsec, view.getUint16(11, Endian.little)); + }); + + test('the gen4 full-record decoder correctly REFUSES this record', () { + // Documents the measured divergence: gen4's v24 field map reads a 0.27 g + // gravity vector here, so its plausibility gate rejects the decode. This + // is why a gen5-specific decoder exists at all — if this test ever starts + // failing, the two layouts converged and this file can shrink. + expect(parseR24(_k18Inner()), isNull); + }); + }); + + group('k-domain handling', () { + test('HR offsets match the gen4 table exactly', () { + // Two independent reverse-engineering efforts, same numbers. This test is + // the tripwire for that agreement quietly breaking. + expect(kGen5HrOffsetByKDomain, {7: 27, 9: 17, 12: 17, 18: 14, 24: 17}); + }); + + test('reads HR at the right offset for each known k-domain', () { + for (final entry in kGen5HrOffsetByKDomain.entries) { + final inner = Uint8List(80); + inner[0] = PacketType.historicalData; + inner[1] = entry.key; + inner[entry.value] = 61; + final r = decodeGen5History(inner); + expect(r, isNotNull, reason: 'k${entry.key} should decode'); + expect(r!.hr, 61, reason: 'k${entry.key} HR at ${entry.value}'); + } + }); + + test('an unknown k-domain returns null so the record gets archived', () { + final inner = Uint8List(80); + inner[0] = PacketType.historicalData; + inner[1] = 99; + expect(decodeGen5History(inner), isNull); + }); + + test('resp and skin temp are k18-only', () { + final inner = Uint8List(80); + inner[0] = PacketType.historicalData; + inner[1] = 24; // a non-k18 history domain + inner[17] = 60; + final r = decodeGen5History(inner)!; + expect(r.respRate, isNull); + expect(r.skinTempCentiC, isNull); + }); + }); + + group('refusals', () { + test('rejects a non-historical packet type', () { + expect(decodeGen5History(parseGen5Frame(hexToBytes(kK2Frame))!.inner), + isNull); + }); + + test('rejects a truncated record', () { + expect(decodeGen5History(Uint8List(4)), isNull); + }); + + test('rejects an implausible heart rate', () { + final inner = Uint8List(80); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[14] = 240; // above any live human HR ⇒ wrong byte, not a reading + expect(decodeGen5History(inner), isNull); + }); + + test('accepts HR 0 — off-wrist is a real reading, not a failure', () { + final inner = Uint8List(80); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[14] = 0; + expect(decodeGen5History(inner)!.hr, 0); + }); + + test('drops an out-of-range respiration rather than reporting it', () { + final inner = Uint8List(80); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[14] = 60; + inner[35] = 200; // nobody breathes 200x a minute + expect(decodeGen5History(inner)!.respRate, isNull); + }); + + test('drops an out-of-range skin temperature', () { + final inner = Uint8List(80); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[14] = 60; + inner[65] = 0x00; + inner[66] = 0x00; // 0.00 °C — not a wrist + expect(decodeGen5History(inner)!.skinTempCentiC, isNull); + }); + + test('leaves unknown fields null rather than defaulting them to zero', () { + // The whole contract of this decoder: absent ≠ zero. + final r = decodeGen5History(_k18Inner())!; + expect(r.skinTempC, isNotNull); + // No accelerometer/RR/SpO2 accessors exist at all — they are not modelled + // as nullable fields, they are simply not claimed. Guard that nothing + // quietly adds a zero-valued one later. + expect(r.toString(), isNot(contains('ax'))); + expect(r.toString(), isNot(contains('rr'))); + }); + }); +} From 1cacfe5a1d7185eaf0fd7087167752632028fd54 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 14 Aug 2026 18:58:43 +0300 Subject: [PATCH 3/6] fix(gen5): bank partial records before the HISTORY_END ACK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WHOOP 5.0 history record decodes to heart rate and time but carries no accel / RR / SpO2 where 4.0 keeps them, so its Sample left those null. That made it fail Sample.hasDecodedOneHz, which sent _decodeOneHzSample on to re-decode the raw hex with the GEN4 decoder — which cannot read a gen5 record either. It returned null, _queueDecodedOneHz wrote nothing, and the same transaction still advanced strap_trim. Net effect: the HISTORY_END ACK told the band it could erase records that had never reached decoded_onehz, the table derivation actually reads. Every gen5 sync would have looked like it worked and banked nothing — the exact failure the safe-trim invariant exists to prevent, introduced by the gen5 branch in _ingestHistoricalFrame. Found by CodeRabbit on #238 and confirmed by tracing hasDecodedOneHz through _decodeOneHzSample. Two changes: db.dart — _decodeOneHzSample falls back to `preferred` instead of returning null when the re-decode fails. Gen4 semantics are untouched: a complete sample already returns at the top, a successful re-decode already returns above, and with no preferred the result is still null. It only stops a deliberately partial sample being discarded. ble_engine.dart — a gen5 partial decode now ALSO archives its raw frame, in the same commit that runs before the ACK. A partial sample is not a decoded one: ACKing on the strength of it would make the undecoded fields permanently unrecoverable. The archive costs a second copy of each gen5 record and buys back the only copy of the bytes this build cannot read. Motion columns land as 0 for gen5, because decoded_onehz has no null motion. That is a placeholder, not a measurement, and gen5_persistence_test asserts it explicitly rather than leaving it to be discovered — the archived frame is what makes it recoverable. 7 new tests. Verified they bite: reverting the db.dart fix fails the four partial-sample cases while the gen4 control cases keep passing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RrVjCqVDMANK5sa5eyjATw --- lib/ble/ble_engine.dart | 30 +++++ lib/data/db.dart | 43 ++++--- test/gen5_persistence_test.dart | 198 ++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 test/gen5_persistence_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index d8ab6eb6..a024278c 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2576,9 +2576,11 @@ class BleEngine { // refuses) — and one capture cannot tell us where those fields really live. // Leaving them null costs actigraphy-driven sleep detail; guessing an offset // would silently corrupt every metric downstream of it. See gen5_records.dart. + var gen5Partial = false; if (sample == null && _session?.family == WhoopFamily.gen5) { final g = decodeGen5History(frame.inner); if (g != null) { + gen5Partial = true; sample = Sample( tsEpoch: g.tsEpoch, counter: g.counter, @@ -2609,6 +2611,34 @@ class BleEngine { } return; } + // SAFE-TRIM FOR PARTIAL DECODES. A gen5 record decodes to HR + time but NOT + // accel / RR / SpO2, and the bytes carrying those are gone the moment the + // band trims flash on our HISTORY_END ACK. A partial sample is NOT the same + // as a decoded one: ACKing on the strength of it would quietly make the + // missing fields unrecoverable, which is exactly what the invariant exists + // to prevent. So archive the frame as well — the archive rides the SAME + // commit that runs before the ACK, so "nothing is ACKed that we have not + // durably kept" keeps meaning what it says, and a future decoder that + // learns where those fields live can reprocess every record we ever saw. + // + // COST: gen5 records are stored twice (raw_records + the never-pruned + // archive). That is deliberate — it buys back the only copy of the fields + // this build cannot read, and it stops as soon as the decode is complete. + if (gen5Partial) { + final archive = ArchiveRecord( + counter: counter, + hex: _innerHex(frame.inner), + packetType: frame.inner.isNotEmpty ? frame.inner[0] : 0, + capturedAt: DateTime.now().millisecondsSinceEpoch, + reason: 'gen5_partial_k$recType', + ); + final d = _drain; + if (d != null) { + d.onUndecodableRecord(archive); + } else { + unawaited(onArchiveRecord?.call(archive) ?? Future.value()); + } + } // PLAUSIBILITY GATE + FRONTIER (RecordGate, shared with the detectors). // Drop records whose unix is implausible vs wall-clock and (when known) the // strap's own GET_DATA_RANGE window — a previous owner's wandering-clock diff --git a/lib/data/db.dart b/lib/data/db.dart index f3d7a8be..bc41c833 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2542,22 +2542,37 @@ class LocalDb { 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, - ); + 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 (_) { - return null; + // fall through to the partial sample below } + // PARTIAL SAMPLES MUST NOT BE DROPPED HERE. Returning null when the re-decode + // fails is safe for gen4 — there, a `preferred` that fails [hasDecodedOneHz] + // means the hex is the better source, and if the hex will not decode there is + // nothing to write. It is NOT safe for a caller that supplies a deliberately + // partial sample: a WHOOP 5.0 record decodes to HR + time but carries no + // accel/SpO2, so it fails [hasDecodedOneHz] AND cannot be re-decoded by the + // gen4 decoder above. Returning null there wrote no `decoded_onehz` row while + // the same transaction still advanced `strap_trim` — so the band was told to + // erase records that never reached the table derivation reads. + // + // Falling back to [preferred] changes nothing for gen4: a complete sample + // already returned at the top, and a successful re-decode already returned + // above. It only stops a partial sample being silently discarded. + return preferred; } /// Queues the decoded_onehz + decoded_rr writes for one raw onto [batch]. diff --git a/test/gen5_persistence_test.dart b/test/gen5_persistence_test.dart new file mode 100644 index 00000000..03d2a10f --- /dev/null +++ b/test/gen5_persistence_test.dart @@ -0,0 +1,198 @@ +// The safe-trim invariant, for records that decode only PARTIALLY. +// +// THE BUG THIS PINS. A WHOOP 5.0 history record decodes to heart rate and time +// but carries no accelerometer / RR / SpO2 where 4.0 keeps them, so its Sample +// leaves those null. That made it fail `Sample.hasDecodedOneHz`, which sent +// `_decodeOneHzSample` on to re-decode the raw hex with the GEN4 decoder — which +// cannot read a gen5 record either. It returned null, `_queueDecodedOneHz` wrote +// nothing, and the very same transaction still advanced `strap_trim`. +// +// Net effect: the band was told (via the HISTORY_END ACK) that it could erase +// records that had never reached `decoded_onehz` — the table derivation actually +// reads. Every gen5 sync would have looked like it worked and banked nothing. +// +// Two things have to hold, and both are tested here: +// 1. a partial sample still produces a `decoded_onehz` row, so the data the +// band is about to erase is genuinely captured; +// 2. gen4 behaviour is completely unchanged — a complete sample still wins, +// and a genuinely undecodable record still writes no row. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/models.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; +} + +/// What the gen5 decoder can honestly produce: time, counter, HR, skin temp. +/// Accel / RR / SpO2 stay null — absent, not zero. +Sample _gen5Partial(int ts, int counter) => + Sample(tsEpoch: ts, counter: counter, hr: 77, skinTempRaw: 3302); + +/// A fully-decoded gen4 sample, for the control cases. +Sample _gen4Complete(int ts, int counter) => Sample( + tsEpoch: ts, + counter: counter, + hr: 61, + rrIntervalsMs: const [900, 910], + ax: 0.1, + ay: 0.2, + az: 0.97, + spo2RedRaw: 1234, + spo2IrRaw: 5678, + skinTempRaw: 3100, +); + +/// Hex that no decoder in this repo can read — stands in for a gen5 inner, +/// whose real bytes the gen4 decoder also refuses. +RawRecord _raw(int ts, int counter) => RawRecord( + counter: counter, + packetType: 47, + hex: 'aa$counter${'00' * 8}', + capturedAt: ts * 1000, + recTs: ts, +); + +Future _oneHzCount() async { + final db = await LocalDb.instance; + final rows = await db.rawQuery('SELECT COUNT(*) AS n FROM decoded_onehz'); + return (rows.first['n'] as int?) ?? 0; +} + +Future?> _oneHzAt(int recTs) async { + final db = await LocalDb.instance; + final rows = await db.rawQuery( + 'SELECT * FROM decoded_onehz WHERE rec_ts = ?', + [recTs], + ); + return rows.isEmpty ? null : rows.first; +} + +void main() { + late Directory tmp; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + tmp = await Directory.systemTemp.createTemp('openstrap_gen5_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + }); + + setUp(() async { + final db = await LocalDb.instance; + // raw_records / decoded_rr are created lazily on first insert, so on the + // first pass they do not exist yet. Missing is as clean as empty. + for (final t in ['decoded_onehz', 'decoded_rr', 'raw_records']) { + try { + await db.delete(t); + } catch (_) {/* table not created yet */} + } + }); + + tearDownAll(() async { + await LocalDb.close(); + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + + test('a PARTIAL sample still lands in decoded_onehz', () async { + // The regression itself. Pre-fix this committed zero rows while still + // advancing the trim cursor — the band erases, we keep nothing. + const ts = 1577582585; + await LocalDb.commitSyncBatch([_raw(ts, 10)], [_gen5Partial(ts, 10)]); + + final row = await _oneHzAt(ts); + expect(row, isNotNull, + reason: 'a partial decode must still be banked before the ACK'); + expect(row!['hr'], 77); + expect(row['counter'], 10); + }); + + test('the partial row carries the fields we DID decode', () async { + const ts = 1577582600; + await LocalDb.commitSyncBatch([_raw(ts, 11)], [_gen5Partial(ts, 11)]); + + final row = (await _oneHzAt(ts))!; + expect(row['hr'], 77); + expect(row['skin_temp_raw'], 3302); + }); + + test('undecoded motion columns are zero-filled, NOT invented', () async { + // Documents the cost of the fix rather than hiding it: the decoded_onehz + // schema has no null motion, so an absent axis is stored as 0. Anything + // reading these columns for a gen5 record is reading a placeholder, not a + // measurement — which is exactly why the raw frame is also archived. + const ts = 1577582610; + await LocalDb.commitSyncBatch([_raw(ts, 12)], [_gen5Partial(ts, 12)]); + + final row = (await _oneHzAt(ts))!; + expect(row['ax'], 0); + expect(row['ay'], 0); + expect(row['az'], 0); + expect(row['spo2_red_raw'], 0); + expect(row['spo2_ir_raw'], 0); + }); + + test('a COMPLETE sample is unchanged — gen4 keeps every field', () async { + const ts = 1577582700; + await LocalDb.commitSyncBatch([_raw(ts, 20)], [_gen4Complete(ts, 20)]); + + final row = (await _oneHzAt(ts))!; + expect(row['hr'], 61); + expect(row['spo2_red_raw'], 1234); + expect(row['spo2_ir_raw'], 5678); + expect(row['skin_temp_raw'], 3100); + expect((row['az'] as num).toDouble(), closeTo(0.97, 1e-9)); + }); + + test('a complete sample still writes its RR beats', () async { + const ts = 1577582800; + await LocalDb.commitSyncBatch([_raw(ts, 21)], [_gen4Complete(ts, 21)]); + + final db = await LocalDb.instance; + final rr = await db.rawQuery( + 'SELECT rr_ms FROM decoded_rr WHERE rec_ts = ? ORDER BY beat_index', + [ts], + ); + expect(rr.map((r) => r['rr_ms']).toList(), [900, 910]); + }); + + test('a record with NO sample at all still writes no row', () async { + // The other half of the contract: the fix must not turn "undecodable" into + // a fabricated row. Nothing decoded ⇒ nothing banked (it goes to the + // durable archive instead, which ble_engine owns). + const ts = 1577582900; + await LocalDb.commitSyncBatch([_raw(ts, 30)], [null]); + expect(await _oneHzCount(), 0); + }); + + test('partial and complete samples coexist in one batch', () async { + // A real gen5 offload is a burst; a mixed batch must bank both kinds. + const a = 1577583000, b = 1577583001; + await LocalDb.commitSyncBatch( + [_raw(a, 40), _raw(b, 41)], + [_gen5Partial(a, 40), _gen4Complete(b, 41)], + ); + + expect(await _oneHzCount(), 2); + expect((await _oneHzAt(a))!['hr'], 77); + expect((await _oneHzAt(b))!['hr'], 61); + }); +} From a9a2f808610afbb68ee254b00f7854709a8f1bc3 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 14 Aug 2026 19:03:29 +0300 Subject: [PATCH 4/6] fix(privacy): redact bystander devices in the discovery probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discoveryProbe runs an unfiltered scan and printed, for EVERY advertiser in range, the local name, remoteId, RSSI, manufacturer data and service data — then logged the whole thing and returned it as a "paste-into-an-issue" report. Everything in that list except the user's own strap belongs to whoever happens to be nearby. A BLE local name is very often a person's name ("Sarah's iPhone"); a remoteId is a MAC on Android and a stable per-phone identifier on iOS; manufacturer data can carry a serial. None of it helps diagnose WHOOP discovery, and the report is designed to be published. Flagged by CodeRabbit on #238. WHOOP straps still print in full — they are the point of the probe and they are the user's own hardware. Everything else now prints as an anonymous per-report index plus RSSI, connectability and bare counts, which keeps the diagnostic value (the scan ran, the band is this crowded, things nearby are connectable) without the identity. Service UUIDs are withheld too: they fingerprint a product as precisely as a name does. The index is a per-report counter, not a hash of the address. A hash of a MAC is reversible by brute force over a small space, and the only property needed is "the same device reads the same within one report". includeThirdPartyDetail restores the old behaviour for the case where a strap genuinely is not being matched. It is off by default and the report says, in the report itself, that the output is then unsafe to post unread. The classification is extracted as probeMayPrintInFull() rather than left inline: it is the single decision separating "diagnostic" from "publishes the names and MAC addresses of everyone nearby", so it gets a name and tests. Matching stays deliberately broad — a band whose service UUID we have wrong must still be caught by name, or the probe hides the one device it exists to find. 3 tests, including that a Polar H10 advertising 0x180D does NOT qualify. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RrVjCqVDMANK5sa5eyjATw --- lib/ble/ble_engine.dart | 94 +++++++++++++++++++++++++++++++++++---- test/ble_engine_test.dart | 58 ++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index a024278c..a9fe1879 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -106,6 +106,25 @@ bool isWhoopServiceUuid(String raw) { return u == kWhoopMemberUuid16 || u.startsWith('0000fd4b'); } +/// May this advertiser print its identity in a discovery-probe report? +/// +/// TRUE only for a WHOOP strap — the user's own hardware, and the entire point +/// of the probe. Everything else in range belongs to a bystander, and the report +/// is written to be pasted into a public issue, so it is redacted. +/// +/// Deliberately a named, tested function rather than an inline condition: it is +/// the single decision that separates "diagnostic" from "leaks the names and MAC +/// addresses of everyone nearby", and it should be impossible to loosen by +/// accident. Matching is broad on purpose — a band whose service UUID we have +/// wrong must still be caught by its name, or the probe hides the one device it +/// exists to find. +bool probeMayPrintInFull({ + required String name, + required Iterable serviceUuids, +}) => + name.toLowerCase().contains('whoop') || + serviceUuids.any(isWhoopServiceUuid); + typedef SampleSink = Future Function(Sample? sample, RawRecord raw); typedef StateSink = void Function(DeviceState state); typedef LogSink = void Function(String line); @@ -1336,11 +1355,20 @@ class BleEngine { /// /// WHY THIS EXISTS: WHOOP 5.0 / MG support is experimental and no maintainer owns /// gen5 hardware, so every gen5 fix depends on a capture from someone who does. - /// This dumps every BLE advertiser in range with its local name, RSSI, advertised - /// service UUIDs and manufacturer data — which is exactly what identifies the gen5 - /// advertisement a gen5 band actually emits — which is what confirms whether it - /// exposes [kGen5ServiceUuid] in full, only the 16-bit [kWhoopMemberUuid16], or - /// neither (name-only), and therefore which net has to catch it. + /// An unfiltered scan is what confirms whether a band exposes [kGen5ServiceUuid] + /// in full, only the 16-bit [kWhoopMemberUuid16], or neither (name-only), and + /// therefore which net has to catch it. + /// + /// PRIVACY: the scan is unfiltered, but the REPORT is not. WHOOP straps print in + /// full — they are the point, and they are the user's own hardware. Every other + /// advertiser in range belongs to whoever is nearby, and this report exists to be + /// pasted into a public issue, so their name, address and manufacturer data are + /// withheld and only an anonymous per-report index, RSSI and connectability + /// remain. A local name is very often a person's name; a remoteId is a MAC on + /// Android; manufacturer data can carry a serial. None of it helps diagnose WHOOP + /// discovery. [includeThirdPartyDetail] restores the full dump for the case where + /// a strap genuinely is not being matched — it makes the report unsafe to post + /// unread, and says so in the report itself. /// /// PLATFORM NOTE: Android returns the complete advertisement, so an Android capture /// is strictly more informative. iOS surfaces 16-bit service UUIDs normally but @@ -1355,9 +1383,16 @@ class BleEngine { /// Returns a human-readable, paste-into-an-issue report. Never throws. Future discoveryProbe({ Duration timeout = const Duration(seconds: 10), + bool includeThirdPartyDetail = false, }) async { final lines = []; final byId = {}; // remoteId → newest formatted line + // Stable per-report label for a redacted advertiser. An index, not a hash: + // a hash of a MAC is reversible by brute force over a small space, and the + // only property this needs is "the same device reads the same within one + // report". Never persisted, never comparable across reports. + final anonIndex = {}; + var redacted = 0; StreamSubscription? sub; try { if (FlutterBluePlus.isScanningNow) { @@ -1384,10 +1419,37 @@ class BleEngine { '${e.value.map((b) => b.toRadixString(16).padLeft(2, "0")).join()}', ) .join(' '); - byId[r.device.remoteId.str] = - '$name id=${r.device.remoteId.str} rssi=${r.rssi} ' - 'connectable=${adv.connectable} ' - 'services=[$services] mfg=[$mfg] svcData=[$svcData]'; + // WHOOP straps are the point of the probe and are the user's own + // hardware, so they print in full. Everything else in range belongs + // to whoever is nearby — their phone, headphones, car — and this + // report is written to be pasted into a public issue. A local name is + // very often a person's name; a remoteId is a MAC on Android and a + // stable per-phone identifier on iOS; manufacturer data can carry a + // serial. None of it helps diagnose WHOOP discovery, so none of it + // leaves the device unless explicitly asked for. + final id = r.device.remoteId.str; + final isWhoop = probeMayPrintInFull( + name: name, + serviceUuids: adv.serviceUuids.map((g) => g.str), + ); + if (isWhoop || includeThirdPartyDetail) { + byId[id] = + '$name id=$id rssi=${r.rssi} ' + 'connectable=${adv.connectable} ' + 'services=[$services] mfg=[$mfg] svcData=[$svcData]'; + } else { + // Keep the shape of the evidence — that the scan worked, how + // crowded the band is, whether anything nearby is connectable — + // without the identity. Service UUIDs are omitted too: they + // fingerprint a product as precisely as a name does. + final n = anonIndex.putIfAbsent(id, () => anonIndex.length + 1); + if (!byId.containsKey(id)) redacted++; + byId[id] = + '(third-party device #$n — redacted) rssi=${r.rssi} ' + 'connectable=${adv.connectable} ' + 'services=${adv.serviceUuids.length} ' + 'mfgEntries=${adv.manufacturerData.length}'; + } } }); // No `withServices` — that is the entire point of the probe. @@ -1406,6 +1468,20 @@ class BleEngine { ..writeln('gen4 service: ${GattUuids.service}') ..writeln('gen5 service: $kGen5ServiceUuid (16-bit: $kWhoopMemberUuid16)') ..writeln('advertisers seen: ${byId.length}'); + if (includeThirdPartyDetail) { + report.writeln( + 'THIRD-PARTY DETAIL IS ON: the lines below include the names and ' + 'addresses of other people\'s nearby devices. Read this through and ' + 'remove anything that is not your strap BEFORE posting it anywhere.', + ); + } else if (redacted > 0) { + report.writeln( + '$redacted non-WHOOP advertiser(s) redacted (names, addresses and ' + 'manufacturer data withheld — they cannot help diagnose WHOOP ' + 'discovery). Re-run with third-party detail only if a strap is ' + 'genuinely not being matched.', + ); + } if (lines.isEmpty) { report.writeln( '(nothing seen — is Bluetooth on and permission granted?)', diff --git a/test/ble_engine_test.dart b/test/ble_engine_test.dart index 03526719..3540425a 100644 --- a/test/ble_engine_test.dart +++ b/test/ble_engine_test.dart @@ -284,6 +284,64 @@ void main() { expect(isWhoopServiceUuid(''), isFalse); }); + test('probe redaction: a WHOOP strap may print in full', () { + expect(probeMayPrintInFull(name: 'WHOOP 4A1B2C', serviceUuids: const []), + isTrue); + expect(probeMayPrintInFull(name: 'WHOOP MGB1234', serviceUuids: const []), + isTrue); + expect(probeMayPrintInFull(name: 'whoop 5ag0296841', serviceUuids: const []), + isTrue); + // Matched by service UUID even when the name is absent — a band whose + // advertised name we do not expect must still be caught. + expect( + probeMayPrintInFull(name: '(no name)', serviceUuids: [kGen5ServiceUuid]), + isTrue, + ); + expect( + probeMayPrintInFull(name: '', serviceUuids: [proto.GattUuids.service]), + isTrue, + ); + expect( + probeMayPrintInFull(name: '', serviceUuids: const ['fd4b']), + isTrue, + ); + }); + + test('probe redaction: a bystander device is NOT printed in full', () { + // The security boundary. Every one of these is somebody else's property, + // and the probe report is written to be pasted into a public issue: a + // local name is very often a person's name, and a remoteId is a MAC. + for (final name in const [ + "Sarah's iPhone", + 'AirPods Pro', + 'Tesla Model 3', + 'Galaxy Watch5', + '(no name)', + '', + ]) { + expect( + probeMayPrintInFull(name: name, serviceUuids: const [ + '0000180d-0000-1000-8000-00805f9b34fb', // heart rate + '0000180f-0000-1000-8000-00805f9b34fb', // battery + ]), + isFalse, + reason: '"$name" must be redacted', + ); + } + }); + + test('probe redaction: a standard HR strap does not qualify as WHOOP', () { + // Deliberate: plenty of chest straps advertise 0x180D. Advertising a + // standard service is not evidence of being the user's own hardware. + expect( + probeMayPrintInFull( + name: 'Polar H10', + serviceUuids: const ['0000180d-0000-1000-8000-00805f9b34fb'], + ), + isFalse, + ); + }); + test('the scan UUID and the transport family agree', () { // kGen5ServiceUuid drives the scan filter and is hand-copied into iOS; // WhoopFamily.gen5 drives service discovery and characteristic lookup. From a4c0e6d12a9af83077bbea43ae71d2c711f82284 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 14 Aug 2026 19:05:53 +0300 Subject: [PATCH 5/6] docs(gen5): the header comment said the transport does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written during the discovery-only phase and never updated when the transport landed. It claimed "there is still no gen5 TRANSPORT" and that "a gen5 band that connects still stops at service discovery below" — the opposite of what the code now does, at the exact place a reader looks to learn what a gen5 connection does. Flagged by CodeRabbit on #238. Checked the rest of lib/, ios/ and README.md for the same stale claim; this was the only one left. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RrVjCqVDMANK5sa5eyjATw --- lib/ble/ble_engine.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index a9fe1879..cc0f2ae3 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -70,15 +70,18 @@ int u32(Uint8List b, int o) => // (GooseSwift/GooseBLEClient.swift `whoopServices`) and its Kotlin port // dsp515/GooseAndroid (WhoopUUIDs.SERVICE_PRIMARY), which agree exactly. // +// A gen5 band now connects, subscribes and syncs: the V5 framing lives in +// gen5_framing.dart (8-byte header with a crc16-modbus over it, against gen4's +// 4-byte header and crc8 over the length), and everything above the envelope is +// the protocol 4.0 already speaks. Discovery below selects the family and +// installs its codecs. +// // Deliberately local to the app rather than added to package:openstrap_protocol -// (separate repo, pinned by hash in pubspec.lock): the app can discover and -// identify a gen5 band, but there is still no gen5 TRANSPORT — the V5 framing -// differs from gen4 (8-byte header, crc16-modbus over the header, vs gen4's -// 4-byte header and crc8 over the length), so a gen5 band that connects still -// stops at service discovery below, deliberately and with a log line describing -// its real GATT tree. Promote these to the protocol package with the transport. -// Keep in sync with gen5ServiceUUID in ios/Runner/AccessorySetup.swift and -// NSAccessorySetupBluetoothServices in ios/Runner/Info.plist. +// (separate repo, pinned by hash in pubspec.lock): no maintainer owns gen5 +// hardware, so this should stay revisable without a protocol-package release. +// Promote it once captures confirm it. Keep in sync with gen5ServiceUUID in +// ios/Runner/AccessorySetup.swift and NSAccessorySetupBluetoothServices in +// ios/Runner/Info.plist. const String kGen5ServiceUuid = 'fd4b0001-cce1-4033-93ce-002d5875f58a'; /// The gen5 characteristic suffix. Gen5 mirrors gen4's GATT layout one-for-one From 69e584f677a2dff3efc266f0c109c83a12fb8379 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 14 Aug 2026 23:20:26 +0300 Subject: [PATCH 6/6] test: do not tie the RR assertion to one decoded_rr schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test queried `decoded_rr WHERE rec_ts = ?`. That column exists only after the time-keyed migration; older schemas key the table on `counter`, so the query fails with "no such column" against them rather than reporting anything about the code under test. Now unfiltered: setUp empties the table and the test commits exactly one record, so every row present belongs to it and the assertion holds either way. Found by porting these tests onto a checkout with the older schema, where the failure looked exactly like a product bug. Worth knowing if you hit it: the sqflite_common_ffi database under .dart_tool persists across runs and across branches, so a schema created by one checkout is still on disk when you test another — `rm -rf .dart_tool/sqflite_common_ffi/databases` before believing a "no such column" error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RrVjCqVDMANK5sa5eyjATw --- test/gen5_persistence_test.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/gen5_persistence_test.dart b/test/gen5_persistence_test.dart index 03d2a10f..f1812ffe 100644 --- a/test/gen5_persistence_test.dart +++ b/test/gen5_persistence_test.dart @@ -166,10 +166,13 @@ void main() { const ts = 1577582800; await LocalDb.commitSyncBatch([_raw(ts, 21)], [_gen4Complete(ts, 21)]); + // Unfiltered on purpose. `decoded_rr` is keyed on `counter` in older + // schemas and on `rec_ts` after the time-keyed migration, so naming either + // column ties this test to one of them. setUp empties the table and this + // commits exactly one record, so every row present belongs to it. final db = await LocalDb.instance; final rr = await db.rawQuery( - 'SELECT rr_ms FROM decoded_rr WHERE rec_ts = ? ORDER BY beat_index', - [ts], + 'SELECT rr_ms FROM decoded_rr ORDER BY beat_index', ); expect(rr.map((r) => r['rr_ms']).toList(), [900, 910]); });