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
164 changes: 148 additions & 16 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,41 @@ struct BondRefusalGiveUp {
"NOOP stopped retrying because your strap keeps refusing to pair. It is likely still held by the official WHOOP app, or your phone is holding an old pairing. Close the WHOOP app, put the strap in pairing mode (tap until the LEDs flash blue), and if it is listed in your Bluetooth settings choose Forget This Device. Then tap Connect to try again."
}

/// #1635: the log epitaph for the SUPPRESSION path.
///
/// Separate from [epitaphLine] because that one asserts a cause ("almost certainly held by the
/// official WHOOP app") that only an auth refusal supports. Reusing it here would print a confident
/// explanation for a write that simply vanished.
///
/// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.helloSuppressedEpitaph`.
static func helloSuppressedEpitaph(refusals: Int, opaqueId: String) -> String {
"Bond epitaph: the strap [\(opaqueId)] never acknowledged the secure handshake \(refusals)x in a row, and the attempt is what drops the link - leaving the handshake off so live heart rate keeps streaming. Tap Connect to try it again."
}

/// #1635: the hint shown when the hello is switched off and the link is KEPT.
///
/// Nothing is paused on this branch, so it must not say auto-reconnect stopped. It names what was lost
/// (history sync) and the one action that restores the attempt, without asserting a cause.
///
/// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.helloSuppressedHint`.
static func helloSuppressedHint() -> String {
"The secure handshake with your strap never completes, and the attempt itself is what drops the link. NOOP has switched it off for this strap so live heart rate keeps streaming. History sync stays unavailable until it pairs. Tap Connect to try the handshake again."
}

/// The paused hint for a bond that failed WITHOUT the strap ever answering (#1635).
///
/// [pausedHint] names a cause — the strap still held by the official WHOOP app, or a stale OS pairing —
/// which is well founded when the refusal arrived as INSUFFICIENT_AUTHENTICATION or
/// INSUFFICIENT_ENCRYPTION: the strap actively said no. It is NOT founded when the CLIENT_HELLO simply
/// goes unanswered and the link drops on a timer, which is a different observation with several
/// possible causes. Telling that user to close the WHOOP app would be a guess dressed as instruction,
/// and if it is wrong they have no way to know.
///
/// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.pausedHintHandshakeUnanswered`.
static func pausedHintHandshakeUnanswered() -> String {
"NOOP stopped retrying because the secure handshake with your strap never completes: the strap does not answer, and the link drops a few seconds later. Auto-reconnect is paused so it stops draining both batteries. Tap Connect to try again, and if it keeps happening please share your strap log."
}

/// #750: a short OPAQUE token from a CoreBluetooth-local peripheral UUID for the epitaph. The CB UUID is
/// per-install (NOT the hardware MAC), and we keep only its first 8 hex chars, so the token is stable
/// within a log but carries no device-identifying PII. Pure + deterministic. Twin of the Android helper.
Expand Down Expand Up @@ -915,6 +950,11 @@ public final class BLEManager: NSObject, ObservableObject {
private var reassembler = Reassembler()
private var seq: UInt8 = 0
private var didBond = false
/// #1635: one explicit Connect grants one fresh CLIENT_HELLO even when the suppression latch is set.
/// Consumed unconditionally by the write site, so the retry belongs to THIS session — leaving it set
/// would hand a stale retry to some later automatic reconnect and restart the loop the suppression
/// exists to end. Kotlin twin: `WhoopBleClient.helloRetryRequested`.
private var helloRetryRequested = false
/// WHOOP 5/MG only: realtime HR has been armed (puffin TOGGLE_REALTIME_HR sent) once for this
/// connection, so the post-bond callback re-firing on later `.withResponse` writes doesn't re-send it.
private var whoop5RealtimeArmed = false
Expand Down Expand Up @@ -1325,6 +1365,12 @@ public final class BLEManager: NSObject, ObservableObject {
autoReconnectPausedForBondLoop = false
bondLoopPausedAt = nil
}
// #1635: the suppression path pauses NOTHING, so the reset above never fires for it. An explicit
// Connect is exactly the signal that the user has acted (pairing mode, closed the WHOOP app), so
// it must grant a fresh handshake regardless — that is also the only way to clear the latch short
// of a genuine bond. Deliberately NOT in connectCore: a system-initiated reconnect must not.
helloRetryRequested = true
bondGiveUp.reset()
connectCore(model: model)
}

Expand Down Expand Up @@ -1447,6 +1493,9 @@ public final class BLEManager: NSObject, ObservableObject {
public func forgetDevice(_ peripheralId: String?) {
let target = peripheralId.flatMap { UUID(uuidString: $0) }
let isCurrent = target == nil || peripheral?.identifier == target
// Captured BEFORE the teardown below nils `peripheral`, since a nil argument means "the current
// strap" and that is the only place its identifier can still be read.
let releasedUUID = peripheralId ?? peripheral?.identifier.uuidString
intentionalDisconnect = true // defuses the disconnect→3s-reconnect loop's guard
cancelScanFallback()
readoptingTo = nil // abandon any in-flight #52 pin handoff
Expand All @@ -1467,6 +1516,12 @@ public final class BLEManager: NSObject, ObservableObject {
}
// #747/#750 invariant: releasing a strap fully resets the give-up + pause (like disconnect()) so
// a paused state can never outlive the strap it belonged to and wedge a later re-add.
//
// #1635: the hello-suppression latch is exactly that kind of state, and it is PERSISTED - so
// without this it outlives the strap it belonged to, silently suppressing the handshake on a
// re-add and leaving a stale key behind for a strap the user removed. Re-latching costs the same
// five refusals it always did, so a strap that genuinely cannot bond is no worse off.
HelloSuppressionStore.setSuppressed(releasedUUID, false)
bondGiveUp.reset()
autoReconnectPausedForBondLoop = false
bondLoopPausedAt = nil
Expand Down Expand Up @@ -3987,8 +4042,18 @@ public final class BLEManager: NSObject, ObservableObject {
}

private func keepAliveFire() {
guard state.connected, didBond else { return }
enableLiveNotifications(reason: "keepalive")
// #1635: a SUPPRESSED 5/MG never bonds, so a bare `didBond` gate switches this whole timer off for
// exactly the link that now stays up for hours - taking the liveness watchdog below with it and
// leaving a stalled stream with nothing to bounce it. That would quietly break the "stable live HR"
// the suppression exists to deliver. Android gates its twin on `bonded` (the live-HR shortcut, #69)
// and so reaches the same watchdog.
//
// Deliberately NARROWER than Android's gate: admitted only for a streaming 5/MG, and only as far as
// the watchdog. Everything past it needs the encrypted bond, so a second `didBond` guard closes the
// door again rather than letting an unbonded link issue puffin work that cannot land.
guard keepAliveMayRun(connected: state.connected, didBond: didBond,
bonded: state.bonded, family: selectedModel.deviceFamily) else { return }
if didBond { enableLiveNotifications(reason: "keepalive") }
// Liveness watchdog: if NOTHING has arrived for a while, the stream/link stalled.
// Bounce the connection — the auto-rescan on disconnect re-bonds and resumes streaming.
// #580 / #1414: the standard 0x2A37 HR profile keeps the link genuinely alive, but its packets can
Expand All @@ -4005,6 +4070,9 @@ public final class BLEManager: NSObject, ObservableObject {
if let p = peripheral { central.cancelPeripheralConnection(p) }
return
}
// The watchdog above is the whole of the keep-alive an unbonded 5/MG can use. Everything below
// sends puffin-framed work that needs the encrypted bond.
guard didBond else { return }
guard !backfilling else { return } // never poke the strap mid-offload
// #927: continuous capture can be overnight-only, which makes the want TIME-dependent; nothing
// else re-evaluates it while the app just sits connected, so the keep-alive tick re-derives it.
Expand Down Expand Up @@ -4890,6 +4958,48 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
return "code?"
}

/// Feed one 5/MG bond refusal into the #747 give-up and act on the trip (#1635).
///
/// Both causes arrive here, and they want OPPOSITE treatment — see `giveUpSuppressesHello`. An auth
/// refusal means the strap actively declined, so pausing auto-reconnect is right. An unanswered
/// handshake means the write vanished while the link streamed live HR the whole time, so pausing
/// would throw away a working strap to punish a handshake nobody is waiting on.
///
/// Shared by both call sites so the split cannot drift: the auth path (a write error) and the
/// unanswered path (a disconnect with a hello still outstanding) reach the same decision.
private func recordWhoop5BondRefusal(authRefusal: Bool, peripheralUUID: String) {
guard bondGiveUp.recordRefusal() else { return }
let opaque = BondRefusalGiveUp.opaqueId(fromLocalUUID: peripheralUUID)
let suppress = giveUpSuppressesHello(authRefusal: authRefusal)
if suppress {
HelloSuppressionStore.setSuppressed(peripheralUUID, true)
log(BondRefusalGiveUp.helloSuppressedEpitaph(refusals: bondGiveUp.refusals, opaqueId: opaque))
} else {
autoReconnectPausedForBondLoop = true
bondLoopPausedAt = Date() // starts the #78 hole-4 salvage-probe floor
// #1539: park the connect in the same breath as the pause, so this can end while backgrounded.
standingConnectWhilePausedIfDue(justTripped: true)
log(BondRefusalGiveUp.epitaphLine(refusals: bondGiveUp.refusals, opaqueId: opaque))
}
// Each branch gets the hint that matches what it actually DID. The paused hints say "auto-reconnect
// is paused"; on the suppression branch nothing is paused, so saying so would be the same
// confidently-wrong diagnostic this issue has produced twice already (#1635). The third arm is
// unreachable while `giveUpSuppressesHello` is `!authRefusal`, and is kept because the Kotlin twin
// keeps it: if that rule ever changes, both platforms must change behaviour together.
if suppress {
state.pairingHint = BondRefusalGiveUp.helloSuppressedHint()
} else if authRefusal {
state.pairingHint = BondRefusalGiveUp.pausedHint()
} else {
state.pairingHint = BondRefusalGiveUp.pausedHintHandshakeUnanswered()
}
if TestCentre.active(.connection) {
state.append(log: "bond gaveUp refusals=\(bondGiveUp.refusals) id=\(opaque) " +
(suppress ? "(hello suppressed, staying connected)" : "(auto-reconnect paused)"),
domain: .connection)
}
}

public func centralManager(_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?) {
Expand Down Expand Up @@ -5007,6 +5117,18 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
}
state.clearBiometrics() // and a stale HR / R-R must not outlive the link either
state.liveFeedActive = false // a drop while Live is open must not leave a stale "Stop live feed"
// #1635: an unanswered CLIENT_HELLO produces no write error at all - the link simply drops a few
// seconds later - so it never reached the give-up on this platform and nothing could end the loop.
// Read it HERE, while `clientHelloWriteAt` and `didBond` are both still valid, and feed it in
// through the same split the auth path uses. `countsAsBondRefusal` gates on family, so a 4.0 (which
// bonds cleanly) can never latch the suppression.
if countsAsBondRefusal(isAuthRefusalStatus: false,
helloUnacked: clientHelloWriteAt != nil,
alreadyBonded: didBond,
family: selectedModel.deviceFamily) {
recordWhoop5BondRefusal(authRefusal: false,
peripheralUUID: peripheral.identifier.uuidString)
}
didBond = false
clientHelloWriteAt = nil // #1635: no hello survives the link that carried it
whoop5RealtimeArmed = false
Expand Down Expand Up @@ -5337,7 +5459,16 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// fires for a 5/MG strap. Live HR/battery come from the standard profiles; this just
// opens the puffin session. Unverified on real MG hardware.
cmdCharacteristic = c
if let hello = selectedModel.deviceFamily.clientHello {
// #1635: once the give-up has latched, the hello is what ends the link — skip it and let
// the standard-profile HR stream keep running. Consumed unconditionally so an explicit
// Connect's single retry belongs to this session and cannot leak into a later automatic one.
let helloUserAsked = helloRetryRequested
helloRetryRequested = false
let helloSuppressed = HelloSuppressionStore.suppressed(peripheral.identifier.uuidString)
if !shouldSendClientHello(suppressedForDevice: helloSuppressed, userInitiated: helloUserAsked) {
log("WHOOP 5/MG: CLIENT_HELLO suppressed for this strap - it was never acknowledged and the write is what drops the link. Staying on live HR (not fully paired); press Connect to try the handshake again (#1635).")
state.pairingHint = BondRefusalGiveUp.helloSuppressedHint()
} else if let hello = selectedModel.deviceFamily.clientHello {
// CONTRIBUTOR FIX (issue #17 — diagnosed from the logs, unverified on hardware here):
// write CLIENT_HELLO with .withResponse so CoreBluetooth runs just-works bonding when
// the link needs authenticating, AND so didWriteValueFor fires. That callback is where
Expand Down Expand Up @@ -5442,19 +5573,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// threshold (the pairing hint has had several cycles to be acted on), pause auto-reconnect so
// we stop hammering a strap that can't bond, write the one-line epitaph (opaque id only, no
// PII), and surface the honest paused hint. A genuine bond or a manual reconnect re-arms it.
if bondGiveUp.recordRefusal() {
autoReconnectPausedForBondLoop = true
bondLoopPausedAt = Date() // starts the #78 hole-4 salvage-probe floor
// #1539: same reasoning as the #617 trip — park a connect now so this pause can end
// while the app is backgrounded, not only when the user next opens it.
standingConnectWhilePausedIfDue(justTripped: true)
let opaque = BondRefusalGiveUp.opaqueId(fromLocalUUID: peripheral.identifier.uuidString)
log(BondRefusalGiveUp.epitaphLine(refusals: bondGiveUp.refusals, opaqueId: opaque))
state.pairingHint = BondRefusalGiveUp.pausedHint()
if TestCentre.active(.connection) {
state.append(log: "bond gaveUp refusals=\(bondGiveUp.refusals) id=\(opaque) (auto-reconnect paused)", domain: .connection)
}
}
// #1635: the trip itself now lives in recordWhoop5BondRefusal, shared with the
// unanswered-handshake path at disconnect, so the two causes cannot drift apart. This
// one is an AUTH refusal — the strap actively declined — so it still pauses.
recordWhoop5BondRefusal(authRefusal: true,
peripheralUUID: peripheral.identifier.uuidString)
}
// Multi-WHOOP stale-pin recovery (#52). When a stale registry pin points at a strap that keeps
// refusing the encrypted bond ("Encryption/Authentication is insufficient") but a DIFFERENT
Expand Down Expand Up @@ -5505,6 +5628,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
isWhoop5: true
) {
didBond = true
// #1635: the handshake demonstrably works on this strap - drop the latch so a later
// transient failure starts from a clean slate rather than inheriting an old verdict.
HelloSuppressionStore.setSuppressed(peripheral.identifier.uuidString, false)
bondGiveUp.reset()
state.bonded = true
state.encryptedBond = true // genuine encrypted bond (not the live-HR shortcut) — #69
bondedAt = Date() // #617: start the bond→drop stopwatch for the bond-loop detector
Expand Down Expand Up @@ -5830,6 +5957,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
if selectedModel.deviceFamily == .whoop5, !state.bonded {
state.bonded = true
log("WHOOP 5/MG: live HR streaming — marking the link established (experimental).")
// #1635: a 5/MG has no confirmed-write handshake, so the keep-alive (and with it the
// liveness watchdog) is started HERE, on the bonded transition, exactly as the Kotlin twin
// does. The other two start sites are post-bond, which a suppressed strap never reaches —
// and this also covers an unbonded 5/MG that has not latched yet.
startKeepAlive()
}
case BLEManager.batteryChar:
// 0x2A19 = percent — 5/MG ONLY. The WHOOP 4.0's 0x2A19 is a stub constant 100 (real value =
Expand Down
Loading
Loading