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
12 changes: 11 additions & 1 deletion Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6904,14 +6904,24 @@ 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
bondedAt = Date() // #617: start the bond→drop stopwatch for the bond-loop detector
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
Expand Down
26 changes: 26 additions & 0 deletions Strand/BLE/Whoop4BondAck.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
27 changes: 27 additions & 0 deletions StrandTests/Whoop4BondAckTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading