Skip to content

iOS/macOS: clarify overnight-only HRV setting copy (#92) - #120

Merged
ryanbr merged 1 commit into
mainfrom
ios-hrv-overnight-copy-92
Jul 9, 2026
Merged

ryanbr merged 1 commit into
mainfrom
ios-hrv-overnight-copy-92

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 9, 2026

Copy link
Copy Markdown
Owner

iOS/macOS half of #92, mirroring the Android copy clarification merged in #95 so both platforms describe the "Overnight only" continuous-HRV setting identically.

What

Strand/Screens/SettingsView.swift (shared iOS + macOS) had the old vague description; this replaces it with the same wording now on Android main:

  • states that continuous background HRV capture (including daytime naps) is paused outside the quiet-hours window
  • points users to the "Take an HRV reading" button on the Live screen for an on-demand daytime reading

Why it's safe / accurate

Copy-only, verbatim match to the Android string. No behaviour change — the overnight-window logic and the manual-snapshot realtime bypass are untouched. I verified both facts the copy asserts against the code:

  • the Live screen has a button with that exact label (LiveView.swift / HRVSnapshotView.swift, [sleep] Sleep & Rest #127)
  • it bypasses the overnight gate: opening the Live screen sets screenWantsRealtime = true, which arms the R-R stream regardless of the setting

The stale localized entry for the old string is simply bypassed (the code now looks up the new key; non-English falls back to the new English until re-translated) — same source-string-only scope as #95.

Closes the iOS/macOS side of #92.

Mirror the Android copy clarification (merged #95) onto the shared iOS/macOS
SettingsView so both platforms explain the 'Overnight only' continuous-HRV
setting the same way: continuous background HRV capture (incl. daytime naps) is
paused outside the quiet-hours window, and for an on-demand daytime reading the
user should use the 'Take an HRV reading' button on the Live screen.

Copy-only, verbatim match to the Android string now on main. No behaviour change
(the overnight-window logic + the manual-snapshot realtime bypass are unchanged);
verified both referenced facts against the code — the Live-screen button exists
with that exact label, and the snapshot arms the R-R stream via screenWantsRealtime
regardless of the overnight gate. Addresses the iOS half of #92.
@ryanbr
ryanbr merged commit 83533c2 into main Jul 9, 2026
@ryanbr
ryanbr deleted the ios-hrv-overnight-copy-92 branch July 14, 2026 10:49
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
iOS/macOS: clarify overnight-only HRV setting copy (ryanbr#92)
ryanbr added a commit that referenced this pull request Aug 27, 2026
…nothing (#1686)

@UncreativeX's capture shows four consecutive completed offloads that banked no
sensor rows, and a strap whose newest stored record is 24 days old. NOOP knew
that second fact - GET_DATA_RANGE gave it, and the client already logged the raw
offset as "2128521s" - and never said it in a form anyone would read. The user
was told only "the strap banked no sensor history", which is the SAME line a
perfectly healthy caught-up strap produces. That ambiguity is why #1541 has
stayed open and unactionable since May.

This is the counterpart futureRtcLine never had. That one handles a strap whose
clock runs AHEAD: it names the day count and gives the remedy. Nothing handled a
strap whose newest record sits far in the PAST.

The wording states the FACT and lets the condition carry the interpretation: "the
strap's newest stored record is about 24 day(s) old. If you have worn it since
then, it has stopped saving history to its flash." A strap left in a drawer shows
the same number innocently, so asserting a corrupt RTC would claim more than the
data supports - which is exactly how this area has misled people before.

It also says the part the standing advice omits. NOOP re-sends SET_CLOCK to a
WHOOP 4.0 on EVERY connect, in both firmware payload forms (#120), so "charge to
100% and reconnect" has effectively been retried every session for 24 days.
Telling this user to do it again was never going to help. The line gives the real
escalation - charge, Restart strap, forget and re-pair - and the one test that
settles whether NOOP is involved at all: if the official WHOOP app is also
missing those days, the strap is the cause.

CHECKED FROM THE CODE BEFORE WRITING ANY OF IT. The "banked nothing" verdict
requires decodedChunks == 0, so this is NOT records decoded and then dropped by
the #547 gate - there was nothing to decode, and the existing attribution is
honest. The backoff is not trapping them either: MANUAL is never floored, and
CONNECT/FOREGROUND use the flat 90s event floor with no multiplier.

Three review passes, three finds:

  - THE BANNER, not just the log. The first version put the age only in the strap
    log, which a user sees by exporting a capture. The message they actually read
    still said "Fully charge it to 100%, then reconnect, and it should start
    banking again" - omitting the age, and PROMISING a recovery that has not
    happened in three weeks. A fix aimed at making this actionable was reaching
    the one audience that already had the log. staleRecordBanner is the same
    honesty in banner length, and a test asserts the promise cannot come back.
  - the field was read twice, once for the log and once for the banner, so a
    mutation between them could have had the two disagree about whether the strap
    is stale. Read once now, in the enclosing scope both consume.
  - PARITY: the twinned functions matched, the INPUT did not. strapNewestTs is
    gated on feedsSync in Swift (#695 - WHOOP4 today) and set for any family that
    answers GET_DATA_RANGE on Android, so a 5/MG would have shown the dated
    wording on one platform only. Both reads are now gated on WHOOP4 explicitly,
    including where it is redundant today, so a later widening of feedsSync cannot
    silently invert which side fires. The pre-existing population difference is
    left alone: changing it would alter 5/MG sync behaviour, which is #695's
    business.

Second, smaller fix in the same area. "Backfill: skipped (FOREGROUND) - policy
floor not met (empty streak 4)" appended the streak for EVERY trigger, but the
streak - and the future-dated clock note beside it - gate PERIODIC and STRAP only;
a FOREGROUND skip is just "58s since the last sync against a 90s floor". The line
named a cause it could not attribute, and its practical cost was real: it misled
me while reading this very capture. Android only, because Swift has no such line.

Verification: the rules are pure and twinned, and BOTH staleRecordLine and
staleRecordBanner are proven byte-identical across platforms by extracting and
comparing. 12 tests (6 per platform), including the reporter's real numbers -
newest 1785692420 against wall 1787820941 - producing "about 24 day(s) old", the
two-day boundary, a night off-wrist staying silent, a future-dated record staying
futureRtcLine's job, and that neither string says "corrupt" or promises banking
will resume. Also compiled standalone with swiftc against those same capture
values. Android 4576 tests green, compile clean, doc_comment_lint and i18n clean.
app-build green on Build Strand, Test Strand and Build NOOPiOS.

Every in-repo surface carrying the old advice was swept; what remains is release
notes and changelog entries, which are historical records and stay as written.
The Troubleshooting WIKI still carries it and is not in this repo - worth
updating, since the issue template points reporters at exactly that page.

What this does NOT do: make the strap bank again. If it has genuinely stopped
writing to flash that is strap-side. It tells the user that, with evidence,
instead of leaving them to re-charge for another three weeks.
ryanbr added a commit that referenced this pull request Sep 2, 2026
Second re-review catch, and the same mistake the original bug was made of.

The charged branch asserted that NOOP re-sends the clock on every connect.
That holds on WHOOP4, where runConnectHandshake calls SET_CLOCK
unconditionally in both payload forms (#120). It is false on a 5/MG: that
clock write is gated behind didBond, and a strap that answers SMP with
Pairing Not Supported (#1635) never sets didBond, so it is never clocked at
all - which is exactly the strap most likely to be sitting at 1970/71 with
this warning on screen.

So the sentence would have confidently told the affected 5/MG users
something untrue about their own device, in a warning whose whole purpose is
to stop giving advice that does not apply.

The charged branch now says only what holds for every strap - that charging
again will not change it - and asks for the log. The mechanism belongs in
the issue, where it can be model-specific; a UI string shown to every model
cannot be. Tests pin the absence of the claim on both platforms so it is not
reintroduced.

Kotlin: 21/21, forced re-run rather than a cached task result.
laurence-cw added a commit to laurence-cw/loop that referenced this pull request Oct 5, 2026
…et unarmed WHOOP 4

Two fixes from one field WHOOP 4.0 log (both Apple and Android, with twin tests):

1. Clock after a reboot. The clock was set only in the connect handshake. When the
   strap rebooted on a flat battery (BOOT, RTC_LOST) while the link stayed up, it banked
   no usable history until a later connect set it: ~66 h lost after 26 Sep, ~9.5 h after
   20 Sep. A live BOOT / RTC_LOST event now re-sets the clock at once (and re-asks
   GET_CLOCK on Apple so the correlation follows), over a live bonded link, at most once
   per 30 s. The same SET_CLOCK / GET_CLOCK the handshake sends; nothing new reaches the
   strap. Android ignores replayed offload events.

2. Liveness fuse. The 120 s 'no data' bounce assumes a streaming strap. An UNARMED 4.0
   (no Live screen, Continuous HRV capture off, the default) is quiet between ~8-min
   battery events, so 100 of the install's 112 disconnects in a day were this bounce,
   each re-running the offload (327 that day). 120 s now applies only while realtime is
   armed; otherwise 600 s, as the 5/MG family already has (ryanbr#1414).

Verification: StrandTests 2279 pass (StrapClockAndLivenessTests 7 new); NOOPiOS builds;
doc_comment_lint OK. Kotlin + tests written, NOT compiled (no JDK). Not yet verified on
the strap: expect far fewer 'No data for >120s' lines and an immediate 'setting its
clock now' line after the next flat-battery reboot.

Refs ryanbr#1414, ryanbr#120

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DX23876 added a commit to DX23876/noop that referenced this pull request Oct 31, 2026
SET_CLOCK's 8-byte body is [seconds u32 LE][subseconds u32 LE] in 1/32768 s, the
unit of the EVENT header u16 at frame 16 and of the alarm body. NOOP sent the
fraction as 0, flooring the phone time to the whole second. A 2026-10-31 MG
capture (fw 50.41.1.0) sat 1.3 to 2.1 s behind after each set while the strap
RTC drifted under 3 ppm over 5.7 h, so the offset came from the set itself.
OpenStrap and meta1971/noop-mg-ecg send the same fraction on MG straps.

New pure builder SetClockPayload.withSubseconds(epochMs:) in WhoopProtocol plus
its Kotlin twin, both pinned by matching tests. The 5/MG connect path and
sendSetClockBothForms use it on iOS/macOS and Android; WHOOP 4 keeps the
zero-fraction 8-byte and 9-byte forms validated on fw 41.17.x (ryanbr#120).

Not yet verified on hardware: the next strap log after a connect should show
the GET_CLOCK reply within a fraction of a second of the phone.

Analysis migration required: no
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