diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 0feffde48b..02d9042979 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -6904,7 +6904,8 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { return } - if !didBond { + switch Whoop4BondAck.classify(didBond: didBond, encryptedBond: state.encryptedBond) { + case .firstBond: didBond = true state.bonded = true state.encryptedBond = true // WHOOP 4 confirmed-write bond is always genuine — #69 @@ -6912,6 +6913,15 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { noteGenuineBond(of: peripheral) // #52: this strap bonds fine; clears any pin-refusal streak emitConnectionBondState("encryptedBond family=whoop4 (confirmed write acked)") log("BONDED (confirmed write acknowledged) — custom channels should now flow") + case .reprove: + // didConnect re-entered with no disconnect before it (a Bluetooth power cycle) and cleared the + // flag; this ack is the same proof the first bond recorded. See `Whoop4BondAck`. + state.encryptedBond = true + noteGenuineBond(of: peripheral) + emitConnectionBondState("encryptedBond family=whoop4 (confirmed write acked, re-entered link)") + log("BONDED again (confirmed write acknowledged on a re-entered link)") + case .alreadyProven: + break } // Run the connect handshake EXACTLY ONCE per connection. didWriteValueFor re-fires on EVERY // .withResponse write — the bond write, every SEND_HISTORICAL, every HISTORY_END ack. Without diff --git a/Strand/BLE/Whoop4BondAck.swift b/Strand/BLE/Whoop4BondAck.swift new file mode 100644 index 0000000000..49593bb9d8 --- /dev/null +++ b/Strand/BLE/Whoop4BondAck.swift @@ -0,0 +1,26 @@ +import Foundation + +/// What a WHOOP 4.0 confirmed-write acknowledgement records on the current link. +/// +/// The ack is the per-connection proof of the encrypted bond (#69): `didConnect` clears `encryptedBond` so +/// each link earns it again at this site. `didBond` is cleared only by the disconnect callback, and +/// CoreBluetooth sends no disconnect when the Mac's Bluetooth radio powers off. On the next `poweredOn` the +/// app re-attaches to the strap macOS still holds, so `didConnect` arrives with `didBond` still true. +/// Recording the bond only when `didBond` was false then never re-proved it: the strap read "Live HR (not +/// fully paired)" for the rest of the link while history sync, which `didBond` gates, kept working, and +/// buzz, which `encryptedBond` gates, was refused. +/// +/// Pure so the rule is unit-tested without a radio. +enum Whoop4BondAck: Equatable { + /// The first bond on this link: record it and run the post-bond bookkeeping. + case firstBond + /// Already bonded, but a re-entered `didConnect` cleared the encrypted flag: this ack restores it. + case reprove + /// Already bonded and already proven on this link: nothing to record. + case alreadyProven + + static func classify(didBond: Bool, encryptedBond: Bool) -> Whoop4BondAck { + if !didBond { return .firstBond } + return encryptedBond ? .alreadyProven : .reprove + } +} diff --git a/StrandTests/Whoop4BondAckTests.swift b/StrandTests/Whoop4BondAckTests.swift new file mode 100644 index 0000000000..d2f21572d2 --- /dev/null +++ b/StrandTests/Whoop4BondAckTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import Strand + +/// Pins what a WHOOP 4.0 confirmed-write ack records. The case that motivated it: the Mac's Bluetooth radio +/// powers off and back on, CoreBluetooth sends no disconnect, the app re-attaches, and `didConnect` clears +/// `encryptedBond` while `didBond` survives. The next ack must restore the encrypted bond rather than skip it, +/// or the strap reads "Live HR (not fully paired)" and buzz is refused for the rest of the link. +final class Whoop4BondAckTests: XCTestCase { + + func testFirstAckOnALinkIsTheFirstBond() { + XCTAssertEqual(Whoop4BondAck.classify(didBond: false, encryptedBond: false), .firstBond) + } + + func testAckAfterAReenteredConnectReprovesTheEncryptedBond() { + XCTAssertEqual(Whoop4BondAck.classify(didBond: true, encryptedBond: false), .reprove) + } + + func testLaterAcksOnAProvenLinkRecordNothing() { + // Every SEND_HISTORICAL / HISTORY_END ack lands here during an offload; none may re-run bond bookkeeping. + XCTAssertEqual(Whoop4BondAck.classify(didBond: true, encryptedBond: true), .alreadyProven) + } + + func testAStaleEncryptedFlagWithoutABondIsStillTheFirstBond() { + // didBond is the link-level fact; a first ack always runs the full first-bond path. + XCTAssertEqual(Whoop4BondAck.classify(didBond: false, encryptedBond: true), .firstBond) + } +}