Repository navigation
Strap log: surface WHERE offloaded rows landed + WHY (stale-RTC "missed sleep", #67) - #69
Merged
Merged
Conversation
…le-RTC "missed sleep" (#67) A reset/stale strap RTC banks last night's sleep with a years-old timestamp, so the offload files it far in the past — off the recent timeline, where it reads as "missed sleep". The log showed the strap was stale and that rows persisted, but NOT where those rows landed or what clock reference decoded them, so the misdating was invisible. Logging only — no BLE/offload behaviour change: - Backfiller: new "Backfill: rows landed on <date(s)>" session line, plus the effective clock state — "IDENTITY fallback (GET_CLOCK never correlated) - stale-record correction OFF", "strap clock Nd behind wall - correction engaged", or "clock ref in sync". The dates are the ACTUAL post-correction landing days (the same night-keys the persist tally builds), so a night that landed in 2024 is self-evident. Captures the decode ref on the first chunk; reset in begin(). - DebugDataDiagnostics: the existing "Strap clock: Nd behind wall" line now notes recent sleep may be filed ~Nd in the past (#67), not just that the alarm is unreliable. - Tests: cover the new formatter — identity-fallback past-date signature, correlated-stale "correction engaged", in-sync, multi-night UTC range, and the no-em-dash convention shared with the sibling strap-log lines. Pairs with the root-cause investigation on #67: on a reset-RTC strap the offload falls back to an identity clock ref (Backfiller.swift), which zeroes the offset FIX #72's correctedWall needs, so stale records are stored verbatim. This makes that failure mode visible before touching the BLE clock handshake.
tanarchytan
pushed a commit
to tanarchytan/noop
that referenced
this pull request
Jul 8, 2026
The Live Body Console derives every status from activeConnection (= live.connected && live.bonded). `bonded` carries WHOOP encrypted-bond semantics and is deliberately never set by the Oura path (ryanbr#69), so a connected, authenticated, streaming ring fell through every WHOOP branch to "Radio connected, stream not yet trusted." — even while HR was live. Add a display-only `ringStreaming` predicate (live.connected && live.streamingLiveHR) and route the status layer through it: mode detail, connection pill, mode badge, mode color, and the Heart rate / Connection signal-trust tiles now read a ring stream as an active, trusted stream. activeConnection itself is untouched, so bond-only feature gates (buzz, alarm, HRV snapshot, encrypted control) still correctly key off the WHOOP bond. Wear/Worn stats stay gated on activeConnection to avoid a false "On wrist" until the ring's wear signal is confirmed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tanarchytan
pushed a commit
to tanarchytan/noop
that referenced
this pull request
Jul 8, 2026
…ecode", not "never" (ryanbr#67 follow-up) The identity-fallback diagnostic line (ryanbr#69) asserted "(GET_CLOCK never correlated)", but sessionUsedIdentityRef is captured on the FIRST chunk. In the normal flow the correlation lands before chunk 1, and in the reset-RTC case it never lands — both fine. But on a slow link GET_CLOCK can reply late: chunk 1 decodes on the identity fallback, later chunks on the real ref, so "never" is false. First-chunk capture is still the right choice (it flags that the early rows landed misdated), only the absolute word overreached — reword to "(no clock correlation at decode)", true regardless of any later reply. Doc comment + test assertion updated to match.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
The Live Body Console derives every status from activeConnection (= live.connected && live.bonded). `bonded` carries WHOOP encrypted-bond semantics and is deliberately never set by the Oura path (ryanbr#69), so a connected, authenticated, streaming ring fell through every WHOOP branch to "Radio connected, stream not yet trusted." — even while HR was live. Add a display-only `ringStreaming` predicate (live.connected && live.streamingLiveHR) and route the status layer through it: mode detail, connection pill, mode badge, mode color, and the Heart rate / Connection signal-trust tiles now read a ring stream as an active, trusted stream. activeConnection itself is untouched, so bond-only feature gates (buzz, alarm, HRV snapshot, encrypted control) still correctly key off the WHOOP bond. Wear/Worn stats stay gated on activeConnection to avoid a false "On wrist" until the ring's wear signal is confirmed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
Strap log: surface WHERE offloaded rows landed + WHY (stale-RTC "missed sleep", ryanbr#67)
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…ecode", not "never" (ryanbr#67 follow-up) The identity-fallback diagnostic line (ryanbr#69) asserted "(GET_CLOCK never correlated)", but sessionUsedIdentityRef is captured on the FIRST chunk. In the normal flow the correlation lands before chunk 1, and in the reset-RTC case it never lands — both fine. But on a slow link GET_CLOCK can reply late: chunk 1 decodes on the identity fallback, later chunks on the real ref, so "never" is false. First-chunk capture is still the right choice (it flags that the early rows landed misdated), only the absolute word overreached — reword to "(no clock correlation at decode)", true regardless of any later reply. Doc comment + test assertion updated to match.
This was referenced Aug 26, 2026
ryanbr
added a commit
that referenced
this pull request
Aug 27, 2026
Android has stopped the 5/MG reconnect loop since #1642. iOS never got it, so a 5/MG here still wrote the CLIENT_HELLO every connect, lost the link ~3s later and reconnected forever - the behaviour v10.6.0 shipped on both platforms. An HCI capture settled the cause: the phone sends an SMP Pairing Request and the strap answers Pairing Not Supported (0x05), so the encrypted bond the hello waits behind can never arrive on a 5/MG. Suppressing it after the give-up latches trades an unreachable capability (puffin commands, history offload) for one that demonstrably works - continuous live HR, the "Live HR, not fully paired" state the app already models (#69). Ported, mirroring the Kotlin twins: shouldSendClientHello, giveUpSuppressesHello, countsAsBondRefusal and helloSuppressionPrefKey in a new pure Strand/BLE/HelloSuppression.swift; helloSuppressedEpitaph, helloSuppressedHint and pausedHintHandshakeUnanswered on BondRefusalGiveUp; and recordWhoop5BondRefusal, shared by both causes so the split cannot drift. #1640 had to come with it. An unanswered hello produces no write error at all - the link just drops - so it never reached this platform's give-up, which only ever saw INSUFFICIENT_AUTHENTICATION. Without that half the suppression could never trigger. didBond stays permanently false once suppressed, and CLAUDE.md names that the trap. The audit: iOS has no bond watchdog and no #982 detector, so neither can undo it; countsAsBondRefusal gates on family, so a 4.0 can never latch; an auth refusal cannot be double-counted or mislabelled, because the write-error branch clears clientHelloWriteAt before the count; and only user-driven paths reach connect(), so the one-shot retry cannot leak into an automatic reconnect. Three re-review passes found three defects, all of that same family: - keepAliveFire ALSO read didBond, so the timer - and with it the liveness watchdog - would have been switched off for exactly the link the suppression keeps up for hours, leaving a stalled stream with nothing to bounce it. The first correction was dead code: both existing startKeepAlive() sites are post-bond, so on a suppressed link the timer never started at all. - the latch outlived the strap. releaseStrap/forgetDevice reset the give-up under the invariant that no state outlives the strap it belonged to, but the latch is persisted and survived a removal. ANDROID HAD THIS TOO, since #1642; the Kotlin half is in this PR, per the parity contract. - the keep-alive started in the wrong place. Android starts it on the live-HR bonded transition, which also covers an unbonded 5/MG that has not latched yet; iOS would have had no watchdog during the give-up's first five cycles. SettingsView carried a comment left for whoever did this port: its status line falls through to "Finishing the secure pairing handshake" and would describe a handshake no longer being attempted. It now carries the same "streaming but not fully paired" arm as SettingsLogic.strapStatusLine, in all ten locales, placed after the hint so the actionable text still wins. NOT ported: the #1669 opt-in override, which is experimental, default-off, and produced 57 reconnect cycles in an hour on hardware before #1670 bounded it. KNOWN REMAINING GAP: Android reads the Device Information Service on an unbonded link (#1647/#1652/#1653); iOS reads it post-bond only, so a suppressed iOS 5/MG never learns its firmware or whether it is an MG, leaving MG-only capabilities locked. Deliberately left as a follow-up - it is a separate feature that was never ported here at all. Verification: the three hint strings are proven byte-identical to the Kotlin source by extracting and comparing both, not by eye. 17 tests over the pure rules, incl. the family gate across every DeviceFamily case, the keep-alive gate either side, and the latch's case-insensitivity; the pure file also compiled standalone with swiftc and every rule exercised. Android 4552 tests green. app-build green on Build Strand, Test Strand and Build NOOPiOS. NOT HARDWARE-VALIDATED. BLE behaviour cannot be CI-tested and no 5/MG was available on iOS. The falsifiable prediction is the one Android's change carried: after the latch, the loop stops and live HR streams continuously. If a link still drops on a cycle with no CLIENT_HELLO line, the port is wrong.
ryanbr
added a commit
that referenced
this pull request
Aug 28, 2026
… exists for Re-review of my own change, and it does not work as shipped. Offload frames never reach FrameRouter. BLEManager routes them straight to the Backfiller - "Historical replay is bulk sync traffic, not live UI traffic" - and CONSOLE_LOGS is in the offload set (type 50, alongside 47/48/49/56). So the `case "CONSOLE_LOGS"` I added to `handle` only ever sees a console frame that arrives OUTSIDE a sync. Which is not the one worth having. The strap narrates its sync engine precisely WHILE offloading: PullStats, "History burst success. Trim: 0x...", "Historical Dump Complete". Every line this change exists to surface was being routed past the code meant to surface it. The tests passed and app-build was green, because nothing asserts that a log line reaches the log. The tree already had the pattern. `dispatchLiveGestureIfFresh` is a targeted carve-out inside the same offload branch so a real double-tap still fires mid-sync (#69), with a cheap type-byte pre-check so the thousands of type-47 records cost one compare rather than a CRC and a full field decode. `mirrorStrapConsoleIfPresent` is the same shape for the same reason, called from BOTH offload branches - WHOOP4 and 5/MG - which are separate code paths and would otherwise have drifted. The emitter is now one private method shared by the live case and the offload carve-out, so the two paths cannot disagree about what they print or where they cap it. Worth naming why this got past me: I checked that the decode produced the text, that the key matched across three implementations, and that the consumer compiled - and never asked whether the frame reaches the consumer at all. Decoding a thing and receiving it are different questions, and only the second one was the bug. Swift package 642 tests 0 failures, Android 4613 tests 0 failures, doc_comment_lint OK. app-build re-dispatched: two app-target Swift files changed.
4 tasks
ksricharank
pushed a commit
to ksricharank/noop
that referenced
this pull request
Sep 8, 2026
…stamp Reported as 'the water tap is too sensitive'. The strap was innocent: the 260906 log has exactly ONE 'SENSORS: IMU double tap detected' and TWO logged cups, at 21:28:13 and 21:28:14. During an offload the same EVENT reaches the gesture handlers through two paths — handle(parsed:) and the mid-offload carve-out dispatchLiveGestureIfFresh (ryanbr#69) — and the strap re-sends the event inside its 45 s freshness window. AppModel's 1.2 s wall-clock debounce could not cover that: the deliveries landed ~1.3 s apart, and the real gap is however long an offload chunk takes, so no fixed window is safe. Dedupe on the event's own event_timestamp (the strap RTC's real-unix second, already parsed and already used by the freshness gate) instead of on arrival time. A timestamp IS the gesture's identity; a debounce only guesses at identity from timing. Keyed per event name so a tap cannot suppress a wrist change in the same second, cleared per connection, and it fails OPEN when no timestamp is present — silently swallowing a real tap is the worse failure, since a double-tap has no on-screen feedback by definition. A triple-tap was considered and is not the answer: the firmware exposes a single double-tap gesture, and sensitivity was never the problem. Tests reproduce the log's exact scenario (both paths, one tap) and pin the other half — a genuinely new tap still fires — so deleting the callback cannot pass. The fixture's own decode is asserted too: a frame that misplaced event_timestamp would make every assertion vacuous.
ksricharank
pushed a commit
to ksricharank/noop
that referenced
this pull request
Sep 9, 2026
…stamp Reported as 'the water tap is too sensitive'. The strap was innocent: the 260906 log has exactly ONE 'SENSORS: IMU double tap detected' and TWO logged cups, at 21:28:13 and 21:28:14. During an offload the same EVENT reaches the gesture handlers through two paths — handle(parsed:) and the mid-offload carve-out dispatchLiveGestureIfFresh (ryanbr#69) — and the strap re-sends the event inside its 45 s freshness window. AppModel's 1.2 s wall-clock debounce could not cover that: the deliveries landed ~1.3 s apart, and the real gap is however long an offload chunk takes, so no fixed window is safe. Dedupe on the event's own event_timestamp (the strap RTC's real-unix second, already parsed and already used by the freshness gate) instead of on arrival time. A timestamp IS the gesture's identity; a debounce only guesses at identity from timing. Keyed per event name so a tap cannot suppress a wrist change in the same second, cleared per connection, and it fails OPEN when no timestamp is present — silently swallowing a real tap is the worse failure, since a double-tap has no on-screen feedback by definition. A triple-tap was considered and is not the answer: the firmware exposes a single double-tap gesture, and sensitivity was never the problem. Tests reproduce the log's exact scenario (both paths, one tap) and pin the other half — a genuinely new tap still fires — so deleting the callback cannot pass. The fixture's own decode is asserted too: a frame that misplaced event_timestamp would make every assertion vacuous.
ksricharank
pushed a commit
to ksricharank/noop
that referenced
this pull request
Sep 12, 2026
…stamp Reported as 'the water tap is too sensitive'. The strap was innocent: the 260906 log has exactly ONE 'SENSORS: IMU double tap detected' and TWO logged cups, at 21:28:13 and 21:28:14. During an offload the same EVENT reaches the gesture handlers through two paths — handle(parsed:) and the mid-offload carve-out dispatchLiveGestureIfFresh (ryanbr#69) — and the strap re-sends the event inside its 45 s freshness window. AppModel's 1.2 s wall-clock debounce could not cover that: the deliveries landed ~1.3 s apart, and the real gap is however long an offload chunk takes, so no fixed window is safe. Dedupe on the event's own event_timestamp (the strap RTC's real-unix second, already parsed and already used by the freshness gate) instead of on arrival time. A timestamp IS the gesture's identity; a debounce only guesses at identity from timing. Keyed per event name so a tap cannot suppress a wrist change in the same second, cleared per connection, and it fails OPEN when no timestamp is present — silently swallowing a real tap is the worse failure, since a double-tap has no on-screen feedback by definition. A triple-tap was considered and is not the answer: the firmware exposes a single double-tap gesture, and sensitivity was never the problem. Tests reproduce the log's exact scenario (both paths, one tap) and pin the other half — a genuinely new tap still fires — so deleting the callback cannot pass. The fixture's own decode is asserted too: a frame that misplaced event_timestamp would make every assertion vacuous.
ksricharank
pushed a commit
to ksricharank/noop
that referenced
this pull request
Sep 19, 2026
…stamp Reported as 'the water tap is too sensitive'. The strap was innocent: the 260906 log has exactly ONE 'SENSORS: IMU double tap detected' and TWO logged cups, at 21:28:13 and 21:28:14. During an offload the same EVENT reaches the gesture handlers through two paths — handle(parsed:) and the mid-offload carve-out dispatchLiveGestureIfFresh (ryanbr#69) — and the strap re-sends the event inside its 45 s freshness window. AppModel's 1.2 s wall-clock debounce could not cover that: the deliveries landed ~1.3 s apart, and the real gap is however long an offload chunk takes, so no fixed window is safe. Dedupe on the event's own event_timestamp (the strap RTC's real-unix second, already parsed and already used by the freshness gate) instead of on arrival time. A timestamp IS the gesture's identity; a debounce only guesses at identity from timing. Keyed per event name so a tap cannot suppress a wrist change in the same second, cleared per connection, and it fails OPEN when no timestamp is present — silently swallowing a real tap is the worse failure, since a double-tap has no on-screen feedback by definition. A triple-tap was considered and is not the answer: the firmware exposes a single double-tap gesture, and sensitivity was never the problem. Tests reproduce the log's exact scenario (both paths, one tap) and pin the other half — a genuinely new tap still fires — so deleting the callback cannot pass. The fixture's own decode is asserted too: a frame that misplaced event_timestamp would make every assertion vacuous.
5 of 12 tasks
ryanbr
pushed a commit
that referenced
this pull request
Oct 13, 2026
…ded link didConnect clears encryptedBond so each link re-proves it at the WHOOP 4.0 confirmed-write ack (#69), but didBond is only cleared by the disconnect callback. When didConnect fires again with no disconnect in between, the ack was skipped because didBond was still true, so encryptedBond stayed false for the rest of the link: the sidebar read "Live HR (not fully paired)" and buzz (AppModel.canBuzz, BiofeedbackController.canBuzz) was refused, while history sync, which keys off didBond, kept working. Two triggers seen on macOS in one strap log: - the radio powering off and back on: CoreBluetooth sends no disconnect, and poweredOn re-attaches to the strap macOS still holds; - the delayed standing-connect retry from scheduleReconnect firing after the strap has already reconnected. The ack site now classifies the ack through Whoop4BondAck: first bond (unchanged), re-prove (restore encryptedBond and run noteGenuineBond), or already proven (unchanged). didBond and the post-bond handshake are not touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 of 12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Diagnostic instrumentation for #67 ("Missed sleep"). Logging only — no BLE/offload behaviour change.
The bug it exposes
Jenssaibe's strap has a reset RTC (
Strap clock: 835d behind wall, newest banked record2024-03-24). Last night's sleep was banked by the strap with that stale clock, so the offload persisted it under ~2024 — off the recent timeline, so it reads as "missed sleep" (Analytics funnels: no sleep session in the last 14 days).The root cause: on a reset-RTC strap the offload falls back to an identity clock ref (
Backfiller.swift:357,clockRef ?? ClockRef(device: now, wall: now)), which zeroes the offset that FIX #72'scorrectedWallneeds to re-base stale timestamps — so the 2024 stamps are stored verbatim. The log gave no way to see this: it showed the strap was stale and that rows persisted, but not where they landed or what ref decoded them.What this adds
Backfiller— a new session line after the persist summary:Backfill: rows landed on 2024-03-24 · clock ref: IDENTITY fallback (GET_CLOCK never correlated) - stale-record correction OFFThe dates are the actual post-correction landing days (the same night-keys the tally already builds), so a night filed in the past is self-evident. Also emits
strap clock Nd behind wall - correction engaged(stale-but-correlated) orclock ref in sync. Captures the decode ref on the first chunk; reset inbegin().DebugDataDiagnostics— the existingStrap clock: Nd behind wallline now flags that recent sleep may be filed ~Nd in the past (Missed sleep #67), not just that the alarm is unreliable.en_US_POSIXlocale so non-Gregorian device calendars don't corrupt the year), and the no-em-dash convention shared withnoCursorLine/futureRtcLine.Scope
This is deliberately just the log. The actual fix (preserve the stale offset for offload re-basing — either capture the pre-SET_CLOCK correlation or derive the offset from
GET_DATA_RANGE) touches the BLE clock handshake and needs on-strap validation; it's tracked separately on #67. One instrumented log from Jenssaibe confirms identity-fallback vs post-set-zeroed before we change any BLE behaviour.Files:
Strand/Collect/Backfiller.swift,Strand/BLE/BLEManager.swift,Strand/System/DebugDataDiagnostics.swift,StrandTests/BackfillerSessionTallyTests.swift.