Skip to content

Strap log: surface WHERE offloaded rows landed + WHY (stale-RTC "missed sleep", #67) - #69

Merged
ryanbr merged 1 commit into
mainfrom
diag-strap-clock-misdate
Jul 8, 2026
Merged

ryanbr merged 1 commit into
mainfrom
diag-strap-clock-misdate

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 8, 2026

Copy link
Copy Markdown
Owner

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 record 2024-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's correctedWall needs 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 OFF
    The 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) or clock ref in sync. Captures the decode ref on the first chunk; reset in begin().
  • DebugDataDiagnostics — the existing Strap clock: Nd behind wall line now flags that recent sleep may be filed ~Nd in the past (Missed sleep #67), not just that the alarm is unreliable.
  • Tests — cover the formatter: identity-fallback past-date signature, correlated-stale "correction engaged", in-sync, multi-night UTC range (en_US_POSIX locale so non-Gregorian device calendars don't corrupt the year), and the no-em-dash convention shared with noCursorLine/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.

…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.
@ryanbr
ryanbr merged commit d31b70f into main Jul 8, 2026
4 checks passed
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.
@ryanbr
ryanbr deleted the diag-strap-clock-misdate branch July 14, 2026 10:49
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.
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.
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.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant