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,38 @@
import Foundation

/// #324 bad-clock strap diagnostics: pure formatters for the Backfiller's strap-log lines about a strap
/// whose RTC reset to a wrong base (future- or far-past-dated banking). Kept here (not in the app target)
/// so the formatting is `swift test`-able on Linux and mirrored byte-for-byte by the Kotlin twin. No state,
/// no `Date()` inside — callers inject `now` so the output is deterministic and testable.
public enum BadClockDiagnostics {

/// UTC `yyyy-MM-dd` for a unix-seconds timestamp — the human-readable day a dropped record CLAIMED,
/// which on a bad-clock strap is the wrong base its RTC jumped to.
public static func isoDay(_ unix: Int) -> String {
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
f.timeZone = TimeZone(identifier: "UTC")
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
return f.string(from: Date(timeIntervalSince1970: TimeInterval(unix)))
}

/// Signed hour offset of `unix` from `now`, worded for a log: "26445h ahead" / "512h behind" / "~now"
/// (within an hour). The magnitude is what tells a future-clock strap (#928) from a stale-clock one.
public static func hoursOffset(_ unix: Int, now: Int) -> String {
let h = (unix - now) / 3600
if h > 0 { return "\(h)h ahead" }
if h < 0 { return "\(-h)h behind" }
return "~now"
}

/// The parenthetical span clause appended to the dropped-record log: " (dated 2028-06-24 -> 2029-07-15,
/// 26445h ahead)". Empty string when nothing was dropped (both nil), so the base sentence reads normally.
/// When oldest == newest a single date is shown. `now` words the offset of the NEWEST (frontier) record.
public static func droppedSpanClause(oldest: Int?, newest: Int?, now: Int) -> String {
guard let o = oldest, let n = newest else { return "" }
let offset = hoursOffset(n, now: now)
if o == n { return " (dated \(isoDay(n)), \(offset))" }
return " (dated \(isoDay(o)) -> \(isoDay(n)), \(offset))"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,20 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame],
sessionOldestUnix: sessionOldestUnix,
sessionNewestUnix: sessionNewestUnix) else {
droppedImplausible += 1
// #324: track the epoch SPAN of the dropped (bad-clock) records — the strap's OWN dated value,
// so the Backfiller can log whether the whole poisoned range is future-dated or mixed.
droppedOldest = min(droppedOldest ?? candidate, candidate)
droppedNewest = max(droppedNewest ?? candidate, candidate)
return nil
}
return candidate
}
// #547: how many records this chunk dropped for an implausible ts. Surfaced to the Backfiller via
// `Streams.droppedImplausible` so the strap log can show a bad-clock strap (observability only).
var droppedImplausible = 0
// #324: oldest/newest own-timestamp among the dropped records (the poisoned-range epoch span).
var droppedOldest: Int? = nil
var droppedNewest: Int? = nil
var out = Streams()
// v26 optical-PPG records (issue #156): no measured HR/motion, just the 24 Hz waveform. Collect
// (corrected-wall ts, samples) here and derive a per-second HR after the loop (PpgHr.derivePpgHr),
Expand Down Expand Up @@ -231,8 +238,18 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame],
// EVENT carries the strap RTC's real-unix seconds. Correct for a grossly-stale RTC
// (FIX #72); a normal strap is unchanged (offset < threshold). #547 gate: skip the event
// when `correctedWall` rejects an implausible ts so no future/far-past event is banked.
guard let rawTs = p["event_timestamp"]?.intValue, let ts = correctedWall(rawTs) else { continue }
guard let rawTs = p["event_timestamp"]?.intValue else { continue }
let kind = p["event"]?.stringValue ?? ""
guard let ts = correctedWall(rawTs) else {
// #324: the #547 gate just dropped this event for an implausible ts. If it's an RTC-STATE
// event (RTC_LOST / BOOT / SET_RTC), that IS the ground truth that the clock reset — capture
// (kind, rawTs) for the strap log before discarding, so a future-dated strap's cause isn't
// silently swallowed by the very gate the bad clock trips.
if DroppedRtcEvent.isRtcStateKind(kind) {
out.droppedRtcEvents.append(DroppedRtcEvent(kind: kind, rawTs: rawTs))
}
continue
}
if kind.hasPrefix("BATTERY_LEVEL") { appendBattery(&out, ts: ts, p: p) } // "BATTERY_LEVEL(3)"
var payload = p
payload.removeValue(forKey: "event")
Expand All @@ -249,5 +266,7 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame],
// records (the WHOOP 4 / v18-only common case), so this is a no-op cost there.
out.ppgHr = PpgHr.derivePpgHr(records: ppgRecords)
out.droppedImplausible = droppedImplausible // #547 diag count (not persisted, not encoded)
out.droppedImplausibleOldestTs = droppedOldest // #324 poisoned-range epoch span (diag only)
out.droppedImplausibleNewestTs = droppedNewest
return out
}
26 changes: 26 additions & 0 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,17 @@ public struct Streams: Equatable, Codable {
/// and NOT round-tripped through Codable (excluded from `CodingKeys`) — it is a transient observability
/// count the Backfiller surfaces to the strap log. Defaults to 0 so it never affects golden fixtures.
public var droppedImplausible: Int = 0
/// #324 diagnostic: the OLDEST / NEWEST own-timestamp (unix seconds, the strap's OWN dated value) among
/// records DROPPED this chunk for an implausible ts. Lets the Backfiller log the epoch SPAN of a bad-clock
/// strap's poisoned range - so a future-dated strap shows "2028-06-24 -> 2029-07" and we can tell a
/// WHOLE-range-future strap (safe to fast-forward-discard) from one mixed with real data. Transient (not
/// in CodingKeys), nil when nothing dropped. Defaults keep golden fixtures byte-identical.
public var droppedImplausibleOldestTs: Int? = nil
public var droppedImplausibleNewestTs: Int? = nil
/// #324 diagnostic: strap RTC-STATE events (RTC_LOST / BOOT / SET_RTC) DROPPED for an implausible own-ts.
/// The #547 gate discards them like any bad-ts record, but these are the GROUND TRUTH that the clock reset
/// - so we capture (kind, rawTs) for the strap log before dropping. Transient, empty by default, not encoded.
public var droppedRtcEvents: [DroppedRtcEvent] = []
public init(hr: [HRSample] = [], rr: [RRInterval] = [],
spo2: [SpO2Sample] = [], skinTemp: [SkinTempSample] = [],
resp: [RespSample] = [], gravity: [GravitySample] = [],
Expand Down Expand Up @@ -272,6 +283,21 @@ public struct Streams: Equatable, Codable {

extension Streams { public static let empty = Streams() }

/// #324 diagnostic record: a strap RTC-STATE event (RTC_LOST / BOOT / SET_RTC) that the #547 plausibility
/// gate dropped for an implausible own-timestamp. `rawTs` is the event's OWN dated value (unix seconds) - on
/// a future-dated strap this is the bad epoch the RTC jumped to, the single most useful signal for #324.
public struct DroppedRtcEvent: Equatable, Codable, Sendable {
public let kind: String
public let rawTs: Int
public init(kind: String, rawTs: Int) { self.kind = kind; self.rawTs = rawTs }

/// The RTC-state event kinds worth capturing when dropped. Kinds are formatted "NAME(n)" (e.g.
/// "RTC_LOST(13)"), matched by prefix - "BOOT" covers both BOOT and BOOT_REPORT.
public static func isRtcStateKind(_ kind: String) -> Bool {
kind.hasPrefix("RTC_LOST") || kind.hasPrefix("SET_RTC") || kind.hasPrefix("BOOT")
}
}

/// Map a device-epoch timestamp to wall-clock unix seconds via a pure linear offset.
/// Assumes strap clock and wall clock tick at the same rate (no skew/drift). Port of _to_wall.
private func toWall(_ deviceTs: Int?, _ deviceClockRef: Int, _ wallClockRef: Int) -> Int? {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import XCTest
@testable import WhoopProtocol

/// #324 — the pure strap-log formatters for a bad-clock strap. Deterministic (now is injected), so the
/// Kotlin twin (`BadClockDiagnostics` in WhoopBleClient) must produce byte-identical strings.
final class BadClockDiagnosticsTests: XCTestCase {

func testIsoDayIsUTC() {
// 1_735_084_800 == 2024-12-25 00:00 UTC (the #547 tests' documented badYule anchor).
XCTAssertEqual(BadClockDiagnostics.isoDay(1_735_084_800), "2024-12-25")
}

func testHoursOffsetWording() {
let now = 1_783_843_824
XCTAssertEqual(BadClockDiagnostics.hoursOffset(now + 26_445 * 3600, now: now), "26445h ahead")
XCTAssertEqual(BadClockDiagnostics.hoursOffset(now - 512 * 3600, now: now), "512h behind")
XCTAssertEqual(BadClockDiagnostics.hoursOffset(now + 60, now: now), "~now") // within an hour
}

func testDroppedSpanClause() {
let now = 1_783_843_824
// Nothing dropped → empty clause so the base sentence reads normally.
XCTAssertEqual(BadClockDiagnostics.droppedSpanClause(oldest: nil, newest: nil, now: now), "")
// A range → "oldest -> newest, offset(newest)". Build the expected from the same pinned primitives
// (isoDay/hoursOffset are covered above) so the test pins the ASSEMBLY, not hand-computed dates.
let o = 1_845_000_000, n = 1_876_000_000
let expected = " (dated \(BadClockDiagnostics.isoDay(o)) -> \(BadClockDiagnostics.isoDay(n)), "
+ "\(BadClockDiagnostics.hoursOffset(n, now: now)))"
XCTAssertEqual(BadClockDiagnostics.droppedSpanClause(oldest: o, newest: n, now: now), expected)
XCTAssertTrue(expected.contains("->"))
// oldest == newest → single date, no arrow.
let single = BadClockDiagnostics.droppedSpanClause(oldest: n, newest: n, now: now)
XCTAssertFalse(single.contains("->"), single)
XCTAssertEqual(single, " (dated \(BadClockDiagnostics.isoDay(n)), \(BadClockDiagnostics.hoursOffset(n, now: now)))")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ final class HistoricalTimestampGateTests: XCTestCase {
)
}

/// Build a synthetic EVENT ParsedFrame carrying `event` (kind, "NAME(n)") + `event_timestamp`.
private func eventFrame(kind: String, unix: Int) -> ParsedFrame {
ParsedFrame(
ok: true, typeName: "EVENT", seq: 48, cmdName: nil, crcOK: true,
lenBytes: 0, rawHex: "", fields: [],
parsed: ["event": .string(kind), "event_timestamp": .int(unix)]
)
}

// MARK: - bounds parity

func testBoundsMatchAndroid() {
Expand Down Expand Up @@ -155,6 +164,61 @@ final class HistoricalTimestampGateTests: XCTestCase {
XCTAssertEqual(st.droppedImplausible, 0)
}

// MARK: - #324 bad-clock diagnostics capture (epoch span + dropped RTC-state events)

func testCapturesDroppedEpochSpan() {
// Three dropped bad-clock records — the returned span must bracket their OWN dated values so the
// strap log can show whether the whole poisoned range is future-dated.
let frames = [histFrame(unix: 1_250_000_000), histFrame(unix: FAR_FUTURE), histFrame(unix: 1_827_642_881)]
let st = extractHistoricalStreams(frames, deviceClockRef: wallNow, wallClockRef: wallNow)
XCTAssertEqual(st.droppedImplausible, 3)
XCTAssertEqual(st.droppedImplausibleOldestTs, 1_250_000_000)
XCTAssertEqual(st.droppedImplausibleNewestTs, FAR_FUTURE)
}

func testNoDropLeavesSpanNil() {
let st = extractHistoricalStreams([histFrame(unix: wallNow)], deviceClockRef: wallNow, wallClockRef: wallNow)
XCTAssertNil(st.droppedImplausibleOldestTs)
XCTAssertNil(st.droppedImplausibleNewestTs)
XCTAssertTrue(st.droppedRtcEvents.isEmpty)
}

func testCapturesDroppedRtcStateEvent() {
// A future-dated RTC_LOST: the #547 gate still DROPS it from persistence (bad ts), but #324 captures
// it (kind + rawTs) as the ground-truth signal that the clock reset.
let st = extractHistoricalStreams([eventFrame(kind: "RTC_LOST(13)", unix: FAR_FUTURE)],
deviceClockRef: wallNow, wallClockRef: wallNow)
XCTAssertEqual(st.events.count, 0, "the future-dated event is still dropped from persistence")
XCTAssertEqual(st.droppedRtcEvents.count, 1)
XCTAssertEqual(st.droppedRtcEvents.first?.kind, "RTC_LOST(13)")
XCTAssertEqual(st.droppedRtcEvents.first?.rawTs, FAR_FUTURE)
}

func testPlausibleRtcEventKeptNotCaptured() {
// A plausible RTC_LOST is a normal kept event — never captured as "dropped".
let st = extractHistoricalStreams([eventFrame(kind: "RTC_LOST(13)", unix: wallNow)],
deviceClockRef: wallNow, wallClockRef: wallNow)
XCTAssertEqual(st.droppedRtcEvents.count, 0)
XCTAssertEqual(st.events.count, 1)
}

func testNonRtcDroppedEventNotCaptured() {
// A future-dated NON-rtc event (WRIST_ON) is dropped + counted, but NOT captured (only RTC-state kinds).
let st = extractHistoricalStreams([eventFrame(kind: "WRIST_ON(9)", unix: FAR_FUTURE)],
deviceClockRef: wallNow, wallClockRef: wallNow)
XCTAssertEqual(st.droppedRtcEvents.count, 0)
XCTAssertEqual(st.droppedImplausible, 1, "still counted as a dropped implausible record")
}

func testRtcStateKindMatch() {
XCTAssertTrue(DroppedRtcEvent.isRtcStateKind("RTC_LOST(13)"))
XCTAssertTrue(DroppedRtcEvent.isRtcStateKind("SET_RTC(16)"))
XCTAssertTrue(DroppedRtcEvent.isRtcStateKind("BOOT(15)"))
XCTAssertTrue(DroppedRtcEvent.isRtcStateKind("BOOT_REPORT(30)"))
XCTAssertFalse(DroppedRtcEvent.isRtcStateKind("WRIST_ON(9)"))
XCTAssertFalse(DroppedRtcEvent.isRtcStateKind("BATTERY_LEVEL(3)"))
}

func testIdentityRefTrustsRealRawTimestamp() {
// The RawHistoryArchive.replay / no-correlation path passes wallClockRef == 0 (identity sentinel):
// the future bound must fall back to the LIVE wall clock, NOT 0, or every real record is rejected.
Expand Down
40 changes: 39 additions & 1 deletion Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,17 @@ public final class BLEManager: NSObject, ObservableObject {
let currentTrim = backfiller?.lastAckedTrim
let trimAdvanced = currentTrim != nil && currentTrim != lastSessionEndTrim
lastSessionEndTrim = currentTrim
// #324/#928: a strap whose newest banked record is dated in the FUTURE (RTC relatched ahead) is
// future-dated regardless of HOW this offload ended — a deep future-dated backlog TIMES OUT as
// readily as it completes (the reporter's #324 session ended on timeout, not HISTORY_COMPLETE).
// Compute the banner once so BOTH outcomes name the real cause instead of "strap went quiet".
let wallNowForBanner = Int(Date().timeIntervalSince1970)
let futureClockBanner = BLEManager.futureDatedStrapBanner(
strapNewestTs: strapNewestTs, wallNowUnix: wallNowForBanner)
if futureClockBanner != nil {
let aheadH = ((strapNewestTs ?? 0) - wallNowForBanner) / 3600
log("Backfill: the strap's newest banked record is \(aheadH)h AHEAD of the wall clock (#324/#928) - clock set in the future; showing the future-clock banner and importing nothing from this range.")
}
// Honest sync outcome for a cloud-free user (mirrors Android exitBackfilling, ed6a31d):
// HISTORY_COMPLETE stamps lastSyncedAt + clears any error; the idle-watchdog timeout surfaces
// a non-silent error. A disconnect mid-sync bypasses this path (didDisconnectPeripheral resets
Expand Down Expand Up @@ -1729,6 +1740,12 @@ public final class BLEManager: NSObject, ObservableObject {
state.lastSyncError = sustainedEmpty
? "Synced, but your strap had no stored history to hand over - only its diagnostic output. This usually means its clock has lost sync, so it isn't saving data to flash. Fully charge it to 100%, then reconnect, and it should start banking again."
: nil
} else if let futureBanner = futureClockBanner {
// #324/#928: the strap banked records but its newest is dated implausibly in the FUTURE
// (RTC relatched ahead). #773 drops the future-dated samples so nothing is misfiled, but
// the "banked something" path above would otherwise report a clean sync and leave the
// user with no data and no reason. Name the real cause + the strap-side remedy.
state.lastSyncError = futureBanner
} else {
state.lastSyncError = nil
}
Expand Down Expand Up @@ -1771,7 +1788,11 @@ public final class BLEManager: NSObject, ObservableObject {
state.lastSyncError = nil
}
} else {
state.lastSyncError = "Sync interrupted - the strap went quiet. It will retry on the next sync."
// #324/#928: a future-dated strap TIMES OUT on its deep future-dated backlog — that's not
// "the strap went quiet", it's the clock being set ahead. Prefer the honest future-clock
// banner so the reporter's timeout case (the common one) names the real cause + remedy.
state.lastSyncError = futureClockBanner
?? "Sync interrupted - the strap went quiet. It will retry on the next sync."
}
}
checkStrapLiveness() // safety-net: strap ahead of us AND our frontier frozen ⇒ stuck?
Expand Down Expand Up @@ -1930,6 +1951,23 @@ public final class BLEManager: NSObject, ObservableObject {
return (bankedSensorRecords, bankedNothing)
}

/// #324/#928: the post-sync banner for a strap whose clock is set in the FUTURE. Unlike the
/// "clock lost / not banking" case (`bankedNothing`), this strap DOES bank records every pass — but
/// its RTC relatched to a future base, so every banked timestamp reads ahead of the wall clock and
/// NOOP won't import them (importing would misfile the night days or years ahead). The existing
/// clock-lost banner is gated on empty syncs and never fires here, so this failure mode was silent
/// (#324). Returns the user-facing string when the strap-reported newest record is future-dated
/// beyond the 48 h skew allowance (`BackfillContinuation.isFutureDatedNewest`), else nil. Pure and
/// deterministic — one detection is decisive (nothing legitimate banks 48 h ahead), so no streak
/// gate is needed. Mirrors Android `futureDatedStrapBanner`.
nonisolated static func futureDatedStrapBanner(strapNewestTs: Int?, wallNowUnix: Int) -> String? {
guard BackfillContinuation.isFutureDatedNewest(strapNewestTs, wallNowUnix: wallNowUnix) else { return nil }
return "Synced, but your strap's clock is set in the future - its banked history is dated ahead of "
+ "today, so NOOP can't trust those timestamps and didn't import them (importing them would "
+ "misfile your data days or years ahead). Fully charge the strap to 100% and power-cycle it so "
+ "its clock re-syncs, then reconnect."
}

/// Start (or restart) the periodic backfill timer. Each tick re-runs the type-47 historical
/// offload while connected+bonded and not already backfilling — the primary metric sync.
// MARK: - Keep-alive (always-ping + liveness watchdog)
Expand Down
Loading
Loading