Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
58befb7
storage: key the decoded tables by device, not just time
abdulsaheel Aug 23, 2026
501906c
ble: a non-whoop device can be seen and connected to now
abdulsaheel Aug 23, 2026
b38427e
ios: the picker reads the plist instead of a second copy of the uuids
abdulsaheel Aug 23, 2026
4a5db68
a device is a row now, not two sharedprefs scalars
abdulsaheel Aug 23, 2026
c529345
briefing: a withheld metric can't be narrated back as a verdict
abdulsaheel Aug 23, 2026
8b8ebaf
a chest strap is a registry entry now, not a second ble stack
abdulsaheel Aug 23, 2026
6799e52
adapters: a band can drive its own session now
abdulsaheel Aug 23, 2026
bea5178
ble: fix the write race before anything can hit it, then table the ge…
abdulsaheel Aug 23, 2026
6f2792f
gen5 is owner-confirmed, and A1's real magnitude
abdulsaheel Aug 23, 2026
633f0a9
pin analytics to the branch head, 0.9.29+61
abdulsaheel Aug 23, 2026
40a2430
review fixes, and the hrs path could never have worked
abdulsaheel Aug 23, 2026
69ba0ea
you can actually pair a heart rate sensor now
abdulsaheel Aug 23, 2026
1c5d8f7
oura: a host, the key install, and stop guessing timestamps
abdulsaheel Aug 23, 2026
88b8d65
sources: list paired sensors, and stop calling a ring a phone
abdulsaheel Aug 23, 2026
ec09fd7
oura + hrs wire format out to protocol; review fixes; kAlgoVersion 78
abdulsaheel Aug 24, 2026
6f4fb8f
pubspec.lock was committed with the local path overrides baked in
abdulsaheel Aug 24, 2026
4924eb2
review fixes: oura key never deleted, sensorLive misattributed, scan/…
abdulsaheel Aug 24, 2026
5fa92bc
run oura ring sync in the background too
abdulsaheel Aug 24, 2026
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
122 changes: 122 additions & 0 deletions docs/isgen5-inventory.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 83 additions & 42 deletions ios/Runner/AccessorySetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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<String, PickerError>) -> 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<String, PickerError>) -> 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
}
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Loading
Loading