diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift
index ee7c0a86..75fa2d76 100644
--- a/ios/Runner/AccessorySetup.swift
+++ b/ios/Runner/AccessorySetup.swift
@@ -26,13 +26,19 @@ import AccessorySetupKit
/// - `removeAll` -> nil (deprovision all — used on unpair)
enum AccessorySetup {
private static let channelName = "openstrap/accessory_setup"
- // WHOOP GATT service UUIDs, one per generation (match GattProfile in Dart).
- // `fileprivate` so the iOS-18 Impl below can read them. BOTH must also be
- // listed in Info.plist under NSAccessorySetupBluetoothServices.
- // • gen4 ("Harvard", WHOOP 4) — 6108…
- // • gen5 ("fd4b", WHOOP 5) — fd4b… (EXPERIMENTAL)
+ // WHOOP GATT service UUIDs (match GattProfile / kWhoopMemberUuid16 in Dart).
+ // `fileprivate` so the iOS-18 Impl below can read them. Every criterion used
+ // in an ASDiscoveryDescriptor must also be listed in Info.plist or iOS
+ // silently ignores it.
+ // • gen4 ("Harvard", WHOOP 4) — 6108… 128-bit vendor service
+ // • gen5 ("fd4b", WHOOP 5.0 / MG) — fd4b0001-cce1-… 128-bit vendor
+ // • 16-bit SIG member UUID 0xFD4B — what still fits a 31-byte AD
+ // The 16-bit form is NOT 0000FD4B-0000-1000-8000-00805F9B34FB; no band
+ // advertises that Bluetooth-base expansion.
fileprivate static let whoopServiceUUIDGen4 = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6"
fileprivate static let whoopServiceUUIDGen5 = "fd4b0001-cce1-4033-93ce-002d5875f58a"
+ fileprivate static let whoopMemberUUID16 = "FD4B"
+ fileprivate static let nameSubstring = "WHOOP"
static func register(messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
@@ -140,44 +146,68 @@ private final class Impl {
return
}
- // Match on the WHOOP custom service UUID alone. The foreground scan finds the
- // band via startScan(withServices:[…]) and succeeds, which proves the band
- // advertises this service — so it's a reliable, sufficient filter. Every
- // descriptor criterion must be declared in Info.plist; the UUIDs are listed
- // under NSAccessorySetupBluetoothServices. (No bluetoothNameSubstring: a
- // single descriptor AND-combines its criteria, and a name filter would also
- // require an NSAccessorySetupBluetoothNames entry and risk excluding the band
- // on a name mismatch.)
+ // ONE ITEM PER MATCH STRATEGY. A single ASDiscoveryDescriptor AND-combines
+ // its criteria, so folding gen5's 128-bit UUID, 16-bit 0xFD4B, and a name
+ // substring onto one descriptor would match nothing. showPicker(for:) takes
+ // an array so each strategy is its own accessory; the sheet de-duplicates
+ // by peripheral.
//
- // ASK matches ANY item in the picker list, so we offer one item per WHOOP
- // generation: gen4 (WHOOP 4) and gen5 (WHOOP 5, experimental). A band that
- // advertises either service can be provisioned; the provisioned identifier is
- // the same CoreBluetooth UUID regardless of generation.
+ // Why three gen5-relevant items: we do not yet know (no nRF Connect capture)
+ // whether fd4b0001-… is in the primary advertisement or only the scan
+ // response. The 16-bit member UUID is what still fits a 31-byte AD; the
+ // name (`WHOOP MGB…` / `WHOOP 5A…`) survives even if iOS hashes the 128-bit
+ // UUID in the overflow area.
let productImage = UIImage(named: "StrapProduct")
?? UIImage(systemName: "sensor.tag.radiowave.forward")
?? UIImage()
- func item(_ serviceUUID: String, _ name: String) -> ASPickerDisplayItem {
+ func makeItem(_ label: String,
+ _ configure: (ASDiscoveryDescriptor) -> Void) -> ASPickerDisplayItem {
let descriptor = ASDiscoveryDescriptor()
- descriptor.bluetoothServiceUUID = CBUUID(string: serviceUUID)
- return ASPickerDisplayItem(
- name: name, productImage: productImage, descriptor: descriptor)
+ configure(descriptor)
+ return ASPickerDisplayItem(name: label, productImage: productImage,
+ descriptor: descriptor)
}
- let items = [
- item(AccessorySetup.whoopServiceUUIDGen4, "WHOOP band"),
- item(AccessorySetup.whoopServiceUUIDGen5, "WHOOP 5 band"),
+ let items: [ASPickerDisplayItem] = [
+ makeItem("WHOOP band") {
+ $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.whoopServiceUUIDGen4)
+ },
+ makeItem("WHOOP 5.0 / MG") {
+ $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.whoopServiceUUIDGen5)
+ },
+ makeItem("WHOOP 5.0 / MG") {
+ $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.whoopMemberUUID16)
+ },
+ makeItem("WHOOP band") {
+ $0.bluetoothNameSubstring = AccessorySetup.nameSubstring
+ },
]
pickerResult = completion
+ present(items, allowGen4Retry: true)
+ }
+
+ /// Presents the picker and resolves `pickerResult`.
+ ///
+ /// If iOS rejects the widened descriptor list (a name-only item is the
+ /// experimental one), retry once with the WHOOP 4.0 item that already ships,
+ /// so the experiment can never take down 4.0 pairing.
+ 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)))
+ 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.
let id = self.session.accessories
.compactMap { $0.bluetoothIdentifier }
.first?.uuidString.uppercased()
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 45d903a2..5d2fc325 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -55,6 +55,18 @@
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 2bb7caad..91b9746f 100644
--- a/lib/ble/ble_engine.dart
+++ b/lib/ble/ble_engine.dart
@@ -84,6 +84,54 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive);
/// trigger now that listening is continuous and there's no discrete sync end.
typedef DataStoredSink = void Function();
+// ── WHOOP 5.0 / MG discovery (not wire-format) ──────────────────────────────
+// Transport/framing lives in package:openstrap_protocol (BandProfile / GattProfile).
+// Edge only decides what to *look for*. The 128-bit vendor service is already
+// on main; what is still unsettled on hardware is whether a real band puts it
+// in the primary advertisement or only the scan response (#238 close note).
+//
+// A 128-bit UUID often does not fit the 31-byte AD. iOS hashes anything that
+// spills into the scan-response overflow area, so AccessorySetupKit never sees
+// it. The SIG member UUID 0xFD4B (2 bytes) and the advertised name
+// (`WHOOP MGB…` / `WHOOP 5A…`) still fit. That 16-bit form is NOT the
+// Bluetooth-base expansion `0000FD4B-0000-1000-8000-00805F9B34FB` — no band
+// advertises that 128-bit value.
+
+/// 16-bit Bluetooth SIG member UUID assigned to WHOOP. Distinct from
+/// [GattProfile.gen5.service]. Platforms disagree on spelling: iOS reports
+/// `"fd4b"`; Android reports the Base-UUID expansion.
+const String kWhoopMemberUuid16 = 'fd4b';
+
+/// Service UUIDs used as the BLE `withServices` scan filter.
+///
+/// `withServices` is OR-combined on both platforms. The 16-bit member UUID
+/// must be its own entry: filtering only on the 128-bit vendor UUID misses a
+/// band that advertised the 2-byte form.
+List whoopScanServiceUuids() => [
+ Guid(GattProfile.gen4.service),
+ Guid(GattProfile.gen5.service),
+ Guid(kWhoopMemberUuid16),
+ ];
+
+/// True when a scan result is a WHOOP strap of either generation.
+///
+/// Matching is broad on purpose: a band whose 128-bit service UUID is hidden
+/// in the scan-response overflow must still be caught by the 16-bit member
+/// UUID or by its advertised name, or pairing never starts.
+bool advertisementLooksLikeWhoop({
+ required String platformName,
+ required Iterable serviceUuids,
+}) {
+ if (platformName.toLowerCase().contains('whoop')) return true;
+ for (final raw in serviceUuids) {
+ final u = raw.toLowerCase();
+ if (u.startsWith(GattProfile.gen4.servicePrefix.toLowerCase())) return true;
+ if (u.startsWith(GattProfile.gen5.servicePrefix.toLowerCase())) return true;
+ if (u == kWhoopMemberUuid16 || u.startsWith('0000fd4b')) return true;
+ }
+ return false;
+}
+
/// Map a decoded gen5 historical record onto the band-agnostic `Sample` type,
/// or null when this record kind has no `Sample` equivalent (yet).
@@ -1288,21 +1336,18 @@ class BleEngine {
await FlutterBluePlus.stopScan();
}
_setPhase(BleConnState.scanning);
- // Advertise-filter on BOTH generations' service UUIDs (gen4 6108xxxx +
- // gen5 fd4bxxxx); the actual generation is pinned later at discovery.
- final gen4Svc = Guid(GattProfile.gen4.service);
- final gen5Svc = Guid(GattProfile.gen5.service);
+ // Advertise-filter on both 128-bit vendor UUIDs plus the 16-bit member
+ // UUID. Generation is pinned later at GATT discovery. See
+ // [whoopScanServiceUuids] / [advertisementLooksLikeWhoop].
BluetoothDevice? found;
final sub = FlutterBluePlus.onScanResults.listen((results) {
for (final r in results) {
- final name = r.device.platformName.toLowerCase();
- final advNames = r.advertisementData.serviceUuids.map(
- (g) => g.str.toLowerCase(),
- );
if (found == null &&
- (name.contains('whoop') ||
- advNames.any((s) =>
- s.startsWith('61080001') || s.startsWith('fd4b0001')))) {
+ advertisementLooksLikeWhoop(
+ platformName: r.device.platformName,
+ serviceUuids:
+ r.advertisementData.serviceUuids.map((g) => g.str),
+ )) {
found = r.device;
FlutterBluePlus.stopScan();
}
@@ -1310,7 +1355,7 @@ class BleEngine {
});
try {
await FlutterBluePlus.startScan(
- withServices: [gen4Svc, gen5Svc], timeout: timeout);
+ withServices: whoopScanServiceUuids(), timeout: timeout);
await FlutterBluePlus.isScanning.where((on) => on == false).first;
} catch (e) {
_log('scan error: $e');
diff --git a/test/gen5_pairing_filter_test.dart b/test/gen5_pairing_filter_test.dart
new file mode 100644
index 00000000..affaea77
--- /dev/null
+++ b/test/gen5_pairing_filter_test.dart
@@ -0,0 +1,164 @@
+// Pairing filter for WHOOP 5.0 / MG — the leftover from #238 after the
+// transport landed in protocol#27 + edge#97.
+//
+// The 128-bit vendor service `fd4b0001-cce1-…` is already on main. What was
+// never settled is whether a real band puts that UUID in the *primary*
+// advertisement or only the scan response. A 128-bit UUID often does not fit
+// the 31-byte AD; iOS then hashes it in the overflow area and AccessorySetupKit
+// never sees it. The SIG member UUID `0xFD4B` (2 bytes) and the advertised
+// local name (`WHOOP MGB…` / `WHOOP 5A…`) are what still fit.
+//
+// This is not a second codec. Edge still holds no wire-format. These tests pin
+// the discovery surface: Dart scan filter, Info.plist, and ASK descriptors.
+
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:openstrap_edge/ble/ble_engine.dart';
+import 'package:openstrap_protocol/openstrap_protocol.dart';
+
+String _posix(String path) => path.replaceAll(Platform.pathSeparator, '/');
+
+String _readRepoFile(String posixPath) {
+ final file = File(posixPath.split('/').join(Platform.pathSeparator));
+ expect(file.existsSync(), isTrue,
+ reason: 'run from the package root; missing $posixPath');
+ return file.readAsStringSync();
+}
+
+void main() {
+ group('16-bit member UUID is not the Bluetooth-base expansion', () {
+ test('kWhoopMemberUuid16 is the short SIG assignment', () {
+ expect(kWhoopMemberUuid16, 'fd4b');
+ expect(kWhoopMemberUuid16, isNot('0000fd4b-0000-1000-8000-00805f9b34fb'));
+ expect(GattProfile.gen5.service, isNot(startsWith('0000fd4b')));
+ expect(GattProfile.gen5.service,
+ 'fd4b0001-cce1-4033-93ce-002d5875f58a');
+ });
+ });
+
+ group('advertisementLooksLikeWhoop', () {
+ test('matches gen4 and gen5 128-bit vendor prefixes', () {
+ expect(
+ advertisementLooksLikeWhoop(
+ platformName: '',
+ serviceUuids: [GattProfile.gen4.service],
+ ),
+ isTrue,
+ );
+ expect(
+ advertisementLooksLikeWhoop(
+ platformName: '',
+ serviceUuids: [GattProfile.gen5.service],
+ ),
+ isTrue,
+ );
+ });
+
+ test('matches the 16-bit member UUID in both platform spellings', () {
+ // iOS reports 16-bit UUIDs short; Android expands them against the base.
+ expect(
+ advertisementLooksLikeWhoop(
+ platformName: '',
+ serviceUuids: const ['fd4b'],
+ ),
+ isTrue,
+ );
+ expect(
+ advertisementLooksLikeWhoop(
+ platformName: '',
+ serviceUuids: const ['0000fd4b-0000-1000-8000-00805f9b34fb'],
+ ),
+ isTrue,
+ );
+ });
+
+ test('matches a WHOOP MG advertised name with no service UUID yet', () {
+ // Issue #237: the band shows up as WHOOP MGB… in system Bluetooth.
+ expect(
+ advertisementLooksLikeWhoop(
+ platformName: 'WHOOP MGB1234',
+ serviceUuids: const [],
+ ),
+ isTrue,
+ );
+ });
+
+ test('rejects a Polar H10 advertising the standard HR service', () {
+ expect(
+ advertisementLooksLikeWhoop(
+ platformName: 'Polar H10',
+ serviceUuids: const ['0000180d-0000-1000-8000-00805f9b34fb'],
+ ),
+ isFalse,
+ );
+ });
+ });
+
+ group('whoopScanServiceUuids', () {
+ test('filters on both 128-bit vendors and the 16-bit member UUID', () {
+ final uuids = whoopScanServiceUuids().map((g) => g.str.toLowerCase());
+ expect(uuids, contains(GattProfile.gen4.service));
+ expect(uuids, contains(GattProfile.gen5.service));
+ expect(
+ uuids.any((u) => u == 'fd4b' || u.startsWith('0000fd4b')),
+ isTrue,
+ reason: '16-bit 0xFD4B must be its own scan filter — the 128-bit '
+ 'vendor UUID is a different value and will not match a band that '
+ 'only advertised the 2-byte form',
+ );
+ });
+ });
+
+ group('iOS ASK / Info.plist stay in lockstep with the Dart filter', () {
+ late String plist;
+ late String swift;
+ late String engine;
+
+ setUpAll(() {
+ plist = _readRepoFile('ios/Runner/Info.plist');
+ swift = _readRepoFile('ios/Runner/AccessorySetup.swift');
+ engine = _readRepoFile('lib/ble/ble_engine.dart');
+ });
+
+ test('Info.plist declares the 128-bit vendor service and 16-bit FD4B', () {
+ expect(plist, contains('FD4B0001-CCE1-4033-93CE-002D5875F58A'));
+ expect(plist, contains('FD4B'));
+ // The Bluetooth-base expansion may be named in a comment as the thing
+ // we must NOT declare. It must never be an actual ASK service string.
+ expect(
+ plist,
+ isNot(contains('0000FD4B-0000-1000-8000-00805F9B34FB')),
+ );
+ });
+
+ test('Info.plist declares the WHOOP name net for ASK', () {
+ expect(plist, contains('NSAccessorySetupBluetoothNames'));
+ expect(plist, contains('WHOOP'));
+ });
+
+ test('ASK has a separate 16-bit FD4B descriptor, not AND-combined', () {
+ expect(swift, contains('whoopServiceUUIDGen5'));
+ expect(swift.toUpperCase(), contains('FD4B0001-CCE1-4033-93CE-002D5875F58A'));
+ // A 16-bit CBUUID("FD4B") is its own picker item. Criteria inside one
+ // ASDiscoveryDescriptor AND-combine, so folding this onto the 128-bit
+ // item would match nothing if the band advertised only one form.
+ expect(swift, contains('whoopMemberUUID16'));
+ expect(
+ swift,
+ contains('CBUUID(string: AccessorySetup.whoopMemberUUID16)'),
+ );
+ });
+
+ test('ASK has a name-substring item as the last net', () {
+ expect(swift, contains('bluetoothNameSubstring'));
+ expect(swift, contains('"WHOOP"'));
+ });
+
+ test('engine scan uses the shared filter helper, not a second UUID list', () {
+ expect(engine, contains('whoopScanServiceUuids()'));
+ expect(engine, contains('advertisementLooksLikeWhoop('));
+ expect(_posix('lib/ble/ble_engine.dart'), 'lib/ble/ble_engine.dart');
+ });
+ });
+}