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
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

/// Whether the alarm time a strap reports back can be compared with the one we sent (#1706).
///
/// The two halves are persisted under flat, device-less keys — `alarm.lastArmSentEpoch` and
/// `alarm.lastReportedEpoch`. On an install with more than one strap registered they can therefore
/// describe DIFFERENT devices, and comparing them then produces a confident "strap didn't accept the
/// time" about a strap that was never asked. On Android the readback is written only on the WHOOP 4.0
/// path, so a 5.0-active install could only ever be comparing across straps.
///
/// That verdict is not cosmetic here: it also drives `alarm.rejectStreak`, which raises a warning in
/// SmartAlarmView at two. A cross-strap comparison could climb that streak forever.
///
/// So attribution is required, not assumed: unless both halves are known to come from the same strap,
/// this refuses to judge. Refusing is the same stance `WindowedStreamPlan` takes — a diagnosis that
/// cannot be proven is worse than none, because it sends the reader after the wrong device.
///
/// Twin of Kotlin `AlarmReadback`.
public enum AlarmReadback {

/// Seconds of slack allowed between what we armed and what the strap reports back.
public static let toleranceS = 120

public enum Verdict: Equatable {
/// Same strap, and the readback agrees within `toleranceS`.
case matches
/// Same strap, and it does not. This is the only value that means the strap refused.
case mismatch
/// The two halves came from different straps. Nothing can be concluded about either.
case differentStrap
/// One or both halves predate device attribution, so they cannot be tied to a strap.
case unattributed
}

public static func verdict(
sentEpoch: Int,
reportedEpoch: Int,
sentDeviceId: String?,
reportedDeviceId: String?,
toleranceS: Int = AlarmReadback.toleranceS
) -> Verdict {
guard let sentDeviceId, !sentDeviceId.isEmpty,
let reportedDeviceId, !reportedDeviceId.isEmpty else { return .unattributed }
guard sentDeviceId == reportedDeviceId else { return .differentStrap }
return abs(reportedEpoch - sentEpoch) > toleranceS ? .mismatch : .matches
}

/// The suffix the debug export appends after the reported time. Byte-identical to the Kotlin twin.
public static func suffix(_ verdict: Verdict) -> String {
switch verdict {
case .matches: return " ✓ matches"
case .mismatch: return " ⚠️ MISMATCH — strap didn't accept the time"
case .differentStrap: return " (readback is from a different strap — not comparable)"
case .unattributed: return " (no strap recorded for one of these — not comparable)"
}
}

/// Whether this verdict may advance the consecutive-rejection streak. Only a proven same-strap
/// disagreement counts: an unattributed or cross-strap reading must leave the streak untouched
/// rather than reset it, since neither is evidence either way.
public static func countsAsRejection(_ verdict: Verdict) -> Bool { verdict == .mismatch }

/// Whether this verdict is evidence the strap DID accept, which clears the streak.
public static func clearsRejectionStreak(_ verdict: Verdict) -> Bool { verdict == .matches }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import XCTest
@testable import StrandAnalytics

/// #1706. The values here are the ones from the field log that produced the issue: an arm sent
/// 2026-08-26 06:30 against a readback claiming 2045-06-10, on a phone with a 4.0 and a 5.0 registered.
/// Twin of Kotlin `AlarmReadbackTest`.
final class AlarmReadbackTests: XCTestCase {

private let sent = 1_787_682_600 // 2026-08-26 06:30 +12:00
private let reported = 2_380_672_980 // 2045-06-10 14:03 +12:00, what the strap reported back

func testSameStrapAndAgreeing() {
XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 5,
sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .matches)
}

func testSameStrapAtTheToleranceBoundary() {
XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 120,
sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .matches)
XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 121,
sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .mismatch)
}

func testSameStrapAndDisagreeingIsTheOnlyRealRefusal() {
let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported,
sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a")
XCTAssertEqual(v, .mismatch)
XCTAssertTrue(AlarmReadback.countsAsRejection(v))
}

/// The field case: the readback can only come from the 4.0, the arm went to the active 5.0.
func testCrossStrapIsNotJudged() {
let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported,
sentDeviceId: "whoop-5mg", reportedDeviceId: "my-whoop")
XCTAssertEqual(v, .differentStrap)
XCTAssertFalse(AlarmReadback.countsAsRejection(v), "a strap that was never asked must not be blamed")
XCTAssertFalse(AlarmReadback.clearsRejectionStreak(v), "nor may it clear a real refusal")
}

/// Data written before attribution existed. Unknown is not the same as innocent.
func testMissingAttributionIsNotJudged() {
let pairs: [(String?, String?)] = [(nil, "whoop-a"), ("whoop-a", nil), (nil, nil), ("", "whoop-a")]
for (a, b) in pairs {
let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported,
sentDeviceId: a, reportedDeviceId: b)
XCTAssertEqual(v, .unattributed, "\(String(describing: a)) / \(String(describing: b))")
XCTAssertFalse(AlarmReadback.countsAsRejection(v))
XCTAssertFalse(AlarmReadback.clearsRejectionStreak(v))
}
}

func testOnlyAProvenAgreementClearsTheStreak() {
XCTAssertTrue(AlarmReadback.clearsRejectionStreak(.matches))
XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.mismatch))
XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.differentStrap))
XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.unattributed))
}

func testSuffixShape() {
XCTAssertEqual(AlarmReadback.suffix(.matches), " ✓ matches")
XCTAssertEqual(AlarmReadback.suffix(.mismatch), " ⚠️ MISMATCH — strap didn't accept the time")
XCTAssertEqual(AlarmReadback.suffix(.differentStrap), " (readback is from a different strap — not comparable)")
XCTAssertEqual(AlarmReadback.suffix(.unattributed), " (no strap recorded for one of these — not comparable)")
}
}
6 changes: 6 additions & 0 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,7 @@ public final class BLEManager: NSObject, ObservableObject {
// and tell the router which decoder to use. Fresh per connection so no stale bytes carry over.
reassembler = Reassembler(family: model.deviceFamily)
router.family = model.deviceFamily
router.deviceId = deviceId // #1706: attribute this connection's alarm readback
// Live 5/MG persistence: point the Collector's decode at the selected family and install the
// identity clock ref for a 5/MG (its live timestamps are already real unix). WHOOP 4.0 keeps
// the GET_CLOCK correlation flow untouched. Re-applied after bootstrapStore builds the
Expand Down Expand Up @@ -4302,6 +4303,7 @@ public final class BLEManager: NSObject, ObservableObject {
selectedModel = model
reassembler = Reassembler(family: model.deviceFamily)
router.family = model.deviceFamily
router.deviceId = deviceId // #1706: attribute this connection's alarm readback
configureCollectorFamily()
central.stopScan()
log("Scanning for \(model.displayName)…")
Expand Down Expand Up @@ -4643,6 +4645,9 @@ public final class BLEManager: NSObject, ObservableObject {
private func recordAlarmArm(sentEpoch: Int) {
let d = UserDefaults.standard
d.set(sentEpoch, forKey: "alarm.lastArmSentEpoch")
// #1706: WHICH strap this arm went to. Without it the export, and the reject streak, compare
// this epoch against a readback that on a multi-strap install may be a different device.
d.set(deviceId, forKey: "alarm.lastArmDeviceId")
d.set(Date().timeIntervalSince1970, forKey: "alarm.lastArmAt")
d.set(commandChannelReady, forKey: "alarm.lastArmConnected") // #613: true only if the arm actually went out
// #34: the strap-clock skew (its own RTC minus wall, seconds) AT THE MOMENT we armed. A wrong RTC
Expand Down Expand Up @@ -5445,6 +5450,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
selectedModel = .persisted
reassembler = Reassembler(family: selectedModel.deviceFamily)
router.family = selectedModel.deviceFamily
router.deviceId = deviceId // #1706: attribute this connection's alarm readback
configureCollectorFamily()
// Collection only runs post-bond, so a restored link was already bonded;
// seed those flags now. `didWriteValueFor` won't re-fire on its own.
Expand Down
39 changes: 36 additions & 3 deletions Strand/BLE/FrameRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ public final class FrameRouter {
/// Called when the strap pushes an EVENT packet (WHOOP's strap-as-clock catch-up signal). The
/// BLEManager wires this to a rate-limited requestSync(.strap). nil in pure/unit contexts.
var onSyncTrigger: (() -> Void)?
/// #1706: which strap this connection is talking to, so an alarm readback can be attributed to a
/// device. Set per connection by BLEManager immediately AFTER `family`, whose didSet clears this —
/// a path that sets the family and forgets the id then attributes nothing rather than carrying the
/// previous connection's strap forward, which is the very mistake this attribution exists to stop.
/// nil in pure/unit contexts, which the verdict treats as unattributed rather than guessing.
var deviceId: String?

/// Which family's framing to decode with. Set per connection by BLEManager. WHOOP 5.0/MG frames
/// use the CRC16/offset-8 envelope; the biometric field decode for puffin is still a stub, so
/// WHOOP 5 custom frames currently surface only their envelope (live HR/battery come from the
Expand All @@ -18,7 +25,7 @@ public final class FrameRouter {
// #900: a fresh connection is a fresh capture session — re-arm the per-command raw-frame dump so
// each connect can re-capture the disputed COMMAND_RESPONSE prefix once. `family` is set fresh per
// connection by BLEManager (connectCore), so this is the per-session reset hook.
didSet { rawDumpedRespCmds.removeAll(); loggedFirmwareGate = nil }
didSet { rawDumpedRespCmds.removeAll(); loggedFirmwareGate = nil; deviceId = nil }
}

/// #900: resp command names (e.g. "GET_BATTERY_LEVEL(26)") whose raw COMMAND_RESPONSE frame has already
Expand Down Expand Up @@ -175,15 +182,41 @@ public final class FrameRouter {
// #34: persist what the strap reports so the debug export can show sent-vs-reported.
let d = UserDefaults.standard
d.set(Int(epoch), forKey: "alarm.lastReportedEpoch")
// #1706: the strap this readback came from, and the bytes it came in. The raw
// frame is what separates a genuinely-stored stale alarm from a misdecode of a
// fixed response field, and the live log rolls long before a debug export is
// taken — a 2045 readback went unexplained for exactly that reason.
d.set(deviceId, forKey: "alarm.lastReportedDeviceId")
d.set(raw, forKey: "alarm.lastReportedRaw")
d.set(Date().timeIntervalSince1970, forKey: "alarm.lastReportedAt")
// #34: count CONSECUTIVE rejections (reported ≠ what we last sent) — the signature of
// a corrupted strap alarm register. A matching readback resets it, so a transient
// (first read stale, then correct) never trips the warning; only a persistent refusal
// climbs. SmartAlarmView surfaces the warning at ≥2; the debug export shows the count.
// Observability only — never gates the BLE arm.
// #1706: the streak raises a UI warning at two, so it must only COUNT a
// disagreement PROVEN to be the same strap. A cross-strap reading is evidence of
// nothing and leaves the streak alone — advancing would warn about a strap that
// was never asked, clearing would hide a real refusal. An unattributed one is a
// different case, handled below.
if let sent = d.object(forKey: "alarm.lastArmSentEpoch") as? Int {
d.set(abs(Int(epoch) - sent) > 120 ? d.integer(forKey: "alarm.rejectStreak") + 1 : 0,
forKey: "alarm.rejectStreak")
let verdict = AlarmReadback.verdict(
sentEpoch: sent,
reportedEpoch: Int(epoch),
sentDeviceId: d.string(forKey: "alarm.lastArmDeviceId"),
reportedDeviceId: deviceId) // the local, not a re-read of what we just wrote
if AlarmReadback.countsAsRejection(verdict) {
d.set(d.integer(forKey: "alarm.rejectStreak") + 1, forKey: "alarm.rejectStreak")
} else if AlarmReadback.clearsRejectionStreak(verdict) {
d.set(0, forKey: "alarm.rejectStreak")
} else if verdict == .unattributed {
// Any streak standing here was built by the cross-strap comparison this
// replaces, so it cannot be trusted — and since only a proven match clears
// the streak now, leaving it would hold SmartAlarmView's warning up forever
// on an install that upgraded mid-streak. Discard once; the next attributed
// readback rebuilds it honestly.
d.set(0, forKey: "alarm.rejectStreak")
}
}
} else if Self.readbackReportsNoAlarm(in: frame) {
// #34 (issue comment 2026-07-12): the strap's "nothing armed" sentinel — the epoch
Expand Down
19 changes: 15 additions & 4 deletions Strand/System/DebugDataDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,9 @@ enum DebugDataDiagnostics {
} else if behind < -3 * 86400 {
lines.append("Strap clock: \(-behind / 86400)d AHEAD of wall (future-dated — alarm unreliable; recent sleep may be misdated, #67)")
} else {
lines.append("Strap clock: OK")
// #1706: say what this measures. It reads RECORD timestamps, and sat two lines above an
// alarm readback claiming 2045, which reads as the two contradicting.
lines.append("Strap clock: OK (from record timestamps, not the alarm readback)")
}
}
if let sent = d.object(forKey: "alarm.lastArmSentEpoch") as? Int {
Expand All @@ -413,14 +415,23 @@ enum DebugDataDiagnostics {
}
lines.append(line)
if let reported = d.object(forKey: "alarm.lastReportedEpoch") as? Int {
let mismatch = abs(reported - sent) > 120
var rline = "Strap reports: \(alarmStamp(reported))"
+ (mismatch ? " ⚠️ MISMATCH — strap didn't accept the time" : " ✓ matches")
// #1706: only judge when both halves are known to be the SAME strap, otherwise this
// blames a device that was never asked.
let verdict = AlarmReadback.verdict(
sentEpoch: sent,
reportedEpoch: reported,
sentDeviceId: d.string(forKey: "alarm.lastArmDeviceId"),
reportedDeviceId: d.string(forKey: "alarm.lastReportedDeviceId"))
var rline = "Strap reports: \(alarmStamp(reported))" + AlarmReadback.suffix(verdict)
// #34: consecutive rejections — a persistent refusal (vs a one-off) points at a strap whose
// alarm register needs a reset, and is what SmartAlarmView warns the user about at ≥2.
let streak = d.integer(forKey: "alarm.rejectStreak")
if streak >= 2 { rline += " · \(streak) in a row (register likely needs a reset, #34)" }
lines.append(rline)
// The bytes the epoch was decoded from: what tells a stored stale alarm from a misdecode.
if let raw = d.string(forKey: "alarm.lastReportedRaw"), !raw.isEmpty {
lines.append("Readback frame: \(raw)")
}
} else {
lines.append("Strap reports: (no readback)")
}
Expand Down
Loading
Loading