diff --git a/README.md b/README.md index 5681a14..581173b 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,9 +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 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 — 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 e057538..f957742 100644 --- a/ios/Runner/AccessorySetup.swift +++ b/ios/Runner/AccessorySetup.swift @@ -28,7 +28,30 @@ 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" + + // 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. 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" static func register(messenger: FlutterBinaryMessenger) { let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) @@ -136,39 +159,92 @@ 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) + }, + // WHOOP 5.0 / MG by its 128-bit vendor service UUID. + makeItem("WHOOP 5.0 / MG") { + $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.gen5ServiceUUID) + }, + // 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 + }, + ] 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 75b3d35..1b0b3b8 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -51,9 +51,34 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAccessorySetupBluetoothServices + 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 + + FD4B0001-CCE1-4033-93CE-002D5875F58A + + FD4B + + + NSAccessorySetupBluetoothNames + + WHOOP NSAccessorySetupKitSupports diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index e0fa3ef..cc0f2ae 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -47,12 +47,87 @@ 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) ─────────────────────────────────────────────────── +// 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. +// +// 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): 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 +/// (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 and gen5 are matched on their 32-bit prefixes. +bool isWhoopServiceUuid(String raw) { + final u = raw.toLowerCase(); + // 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'); +} + +/// 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); @@ -296,11 +371,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. @@ -1197,24 +1297,42 @@ class BleEngine { await FlutterBluePlus.stopScan(); } _setPhase(BleConnState.scanning); - final svc = Guid(GattUuids.service); + final gen4 = Guid(GattUuids.service); + // 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. + 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, gen5Short], + timeout: timeout, + ); await FlutterBluePlus.isScanning.where((on) => on == false).first; } catch (e) { _log('scan error: $e'); @@ -1224,10 +1342,163 @@ 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. + /// 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 + /// 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), + 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) { + 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(' '); + // 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. + 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: $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?)', + ); + } 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)); @@ -1389,15 +1660,44 @@ 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) { - _log('Harvard service not found on device.'); + 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) => + '${s.uuid.str}[${s.characteristics.map((c) => c.uuid.str).join(",")}]', + ) + .join(' '); + _log( + '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; @@ -1405,18 +1705,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'); @@ -1967,9 +2277,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; @@ -1983,7 +2307,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 { @@ -2320,6 +2644,30 @@ 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. + 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, + 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 @@ -2342,6 +2690,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/ble/gen5_framing.dart b/lib/ble/gen5_framing.dart new file mode 100644 index 0000000..4f0d8c2 --- /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 0000000..e50ee6c --- /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/lib/data/db.dart b/lib/data/db.dart index f3d7a8b..bc41c83 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/lib/state/app_state.dart b/lib/state/app_state.dart index 288ff2b..c3af529 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 a83ac90..d9c8a62 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', + ), + ), ], ), ), diff --git a/test/ble_engine_test.dart b/test/ble_engine_test.dart index 3234f63..3540425 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,117 @@ 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('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. + // 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 0000000..dfa2a13 --- /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_persistence_test.dart b/test/gen5_persistence_test.dart new file mode 100644 index 0000000..f1812ff --- /dev/null +++ b/test/gen5_persistence_test.dart @@ -0,0 +1,201 @@ +// 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)]); + + // 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 ORDER BY beat_index', + ); + 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); + }); +} diff --git a/test/gen5_records_test.dart b/test/gen5_records_test.dart new file mode 100644 index 0000000..42b7982 --- /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'))); + }); + }); +}