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