diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift index 5b229c8909..d2c3b079a2 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift @@ -210,32 +210,26 @@ public enum ConnectionReadout { return nil } - /// #987/#261: the "clock latched" readout value. "yes" once EITHER signal lands with a plausible - /// (post-1972) timestamp: a GET_CLOCK correlation (`deviceClockUnix`, the WHOOP4 path), or a - /// GET_DATA_RANGE reply's newest banked record (`strapNewestUnix`, the fallback). "no (RTC reads - /// 1970/71)" when whichever signal landed reads epoch-era; "no (waiting for the strap clock)" before - /// either replies. - /// - /// A WHOOP 5/MG's GET_CLOCK reply rides the puffin notify channel and never reaches the WHOOP4-only - /// correlation path that sets `deviceClockUnix` (see `BLEManager`'s connect-handshake comment) — its - /// records carry absolute timestamps, so it never NEEDS that correlation to decode history. Without - /// this fallback the row read "no (waiting for the strap clock)" forever on every 5/MG, even a fully - /// working one, because the one signal it checked structurally never populates for that family. The - /// data-range reply is an equal-weight proof the strap answered with a working clock, not a downgrade - /// — `rtcWarning` below already trusts it the same way. - public static func clockLatchedLabel(deviceClockUnix: Int?, strapNewestUnix: Int? = nil) -> String { - if let d = deviceClockUnix { - return d < ConnectionTrace.rtcEpochCeilingUnix ? "no (RTC reads 1970/71)" : "yes" + /// The current link's clock evidence, including future-dated and stale records. A post-1972 + /// timestamp alone is not proof that the clock is right (#2091). + /// Kotlin twin: `ConnectionReadout.clockStatusLabel`. + public static func clockStatusLabel(deviceClockUnix: Int?, strapNewestUnix: Int?, nowUnix: Int) -> String { + guard deviceClockUnix != nil || strapNewestUnix != nil else { return "waiting for clock evidence" } + let clock = deviceClockUnix.map { unix in + ConnectionTrace.clockVerdict(aheadSeconds: unix - nowUnix, newestUnix: unix, + futureToleranceSeconds: 120, + behindToleranceSeconds: ConnectionTrace.behindToleranceDefault) } - // #1823: this branch has NOT read a clock. It is reached when no clock correlation exists - which - // is every 5/MG, whose GET_CLOCK reply rides the puffin notify chars and never touches the WHOOP4 - // correlation path - so the only evidence is how the strap DATED its banked records. Saying "RTC - // reads 1970/71" there claimed a reading we never took, in the one readout a reporter quotes when - // asking why nothing syncs. Report the evidence we actually have. - if let n = strapNewestUnix { - return n < ConnectionTrace.rtcEpochCeilingUnix ? "no (records dated 1970/71)" : "yes" + let record = strapNewestUnix.map { unix in + ConnectionTrace.clockVerdict(aheadSeconds: unix - nowUnix, newestUnix: unix, + futureToleranceSeconds: 120, + behindToleranceSeconds: ConnectionTrace.behindToleranceDefault) } - return "no (waiting for the strap clock)" + if clock?.contains("FUTURE-DATED") == true || record?.contains("FUTURE-DATED") == true { return "future-dated" } + if clock?.contains("RTC-EPOCH") == true { return "RTC reads 1970/71" } + if record?.contains("RTC-EPOCH") == true { return "records dated 1970/71" } + if record?.contains("CLOCK-WARNING") == true { return "record is over 48h old" } + return strapNewestUnix != nil ? "records dated normally" : "clock OK" } /// #1818: at or above this charge the "charge it" remedy is already satisfied, so repeating it is diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift index 0f72937683..e10b104374 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift @@ -191,33 +191,6 @@ final class ConnectionReadoutTests: XCTestCase { XCTAssertNil(ConnectionReadout.clockCorrelatedDevice(logLines: ["connect up"])) } - func testClockLatchedLabel() { - XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: 1_782_475_600), "yes") - XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: 40_000_000), "no (RTC reads 1970/71)") - XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil), "no (waiting for the strap clock)") - } - - // #261: a WHOOP 5/MG never populates deviceClockUnix (its GET_CLOCK reply rides the puffin channel, - // never the WHOOP4 correlation path) — the data-range fallback is what keeps the row from reading - // "waiting" forever on a strap that's actually fine. - func testClockLatchedLabelFallsBackToStrapNewestForFiveMG() { - XCTAssertEqual( - ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: 1_782_475_600), - "yes") - // #1823: no clock was READ on this path - it is the 5/MG fallback, where the only evidence is - // how the strap dated its records. The wording must not claim a clock reading. - XCTAssertEqual( - ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: 40_000_000), - "no (records dated 1970/71)") - XCTAssertEqual( - ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: nil), - "no (waiting for the strap clock)") - // deviceClockUnix wins when BOTH signals are present (the WHOOP4 correlation is the more direct one). - XCTAssertEqual( - ConnectionReadout.clockLatchedLabel(deviceClockUnix: 1_782_475_600, strapNewestUnix: 40_000_000), - "yes") - } - func testRtcWarningFiresOnEpochEraClockOrNewest() { XCTAssertNotNil(ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil)) XCTAssertNotNil(ConnectionReadout.rtcWarning(deviceClockUnix: nil, strapNewestUnix: 30_000_000)) @@ -226,6 +199,24 @@ final class ConnectionReadoutTests: XCTestCase { "no signal seen yet must not fabricate a fault") } + func testClockStatusRejectsFutureDatedRecords() { + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, + strapNewestUnix: nil, + nowUnix: 1_800_000_000), "waiting for clock evidence") + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: 1_799_999_990, + strapNewestUnix: nil, + nowUnix: 1_800_000_000), "clock OK") + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, + strapNewestUnix: 1_800_003_600, + nowUnix: 1_800_000_000), "future-dated") + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, + strapNewestUnix: 40_000_000, + nowUnix: 1_800_000_000), "records dated 1970/71") + XCTAssertEqual(ConnectionReadout.clockStatusLabel(deviceClockUnix: nil, + strapNewestUnix: 1_799_999_990, + nowUnix: 1_800_000_000), "records dated normally") + } + /// #1818: the remedy must track the battery. A charged strap told to "charge to 100%" is the bug /// the field report hit - the user had already done it, twice. func testRtcWarningRemedyTracksBattery() { diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index c7ed8d96a6..59b2b14e82 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -102,14 +102,15 @@ private struct DevicesContent: View { guard live.connected else { return nil } let deviceClock = ConnectionReadout.clockCorrelatedDevice(logLines: live.log) guard deviceClock != nil || live.strapRange != nil || live.lastFrameAtUnix != nil else { return nil } - let latched = ConnectionReadout.clockLatchedLabel(deviceClockUnix: deviceClock, - strapNewestUnix: live.strapRange?.newestUnix) + let latched = ConnectionReadout.clockStatusLabel(deviceClockUnix: deviceClock, + strapNewestUnix: live.strapRange?.newestUnix, + nowUnix: Int(Date().timeIntervalSince1970)) let frame = ConnectionReadout.lastFrameLabel(lastFrameUnix: live.lastFrameAtUnix, nowUnix: Int(Date().timeIntervalSince1970)) let warning = ConnectionReadout.rtcWarning(deviceClockUnix: deviceClock, strapNewestUnix: live.strapRange?.newestUnix, batteryPct: live.batterySamples.last?.soc) - return (String(localized: "Clock latched: \(latched) · last frame \(frame)"), warning) + return (String(localized: "Clock") + ": \(latched) · last frame \(frame)", warning) } /// #802: same shape and copy as `LiveView.reconnectGuideBanner`. Duplicated rather than hoisted @@ -760,7 +761,7 @@ private struct DeviceCard: View { var liveFirmware: String? = nil /// The active+connected strap's observed banked-history record layout (`hist_version`). var liveHistoryLayout: Int? = nil - /// #987: the active+connected strap's clock-state line ("Clock latched: yes · last frame 12s ago"), + /// #987/#2091: the active+connected strap's clock-state line ("Clock: records dated normally · last frame 12s ago"), /// nil for every other card. Built by the parent off the same pure ConnectionReadout parsers the /// Test Centre Connection panel binds, so the two readouts can never disagree. var liveClockLine: String? = nil diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index c5adbb48b6..591666584d 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -1088,9 +1088,10 @@ private struct ConnectionReadoutPanel: View { ReadoutRow(label: String(localized: "Rows drained (session)"), value: sessionRows.map(String.init) ?? String(localized: "no offload yet")) ReadoutRow(label: String(localized: "Rows drained (all time)"), value: String(allTimeRows)) - ReadoutRow(label: String(localized: "Clock latched"), - value: ConnectionReadout.clockLatchedLabel(deviceClockUnix: deviceClock, - strapNewestUnix: live.strapRange?.newestUnix)) + ReadoutRow(label: String(localized: "Clock"), + value: ConnectionReadout.clockStatusLabel(deviceClockUnix: deviceClock, + strapNewestUnix: live.strapRange?.newestUnix, + nowUnix: now)) ReadoutRow(label: String(localized: "Last frame"), value: ConnectionReadout.lastFrameLabel(lastFrameUnix: live.lastFrameAtUnix, nowUnix: now)) if let rtcWarning { diff --git a/Tools/parity_ledger_baseline.json b/Tools/parity_ledger_baseline.json index b47afe72e0..99c3e95174 100644 --- a/Tools/parity_ledger_baseline.json +++ b/Tools/parity_ledger_baseline.json @@ -18,7 +18,7 @@ {"rule": "test-only-callsite", "scope": "Packages/StrandImport", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/StrandImport; no wildcard matching.", "count": 14, "identities_sha256": "1acdc71178e0a555f000c42980345e8c38495f9e3806a866f31b260865d3ac98"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopProtocol; no wildcard matching.", "count": 29, "identities_sha256": "a13306c37eb1a9272b8666d09700d5120fb5fe0e629f3aaaeef649357f7f8b45"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopStore", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopStore; no wildcard matching.", "count": 37, "identities_sha256": "2a62c1ece1c541b662dd407f0668d9a02782bb4d21f9bb181518272ff12f0e40"}, - {"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 65, "identities_sha256": "fda58e5bb0fe07a9fa773d8e5f8961d483e8b177f1529b6e9dc3ac26df7c31cf"}, + {"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 60, "identities_sha256": "56788639820a818ea805a5fa58ead50867b1c269bf5d3974ba3724704c38dde6"}, {"rule": "test-only-callsite", "scope": "android/data", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/data; no wildcard matching.", "count": 13, "identities_sha256": "6abaae19303cadccbe259d7db320850e269433472b28c84a1bfce7d32d54ce5f"}, {"rule": "test-only-callsite", "scope": "android/ingest", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/ingest; no wildcard matching.", "count": 12, "identities_sha256": "d4f45d66087768eab133b918af6658b06ec1d91305e38fb045f6ae1629c94678"}, {"rule": "test-only-callsite", "scope": "android/oura", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/oura; no wildcard matching.", "count": 4, "identities_sha256": "9beaa9034af0a1021b9a9f43739971934f260147e0a9a5c462468548a7fd577d"}, diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 9a94e3543b..b8abfbad09 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,15 +19,15 @@ }, "authority": { "files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"}, - "functions": {"count": 4571, "sha256": "ea0267f6ff16718fb08b7526558f27e525ed4fbebf6bbaf8ae199296094cc3a1"}, + "functions": {"count": 4571, "sha256": "e1f7ab9c9bcd01d95b11f209fd4cf0ec0af6b83f1b57125947a0b2d00a397fd3"}, "properties": {"count": 496, "sha256": "18c74d4787010d269ae31f0ca2a9898b60cd5e79591bb82194007038c625054c"}, "constants": {"count": 2041, "sha256": "cd3d7c81c676d21d9bcc515f900ed585d68b8ba7e2440840b3fe52a702d4b8ef"}, "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 238, "sha256": "62681716d701dbd647daf6676dbbf32b6ae1e19f8a5b7ce177824619a61700bd"}, + "function_pairs": {"count": 239, "sha256": "bec0088faad2f652b96291a1b6d2cedbd60a9966961d6cb484ca4d4972c22456"}, "property_pairs": {"count": 167, "sha256": "a6877b76b4ad9d4f6705421de0df1e17a06881c47b1d693a5cdb4161067ae8dd"}, "constant_pairs": {"count": 720, "sha256": "ac70a1ecc9b69f9d3b9a6f98ebbdf7ea3ab6a61c7ee03ba52b5d89f2ac3e6332"}, "unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"}, - "unpaired_functions": {"count": 4108, "sha256": "4c548bc990ef3288f4934076863bdb4d727caf885387f88f89aa5019c8a124df"}, + "unpaired_functions": {"count": 4106, "sha256": "c1de50c27dbbf43d462583940b3fbaf61bf244fc8ddda7406aa33af280de7ec5"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, "unpaired_constants": {"count": 601, "sha256": "13693d0d240b34610a5ab2c6b1dcea1d9702b9da5e9e3714b631ef3795e71a38"} } diff --git a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt index 0ec98c9d72..b7e0e7ef67 100644 --- a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt +++ b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt @@ -238,20 +238,33 @@ object ConnectionReadout { return null } - /** #987/#261: the "clock latched" readout value: "yes" once EITHER signal lands with a plausible - * (post-1972) timestamp — a GET_CLOCK correlation (deviceClockUnix, the WHOOP4 path) or a - * GET_DATA_RANGE reply's newest banked record (strapNewestUnix, the fallback a WHOOP 5/MG needs - * since its GET_CLOCK reply never populates deviceClockUnix — see the Swift twin's doc comment for - * why). "no (RTC reads 1970/71)" on an epoch-era signal; "no (waiting for the strap clock)" before - * either replies. Twin of the Swift labeller. */ - fun clockLatchedLabel(deviceClockUnix: Long?, strapNewestUnix: Long? = null): String { - val ceiling = ConnectionTrace.RTC_EPOCH_CEILING_UNIX - if (deviceClockUnix != null) return if (deviceClockUnix < ceiling) "no (RTC reads 1970/71)" else "yes" - // #1823: this branch has NOT read a clock - it is reached when no correlation exists, which is - // every 5/MG. The only evidence is how the strap DATED its records, so say that rather than - // claiming a clock reading we never took. Twin of the Swift wording. - if (strapNewestUnix != null) return if (strapNewestUnix < ceiling) "no (records dated 1970/71)" else "yes" - return "no (waiting for the strap clock)" + /** The current link's clock evidence, including future-dated and stale records. A post-1972 + * timestamp alone is not proof that the clock is right (#2091). + * Swift twin: `ConnectionReadout.clockStatusLabel`. */ + fun clockStatusLabel(deviceClockUnix: Long?, strapNewestUnix: Long?, nowUnix: Long): String { + if (deviceClockUnix == null && strapNewestUnix == null) return "waiting for clock evidence" + val clock = deviceClockUnix?.let { unix -> + ConnectionTrace.clockVerdict( + aheadSeconds = unix - nowUnix, newestUnix = unix, + futureToleranceSeconds = 120L, + behindToleranceSeconds = ConnectionTrace.BEHIND_TOLERANCE_DEFAULT, + ) + } + val record = strapNewestUnix?.let { unix -> + ConnectionTrace.clockVerdict( + aheadSeconds = unix - nowUnix, newestUnix = unix, + futureToleranceSeconds = 120L, + behindToleranceSeconds = ConnectionTrace.BEHIND_TOLERANCE_DEFAULT, + ) + } + return when { + clock?.contains("FUTURE-DATED") == true || record?.contains("FUTURE-DATED") == true -> "future-dated" + clock?.contains("RTC-EPOCH") == true -> "RTC reads 1970/71" + record?.contains("RTC-EPOCH") == true -> "records dated 1970/71" + record?.contains("CLOCK-WARNING") == true -> "record is over 48h old" + strapNewestUnix != null -> "records dated normally" + else -> "clock OK" + } } /** #1818: at or above this charge the "charge it" remedy is already satisfied, so repeating it is diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index b6aa650b6d..d989605405 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -153,6 +153,8 @@ data class LiveState( * [withRRIntervals]; emptied by [clearedBiometrics]. Twin of macOS LiveState.rrRecent (PR#191). */ val rrRecent: List = emptyList(), val batteryPct: Double? = null, + /** A battery event arrived on this link; the retained percentage alone may be from an older link. */ + val batterySeenThisLink: Boolean = false, /** Strap battery pack VOLTAGE (mV), decoded from the ~8-min BATTERY_LEVEL event (mv@21/@25) and the * GET_EXTENDED_BATTERY_INFO response (#592). Shown on the Devices card as a "x.xx V" readout beside * the percent. null until the first battery event lands. */ @@ -300,6 +302,8 @@ data class LiveState( * 5.0/MG. null before the first reply this session, or when the frame did not decode. Twin of * macOS LiveState.pagesBehindAtConnect. */ val pagesBehindAtConnect: Int? = null, + /** Newest banked record from this link's validated GET_DATA_RANGE reply, for clock diagnostics. */ + val strapNewestUnix: Long? = null, /** #612: TRUE when the WHOOP-4/generic empty-offload streak ([emptySyncTracker]) is currently * SUSTAINED (3+ consecutive completed-but-empty offloads). Not 5/MG-specific and not coupled to HR: * a connected strap that keeps handing over nothing has this true regardless of live-HR status. @@ -2315,7 +2319,7 @@ class WhoopBleClient( * ignored. Mirrors the Swift StandardHRSource→LiveState.setBattery wiring. */ fun publishExternalBattery(pct: Int) { - if (pct in 0..100) _state.update { it.copy(batteryPct = pct.toDouble()) } + if (pct in 0..100) _state.update { it.copy(batteryPct = pct.toDouble(), batterySeenThisLink = true) } } // MARK: Android Bluetooth handles. @@ -3678,6 +3682,10 @@ class WhoopBleClient( @Volatile private var realtimeArmed = false /** Wall-clock of the last inbound notification — drives the keep-alive liveness watchdog. */ @Volatile private var lastDataAtMs = 0L + @Volatile private var lastInboundFrameAtMs = 0L + + /** Read without publishing every inbound frame to all LiveState observers. */ + fun lastInboundFrameUnix(): Long? = lastInboundFrameAtMs.takeIf { it > 0L }?.div(1_000L) /** * #1865: when a LIVE HR sample last arrived — distinct from [lastDataAtMs], which any inbound frame @@ -8093,6 +8101,7 @@ class WhoopBleClient( inboundBytes += bytes.size if (uuid == CMD_NOTIFY_CHAR) cmdChannelFrames++ lastDataAtMs = System.currentTimeMillis() // feeds the keep-alive liveness watchdog + lastInboundFrameAtMs = lastDataAtMs resubscribedSinceData = false // data is flowing again — re-arm the one-shot resubscribe when { uuid == HEART_RATE_CHAR -> parseStandardHr(bytes) // 0x2A37 @@ -8385,6 +8394,7 @@ class WhoopBleClient( } (if (acceptsWindow) dataRangeNewestUnix(frame) else null)?.let { strapNewestTs = it + _state.update { state -> state.copy(strapNewestUnix = it) } // Capture the wall clock of THIS reading so the backfiller correlation pairs // the strap's device time with the wall time of the same instant (see field doc). strapNewestTsWall = System.currentTimeMillis() / 1000L @@ -9283,7 +9293,7 @@ class WhoopBleClient( /** Single funnel for battery readings (port of LiveState.setBattery). */ private fun setBattery(pct: Double) { - _state.update { it.copy(batteryPct = pct) } + _state.update { it.copy(batteryPct = pct, batterySeenThisLink = true) } // Battery test mode: one tagged (t, soc) line per reading, gated zero-cost when off (the gate is a // single SharedPreferences bool read; the formatter below only runs when the mode is on). Rides the // redacting log() sink; the Room battery series is the readout + trace source (#713, Test Centre). @@ -12277,6 +12287,8 @@ class WhoopBleClient( backfilling = false backfillDrain.reset() strapNewestTs = null + _state.update { it.copy(strapNewestUnix = null, batterySeenThisLink = false) } + lastInboundFrameAtMs = 0L strapNewestTsWall = null offloadFramesThisSession = 0 lastOffloadFrameAtMs = 0L // #174: don't carry a stale cooldown reference into the next session diff --git a/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt b/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt index 5e8c7b7c8f..a55a908d5f 100644 --- a/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt +++ b/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt @@ -86,7 +86,8 @@ object TestModeRegistry { questionnaire = listOf( Question("otherDevicePaired", "Is another phone or the WHOOP app paired to the strap right now?", Question.Kind.YES_NO), ), - liveReadout = listOf("connectionUptime", "reconnectCount", "lastOffloadResult"), + liveReadout = listOf("connectionUptime", "reconnectCount", "lastOffloadResult", + "sessionRows", "allTimeDrainedRows", "clockStatus", "lastFrame", "clockWarning"), capture = CaptureKind.Toggle, includesScreenshot = false, requires5MG = false, ) diff --git a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt index 1019df3dbb..fead7b24a5 100644 --- a/android/app/src/main/java/com/noop/ui/DevicesScreen.kt +++ b/android/app/src/main/java/com/noop/ui/DevicesScreen.kt @@ -1,6 +1,7 @@ package com.noop.ui import com.noop.R +import com.noop.analytics.ConnectionReadout import androidx.compose.ui.res.stringResource import android.widget.Toast import androidx.compose.foundation.background @@ -289,6 +290,14 @@ fun DevicesScreen( // Historical record layout from the current backfill, distinct from strap firmware. liveHistoryLayout = if (device.status == DeviceStatus.active.name && live.connected) live.historyLayoutVersion else null, + clockStatus = if (device.status == DeviceStatus.active.name && live.connected && + device.brand.equals("WHOOP", ignoreCase = true)) + ConnectionReadout.clockStatusLabel(null, live.strapNewestUnix, + System.currentTimeMillis() / 1_000L) else null, + clockWarning = if (device.status == DeviceStatus.active.name && live.connected && + device.brand.equals("WHOOP", ignoreCase = true)) + ConnectionReadout.rtcWarning(null, live.strapNewestUnix, + live.batteryPct.takeIf { live.batterySeenThisLink }) else null, onMakeActive = { switchTarget = device }, onRename = { renameTarget = device }, onRemove = { removeTarget = device }, @@ -748,6 +757,8 @@ private fun DeviceCard( liveFirmware: String? = null, /** The active+connected strap's observed banked-history record layout (`hist_version`). */ liveHistoryLayout: Int? = null, + clockStatus: String? = null, + clockWarning: String? = null, onMakeActive: () -> Unit, onRename: () -> Unit, onRemove: (() -> Unit)?, @@ -858,6 +869,13 @@ private fun DeviceCard( if (liveBatteryPct != null) { BatteryTube(pct = liveBatteryPct) } + if (clockStatus != null) { + Text(stringResource(R.string.connection_clock_status, clockStatus), + style = NoopType.footnote, color = Palette.textSecondary) + } + if (clockWarning != null) { + Text(clockWarning, style = NoopType.footnote, color = Palette.textSecondary) + } // #592: strap pack voltage (mV → volts, 2dp) beside the percent, when the battery event has // reported it. Localized unit resource; purely additive, the percent tube is unchanged. diff --git a/android/app/src/main/java/com/noop/ui/TestCentreLiveReadouts.kt b/android/app/src/main/java/com/noop/ui/TestCentreLiveReadouts.kt index 948b137e92..95c2972e10 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreLiveReadouts.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreLiveReadouts.kt @@ -32,7 +32,11 @@ internal data class TestCentreLiveSnapshot( val domainLogLines: List? = null, val nowUnix: Long = System.currentTimeMillis() / 1_000, val connected: Boolean = false, + val strapNewestUnix: Long? = null, + val lastFrameUnix: Long? = null, + val allTimeDrainedRows: Long = 0, val batteryPct: Double? = null, + val currentLinkBatteryPct: Double? = null, val batteryEstimate: BatteryEstimator.Estimate? = null, val hrSamples: List = emptyList(), val gravitySamples: List = emptyList(), @@ -65,7 +69,8 @@ internal object TestCentreLiveRefreshPolicy { internal object TestCentreLiveReadouts { val mappedIds: Set = setOf( "hrDensityNow", "gravityCoverageNow", "lastNightGateFired", - "connectionUptime", "reconnectCount", "lastOffloadResult", + "connectionUptime", "reconnectCount", "lastOffloadResult", "sessionRows", + "allTimeDrainedRows", "clockStatus", "lastFrame", "clockWarning", "lastSessionSummary", "deviceMetricsNow", "lastImportSummary", "stepsToday", "calibrationState", "currentSoc", "estimateDaysLeft", "slopeSource", @@ -105,6 +110,28 @@ internal object TestCentreLiveReadouts { "lastOffloadResult" -> LiveReadoutRow( id, "Last offload result", ConnectionReadout.lastOffloadResult(tail) ?: "no offload yet", ) + "sessionRows" -> LiveReadoutRow( + id, "Rows drained (session)", ConnectionReadout.sessionRows(tail)?.toString() ?: "no offload yet", + ) + "allTimeDrainedRows" -> LiveReadoutRow( + id, "Rows drained (all time)", snapshot.allTimeDrainedRows.toString(), + ) + "clockStatus" -> LiveReadoutRow( + id, "Clock", ConnectionReadout.clockStatusLabel( + ConnectionReadout.clockCorrelatedDevice(snapshot.logLines), + snapshot.strapNewestUnix, snapshot.nowUnix, + ), + ) + "lastFrame" -> LiveReadoutRow( + id, "Last frame", ConnectionReadout.lastFrameLabel(snapshot.lastFrameUnix, snapshot.nowUnix), + ) + "clockWarning" -> LiveReadoutRow( + id, "Clock warning", ConnectionReadout.rtcWarning( + ConnectionReadout.clockCorrelatedDevice(snapshot.logLines), + snapshot.strapNewestUnix, + snapshot.currentLinkBatteryPct.takeIf { snapshot.connected }, + ) ?: "none", + ) "lastSessionSummary" -> LiveReadoutRow( id, "Last session", WorkoutsReadout.lastSessionSummary(tail) ?: "no session yet", ) diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index d16935bd65..cf99bc58be 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -687,6 +687,8 @@ private fun TestCentreLiveReadoutPanel( activeStrapId: String, vm: AppViewModel, ) { + val context = LocalContext.current + val testCentre = remember { TestCentre.from(context) } var hrSamples by remember(mode.id) { mutableStateOf(emptyList()) } var gravitySamples by remember(mode.id) { mutableStateOf(emptyList()) } var batteryEstimate by remember(mode.id) { mutableStateOf(null) } @@ -748,7 +750,11 @@ private fun TestCentreLiveReadoutPanel( domainLogLines = domainLogLines, nowUnix = nowUnix, connected = live.connected, + strapNewestUnix = live.strapNewestUnix.takeIf { live.connected }, + lastFrameUnix = vm.ble.lastInboundFrameUnix().takeIf { live.connected }, + allTimeDrainedRows = testCentre.cumulativeDrainedRows(), batteryPct = live.batteryPct, + currentLinkBatteryPct = live.batteryPct.takeIf { live.connected && live.batterySeenThisLink }, batteryEstimate = batteryEstimate, hrSamples = hrSamples, gravitySamples = gravitySamples, @@ -756,11 +762,15 @@ private fun TestCentreLiveReadoutPanel( ) Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 2.dp)) { rows.forEach { row -> + if (row.id == "clockWarning" && row.value != "none") { + Text(row.value, style = NoopType.footnote, color = Palette.textSecondary) + } else if (row.id != "clockWarning") { Row(Modifier.fillMaxWidth()) { Text(row.label, style = NoopType.footnote, color = Palette.textTertiary) Spacer(Modifier.weight(1f)) Text(row.value, style = NoopType.mono, color = Palette.textSecondary) } + } } } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 8399bc377c..7d844a82d5 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2871,6 +2871,7 @@ EKG-Aufzeichnung starten EKG-Aufzeichnung stoppen Auf diesem Strap läuft möglicherweise noch eine Aufzeichnung. Das Ergebnis steht im Strap-Log. + Uhr: %1$s Erscheint heute, wenn Live Sessions in den Einstellungen aktiviert sind oder eine Session aktiv ist. Today nach Ihren Wünschen, Herzfrequenz für jede App und Italienisch diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 20aed28131..9c4d21e023 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2858,6 +2858,7 @@ Iniciar captura de ECG Detener captura de ECG Puede que siga activa una captura en esta correa. El veredicto se registra en el strap log. + Reloj: %1$s Aparece hoy si Live Sessions está activado en Ajustes o hay una sesión activa. Hoy a tu manera, frecuencia cardíaca para cualquier aplicación e italiano diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index bd1c4cc3b1..dcf82b879a 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2857,6 +2857,7 @@ Démarrer la capture ECG Arrêter la capture ECG Une capture est peut-être encore active sur ce bracelet. Le verdict est écrit dans le strap log. + Horloge : %1$s Apparaît aujourd’hui si les sessions en direct sont activées dans Paramètres ou si une session est active. Aujourd\'hui à votre façon, fréquence cardiaque lisible par toute application, et l\'italien diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 1a47561f99..b546af0132 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2826,6 +2826,7 @@ Avvia acquisizione ECG Interrompi acquisizione ECG Una acquisizione potrebbe essere ancora attiva su questo strap. Il verdetto va nello strap log. + Orologio: %1$s Compare oggi se le sessioni in diretta sono attivate nelle Impostazioni o una sessione è attiva. Oggi a modo tuo, frequenza cardiaca leggibile da ogni app e l\'italiano diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index b5e9c52de8..9dcb43df7b 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2874,6 +2874,7 @@ Rozpocznij zapis EKG Zatrzymaj zapis EKG Na tym pasku może nadal trwać zapis. Werdykt trafia do strap log. + Zegar: %1$s Pojawia się dzisiaj, gdy sesje na żywo są włączone w Ustawieniach lub trwa sesja. Dziś po swojemu, tętno dostępne dla każdej aplikacji i język włoski diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index b64877aeb3..2d9e235220 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2850,6 +2850,7 @@ Iniciar captura de ECG Parar captura de ECG Pode ainda estar a decorrer uma captura nesta pulseira. O veredicto vai para o strap log. + Relógio: %1$s Aparece hoje quando as sessões em direto estão ativadas nas Definições ou existe uma sessão ativa. Hoje à sua maneira, frequência cardíaca para qualquer aplicação e italiano diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 6fbec7268f..55b80e2029 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2753,6 +2753,7 @@ Начать запись ЭКГ Остановить запись ЭКГ На этом ремешке запись может всё ещё идти. Результат попадёт в strap log. + Часы: %1$s Появляется сегодня, если сессии в реальном времени включены в Настройках или сеанс уже активен. «Сегодня» на ваш вкус, пульс для любого приложения и итальянский язык diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 75a9e8a04f..be56eb355e 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2828,6 +2828,7 @@ 开始心电图采集 停止心电图采集 此腕带上可能仍在采集。结论会写入 strap log。 + 时钟:%1$s 在设置中启用Live Sessions 实时记录或有正在进行的训练时,今天会显示此部分。 自定义「今天」页面、任何应用都能读取的心率,以及意大利语 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5ae979343a..0caa5c5f9a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2887,6 +2887,7 @@ Start ECG capture Stop ECG capture A capture may still be running on this strap. The verdict goes to the strap log. + Clock: %1$s Appears today when Live Sessions is enabled in Settings or a session is active. Today your way, heart rate any app can read, and Italian diff --git a/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt b/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt index 605d3f07de..2f11adf0e9 100644 --- a/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt +++ b/android/app/src/test/java/com/noop/analytics/ConnectionReadoutTest.kt @@ -203,24 +203,6 @@ class ConnectionReadoutTest { assertNull(ConnectionReadout.clockCorrelatedDevice(listOf("connect up"))) } - @Test fun clockLatchedLabel() { - assertEquals("yes", ConnectionReadout.clockLatchedLabel(1_782_475_600L)) - assertEquals("no (RTC reads 1970/71)", ConnectionReadout.clockLatchedLabel(40_000_000L)) - assertEquals("no (waiting for the strap clock)", ConnectionReadout.clockLatchedLabel(null)) - } - - // #261: a WHOOP 5/MG never populates deviceClockUnix (its GET_CLOCK reply rides the puffin channel, - // never the WHOOP4 correlation path) — the data-range fallback is what keeps the row from reading - // "waiting" forever on a strap that's actually fine. - @Test fun clockLatchedLabelFallsBackToStrapNewestForFiveMG() { - assertEquals("yes", ConnectionReadout.clockLatchedLabel(null, 1_782_475_600L)) - // #1823: no clock was READ on this path - the wording must not claim one. - assertEquals("no (records dated 1970/71)", ConnectionReadout.clockLatchedLabel(null, 40_000_000L)) - assertEquals("no (waiting for the strap clock)", ConnectionReadout.clockLatchedLabel(null, null)) - // deviceClockUnix wins when BOTH signals are present (the WHOOP4 correlation is the more direct one). - assertEquals("yes", ConnectionReadout.clockLatchedLabel(1_782_475_600L, 40_000_000L)) - } - @Test fun rtcWarningFiresOnEpochEraClockOrNewest() { assertTrue(ConnectionReadout.rtcWarning(40_000_000L, null) != null) assertTrue(ConnectionReadout.rtcWarning(null, 30_000_000L) != null) @@ -229,6 +211,16 @@ class ConnectionReadoutTest { assertNull(ConnectionReadout.rtcWarning(null, null)) } + @Test fun clockStatusClassifiesBothCurrentLinkSignals() { + val now = 1_800_000_000L + assertEquals("waiting for clock evidence", ConnectionReadout.clockStatusLabel(null, null, now)) + assertEquals("clock OK", ConnectionReadout.clockStatusLabel(now - 10, null, now)) + assertEquals("future-dated", ConnectionReadout.clockStatusLabel(null, now + 3_600, now)) + assertEquals("records dated 1970/71", ConnectionReadout.clockStatusLabel(null, 40_000_000L, now)) + assertEquals("RTC reads 1970/71", ConnectionReadout.clockStatusLabel(40_000_000L, now - 10, now)) + assertEquals("records dated normally", ConnectionReadout.clockStatusLabel(null, now - 10, now)) + } + /** #1818: the remedy must track the battery. A charged strap told to "charge to 100%" is the bug * the field report hit - the user had already done it, twice. Twin of the Swift test. */ @Test fun rtcWarningRemedyTracksBattery() { diff --git a/android/app/src/test/java/com/noop/testcentre/TestModeRegistryParityTest.kt b/android/app/src/test/java/com/noop/testcentre/TestModeRegistryParityTest.kt index 2b32f9668c..d4bbde60e2 100644 --- a/android/app/src/test/java/com/noop/testcentre/TestModeRegistryParityTest.kt +++ b/android/app/src/test/java/com/noop/testcentre/TestModeRegistryParityTest.kt @@ -172,7 +172,8 @@ class TestModeRegistryParityTest { } @Test fun phase2LiveReadoutIds() { - assertEquals(listOf("connectionUptime", "reconnectCount", "lastOffloadResult"), + assertEquals(listOf("connectionUptime", "reconnectCount", "lastOffloadResult", + "sessionRows", "allTimeDrainedRows", "clockStatus", "lastFrame", "clockWarning"), TestModeRegistry.mode(TestDomain.CONNECTION)?.liveReadout) assertEquals(listOf("lastSessionSummary"), TestModeRegistry.mode(TestDomain.WORKOUTS)?.liveReadout) diff --git a/android/app/src/test/java/com/noop/ui/TestCentreLiveReadoutsTest.kt b/android/app/src/test/java/com/noop/ui/TestCentreLiveReadoutsTest.kt index f04d7fd72e..cb920ad365 100644 --- a/android/app/src/test/java/com/noop/ui/TestCentreLiveReadoutsTest.kt +++ b/android/app/src/test/java/com/noop/ui/TestCentreLiveReadoutsTest.kt @@ -43,7 +43,7 @@ class TestCentreLiveReadoutsTest { @Test fun everyRegistryDeclaredIdHasExactlyOnePresentationMapping() { val declared = TestModeRegistry.all.flatMap { it.liveReadout }.toSet() - assertEquals(16, declared.size) + assertEquals(21, declared.size) assertEquals(declared, TestCentreLiveReadouts.mappedIds) TestModeRegistry.all.forEach { mode -> @@ -58,6 +58,27 @@ class TestCentreLiveReadoutsTest { } } + @Test fun connectionShowsSessionRowsAndFutureDatedClockInsteadOfHealthyLatch() { + val mode = requireNotNull(TestModeRegistry.mode(TestDomain.CONNECTION)) + val rows = TestCentreLiveReadouts.rows( + mode = mode, + active = true, + snapshot = TestCentreLiveSnapshot( + logLines = listOf("[connection] offload progress sessionRows=12"), + nowUnix = 1_800_000_000L, + connected = true, + strapNewestUnix = 1_800_003_600L, + lastFrameUnix = 1_799_999_990L, + allTimeDrainedRows = 402L, + ), + ).associateBy { it.id } + + assertEquals("12", rows.getValue("sessionRows").value) + assertEquals("402", rows.getValue("allTimeDrainedRows").value) + assertEquals("future-dated", rows.getValue("clockStatus").value) + assertEquals("10s ago", rows.getValue("lastFrame").value) + } + @Test fun inactiveRowIsCompactAndDoesNotResolveEvenAnUnknownReadout() { val futureMode = requireNotNull(TestModeRegistry.mode(TestDomain.RECOVERY)) .copy(liveReadout = listOf("futureReadout"))