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
24 changes: 18 additions & 6 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,24 @@ final class AppModel: ObservableObject {
$bpm.sink { [weak self] hr in self?.coachZone(hr) }.store(in: &hrCancellables)
// Illness/strain early-warning recomputes when the daily history changes.
repo.$days.sink { [weak self] days in self?.evaluateIllness(days) }.store(in: &hrCancellables)
// Re-arm the strap's firmware alarm whenever it (re)bonds. A smart-alarm time changed while the
// strap was away never reached it , the send is gated on bond , so the strap kept the OLD time
// and fired at it (#59). removeDuplicates() fires once per bond; gated on enabled so a disabled
// alarm doesn't disarm on every reconnect.
live.$bonded.removeDuplicates().sink { [weak self] bonded in
guard let self, bonded, self.behavior.smartAlarmEnabled else { return }
// Re-arm the strap's firmware alarm once the connection has SETTLED — not the instant it (re)bonds.
// A smart-alarm time changed while the strap was away never reached it , the send is gated on bond
// , so the strap kept the OLD time and fired at it (#59).
//
// #34: keyed off `connectSettled` (a monotonic counter BLEManager bumps once the connect handshake
// has both run AND the cmd-notify characteristic has confirmed subscribed — see LiveState.swift /
// BLEManager.maybeSignalConnectSettled), NOT off raw `bonded`. `state.bonded` publishes from
// INSIDE BLEManager's connect-handshake continuation (the bonding-confirm write's
// didWriteValueFor), and Combine delivered to a `$bonded` sink SYNCHRONOUSLY on that same call
// stack — arming there nested the alarm's SET_CLOCK/SET_ALARM_TIME/GET_ALARM_TIME burst in the
// MIDDLE of the handshake, ahead of its own clock-set and before the cmd-notify channel was
// confirmed subscribed. A strap log (#34 v8.6.2) confirmed the result: the alarm's GET_ALARM_TIME
// readback got no reply at all — the strap's answer had nowhere confirmed-subscribed to land.
// `connectSettled` only bumps once that channel is confirmed live, so the readback (and the arm
// itself) always goes out on a link that's actually ready. `dropFirst()` skips the initial
// published value (0) at subscribe time, so this doesn't fire on app launch before any connection.
live.$connectSettled.dropFirst().sink { [weak self] _ in
guard let self, self.behavior.smartAlarmEnabled else { return }
self.applySmartAlarm()
}.store(in: &hrCancellables)
// The firmware alarm is a single absolute instant with no recurrence, and was re-armed ONLY on
Expand Down
48 changes: 48 additions & 0 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,16 @@ public final class BLEManager: NSObject, ObservableObject {
/// this guard those re-entries re-blasted hello/SET_CLOCK at the strap mid-offload and stopped it
/// from streaming type-47 — THE iOS "won't serve" root cause. Reset on disconnect.
private var connectHandshakeDone = false
/// #34: true once the cmd-notify characteristic (61080003) has CONFIRMED it's subscribed
/// (`didUpdateNotificationStateFor` fired for it with `isNotifying == true`) — as opposed to merely
/// requested. Together with `connectHandshakeDone`, gates `state.connectSettled` (see LiveState.swift)
/// so the alarm re-arm doesn't fire GET_ALARM_TIME before the strap's reply channel is actually live.
/// Reset on disconnect alongside `connectHandshakeDone`.
private var cmdNotifyConfirmedActive = false
/// #34: latches once `state.connectSettled` has been bumped for the CURRENT connection, so a
/// didUpdateNotificationStateFor re-fire (or any other later call into the check) can't double-bump.
/// Reset on disconnect.
private var connectSettledSignaled = false
/// Re-entrancy guard for captureRawAccel: true while a bounded on-demand window is running.
/// A second tap is a no-op until the active capture's asyncAfter block fires and clears this.
private var rawCaptureInFlight = false
Expand Down Expand Up @@ -3040,6 +3050,8 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
whoop5SessionStarted = false
clockRequested = false
connectHandshakeDone = false
cmdNotifyConfirmedActive = false // #34: a fresh connection needs its own notify-confirm + settle
connectSettledSignaled = false
realtimeArmedAt = nil // cleared after the marginal-radio detector above read it (#80)
// Reset backfill state so the next connect starts a fresh offload (incl. the syncing pill —
// a dropped link mid-offload must not leave "Syncing strap history…" stuck on, #77).
Expand Down Expand Up @@ -3455,6 +3467,15 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// connectHandshakeDone, so a racing foreground/restore trigger can't fire it early.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.requestSync(.connect) }
startBackfillTimer() // re-offload the type-47 store every backfillIntervalSeconds
// #34: signal settled directly (skip the cmd-notify gate `maybeSignalConnectSettled()`
// uses) — armStrapAlarm's 5/MG branch never sends GET_ALARM_TIME (log-only readback is
// WHOOP4-only, and the 5/MG alarm itself stays behind the Experimental toggle), so there's
// no reply-channel race to wait out here; this just keeps the re-arm-on-bond signal firing
// for 5/MG the way it did before `connectSettled` replaced raw `bonded`.
if !connectSettledSignaled {
connectSettledSignaled = true
state.connectSettled &+= 1
}
}
return
}
Expand Down Expand Up @@ -3538,6 +3559,25 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
realtimeArmedAt = Date() // start the arm→drop stopwatch for the marginal-radio detector
}
}
// #34: the handshake body above (hello, SET_CLOCK, notify-resubscribe requests) has now been
// fully ISSUED — check whether the cmd-notify characteristic has also CONFIRMED (the other half
// may already have landed, e.g. from the discovery-phase subscribe, or may still be in flight and
// land via didUpdateNotificationStateFor below).
maybeSignalConnectSettled()
}

/// #34: bump `state.connectSettled` once, per connection, the first moment BOTH `connectHandshakeDone`
/// (hello + SET_CLOCK sent) AND `cmdNotifyConfirmedActive` (cmd-notify characteristic confirmed
/// subscribed) are true — whichever lands second. Called from the tail of the WHOOP4 handshake and
/// from `didUpdateNotificationStateFor` on a cmd-notify confirmation; a no-op until both are true, and
/// latched by `connectSettledSignaled` so it can't double-bump on a second call. This is the signal
/// the alarm re-arm (AppModel's `live.$connectSettled` sink) waits on instead of raw `bonded`, so
/// SET_ALARM_TIME/GET_ALARM_TIME go out on a link whose reply channel is confirmed live (#34).
private func maybeSignalConnectSettled() {
guard connectHandshakeDone, cmdNotifyConfirmedActive, !connectSettledSignaled else { return }
connectSettledSignaled = true
state.connectSettled &+= 1
log("Connect settled: handshake done + cmd-notify confirmed — alarm re-arm (if due) can fire now")
}

/// SET_CLOCK(10) payload — the 8-byte form `[seconds u32 LE][subseconds u32 LE]`, subseconds in
Expand Down Expand Up @@ -3777,6 +3817,14 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
log("Notify enable failed for \(characteristic.uuid): \(error.localizedDescription)")
} else {
log("Notify \(characteristic.isNotifying ? "active" : "off") \(characteristic.uuid)")
// #34: the cmd-notify channel carries GET_ALARM_TIME's (and every other COMMAND_RESPONSE's)
// reply. Once IT confirms subscribed, check whether the handshake side of connectSettled is
// also done — this is the "notify confirmed" half arriving AFTER the handshake body, the
// ordering a v8.6.2 strap log showed actually happens.
if characteristic === cmdNotifyCharacteristic, characteristic.isNotifying {
cmdNotifyConfirmedActive = true
maybeSignalConnectSettled()
}
}
}
}
12 changes: 12 additions & 0 deletions Strand/BLE/LiveState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ public final class LiveState: ObservableObject {
/// connect/disconnect. Drives the Live pill's two-state distinction; the encrypted channel (buzz,
/// alarm, double-tap, history offload) only works when this is true.
@Published public var encryptedBond: Bool = false
/// #34: bumped by BLEManager once a WHOOP 4.0 connection has BOTH run its connect handshake (hello +
/// SET_CLOCK, exactly once — `connectHandshakeDone`) AND had the cmd-notify characteristic confirm
/// subscribed (`didUpdateNotificationStateFor` for it fired with `isNotifying == true`) — whichever of
/// the two lands second. `bonded` alone fires the instant the confirmed-write bond ack lands, which is
/// BEFORE either of those — arming the firmware alarm off `bonded` sent SET_ALARM_TIME/GET_ALARM_TIME
/// while the cmd-notify channel wasn't confirmed active yet, so the strap's GET_ALARM_TIME readback
/// was silently dropped (evidenced in a v8.6.2 strap log, issue #34). A monotonic counter (not a Bool)
/// so a re-arm-eligible sink can `.dropFirst()` the initial published value and fire on every bump,
/// exactly once per settled connection. Reset to a fresh (un-bumped) state is implicit: BLEManager's
/// per-connection guards (`connectHandshakeDone`, the cmd-notify-confirmed flag) reset on disconnect,
/// so the next connection can bump this again.
@Published public var connectSettled: Int = 0
/// True ONLY when a non-WHOOP live source (currently the Oura ring) is actively streaming live HR.
/// This is the green "STREAMING" signal for sources that have no WHOOP-style encrypted bond: it is
/// DELIBERATELY separate from `bonded`, which carries WHOOP encrypted-bond + buzz semantics (it gates
Expand Down
Loading