Repository navigation
Conversation
Reviewing #2085 (iOS, WHOOP 5.0, live HR fine and no sleep ever) turned up that the two readouts that diagnose it have no Android caller at all. ConnectionReadout.clockLatchedLabel and ConnectionReadout.rtcWarning are both implemented in the Kotlin twin AND covered by ConnectionReadoutTest, and nothing in production calls either. iOS has shown both on Test Centre and Devices since the #987 work. An Android user with the same strap could only find out by exporting a log and reading it themselves. The gap that kept it unshowable was a missing value, not a missing view. A WHOOP 5/MG does not serve GET_CLOCK, so the correlated device clock the WHOOP4 path derives is always null there, and the only evidence of a never-set RTC is how the strap DATED its own banked records. iOS reads that from LiveState.strapRange.newestUnix; Android had no equivalent field, so the readout could only ever have said "waiting for the strap clock" forever, which is the shape #827 describes and #1823 changed on iOS. LiveState now carries strapNewestUnix, published where the GET_DATA_RANGE reply is already parsed. It is set unconditionally rather than inside the Test Centre gate beside it: the readout that needs it is the one a reporter is asked to quote, and gating the value on a diagnostic mode would leave it null for exactly the user who has not turned one on. Rendered the way iOS renders it, in the connection panel rather than through the registry's liveReadout ids. A first attempt added an id there and broke TestModeRegistryParityTest, which is right to fail: that list is a spec both platforms carry byte-for-byte, and iOS's own panel already shows more rows than it names. The pure helper keeps the wiring testable without the Composable. Also carries the #1818 nuance Android was missing. The log-only clock verdict says "charge to 100%" unconditionally, while rtcWarning tells an already-charged strap that charging again will not help and to export a log instead. Android users at 100% were being sent round the loop that copy was written to end.
Re-review of my own change, two findings. The verdict could outlive the link that produced it. strapNewestUnix was published and never cleared, so after a disconnect the Clock-latched row kept answering from a dead link, and after a device SWITCH it would have answered for the previous strap. macOS clears its strapRange on exactly this path, with the note that a stale clock-drift window must not outlive the link either; clearedBiometrics is that path here, and the field is cleared there now. Pinned by a test that reads the row either side of the clear. The publish line also landed between the CAPTURE-B comment and the block it explains, so that comment read as documentation for my line and the block it belonged to lost its own. Plain slash comments, which the doc lint does not inspect, so it had to be caught by reading. Moved above the block.
…dead link Re-review of my own change, two findings, both in the call I added. The scan ran for every mode, not just Connection. I wrote it as a call followed by takeIf, and takeIf runs on the RESULT, so the log walk happened for every Test Centre panel and was then thrown away. Worse than a wasted call: this panel recomposes once a second off the connection clock, and clockCorrelatedDevice walks the whole log looking for a line a WHOOP 5/MG never emits, so the miss is a full scan, once a second, on the family the readout exists for. Gated before the call now, and remembered on its inputs so a clock tick alone cannot re-run it. The charge could come from a link that had ended. rtcWarning tells its callers in as many words to pass a reading from the CURRENT link and never a last-known one, because a stale 100% withdraws the "charge it" remedy from the very strap that earned it by running flat and resetting its RTC. Android never clears batteryPct on disconnect, where macOS clears its battery samples, so the stale value is exactly what my call passed. It is gated on connected now; null keeps the charge advice, which is the safe direction to be wrong in. A residual stays, and is not this change's to take on: a reconnect that has not yet read battery on the new link still carries the previous one's value. Clearing batteryPct on disconnect is the real fix and belongs with the battery display it would also change.
…look Test Centre is a diagnostic mode a user has to switch on. Devices is the screen they already have open, and macOS has shown the clock state on the active card since the #987 work. Surfacing it only in Test Centre left the person most likely to need it still unable to see it. The device card takes the same two values macOS passes it, liveClockLine and liveClockWarning, active card only. The line carries the tokens; the warning carries the sentence, which is the half that says what to do. Two deliberate differences from macOS, both because of where Android keeps things. The line is the clock half only. macOS pairs it with a last-frame freshness that this platform cannot show: it has no lastFrameAtUnix to read. That belongs with the other ConnectionReadout functions Android computes and never surfaces, not smuggled in here. The device clock is not resolved on this screen. It comes from the strap log, which lives in the BLE client here rather than in LiveState as it does on macOS, so reading it would subscribe the Devices screen to the log revision and rebuild the line list several times a second through an offload burst. It is a WHOOP4-only signal anyway, and the strap's own dating of its records is what matters and is the only evidence a 5/MG gives. The cost is wording on a 4.0 with a dead RTC, not the verdict. So the helper now takes the device clock RESOLVED rather than the log to resolve it from, because the two callers legitimately differ, and a test pins that it honours whichever it is handed.
The Devices call site runs per device, and I passed the clock line and warning to every card without gating on which one is active. liveHistoryLayout sits two lines above doing exactly that gate, so on a two-strap install the inactive strap's card would have printed the ACTIVE strap's verdict: a diagnostic answering for a device it never observed, which is the failure this codebase already has a rule against. macOS gates both on device.status == .active; so does this now.
|
Closing unmerged. The deciding fact came out of reviewing this against the Android reports it was meant to serve. #1313 ("Sleep isn't tracked at all") is the only Android occurrence of this failure on record, and its clock was FUTURE-dated, not epoch-era. Widening the label is not the answer either. A future-dated clock IS latched, it is set and wrong, so making the yes/no say "no" would be false. That case wants the clock VERDICT, and So this is narrow, blind to the one Android case on file, and was never exercised against a strap with a broken RTC. What is worth keeping is the finding rather than the code, and it is recorded in the issue linked below: Android computes five ConnectionReadout functions it never surfaces, all unit-tested and none wired, and the clock readout probably wants to be a verdict rather than a yes/no. The branch is left in place for anyone who picks that up; the plumbing here (LiveState.strapNewestUnix, published where GET_DATA_RANGE is already parsed and cleared with the link) is the prerequisite either way. Recorded as #2091. |
Parity gap found while reviewing #2085 (iOS, WHOOP 5.0: live HR and steps fine, no sleep or HRV ever, Test Centre showing
Clock latched: no, records dated 1970/71).That reporter is on iOS, where the diagnosis is visible. On Android it would not have been.
Two readouts, computed and never shown
ConnectionReadout.clockLatchedLabelandConnectionReadout.rtcWarningare both implemented in the Kotlin twin and covered byConnectionReadoutTest, and neither had a production caller. Android's entire use ofConnectionReadoutwasuptimeLabel,reconnectCount,lastOffloadResult,linkEpitaphandlinkBankedSummary.So an Android user whose strap clock was never set, which is the most common cause of "live HR works but no history ever arrives", could learn that only by exporting a strap log and reading the verdict token themselves. iOS has shown the row and the plain-words warning on Test Centre and Devices throughout.
What made it unshowable
A missing value, not a missing view.
A WHOOP 5/MG does not serve
GET_CLOCK(the Interpreter's capture note is explicit that it is intentionally left undecoded on that family), so the correlated device clock the WHOOP4 path derives is always nil there. The only evidence of a never-set RTC is how the strap dated its own banked records.iOS reads that from
LiveState.strapRange.newestUnix. Android'sLiveStatehad no equivalent field, so even a wired-up row could only ever have said "waiting for the strap clock" forever, which is the shape #827 describes.LiveStatenow carriesstrapNewestUnix, published where theGET_DATA_RANGEreply is already parsed. It is set unconditionally, not inside the Test Centre gate sitting beside it: the readout that needs it is the one a reporter is asked to quote, and gating the value on a diagnostic mode would leave it null for exactly the user who has not turned one on.Rendered the way iOS renders it
Through the connection panel, not the registry's
liveReadoutids.My first attempt added a
clockLatchedid to the registry and brokeTestModeRegistryParityTest, which was right to fail: that list must match the SwiftTestModeRegistrybyte-for-byte, and iOS does not bind those ids row-by-row anyway. ItsConnectionReadoutPanelhard-codes seven rows while the list names three. So the id list stays untouched and the row is rendered beside the others, with the logic in a pure helper so the wiring stays testable without a Composable.It also restores the #1818 nuance
The log-only clock verdict Android does emit says "charge to 100% and reconnect" unconditionally.
rtcWarningtells an already-charged strap that charging again will not change anything and to export a log instead. Android users at 100% were being sent round exactly the loop that copy was written to end.Found by re-review
strapNewestUnixwas published and never cleared, so after a disconnect the row kept answering from a dead link, and after a device switch it would have answered for the previous strap. macOS clears itsstrapRangeon exactly this path, noting that "a stale clock-drift window must not outlive the link either";clearedBiometricsis that path here, and the field is cleared there now. A test reads the row either side of the clear.//comments, whichdoc_comment_lintdoes not inspect, so it had to be caught by reading.Pass two, both in the call itself
takeIf, andtakeIfruns on the result, so the log walk happened for every Test Centre panel and was then discarded. Worse than a wasted call: this panel recomposes once a second off the connection clock, andclockCorrelatedDevicewalks the whole log looking for a line a 5/MG never emits; the miss is therefore a full scan, once a second, on the family this readout exists for. Gated before the call now, andremembered on its inputs so a clock tick alone cannot re-run it.rtcWarningrequires callers to pass a reading from the CURRENT link and never a last-known one, because a stale 100% withdraws the "charge it" remedy from the very strap that earned it by running flat and resetting its RTC. Android never clearsbatteryPcton disconnect (macOS clears its battery samples), so the stale value is exactly what my call passed. Gated onconnected; null keeps the charge advice, which is the safe direction to be wrong in.A residual stays, deliberately: a reconnect that has not yet read battery on the new link still carries the previous one's value. Clearing
batteryPcton disconnect is the real fix and belongs with the battery display it would also change.Also on the Devices card
Test Centre is a diagnostic mode a user has to switch on; Devices is the screen they already have open, and macOS has shown the clock state on the active card since the #987 work. Surfacing it only in Test Centre left the person most likely to need it still unable to see it. The card takes the same two values macOS passes it,
liveClockLineandliveClockWarning, active card only.Two deliberate differences from macOS, both about where Android keeps things:
lastFrameAtUnixto read. That belongs with the other unsurfacedConnectionReadoutfunctions, not smuggled in here.LiveStateas on macOS, so reading it would subscribe Devices to the log revision and rebuild the line list several times a second through an offload burst. It is a WHOOP4-only signal anyway, and the strap's own record dating is what matters and is the only evidence a 5/MG gives. The cost is wording on a 4.0 with a dead RTC, not the verdict.So the helper takes the device clock resolved rather than the log to resolve it from, since the two callers legitimately differ, and a test pins that it honours whichever it is handed.
Pass three
liveClockLine/liveClockWarningungated whileliveHistoryLayouttwo lines above does exactly that gate. On a two-strap install the inactive strap's card would have printed the active strap's verdict: a diagnostic answering for a device it never observed. macOS gates both ondevice.status == .active; so does this now.Verification
ConnectionReadoutTest; these pin that they reach a screendoc_comment_lint.pyandi18n_audit.py --cigreenGET_DATA_RANGEis already parsed and the readout functions were already tested, but the end-to-end path from a real epoch-clock strap to the rendered warning has not been seen on a device.#2085 itself stays open: it is an iOS report and still needs that reporter's strap log.