Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 79 additions & 28 deletions ios/Runner/AccessorySetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -86,6 +92,12 @@ private final class Impl {
private let queue = DispatchQueue.main
// Set while a showPicker is in flight; resolved by the completion handler.
private var pickerResult: ((Result<String, PickerError>) -> Void)?
// True from the moment the Gen 4 retry's showPicker is issued until its
// completion handler runs. `.pickerDidDismiss` fires for the FIRST (rejected)
// sheet during this window — without this guard it resolves `pickerResult`
// as cancelled before the retry gets a chance to report its own outcome,
// so a successfully provisioned accessory gets reported to Dart as cancelled.
private var retryInFlight = false

struct PickerError: Error { let message: String }

Expand All @@ -103,6 +115,9 @@ private final class Impl {
// pending showPicker as "cancelled".
switch event.eventType {
case .pickerDidDismiss:
// Ignore the first sheet's dismissal while the Gen 4 retry is in flight —
// see `retryInFlight`'s doc comment.
guard !retryInFlight else { return }
// If a picker was in flight and nothing got added, treat as cancelled. (If an
// accessory WAS added, showPicker's completion handler already resolved it.)
if let cb = pickerResult {
Expand Down Expand Up @@ -140,44 +155,80 @@ 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 }
// The retry (if any) that led to THIS completion running is no longer
// in flight — whatever we resolve below is the actual outcome.
self.retryInFlight = false
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
// Prefer the typed error code over sniffing the localized message —
// a message that doesn't happen to contain "cancel" would otherwise
// incorrectly trigger a second picker on a real user cancellation.
let cancelled: Bool
if let askError = error as? ASError {
cancelled = askError.code == .userCancelled
} else {
cancelled = message.lowercased().contains("cancel")
}
if allowGen4Retry, !cancelled, items.count > 1 {
NSLog("[ASK] picker rejected the %d-item descriptor list (%@) — "
+ "retrying with the WHOOP 4.0 item only.", items.count, message)
self.retryInFlight = true
self.present([items[0]], allowGen4Retry: false)
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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()
Expand Down
12 changes: 12 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@
<array>
<string>61080001-8D6D-82B8-614A-1C8CB0F8DCC6</string>
<string>FD4B0001-CCE1-4033-93CE-002D5875F58A</string>
<!-- 16-bit SIG member UUID. Distinct from the 128-bit vendor service
above, and NOT the Bluetooth-base expansion
0000FD4B-0000-1000-8000-00805F9B34FB (no band advertises that).
A 128-bit UUID often does not fit the 31-byte advertisement. -->
<string>FD4B</string>
</array>
<!-- Last net for ASK: MG advertises as WHOOP MGB…, 5.0 as WHOOP 5A…,
4.0 as WHOOP 4…. Criteria inside one descriptor AND-combine, so the
name lives on its own ASPickerDisplayItem, not on the UUID items. -->
<key>NSAccessorySetupBluetoothNames</key>
<array>
<string>WHOOP</string>
</array>
<key>NSAccessorySetupKitSupports</key>
<array>
Expand Down
75 changes: 62 additions & 13 deletions lib/ble/ble_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,54 @@ typedef ArchiveSink = Future<void> 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<Guid> 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<String> 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).
Expand Down Expand Up @@ -1288,29 +1336,30 @@ 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();
unawaited(
FlutterBluePlus.stopScan().catchError(
(Object e) => _log('stopScan after match failed: $e'),
),
);
}
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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');
Expand Down
Loading
Loading