Skip to content
Closed
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
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
118 changes: 97 additions & 21 deletions ios/Runner/AccessorySetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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()
Expand Down
25 changes: 25 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,34 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<!-- Every criterion an ASDiscoveryDescriptor uses must be declared here or the
system silently ignores it, so this list is the real gate on what the
AccessorySetupKit pairing sheet can ever show. Keep it in sync with the
constants in AccessorySetup.swift. -->
<key>NSAccessorySetupBluetoothServices</key>
<array>
<!-- WHOOP 4.0 "Harvard" GATT service. The tested, supported path. -->
<string>61080001-8D6D-82B8-614A-1C8CB0F8DCC6</string>
<!-- WHOOP 5.0 / MG GATT service — a full 128-bit vendor UUID, in the same
shape as the 4.0 service above. This 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, and an undeclared/incorrect service is exactly
how a 5.0 / MG ends up as "No Accessory Found" in the picker. -->
<string>FD4B0001-CCE1-4033-93CE-002D5875F58A</string>
<!-- The 16-bit member UUID as well: a 128-bit service UUID often does not fit
the 31-byte advertisement, so some gen5 firmware advertises only this. -->
<string>FD4B</string>
</array>
<!-- EXPERIMENTAL (WHOOP 5.0 / MG): name-substring net for the case where the
gen5 service UUID above is wrong, or where iOS hides it (128-bit UUIDs can
land in the scan response's hashed overflow area, where they are unreadable —
the advertised local name is not). Reported gen5 names vary — "WHOOP MGB…"
as well as "WHOOP 5AM…" (MG) / "WHOOP 5AG…" (5.0) — and 4.0 is "WHOOP 4…",
so the single substring "WHOOP" covers every family. -->
<key>NSAccessorySetupBluetoothNames</key>
<array>
<string>WHOOP</string>
</array>
<key>NSAccessorySetupKitSupports</key>
<array>
Expand Down
Loading