diff --git a/docs/isgen5-inventory.md b/docs/isgen5-inventory.md new file mode 100644 index 00000000..f0e5d40f --- /dev/null +++ b/docs/isgen5-inventory.md @@ -0,0 +1,122 @@ +# `isGen5` inventory — DATA vs BEHAVIOUR + +Every band-specific branch in `lib/ble/ble_engine.dart`, classified — and, as +of the D4/D9 wave, **the DATA half is done**: it lives in `BandEntry` / +`BandWireCommands` in `lib/ble/adapters/_registry.dart` and the code around it +is unconditional. + +Line numbers are against `ble_engine.dart` at **6,854 lines** (post-split). +Re-derive them before acting — this file rots, and the previous revision's +numbers had drifted by +21 to +23 while claiming 30 occurrences when there +were 44. + +**DATA** — the branch chooses a *value*: an opcode, a payload, a delay, an +offset, a flag. It belongs in the registry entry; the code around it becomes +unconditional. + +**BEHAVIOUR** — the branch chooses a *different sequence of operations*: an +extra handshake step, a different decoder, a different failure policy. It +belongs in an adapter — except there is no adapter to put it in: the +`run(BandLink)` move was DECLINED (ASSUMPTIONS G1–G4), so behaviour stays in +the engine, deliberately, and this file no longer pretends otherwise. + +**Counts, re-derived: 21 lines mention `isGen5`, from 44.** The unit is a LINE +matching `isGen5` and not matching `isGen5Clock` — `grep -c isGen5` reports 24 +here and 47 on main, and the three-line difference either end is `isGen5Clock`, +which is not a band branch at all (last section). The previous revision counted +one number each way and the breakdown could not add up. + +Sixteen logical sites moved to the table. The 21 that are left: + +| Lines | What | +|---|---| +| 11 | 9 BEHAVIOUR sites (one of the eleven is a comment) | +| 4 | already-table-driven policy arguments | +| 2 | the `final isGen5 = …` locals that feed two of those arguments | +| 4 | log strings — 2 sites, a declaration and a use each | + +--- + +## MOVED — the table (16 sites) + +Every value is transcribed verbatim from the arm it replaced and pinned in +`test/band_registry_test.dart` ("the gen4/gen5 arm of every branch that moved"). + +| Line | Site | Field it reads now | gen4 → gen5 | +|---|---|---|---| +| 2135 | pre-registration pause | `BandEntry.preRegistrationDelay` | `Duration.zero` → 600 ms | +| 2333 | post-registration pause | `BandEntry.postRegistrationDelay` | `Duration.zero` → 500 ms | +| 2446 | `_bootstrapSetClock` | `BandEntry.setClockDriftGated` | `false` → `true` | +| 2585 | keep-alive live re-arm | `BandWireCommands.r10R11Realtime` | `0x3F` → `null` | +| 3234 | `_offloadPayload` | `BandWireCommands.offloadBody` | `[0x00]` → `[]` | +| 3467 | console-log line | `BandEntry.logsConsoleOutput` | `false` → `true` | +| 4610 | `gateEnforced` | `BandEntry.burstCountGateEnforced` | `false` → `true` | +| 5863 | `getStrapName` | `getAdvertisingName` + `…Body` | Harvard/`[0x00]` → custom/`[rev1]` | +| 5877 | `setStrapName` | `setAdvertisingName` | Harvard → custom (body shared) | +| 5888 | `getHello` | `hello` + `helloBody` | Harvard/`[0x00]` → `0x91`/`[0x01]` | +| 5929 | `enableLiveStreams` R10/R11 | `r10R11Realtime` | send → omit | +| 5943 | `enableLiveStreams` optical | `opticalDataIsLiveToggle` | `true` → `false` | +| 5948 | `enableLiveStreams` log | `opticalDataIsLiveToggle` | string unchanged | +| 5981 | `enableHrOnlyLive` | `r10R11Realtime` | send → omit | +| 5999 | `disableLiveStreams` | `r10R11Realtime` | send → omit | +| — | the two delays themselves | `kGen5Pre/PostRegistrationDelay` | moved out of `BleEngine` into `_registry.dart`; one copy, not two | + +**Two of these are the rows marked DO NOT UNIFY, and they still are.** +`setClockDriftGated` and `burstCountGateEnforced` are `false` on WHOOP 4 because +its unconditional SET_CLOCK is the proven flow and an enforced burst gate on a +band whose count semantics nothing has pinned is a permanent stall (15 failures +→ abort → Stuck). Moving a flag is not flipping it; both comments travelled with +the field and are repeated on the field's own doc. + +## BEHAVIOUR — stays in the engine (9 sites, 11 lines) + +| Line | Site | What actually differs | +|---|---|---| +| 2363 | `_readGen5Hello()` in `_bootstrapAfterRegistration` | An extra handshake round-trip with its own link-death abort, ordered before the clock read. | +| 2469 | `_readAdvertisingNameGen5` | A gen5-only setup command with its own "not a readiness gate" failure semantics. | +| 2496 | `_maybeStartBatteryPackFollowUp` | A gen5-only background task with its own retry schedule and once-per-session latch. | +| 3635–3674 | historical record dispatch | `decodeGen5HistoricalSample` vs the gen4 version-routed chain, and which kinds fall through to `raw_archive`. | +| 5134 | `enableGen5DeepBuffers` | A gen5-only multi-frame `SET_FF_VALUE` sequence behind the one audited `allowDangerous: true`. | +| 5160 | `sendInit` | Two INIT state machines in one method. Biggest single item — **and it writes host connection-priority state**: its `finally` clears `_connectSetup`, and a throw above that clear leaves the link pinned at setup priority for the whole connection with `_applyLinkPriority` early-returning forever. That is host state; it does not become data. | +| 5734 | `setAlarm` pre-arm | gen5 issues SET_CLOCK + a 120 ms settle before arming. A sequence, not a payload. | +| 5833 | `runAlarm` | Opcode swap **plus a computed body** — see below. | +| 5898 | `buzzPattern` | Opcode swap **plus a computed body** — see below. | + +## NOT MOVED, and why — the honest boundary + +**`runAlarm` (5833) and `buzzPattern` (5898) — DATA that a `const` table cannot +hold.** Both pick opcode *and* body. gen5's body is +`AlarmPayloads.gen5MaverickBuzz()`, a computed list; `kWhoopGen4`/`kWhoopGen5` +are `const`, so a field could only hold the twelve bytes transcribed into a +second place — which is exactly the duplication a named constant exists to +prevent. Moving the opcode alone would leave the body branch anyway, so both +stay whole. + +**The four `AlarmPayloads` discriminators (5756, 5819, 5852, and `setAlarm`'s +own 5733) are already table-driven.** They are not branches in the engine; they +are one named ARGUMENT to a pure policy in `ble_state.dart` that already selects +by band. Widening `isGen5:` to take the entry moves no decision, and that file is +owned elsewhere. Left alone. + +**Two sites, four lines, are log text only** — 5353/5364 (`SET_CLOCK (gen5)`) +and 5764/5768 (the alarm log), each a `final isGen5 = …` and its use. The +live-stream log used to be a third and is keyed off `opticalDataIsLiveToggle` +now, so it reads `isGen5` no more. Changing any of them changes a user-visible +log line with no wire meaning. + +**`_maybeAugmentClockEpoch` (6206/6207/6223) is NOT a band branch — the previous +revision got this wrong.** It reads `op == Cmd.getClockGen5` on the *received* +response, not `session.band`, and edge sends `Cmd.getClock` (11) on both bands. +It mirrors protocol's own `control.dart:928` (`op == Cmd.getClockGen5 ? 3 : 2`). +Keying the offset off the session band would change behaviour for a `147` reply +on either link and would diverge from the parse it exists to patch. Left as is, +and it is why the count above excludes `isGen5Clock` at both ends: three lines +here, three on main, and neither number is about the band. + +## What this wave actually cost + +One new type (`BandWireCommands`, eight fields), five new `BandEntry` fields, +two constants relocated. No new mechanism, no capability booleans about our own +features, no command DSL: `r10R11Realtime` being `null` is an ABSENT COMMAND, +not a `supportsX()` claim, and it is the only field that decides whether +something happens rather than what gets sent. diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift index ee7c0a86..c68a4a14 100644 --- a/ios/Runner/AccessorySetup.swift +++ b/ios/Runner/AccessorySetup.swift @@ -7,11 +7,19 @@ import AccessorySetupKit /// AccessorySetupKit (ASK) bridge — iOS 18+ only. /// -/// WHY: per Apple TN3115, starting in iOS 26 the OS only relaunches a *terminated* app -/// into the background for a Bluetooth accessory that was provisioned via ASK. Our -/// CoreBluetooth state-restoration central (BleRestoreManager) still does the actual -/// relaunch/pending-connect work, but iOS 26 will only honour it if the peripheral was -/// set up through the ASK picker. So pairing on iOS 18+ goes through this picker. +/// WHY: TN3115's relaunch table has two rows that are "No" without ASK and "Yes" with it — +/// "App Force Quit by the user" and "Control Center Bluetooth button toggled". Note 5: +/// "Starting in iOS 26 and iPadOS 26, only apps that use AccessorySetupKit to setup +/// Bluetooth accessories will be relaunched." Apple DTS has since clarified on the forums +/// that note 5 scopes to *those two rows only* — it is a capability apps GAIN, not one they +/// lose, and the ordinary relaunch cases (app removed from memory, crashed, device +/// restarted) never depended on ASK. The condition is also stated per-APP, not +/// per-accessory; the relaunch itself still fires "if and only if" a pending Core Bluetooth +/// request completes, which is what BleRestoreManager holds. +/// +/// So the load-bearing reason to route pairing through the picker is simpler than +/// "otherwise no background sync": on iOS 18+ this picker IS how a user grants us the +/// accessory, plus those two extra relaunch cases. /// /// COEXISTENCE: ASK is a provisioning/authorization gate, NOT a connection owner. It hands /// back `ASAccessory.bluetoothIdentifier` — the CoreBluetooth peripheral UUID, which is the @@ -21,18 +29,17 @@ import AccessorySetupKit /// /// Dart MethodChannel `openstrap/accessory_setup`: /// - `isSupported` -> Bool (true only on iOS 18+) -/// - `provisionedId` -> String?(uppercased UUID of an already-provisioned WHOOP, or nil) -/// - `showPicker` -> String (the provisioned band's UUID; throws on cancel/error) +/// - `provisionedId` -> String?(uppercased UUID of an already-provisioned band, or nil) +/// - `showPicker` -> String (the band provisioned by THIS call; throws on cancel/error) +/// optional Bool argument: true = add another accessory /// - `removeAll` -> nil (deprovision all — used on unpair) +/// +/// The service UUIDs the picker matches on are NOT duplicated here: they come from +/// Info.plist's NSAccessorySetupBluetoothServices, which Apple requires to list every +/// descriptor criterion anyway, and which is generated from `kBandRegistry` by +/// `tool/gen_ios_ask_plist.dart`. 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) - fileprivate static let whoopServiceUUIDGen4 = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6" - fileprivate static let whoopServiceUUIDGen5 = "fd4b0001-cce1-4033-93ce-002d5875f58a" static func register(messenger: FlutterBinaryMessenger) { let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) @@ -43,14 +50,19 @@ enum AccessorySetup { case "provisionedId": if #available(iOS 18.0, *) { - Impl.shared.provisionedId { result($0) } + // ponytail: the channel still hands Dart ONE id because the Dart side is + // still two SharedPreferences scalars (change-list E3). Swift holds the + // whole array; widen this to a list when the device table lands. + Impl.shared.provisionedIds { result($0.first) } } else { result(nil) } case "showPicker": if #available(iOS 18.0, *) { - Impl.shared.showPicker { res in + // No argument (today's only caller) = today's behaviour exactly. + let addAnother = (call.arguments as? Bool) ?? false + Impl.shared.showPicker(addAnother: addAnother) { res in switch res { case .success(let id): result(id) case .failure(let err): @@ -114,28 +126,44 @@ private final class Impl { } } - /// Returns the uppercased UUID of an already-provisioned WHOOP, or nil. - func provisionedId(_ completion: @escaping (String?) -> Void) { + /// Every provisioned accessory's uppercased CoreBluetooth UUID, in session order. + /// `session.accessories` is an ARRAY — one entry per accessory the user has granted. + private var provisionedIdList: [String] { + session.accessories.compactMap { $0.bluetoothIdentifier?.uuidString.uppercased() } + } + + /// Returns the uppercased UUIDs of the already-provisioned accessories (possibly empty). + func provisionedIds(_ completion: @escaping ([String]) -> Void) { ensureActivated() // `accessories` is reliable only after activation has reported .activated; give the // session a brief beat to populate on a cold start, then read it. queue.asyncAfter(deadline: .now() + 0.2) { [weak self] in - guard let self = self else { completion(nil); return } - let id = self.session.accessories - .compactMap { $0.bluetoothIdentifier } - .first? - .uuidString - .uppercased() - completion(id) + guard let self = self else { completion([]); return } + completion(self.provisionedIdList) } } - func showPicker(_ completion: @escaping (Result) -> Void) { + /// - Parameter addAnother: show the picker even though an accessory is already + /// provisioned, i.e. provision a SECOND band. See the ordering warning below. + func showPicker(addAnother: Bool = false, + _ completion: @escaping (Result) -> Void) { ensureActivated() - // Already provisioned? Don't re-show the picker — just return the known id. - if let existing = session.accessories - .compactMap({ $0.bluetoothIdentifier }) - .first?.uuidString.uppercased() { + let known = provisionedIdList + // Already provisioned and not explicitly adding another? Don't re-show the picker — + // just return the known id. + // + // ORDERING (do not "fix" this into an unconditional showPicker): ASK's picker fails + // with "CBManager is active with global permissions" once ANY CBCentralManager exists + // in the process, and BleRestoreManager creates one at launch on every already-paired + // launch. So this early return is also what keeps a repeat "pair" tap from turning + // into a guaranteed picker failure. + // + // ponytail: `addAnother` is therefore plumbing, not a working second-band flow — a + // second accessory can only be provisioned while no central is alive (fresh install, + // or after unpair, which releases the restore central via BleRestoreManager.disarm). + // A real "add a band" flow has to tear both centrals down first; that belongs with + // the device table (change-list E3/E4), not here. + if !addAnother, let existing = known.first { completion(.success(existing)) return } @@ -149,10 +177,15 @@ private final class Impl { // require an NSAccessorySetupBluetoothNames entry and risk excluding the band // on a name mismatch.) // - // 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. + // ASK matches ANY item in the picker list, so we offer one item per band in the + // registry. A band advertising any listed service can be provisioned; the + // provisioned identifier is the same CoreBluetooth UUID whichever it is. + // + // The list comes straight from Info.plist rather than a Swift copy: Apple requires + // every descriptor criterion to be declared there, so that array is by definition + // the complete set — a second copy here could only ever be the stale one. It is + // generated from kBandRegistry (tool/gen_ios_ask_plist.dart, pinned by + // test/ios_ask_plist_test.dart), so adding a band stays a one-file edit in Dart. let productImage = UIImage(named: "StrapProduct") ?? UIImage(systemName: "sensor.tag.radiowave.forward") ?? UIImage() @@ -162,10 +195,15 @@ private final class Impl { return ASPickerDisplayItem( name: name, productImage: productImage, descriptor: descriptor) } - let items = [ - item(AccessorySetup.whoopServiceUUIDGen4, "WHOOP band"), - item(AccessorySetup.whoopServiceUUIDGen5, "WHOOP 5 band"), - ] + let info = Bundle.main.infoDictionary ?? [:] + let services = info["NSAccessorySetupBluetoothServices"] as? [String] ?? [] + let labels = info["OSBandLabels"] as? [String: String] ?? [:] + let items = services.map { item($0, labels[$0.uppercased()] ?? "Band") } + guard !items.isEmpty else { + completion(.failure(PickerError( + message: "No accessory services are declared in Info.plist."))) + return + } pickerResult = completion session.showPicker(for: items) { [weak self] error in @@ -177,10 +215,13 @@ private final class Impl { } return } - // Picker succeeded — read the newly provisioned accessory's identifier. - let id = self.session.accessories - .compactMap { $0.bluetoothIdentifier } - .first?.uuidString.uppercased() + // Picker succeeded — return the accessory THIS run added, not `accessories.first`: + // once a second band is provisioned the first entry is the OLD one, so `.first` + // would hand Dart the wrong device to connect to. (With none previously known — + // every pairing today — the added one IS the first, so this is unchanged.) + let current = self.provisionedIdList + let knownSet = Set(known) + let id = current.first { !knownSet.contains($0) } ?? current.first if let cb = self.pickerResult { self.pickerResult = nil if let id = id { diff --git a/ios/Runner/BleRestoreManager.swift b/ios/Runner/BleRestoreManager.swift index 7634ae10..82db4233 100644 --- a/ios/Runner/BleRestoreManager.swift +++ b/ios/Runner/BleRestoreManager.swift @@ -47,7 +47,12 @@ class BleRestoreManager: NSObject { private var central: CBCentralManager? private var bandUUID: UUID? - private var pending: CBPeripheral? // retained so ARC doesn't drop it mid-connect + // Peripherals we hold a pending connect for, retained so ARC doesn't drop them + // mid-connect. An array because willRestoreState hands back an array: dropping all but + // the first loses the retain on the rest, and a peripheral we no longer hold is one + // cancelPending can no longer cancel — so the two centrals would fight over it when the + // app reclaims the band. With one provisioned band there is exactly one entry. + private var pending: [CBPeripheral] = [] private var channel: FlutterMethodChannel? private var flutterReady = false private var wakeQueuedBeforeReady = false @@ -239,14 +244,14 @@ class BleRestoreManager: NSObject { NSLog("[ble-restore] band not retrievable yet") return } - pending = p + pending = [p] central.connect(p, options: nil) // no timeout → persists, relaunches us when reachable NSLog("[ble-restore] armed pending connect") } private func cancelPending() { - if let p = pending { central?.cancelPeripheralConnection(p) } - pending = nil + for p in pending { central?.cancelPeripheralConnection(p) } + pending = [] } private func disarm() { @@ -327,12 +332,13 @@ extension BleRestoreManager: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { - if let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], - let p = restored.first { - pending = p - NSLog("[ble-restore] willRestoreState restored \(restored.count) peripheral(s)") - // The pending/active connect was preserved; didConnect fires if it lands. - } + let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] + guard !restored.isEmpty else { return } + // Take ALL of them, not just the first — the count was already being logged, so the + // code always knew there could be more. Each carries a pending/active connect + // bluetoothd preserved for us; didConnect fires per peripheral if one lands. + pending = restored + NSLog("[ble-restore] willRestoreState restored \(restored.count) peripheral(s)") } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 338d3723..bf050401 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -51,6 +51,11 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAccessorySetupBluetoothServices 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 @@ -62,6 +67,15 @@ NSBluetoothAlwaysUsageDescription OpenStrap connects to your WHOOP band over Bluetooth to sync your health data. + + OSBandLabels + + 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 + WHOOP 4 + FD4B0001-CCE1-4033-93CE-002D5875F58A + WHOOP 5 +