From a8d55a346184bbb49d5b047a2a8cc518f0ba09e6 Mon Sep 17 00:00:00 2001 From: nasser <6360485+eledroos@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:34:07 -0400 Subject: [PATCH] Fix biometric logging on bands with empty GET_CLOCK / long-dormant RTC Two related fixes for WHOOP 4.0 units whose firmware returns an empty GET_CLOCK response and/or whose RTC was lost after long dormancy. On such a unit the strap suppresses type-47 biometric logging and the live HR pipeline never persists, so the app shows live HR but records no history. 1. Clock correlation from the realtime stream (BLE/BLEManager.swift). GET_CLOCK can return an empty payload on some firmware, so ClockCorrelation never lands and the live Collector (which gates persistence on clockRef) buffers HR forever / mis-dates it to ~1971. When a REALTIME_DATA frame arrives and clockRef is still nil, derive clockRef from that frame's own device timestamp paired with wall-now. 2. Stale-strap clock re-latch (BLE/BLEManager.swift, BLE/Commands.swift). A strap whose RTC was lost during long dormancy stops logging type-47 biometrics; the documented recovery is SET_CLOCK + REBOOT_STRAP to latch (docs/specs/2026-05-24-whoop-protocol-complete.md). Adds WhoopCommand.rebootStrap (29) and a one-shot, on-connect recovery that fires only when the data-range looks stale (newest record < 2025-01-01), so healthy bands are never touched. Reboot is non-destructive (not a wipe). Verified on a band dormant ~21 months: full biometric logging resumed. Also gitignore server/.env (it holds the API key + DB password). --- .gitignore | 3 +++ ios/OpenWhoop/BLE/BLEManager.swift | 39 +++++++++++++++++++++++++++++- ios/OpenWhoop/BLE/Commands.swift | 6 +++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 30f5c64..7344716 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ ios/build/ # Mac-side device-soak captures (gitignored; not the committed hist_biometric.bin fixture) fixtures/soak_*.bin + +# Local server secrets — never commit +server/.env diff --git a/ios/OpenWhoop/BLE/BLEManager.swift b/ios/OpenWhoop/BLE/BLEManager.swift index f44e1e5..30f957a 100644 --- a/ios/OpenWhoop/BLE/BLEManager.swift +++ b/ios/OpenWhoop/BLE/BLEManager.swift @@ -95,6 +95,9 @@ public final class BLEManager: NSObject, ObservableObject { private var didBond = false private var clockRequested = false private var intentionalDisconnect = false + /// RECOVERY: fire the SET_CLOCK + REBOOT_STRAP latch at most once per app launch. Deliberately + /// NOT reset on disconnect, so the post-reboot reconnect does not reboot again (no boot loop). + private var didRebootForLatch = false /// Stable device id; matches the server's existing device for sync parity. Overridable. let deviceId: String @@ -755,6 +758,26 @@ extension BLEManager: CBPeripheralDelegate { // Backfiller's per-chunk insert→ack. They run from exitBackfilling() once the offload drains. startUploadTimer() // keep the server current during the live session startBackfillTimer() // re-offload the type-47 store every backfillIntervalSeconds + + // RECOVERY (stale-strap re-arm): a strap whose RTC was lost during long dormancy stops + // logging biometrics and its data-range stays frozen in the past. Documented fix: SET_CLOCK + // then REBOOT_STRAP to LATCH the clock (2026-05-24-whoop-protocol-complete.md §0-bis). Fire + // ONCE per launch, ~4s after connect (so GET_DATA_RANGE has populated strapNewestTs), and + // only when the strap looks stale (newest record before 2025, or unknown). Reboot is + // non-destructive; the link drops and we auto-reconnect with a freshly-latched clock. + if !didRebootForLatch { + DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { [weak self] in + guard let self, !self.didRebootForLatch else { return } + let stale = (self.strapNewestTs ?? 0) < 1_735_689_600 // < 2025-01-01 (nil ⇒ stale) + guard stale else { return } + self.didRebootForLatch = true + self.log("Recovery: strap stale (newest=\(self.strapNewestTs.map(String.init) ?? "nil")) — SET_CLOCK + REBOOT_STRAP to latch") + self.send(.setClock, payload: BLEManager.setClockPayload()) + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.send(.rebootStrap, payload: [0x00]) + } + } + } } /// SET_CLOCK(10) payload = the strap's 8-byte form: [seconds u32 LE][subseconds @@ -805,7 +828,8 @@ extension BLEManager: CBPeripheralDelegate { // unblocks both the Collector (live path) and the Backfiller (chunk decoding). if clockRef == nil { let parsed = parseFrame(frame) - if let ref = ClockCorrelation.clockRef(from: parsed, wall: Int(Date().timeIntervalSince1970)) { + let wallNow = Int(Date().timeIntervalSince1970) + if let ref = ClockCorrelation.clockRef(from: parsed, wall: wallNow) { clockRef = ref collector?.clockRef = ref // unblocks buffered persistence backfiller?.clockRef = ref // unblocks historical chunk decode @@ -817,6 +841,19 @@ extension BLEManager: CBPeripheralDelegate { log("Clock drift detected — issuing SET_CLOCK") send(.setClock, payload: BLEManager.setClockPayload()) } + } else if parsed.ok, parsed.crcOK != false, + parsed.typeName == "REALTIME_DATA", + let deviceTs = parsed.parsed["timestamp"]?.intValue { + // Firmware that answers GET_CLOCK with an EMPTY payload gives no "clock" field to + // correlate, so live HR maps to ~1971. Correlate from the realtime stream's own + // device-monotonic counter instead: pair its "timestamp" with wall-now. No drift + // check (device is a monotonic counter, not the RTC; SET_CLOCK already ran in the + // connect handshake). + let ref = ClockRef(device: deviceTs, wall: wallNow) + clockRef = ref + collector?.clockRef = ref + backfiller?.clockRef = ref + log("Clock correlated from realtime stream (GET_CLOCK empty): device=\(ref.device) wall=\(ref.wall)") } } if backfilling { diff --git a/ios/OpenWhoop/BLE/Commands.swift b/ios/OpenWhoop/BLE/Commands.swift index 3087c1b..c5e280d 100644 --- a/ios/OpenWhoop/BLE/Commands.swift +++ b/ios/OpenWhoop/BLE/Commands.swift @@ -40,6 +40,11 @@ public enum WhoopCommand: UInt8, CaseIterable { /// biometric retention + disconnected operation). Safe/reversible (just a data stream). Verified /// on-device: 2.1/s → 0/s, and it persists across reconnect. case sendR10R11Realtime = 63 + /// RECOVERY ONLY: reboot the strap to LATCH a freshly-set clock. A strap whose RTC was lost + /// during long dormancy stops logging biometrics; the documented fix is SET_CLOCK + REBOOT_STRAP + /// to re-arm it (docs/specs/2026-05-24-whoop-protocol-complete.md §0-bis). Non-destructive — a + /// reboot, NOT a wipe (that would be FORCE_TRIM, deliberately not included). + case rebootStrap = 29 // MARK: Alarm commands (confirmed for interoperability) /// Arm the strap's FIRMWARE alarm for a specific UTC time. The strap will buzz at that time @@ -80,6 +85,7 @@ public enum WhoopCommand: UInt8, CaseIterable { case .runHapticsPattern: return "Run Haptics Pattern" case .stopHaptics: return "Stop Haptics" case .sendR10R11Realtime: return "R10/R11 Realtime (raw stream)" + case .rebootStrap: return "Reboot Strap (recovery)" case .setAlarmTime: return "Set Alarm Time" case .getAlarmTime: return "Get Alarm Time" case .runAlarm: return "Run Alarm"