Skip to content

Add compact Android widget - #77

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
kavemang:compact-android-widget-icons
Jul 12, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
kavemang:compact-android-widget-icons

Conversation

@kavemang

@kavemang kavemang commented Jul 8, 2026 •

Copy link
Copy Markdown

What this PR does

Adds a separate NOOP Compact Android home-screen widget for Rest, Charge, and Effort.

The existing widget stays unchanged. The compact widget uses moon, lightning, and dumbbell icons for the three daily scores, with live heart rate and strap battery underneath.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

  • ./gradlew --no-daemon :app:compileFullDebugKotlin
  • Installed on a spare Android phone and checked both the existing widget and the new NOOP Compact widget in the launcher picker.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens - no hardcoded colors, fonts, or spacing
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

@kavemang
kavemang force-pushed the compact-android-widget-icons branch from 5e36752 to 1199b9f Compare July 8, 2026 07:07
@kavemang
kavemang force-pushed the compact-android-widget-icons branch from 1199b9f to 67b5409 Compare July 9, 2026 17:57
@kavemang

kavemang commented Jul 9, 2026

Copy link
Copy Markdown
Author
Screenshot_20260709-135327_Nova Launcher Screenshot_20260709-135345_Nova Launcher

@ryanbr
ryanbr merged commit 326a34f into ryanbr:main Jul 12, 2026
ryanbr pushed a commit that referenced this pull request Jul 12, 2026
GermanLocalizationTest.germanResourcesResolveForEveryLocalizedKey fails on
main since the compact widget landed (#77): widget_compact_name and
widget_compact_description exist in values/ but not values-de/. Adds both,
reusing the app's established German terms (Ruhe/Ladung/Anstrengung,
Live-Herzfrequenz, Band-Akku) from widget_description.
ryanbr added a commit that referenced this pull request Jul 12, 2026
…ecovers (#312) (#317)

Reported on Android / WHOOP 5.0 / 8.6.2-staging: HRV & Autonomic shows no live data
while the Live section streams HR fine, after a remove+reconnect. The strap log shows
`→ TOGGLE_REALTIME_HR` then `writeCharacteristic busy; retry 1/12`.

Root cause (Android-only): plain HR rides the standard unbonded 0x2A37 profile, but
live R-R (→ HRV / Autonomic) needs the TOGGLE_REALTIME_HR write to land. Every path
that arms it (reconcileRealtime + the direct arm-on-connect / keep-alive paths) latches
realtimeArmed = the value it SENT *before* the write is confirmed. Android GATT allows
one write in flight, so under contention the toggle can exhaust MAX_WRITE_RETRIES (12)
and be dropped — but realtimeArmed is already latched, so reconcileRealtime and the 30s
keep-alive tick see no edge (want == armed) and never re-send. R-R stays dark forever
while HR keeps flowing. The drop path even documents that a dropped TOGGLE_REALTIME_HR
"silently breaks live HR" (#77) but only mitigates with the 12 retries, no re-arm.

Fix: when the write queue drops a frame, if it was TOGGLE_REALTIME_HR, flip realtimeArmed.
The write never reached the strap, so its true state is the OPPOSITE of the optimistically
latched value; flipping makes the next keep-alive reconcile detect the edge and re-send
the CURRENT want — recovering a dropped ARM *or* disarm within ~30s. Bounded: the re-send
rides the keep-alive cadence (not the drop path), so a persistently-busy stack retries
once per tick, never in a loop. PendingWrite now carries its CommandNumber so the drop
site can tell it was the toggle; a pure predicate shouldReArmRealtimeAfterDrop gates it.

Android-only: iOS CoreBluetooth serialises writes internally and has no "drop after N GATT
retries" path (its realtimeArmedAt is the separate marginal-radio detector), so no Swift twin.

Tests: RealtimeReArmOnDropTest pins the predicate (toggle re-arms; every other command +
null does not) — the full drop→re-arm needs a live GATT stack the JVM harness can't fake
(no Robolectric; same limit + pattern as GattCrashSafetyTest). Needs on-strap confirmation
by the reporter (5/MG live is hardware-only-testable). compileFullDebugKotlin + tests green.
ryanbr added a commit that referenced this pull request Jul 16, 2026
…bond (#490) (#495)

A WHOOP 5/MG's 0x2A19 battery read fires once in didDiscoverCharacteristicsFor
— BEFORE the link is encrypted — which an MG rejects ("Authentication is
insufficient"). The rejection is logged and dropped, and the read is never
retried, so LiveState.batteryPct stays nil from connect. The 5/MG sends no
unsolicited battery notification, so the notify subscription never fills it on
its own; the only re-read path (refreshBattery) is reachable only from the Live
screen. A user on any other screen never sees a battery %.

Re-issue the read in enableLiveNotifications, after the notify loop. Every
caller is post-bond (CLIENT_HELLO-ack, post-bond, keep-alive), so the link is
encrypted and the read succeeds; the keep-alive caller also re-polls ~every 30s
so the reading stays current and BatteryEstimator gets its discharge samples on
any screen. This is the iOS parity for Android's post-handshake read + ~60s
keep-alive poll (WhoopBleClient BATTERY_ON_CONNECT_DELAY_MS / refreshBattery).

4.0 excluded (its 0x2A19 is a stub 100; #77). Estimator guarded by LiveState's
600s same-% throttle. Android already correct — no Kotlin change. Shared Strand
BLEManager, so this fixes macOS too.

Reported and diagnosed by vishk23.
ryanbr added a commit that referenced this pull request Jul 16, 2026
…ault

fillWorkoutHrFromStrap read every workout's HR window under a hardcoded
"my-whoop". A strap-native workout recorded on a SECOND WHOOP (id
"whoop-<mac>", its hrSample rows banked under that id) therefore read an
empty window, so `stats.n < minSamples` returned the row UNCHANGED: its
Avg HR / Max HR were never reconciled from the strap trace and a null Effort
(strain) was never recomputed. Single-WHOOP installs keep the canonical
"my-whoop" id, so they matched the default and were unaffected — this only
bit multi-WHOOP users.

(Calories/energyKcal are NOT touched by this fill — they're computed by the
detector under the day owner, which already resolves multi-device correctly.
The fill only sets avgHr / maxHr / strain.)

Resolve the HR read key per row from the recording device instead:
 - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted
   and zoned from their OWN strap's trace, so key on that strap — strip the
   computed "-noop" suffix to reach the raw hrSample id.
 - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file)
   carry no strap HR; #77 still fills them from the strapDeviceId default, so
   their behaviour is byte-identical (which strap was worn during an imported
   session on a multi-strap install is undetermined and left as-is).

Single-device behaviour is unchanged (detected "my-whoop-noop" strips to
"my-whoop", same as the old default). New JVM test pins the classification
and the per-row key resolution. Surfaced by #510 (a single-WHOOP report whose
root cause is still under investigation); this fixes the multi-WHOOP class.

Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the
Repository's single active id and its read-model WorkoutRow carries no
deviceId — a parity follow-up will thread the source device through the
workout fetch.
ryanbr added a commit that referenced this pull request Jul 16, 2026
…ault

fillWorkoutHrFromStrap read every workout's HR window under a hardcoded
"my-whoop". A strap-native workout recorded on a SECOND WHOOP (id
"whoop-<mac>", its hrSample rows banked under that id) therefore read an
empty window, so `stats.n < minSamples` returned the row UNCHANGED: its
Avg HR / Max HR were never reconciled from the strap trace and a null Effort
(strain) was never recomputed. Single-WHOOP installs keep the canonical
"my-whoop" id, so they matched the default and were unaffected — this only
bit multi-WHOOP users.

(Calories/energyKcal are NOT touched by this fill — they're computed by the
detector under the day owner, which already resolves multi-device correctly.
The fill only sets avgHr / maxHr / strain.)

Resolve the HR read key per row from the recording device instead:
 - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted
   and zoned from their OWN strap's trace, so key on that strap — strip the
   computed "-noop" suffix to reach the raw hrSample id.
 - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file)
   carry no strap HR; #77 still fills them from the strapDeviceId default, so
   their behaviour is byte-identical (which strap was worn during an imported
   session on a multi-strap install is undetermined and left as-is).

Single-device behaviour is unchanged (detected "my-whoop-noop" strips to
"my-whoop", same as the old default). New JVM test pins the classification
and the per-row key resolution. Surfaced by #510 (a single-WHOOP report whose
root cause is still under investigation); this fixes the multi-WHOOP class.

Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the
Repository's single active id and its read-model WorkoutRow carries no
deviceId — a parity follow-up will thread the source device through the
workout fetch.
ryanbr added a commit that referenced this pull request Jul 16, 2026
…ault (#512)

fillWorkoutHrFromStrap read every workout's HR window under a hardcoded
"my-whoop". A strap-native workout recorded on a SECOND WHOOP (id
"whoop-<mac>", its hrSample rows banked under that id) therefore read an
empty window, so `stats.n < minSamples` returned the row UNCHANGED: its
Avg HR / Max HR were never reconciled from the strap trace and a null Effort
(strain) was never recomputed. Single-WHOOP installs keep the canonical
"my-whoop" id, so they matched the default and were unaffected — this only
bit multi-WHOOP users.

(Calories/energyKcal are NOT touched by this fill — they're computed by the
detector under the day owner, which already resolves multi-device correctly.
The fill only sets avgHr / maxHr / strain.)

Resolve the HR read key per row from the recording device instead:
 - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted
   and zoned from their OWN strap's trace, so key on that strap — strip the
   computed "-noop" suffix to reach the raw hrSample id.
 - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file)
   carry no strap HR; #77 still fills them from the strapDeviceId default, so
   their behaviour is byte-identical (which strap was worn during an imported
   session on a multi-strap install is undetermined and left as-is).

Single-device behaviour is unchanged (detected "my-whoop-noop" strips to
"my-whoop", same as the old default). New JVM test pins the classification
and the per-row key resolution. Surfaced by #510 (a single-WHOOP report whose
root cause is still under investigation); this fixes the multi-WHOOP class.

Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the
Repository's single active id and its read-model WorkoutRow carries no
deviceId — a parity follow-up will thread the source device through the
workout fetch.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
Co-authored-by: kavemang <kavemang@users.noreply.github.com>
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
GermanLocalizationTest.germanResourcesResolveForEveryLocalizedKey fails on
main since the compact widget landed (ryanbr#77): widget_compact_name and
widget_compact_description exist in values/ but not values-de/. Adds both,
reusing the app's established German terms (Ruhe/Ladung/Anstrengung,
Live-Herzfrequenz, Band-Akku) from widget_description.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ecovers (ryanbr#312) (ryanbr#317)

Reported on Android / WHOOP 5.0 / 8.6.2-staging: HRV & Autonomic shows no live data
while the Live section streams HR fine, after a remove+reconnect. The strap log shows
`→ TOGGLE_REALTIME_HR` then `writeCharacteristic busy; retry 1/12`.

Root cause (Android-only): plain HR rides the standard unbonded 0x2A37 profile, but
live R-R (→ HRV / Autonomic) needs the TOGGLE_REALTIME_HR write to land. Every path
that arms it (reconcileRealtime + the direct arm-on-connect / keep-alive paths) latches
realtimeArmed = the value it SENT *before* the write is confirmed. Android GATT allows
one write in flight, so under contention the toggle can exhaust MAX_WRITE_RETRIES (12)
and be dropped — but realtimeArmed is already latched, so reconcileRealtime and the 30s
keep-alive tick see no edge (want == armed) and never re-send. R-R stays dark forever
while HR keeps flowing. The drop path even documents that a dropped TOGGLE_REALTIME_HR
"silently breaks live HR" (ryanbr#77) but only mitigates with the 12 retries, no re-arm.

Fix: when the write queue drops a frame, if it was TOGGLE_REALTIME_HR, flip realtimeArmed.
The write never reached the strap, so its true state is the OPPOSITE of the optimistically
latched value; flipping makes the next keep-alive reconcile detect the edge and re-send
the CURRENT want — recovering a dropped ARM *or* disarm within ~30s. Bounded: the re-send
rides the keep-alive cadence (not the drop path), so a persistently-busy stack retries
once per tick, never in a loop. PendingWrite now carries its CommandNumber so the drop
site can tell it was the toggle; a pure predicate shouldReArmRealtimeAfterDrop gates it.

Android-only: iOS CoreBluetooth serialises writes internally and has no "drop after N GATT
retries" path (its realtimeArmedAt is the separate marginal-radio detector), so no Swift twin.

Tests: RealtimeReArmOnDropTest pins the predicate (toggle re-arms; every other command +
null does not) — the full drop→re-arm needs a live GATT stack the JVM harness can't fake
(no Robolectric; same limit + pattern as GattCrashSafetyTest). Needs on-strap confirmation
by the reporter (5/MG live is hardware-only-testable). compileFullDebugKotlin + tests green.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…bond (ryanbr#490) (ryanbr#495)

A WHOOP 5/MG's 0x2A19 battery read fires once in didDiscoverCharacteristicsFor
— BEFORE the link is encrypted — which an MG rejects ("Authentication is
insufficient"). The rejection is logged and dropped, and the read is never
retried, so LiveState.batteryPct stays nil from connect. The 5/MG sends no
unsolicited battery notification, so the notify subscription never fills it on
its own; the only re-read path (refreshBattery) is reachable only from the Live
screen. A user on any other screen never sees a battery %.

Re-issue the read in enableLiveNotifications, after the notify loop. Every
caller is post-bond (CLIENT_HELLO-ack, post-bond, keep-alive), so the link is
encrypted and the read succeeds; the keep-alive caller also re-polls ~every 30s
so the reading stays current and BatteryEstimator gets its discharge samples on
any screen. This is the iOS parity for Android's post-handshake read + ~60s
keep-alive poll (WhoopBleClient BATTERY_ON_CONNECT_DELAY_MS / refreshBattery).

4.0 excluded (its 0x2A19 is a stub 100; ryanbr#77). Estimator guarded by LiveState's
600s same-% throttle. Android already correct — no Kotlin change. Shared Strand
BLEManager, so this fixes macOS too.

Reported and diagnosed by vishk23.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ault (ryanbr#512)

fillWorkoutHrFromStrap read every workout's HR window under a hardcoded
"my-whoop". A strap-native workout recorded on a SECOND WHOOP (id
"whoop-<mac>", its hrSample rows banked under that id) therefore read an
empty window, so `stats.n < minSamples` returned the row UNCHANGED: its
Avg HR / Max HR were never reconciled from the strap trace and a null Effort
(strain) was never recomputed. Single-WHOOP installs keep the canonical
"my-whoop" id, so they matched the default and were unaffected — this only
bit multi-WHOOP users.

(Calories/energyKcal are NOT touched by this fill — they're computed by the
detector under the day owner, which already resolves multi-device correctly.
The fill only sets avgHr / maxHr / strain.)

Resolve the HR read key per row from the recording device instead:
 - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted
   and zoned from their OWN strap's trace, so key on that strap — strip the
   computed "-noop" suffix to reach the raw hrSample id.
 - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file)
   carry no strap HR; ryanbr#77 still fills them from the strapDeviceId default, so
   their behaviour is byte-identical (which strap was worn during an imported
   session on a multi-strap install is undetermined and left as-is).

Single-device behaviour is unchanged (detected "my-whoop-noop" strips to
"my-whoop", same as the old default). New JVM test pins the classification
and the per-row key resolution. Surfaced by ryanbr#510 (a single-WHOOP report whose
root cause is still under investigation); this fixes the multi-WHOOP class.

Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the
Repository's single active id and its read-model WorkoutRow carries no
deviceId — a parity follow-up will thread the source device through the
workout fetch.
ryanbr added a commit that referenced this pull request Jul 25, 2026
…#797)

drainWriteQueue treats writeCharacteristicCompat returning false as proof the
frame was not delivered, holds it in pendingRetry and re-writes it. On a Galaxy S24
the write was delivered anyway: one GET_DATA_RANGE drew three CRC-valid responses
whose strap-side sequence advanced while the origin-seq echo stayed unchanged, so
the strap received the command three times. Only ever after a burst of busy-retries,
never on quiet ticks.

The reason the retry was never called off is narrow: pendingRetry was cleared only
when the drain consumed it, or on reset. Nothing cleared it on a write-completion
callback — which is precisely the evidence that the "refused" write landed. So the
completion released the in-flight slot, left the retry armed, and the retry sent the
frame a second time.

A completion for a frame still held for retry now cancels it. This cannot swallow a
legitimate retry: pendingRetry is non-null only when the most recent attempt
returned BUSY, and the drain never starts a write while one is in flight, so no
other outstanding command-channel write could own the completion. A frame the stack
truly refused produces no completion at all, so its retry still fires — the
#77/#312 protection against a silently dropped TOGGLE_REALTIME_HR / SET_CLOCK /
offload-ack is untouched, which is why the retry itself was left in place rather
than removed.

Not airtight by construction: a completion arriving after the retry has already
fired (12 ms base delay) is too late, so this removes the observed case rather than
making duplication impossible.

The decision is a pure companion predicate because the instance path cannot be
unit-tested here — the constructor needs a real Looper and Context, and there is no
Robolectric. That is the constraint GattCrashSafetyTest documents and the pattern it
follows.

Cancellation runs on the main looper, since pendingRetry is main-looper-only state,
and is posted without delay so it precedes the retry it cancels.
ryanbr added a commit that referenced this pull request Jul 25, 2026
…#797)

Re-review of the previous commit found the safety argument overstated. It claimed
no other outstanding command-channel write could own a completion, because the
drain never starts a write while one is in flight. writeBondFrame is exactly such
a write: it writes the same characteristic DELIBERATELY outside the queue, sets
writeInFlight itself, and never consumes pendingRetry. Its ack is therefore not
evidence that a held frame went out, and acting on it would cancel the retry for a
frame that never left — turning the duplicate this fixes into the silent loss
#77/#312 is about, which is strictly worse than a duplicate.

The bond write is now tracked and its completion excluded, with the flag consumed
on the callback so a later completion cannot inherit it, and cleared in reset() so
a stale flag cannot suppress a legitimate cancellation in the next session.

Also corrects the scope claim. Only WITH-response writes reach this path at all,
because a without-response write gets no completion callback — the drain frees its
own slot after a pacing gap. That happens to cover the reported command and the
ones where a duplicate actually harms: GET_DATA_RANGE, SET_CLOCK, the historical
acks, haptics and RUN_ALARM are all sent withResponse = true. But it is a real
limit rather than a general guarantee, and the previous commit read as though the
fix applied to every write.
ryanbr added a commit that referenced this pull request Jul 25, 2026
…agnostics that pinned it (#797) (#795)

Fixes #797, reported as "finding 1" inside the battery report in issue 791.

THE BUG. One GET_DATA_RANGE produced three CRC-valid responses whose strap-side
sequence advanced (0x7e/0x7f/0x80) while the origin-seq echo stayed 0x0a, so the
strap received the command three times. Only ever after a burst of busy-retries,
never on quiet ticks; reproduced three times in one session on a Galaxy S24 Ultra.

drainWriteQueue treated writeCharacteristicCompat returning false as proof of
non-delivery. The reason the retry was never called off is narrow: pendingRetry was
cleared only when the drain consumed it, or on reset. Nothing cleared it on a
write-completion callback — which is precisely the evidence that the "refused" write
landed. The completion released the in-flight slot, left the retry armed, and the
retry sent the frame again.

THE FIX. A completion for a frame still held for retry cancels that retry. A frame
the stack truly refused produces no completion at all, so its retry still fires and
the #77/#312 protection against a silently dropped TOGGLE_REALTIME_HR / SET_CLOCK /
offload-ack is untouched — which is why the retry was left in place rather than
removed. Trading a duplicate for a silent loss would be worse.

Two limits, both found by re-review rather than assumed away:

The out-of-queue bond write is excluded. writeBondFrame writes the same
characteristic deliberately outside the queue and never consumes pendingRetry, so its
ack is not evidence about a held frame; acting on it would cancel a retry for a frame
that never left. The flag is consumed on the callback so a later completion cannot
inherit it, and cleared in reset().

Only WITH-response writes reach this path, because a without-response write gets no
completion callback. That covers the reported command and every case where a
duplicate actually harms — GET_DATA_RANGE, SET_CLOCK, the historical acks, haptics
and RUN_ALARM are all sent withResponse = true — but it is a limit, not a general
guarantee. A completion arriving after the retry has already fired is also too late.

THE DIAGNOSTICS that made this provable. The Android 13+ write status code is now
logged instead of being collapsed into a Boolean, since ERROR_GATT_WRITE_REQUEST_BUSY
is documented as "not initiated" and knowing which code came back is the difference
between a safe retry and a duplicate. And the 4.0 COMMAND_RESPONSE now exposes
resp_seq and result, which the 5/MG path always did: a 4.0 log previously could not
say whether a command succeeded, and the reporter had to hand-decode result=0x00 to
establish that their strap answers GET_EXTENDED_BATTERY_INFO with FAILURE rather than
an empty success. Offsets verified against their real frames.

Merged without a compile or a device run: no JDK on the authoring machine and
android.yml is disabled. The decision logic is pinned by pure predicates in the
repo's established pattern for BLE code, and the decode offsets were verified against
real capture frames, but the write-path behaviour has not been exercised on hardware.
ryanbr pushed a commit that referenced this pull request Aug 6, 2026
…ords evicting real ones (#1101)

* fix(archive): evict all-zero-payload records before informative ones

The reject archive (#77 / #91) is a ~5 MB rolling JSONL and its eviction was
purely age-ordered below the per-version floor (#344). Measured on a WHOOP 5/MG
offload: 1,275 consecutive type-47 records of layout version 16, 1584 bytes
each, with every byte from offset 21 to the 4-byte CRC32 trailer zero in every
one of them. Their own frame clock advanced by 1 s across 933 of the gaps and
0 s across the other 339 — a contiguous 1 Hz producer, not a burst. At ~3.2 KB
per hex JSONL line the archive holds about 1,500 of them, i.e. ~21 minutes of
retention: under an age-ordered policy every informative record older than that
is evicted by frames that carry nothing, while the UI reports a clean sync.

Eviction is now weakest-value-first. Everything in a band goes before anything
in the next: malformed lines, then surplus lines whose payload region is
ENTIRELY zero, then surplus informative lines. The floor splits with it — the
newest 64 informative lines per layout version stay protected, all-zero lines
get a floor of 8, enough to date the artefact without owning the archive. A
version whose records are only ever empty therefore keeps dated proof it exists
instead of being erased, and the full 64 stays reserved for records that
actually contain something.

The emptiness test is strict: one non-zero byte anywhere makes the frame
informative, because a small populated block inside an otherwise-empty buffer is
exactly what this archive is for. The region starts AFTER the record header (21
on 5/MG, 17 on WHOOP 4 — the same +4 that shifts the type byte 4→8 and the
version byte 5→9) and ends before the CRC trailer; a region that included the
non-zero seq/timestamp bytes would never test as all-zero and the rule would
never fire. A payload region under 32 bytes is not judged at all, so a truncated
frame does not lose priority on the strength of a few zero bytes.

The 5 MB cap is deliberately NOT raised — at ~3.2 KB a line a 1 Hz stream fills
any cap in hours, so a bigger number buys a linear factor of time and costs
every user disk. Value, not volume, was the missing thing. An over-cap rewrite
now evicts to 7/8 of the cap instead of exactly the cap, so a 1 Hz stream no
longer read-parse-rewrites the whole file on the main actor once per record; the
bytes given up are the ones eviction would have taken next anyway, in the same
priority order.

readAll and retention now share one parseArchiveLine, so retention can never
protect a line the read-back would then skip.

Kotlin twin included: hasZeroPayload/payloadStart/LOW_WATER_DIVISOR and the
banded evictLines match byte-for-byte in behaviour. zeroFloor is defaulted on
both sides so no existing call site churns.

* fix(protocol): archive 5/MG records on layout version, not decode outcome

rejectedHistoricalRecords archives a type-47 record only when the DECODE came up
short — parse failed, CRC mismatched, or the result carried no unix, or neither
heart_rate nor gravity_x. That is the wrong question to ask about a layout NOOP
has no field map for. A record whose hist_version is unmapped but which happens
to yield a plausible unix plus one of those fields passes the screen and is then
kept NOWHERE at all: not as rows, because nothing mapped it, and not as bytes,
because it was not archived. The very next trim ack frees it on the strap and it
is gone while the UI reports a clean sync (#77 / #91).

Today that cannot fire, but only by accident: the unmapped branch of
decodeWhoop5Historical reads no offsets, so nothing but hist_version ever
reaches `parsed` for such a record. One added static schema field for type 47,
or one partially-mapped new version, silently reopens it.

The decision is now structural. mappedWhoop5HistoricalVersions ([18, 20, 21, 26])
is the single source of truth behind both the decoder's dispatch and the archive
filter, and any 5/MG record outside it is archived whatever it decoded. The
if-chain in decodeWhoop5Historical became a switch so the mapped versions read
in one place. v18/v20/v21/v26 are unaffected, so nothing already understood
starts being archived wholesale.

WHOOP 4 is untouched: the rule keys off frame[9], which on a WHOOP 4 frame is a
payload byte rather than a version, and its unmapped versions go through the
schema's v24 fallback — which keeps a record only when it validates
physiologically and otherwise drops the biometrics — so those records already
reach the archive by the decode-outcome route.

UnmappedHistoricalLayoutTests pins both directions, including a lockstep check
that for EVERY version outside the mapped set the decoder populates nothing but
hist_version, so the constant and the dispatch switch cannot drift apart without
a test failing. The headline test takes a real v18 record, changes only the
version byte and re-stamps the CRC32 trailer so the frame stays valid: read at
the v18 offsets those bytes still decode a valid unix, a plausible heart rate
and a ~1 g gravity vector, which is precisely the record that used to pass.

Kotlin twin included, with one deliberate divergence documented at the
declaration and asserted in the test: MAPPED_WHOOP5_HISTORICAL_VERSIONS is
{18, 26} on Android, not {18, 20, 21, 26}. Swift dispatches v20 (raw optical)
and v21 (raw IMU) through decodeWhoop5HistoricalV2021; the Kotlin historical
path has no such branch — Whoop5RawOptical and Whoop5RawImu exist there but are
wired to the live deep-buffer route. Claiming 20/21 on Android would assert a
field map that platform does not have, which is the exact class of bug this
constant closes. The archive outcome is identical today either way.
brendankowitz pushed a commit to brendankowitz/noop that referenced this pull request Aug 7, 2026
The 7b line: one slim status line for the Today sky with exactly four states —
syncing (1.1s-rotating spinner + breathing "Reading the strap · N chunks"),
live (breathing sage dot + "Live · NN bpm", the 5/MG experimental-history
case), a TRANSIENT "Up to date" that collapses a few seconds after a sync
settles, and hidden (zero pixels). All states resolve from the existing
SyncChipState/LiveState seam (ryanbr#245); the per-chunk backfilling strobe rides
the same 3s debounce the old refresh vessel used. Chunk counts only, never a
percent (the strap never says how many remain, so "N chunks left" cannot be
said truthfully — the line counts chunks pulled); motion is opacity-only
breathing + the spinner rotation, both gated still under reduce-motion.

The 7c page (TabRoute.syncStatus → SyncPageView): flat ink, because it is a
record of a device, not a moment. The mockup's per-category "Coming across"
rows (sleep stages / HRV / respiratory) are illustrative fiction the pipeline
cannot back — the offload knows THAT a drain runs and how many chunks landed,
not which physiology each chunk carries — so the page shows the single honest
"Strap history" row (plus the real preserved-raw-frames count when nonzero,
ryanbr#77/ryanbr#91), the Device facts LiveState actually holds (connection label ryanbr#266,
battery, last full sync), an "All devices" handoff to DevicesView rather than
a parallel device surface, and the "you can leave, it keeps going" reassurance
while a sync runs. `--demo-sync` (DEBUG-only) pins the states for screenshots,
since no simulator run ever backfills a real strap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GPcFU4xvYzxovzd4aQ7uGB
ryanbr added a commit that referenced this pull request Aug 10, 2026
WHOOP 4.0 users report the strap battery blanking to "—" on Today while
the charging bolt keeps working. Root cause: the two signals had different
sources. Battery % came ONLY from the polled GET_BATTERY_LEVEL
command-response (keep-alive, ~60s), so a stalled/late poll — or a fresh
LiveState after relaunch — leaves batteryPct nil. Charging, meanwhile,
comes from the UNSOLICITED BATTERY_LEVEL event (~8 min) + CHARGING_ON/OFF
that the strap pushes, so it keeps updating.

The same pushed BATTERY_LEVEL event already carries the real SoC
(soc@17/10, range-gated raw<=1100 in the parser — the exact value history
banks). FrameRouter / WhoopBleClient already read charging + mV from it but
not the percent. Read battery_pct from that event too, so the live % is as
robust as charging and no longer depends solely on the polled command.

Live-only: Swift's FrameRouter never sees replayed offload frames (backfill
skips it); the Kotlin twin gates on the same shouldApplyChargingFromBattery-
Event(replayedOffload) guard charging uses. The #77 family caveat does not
apply — that was the 0x2A19 characteristic STUB (constant 100 on 4.0), a
different source; the BATTERY_LEVEL event's SoC is real for both families.

BLE path — needs on-strap validation (a 4.0 where the % had blanked should
now refill within ~8 min without a reconnect). Kotlin compiles; Swift
(FrameRouter, app-target) compile-gated via a testing build.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
Co-authored-by: kavemang <kavemang@users.noreply.github.com>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
GermanLocalizationTest.germanResourcesResolveForEveryLocalizedKey fails on
main since the compact widget landed (ryanbr#77): widget_compact_name and
widget_compact_description exist in values/ but not values-de/. Adds both,
reusing the app's established German terms (Ruhe/Ladung/Anstrengung,
Live-Herzfrequenz, Band-Akku) from widget_description.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ecovers (ryanbr#312) (ryanbr#317)

Reported on Android / WHOOP 5.0 / 8.6.2-staging: HRV & Autonomic shows no live data
while the Live section streams HR fine, after a remove+reconnect. The strap log shows
`→ TOGGLE_REALTIME_HR` then `writeCharacteristic busy; retry 1/12`.

Root cause (Android-only): plain HR rides the standard unbonded 0x2A37 profile, but
live R-R (→ HRV / Autonomic) needs the TOGGLE_REALTIME_HR write to land. Every path
that arms it (reconcileRealtime + the direct arm-on-connect / keep-alive paths) latches
realtimeArmed = the value it SENT *before* the write is confirmed. Android GATT allows
one write in flight, so under contention the toggle can exhaust MAX_WRITE_RETRIES (12)
and be dropped — but realtimeArmed is already latched, so reconcileRealtime and the 30s
keep-alive tick see no edge (want == armed) and never re-send. R-R stays dark forever
while HR keeps flowing. The drop path even documents that a dropped TOGGLE_REALTIME_HR
"silently breaks live HR" (ryanbr#77) but only mitigates with the 12 retries, no re-arm.

Fix: when the write queue drops a frame, if it was TOGGLE_REALTIME_HR, flip realtimeArmed.
The write never reached the strap, so its true state is the OPPOSITE of the optimistically
latched value; flipping makes the next keep-alive reconcile detect the edge and re-send
the CURRENT want — recovering a dropped ARM *or* disarm within ~30s. Bounded: the re-send
rides the keep-alive cadence (not the drop path), so a persistently-busy stack retries
once per tick, never in a loop. PendingWrite now carries its CommandNumber so the drop
site can tell it was the toggle; a pure predicate shouldReArmRealtimeAfterDrop gates it.

Android-only: iOS CoreBluetooth serialises writes internally and has no "drop after N GATT
retries" path (its realtimeArmedAt is the separate marginal-radio detector), so no Swift twin.

Tests: RealtimeReArmOnDropTest pins the predicate (toggle re-arms; every other command +
null does not) — the full drop→re-arm needs a live GATT stack the JVM harness can't fake
(no Robolectric; same limit + pattern as GattCrashSafetyTest). Needs on-strap confirmation
by the reporter (5/MG live is hardware-only-testable). compileFullDebugKotlin + tests green.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…bond (ryanbr#490) (ryanbr#495)

A WHOOP 5/MG's 0x2A19 battery read fires once in didDiscoverCharacteristicsFor
— BEFORE the link is encrypted — which an MG rejects ("Authentication is
insufficient"). The rejection is logged and dropped, and the read is never
retried, so LiveState.batteryPct stays nil from connect. The 5/MG sends no
unsolicited battery notification, so the notify subscription never fills it on
its own; the only re-read path (refreshBattery) is reachable only from the Live
screen. A user on any other screen never sees a battery %.

Re-issue the read in enableLiveNotifications, after the notify loop. Every
caller is post-bond (CLIENT_HELLO-ack, post-bond, keep-alive), so the link is
encrypted and the read succeeds; the keep-alive caller also re-polls ~every 30s
so the reading stays current and BatteryEstimator gets its discharge samples on
any screen. This is the iOS parity for Android's post-handshake read + ~60s
keep-alive poll (WhoopBleClient BATTERY_ON_CONNECT_DELAY_MS / refreshBattery).

4.0 excluded (its 0x2A19 is a stub 100; ryanbr#77). Estimator guarded by LiveState's
600s same-% throttle. Android already correct — no Kotlin change. Shared Strand
BLEManager, so this fixes macOS too.

Reported and diagnosed by vishk23.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ault (ryanbr#512)

fillWorkoutHrFromStrap read every workout's HR window under a hardcoded
"my-whoop". A strap-native workout recorded on a SECOND WHOOP (id
"whoop-<mac>", its hrSample rows banked under that id) therefore read an
empty window, so `stats.n < minSamples` returned the row UNCHANGED: its
Avg HR / Max HR were never reconciled from the strap trace and a null Effort
(strain) was never recomputed. Single-WHOOP installs keep the canonical
"my-whoop" id, so they matched the default and were unaffected — this only
bit multi-WHOOP users.

(Calories/energyKcal are NOT touched by this fill — they're computed by the
detector under the day owner, which already resolves multi-device correctly.
The fill only sets avgHr / maxHr / strain.)

Resolve the HR read key per row from the recording device instead:
 - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted
   and zoned from their OWN strap's trace, so key on that strap — strip the
   computed "-noop" suffix to reach the raw hrSample id.
 - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file)
   carry no strap HR; ryanbr#77 still fills them from the strapDeviceId default, so
   their behaviour is byte-identical (which strap was worn during an imported
   session on a multi-strap install is undetermined and left as-is).

Single-device behaviour is unchanged (detected "my-whoop-noop" strips to
"my-whoop", same as the old default). New JVM test pins the classification
and the per-row key resolution. Surfaced by ryanbr#510 (a single-WHOOP report whose
root cause is still under investigation); this fixes the multi-WHOOP class.

Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the
Repository's single active id and its read-model WorkoutRow carries no
deviceId — a parity follow-up will thread the source device through the
workout fetch.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…agnostics that pinned it (ryanbr#797) (ryanbr#795)

Fixes ryanbr#797, reported as "finding 1" inside the battery report in issue 791.

THE BUG. One GET_DATA_RANGE produced three CRC-valid responses whose strap-side
sequence advanced (0x7e/0x7f/0x80) while the origin-seq echo stayed 0x0a, so the
strap received the command three times. Only ever after a burst of busy-retries,
never on quiet ticks; reproduced three times in one session on a Galaxy S24 Ultra.

drainWriteQueue treated writeCharacteristicCompat returning false as proof of
non-delivery. The reason the retry was never called off is narrow: pendingRetry was
cleared only when the drain consumed it, or on reset. Nothing cleared it on a
write-completion callback — which is precisely the evidence that the "refused" write
landed. The completion released the in-flight slot, left the retry armed, and the
retry sent the frame again.

THE FIX. A completion for a frame still held for retry cancels that retry. A frame
the stack truly refused produces no completion at all, so its retry still fires and
the ryanbr#77/ryanbr#312 protection against a silently dropped TOGGLE_REALTIME_HR / SET_CLOCK /
offload-ack is untouched — which is why the retry was left in place rather than
removed. Trading a duplicate for a silent loss would be worse.

Two limits, both found by re-review rather than assumed away:

The out-of-queue bond write is excluded. writeBondFrame writes the same
characteristic deliberately outside the queue and never consumes pendingRetry, so its
ack is not evidence about a held frame; acting on it would cancel a retry for a frame
that never left. The flag is consumed on the callback so a later completion cannot
inherit it, and cleared in reset().

Only WITH-response writes reach this path, because a without-response write gets no
completion callback. That covers the reported command and every case where a
duplicate actually harms — GET_DATA_RANGE, SET_CLOCK, the historical acks, haptics
and RUN_ALARM are all sent withResponse = true — but it is a limit, not a general
guarantee. A completion arriving after the retry has already fired is also too late.

THE DIAGNOSTICS that made this provable. The Android 13+ write status code is now
logged instead of being collapsed into a Boolean, since ERROR_GATT_WRITE_REQUEST_BUSY
is documented as "not initiated" and knowing which code came back is the difference
between a safe retry and a duplicate. And the 4.0 COMMAND_RESPONSE now exposes
resp_seq and result, which the 5/MG path always did: a 4.0 log previously could not
say whether a command succeeded, and the reporter had to hand-decode result=0x00 to
establish that their strap answers GET_EXTENDED_BATTERY_INFO with FAILURE rather than
an empty success. Offsets verified against their real frames.

Merged without a compile or a device run: no JDK on the authoring machine and
android.yml is disabled. The decision logic is pinned by pure predicates in the
repo's established pattern for BLE code, and the decode offsets were verified against
real capture frames, but the write-path behaviour has not been exercised on hardware.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ords evicting real ones (ryanbr#1101)

* fix(archive): evict all-zero-payload records before informative ones

The reject archive (ryanbr#77 / ryanbr#91) is a ~5 MB rolling JSONL and its eviction was
purely age-ordered below the per-version floor (ryanbr#344). Measured on a WHOOP 5/MG
offload: 1,275 consecutive type-47 records of layout version 16, 1584 bytes
each, with every byte from offset 21 to the 4-byte CRC32 trailer zero in every
one of them. Their own frame clock advanced by 1 s across 933 of the gaps and
0 s across the other 339 — a contiguous 1 Hz producer, not a burst. At ~3.2 KB
per hex JSONL line the archive holds about 1,500 of them, i.e. ~21 minutes of
retention: under an age-ordered policy every informative record older than that
is evicted by frames that carry nothing, while the UI reports a clean sync.

Eviction is now weakest-value-first. Everything in a band goes before anything
in the next: malformed lines, then surplus lines whose payload region is
ENTIRELY zero, then surplus informative lines. The floor splits with it — the
newest 64 informative lines per layout version stay protected, all-zero lines
get a floor of 8, enough to date the artefact without owning the archive. A
version whose records are only ever empty therefore keeps dated proof it exists
instead of being erased, and the full 64 stays reserved for records that
actually contain something.

The emptiness test is strict: one non-zero byte anywhere makes the frame
informative, because a small populated block inside an otherwise-empty buffer is
exactly what this archive is for. The region starts AFTER the record header (21
on 5/MG, 17 on WHOOP 4 — the same +4 that shifts the type byte 4→8 and the
version byte 5→9) and ends before the CRC trailer; a region that included the
non-zero seq/timestamp bytes would never test as all-zero and the rule would
never fire. A payload region under 32 bytes is not judged at all, so a truncated
frame does not lose priority on the strength of a few zero bytes.

The 5 MB cap is deliberately NOT raised — at ~3.2 KB a line a 1 Hz stream fills
any cap in hours, so a bigger number buys a linear factor of time and costs
every user disk. Value, not volume, was the missing thing. An over-cap rewrite
now evicts to 7/8 of the cap instead of exactly the cap, so a 1 Hz stream no
longer read-parse-rewrites the whole file on the main actor once per record; the
bytes given up are the ones eviction would have taken next anyway, in the same
priority order.

readAll and retention now share one parseArchiveLine, so retention can never
protect a line the read-back would then skip.

Kotlin twin included: hasZeroPayload/payloadStart/LOW_WATER_DIVISOR and the
banded evictLines match byte-for-byte in behaviour. zeroFloor is defaulted on
both sides so no existing call site churns.

* fix(protocol): archive 5/MG records on layout version, not decode outcome

rejectedHistoricalRecords archives a type-47 record only when the DECODE came up
short — parse failed, CRC mismatched, or the result carried no unix, or neither
heart_rate nor gravity_x. That is the wrong question to ask about a layout NOOP
has no field map for. A record whose hist_version is unmapped but which happens
to yield a plausible unix plus one of those fields passes the screen and is then
kept NOWHERE at all: not as rows, because nothing mapped it, and not as bytes,
because it was not archived. The very next trim ack frees it on the strap and it
is gone while the UI reports a clean sync (ryanbr#77 / ryanbr#91).

Today that cannot fire, but only by accident: the unmapped branch of
decodeWhoop5Historical reads no offsets, so nothing but hist_version ever
reaches `parsed` for such a record. One added static schema field for type 47,
or one partially-mapped new version, silently reopens it.

The decision is now structural. mappedWhoop5HistoricalVersions ([18, 20, 21, 26])
is the single source of truth behind both the decoder's dispatch and the archive
filter, and any 5/MG record outside it is archived whatever it decoded. The
if-chain in decodeWhoop5Historical became a switch so the mapped versions read
in one place. v18/v20/v21/v26 are unaffected, so nothing already understood
starts being archived wholesale.

WHOOP 4 is untouched: the rule keys off frame[9], which on a WHOOP 4 frame is a
payload byte rather than a version, and its unmapped versions go through the
schema's v24 fallback — which keeps a record only when it validates
physiologically and otherwise drops the biometrics — so those records already
reach the archive by the decode-outcome route.

UnmappedHistoricalLayoutTests pins both directions, including a lockstep check
that for EVERY version outside the mapped set the decoder populates nothing but
hist_version, so the constant and the dispatch switch cannot drift apart without
a test failing. The headline test takes a real v18 record, changes only the
version byte and re-stamps the CRC32 trailer so the frame stays valid: read at
the v18 offsets those bytes still decode a valid unix, a plausible heart rate
and a ~1 g gravity vector, which is precisely the record that used to pass.

Kotlin twin included, with one deliberate divergence documented at the
declaration and asserted in the test: MAPPED_WHOOP5_HISTORICAL_VERSIONS is
{18, 26} on Android, not {18, 20, 21, 26}. Swift dispatches v20 (raw optical)
and v21 (raw IMU) through decodeWhoop5HistoricalV2021; the Kotlin historical
path has no such branch — Whoop5RawOptical and Whoop5RawImu exist there but are
wired to the live deep-buffer route. Claiming 20/21 on Android would assert a
field map that platform does not have, which is the exact class of bug this
constant closes. The archive outcome is identical today either way.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…r#1226)

WHOOP 4.0 users report the strap battery blanking to "—" on Today while
the charging bolt keeps working. Root cause: the two signals had different
sources. Battery % came ONLY from the polled GET_BATTERY_LEVEL
command-response (keep-alive, ~60s), so a stalled/late poll — or a fresh
LiveState after relaunch — leaves batteryPct nil. Charging, meanwhile,
comes from the UNSOLICITED BATTERY_LEVEL event (~8 min) + CHARGING_ON/OFF
that the strap pushes, so it keeps updating.

The same pushed BATTERY_LEVEL event already carries the real SoC
(soc@17/10, range-gated raw<=1100 in the parser — the exact value history
banks). FrameRouter / WhoopBleClient already read charging + mV from it but
not the percent. Read battery_pct from that event too, so the live % is as
robust as charging and no longer depends solely on the polled command.

Live-only: Swift's FrameRouter never sees replayed offload frames (backfill
skips it); the Kotlin twin gates on the same shouldApplyChargingFromBattery-
Event(replayedOffload) guard charging uses. The ryanbr#77 family caveat does not
apply — that was the 0x2A19 characteristic STUB (constant 100 on 4.0), a
different source; the BATTERY_LEVEL event's SoC is real for both families.

BLE path — needs on-strap validation (a 4.0 where the % had blanked should
now refill within ~8 min without a reconnect). Kotlin compiles; Swift
(FrameRouter, app-target) compile-gated via a testing build.
ryanbr added a commit that referenced this pull request Aug 25, 2026
Re-review, and the history sharpens what this was.

`strapDeviceId` and its canonical default came from #77, the original
imported-HR feature. The union resolver came much later, in #857 — "one HR
device-id rule for the chart, zones, Avg HR and HRR". That commit wired
the fill to the new resolver but left it passing this #77-era parameter,
so the one rule ran on two different inputs, and Avg HR was the single
surface that did not get it. The reported page is that gap: chart, zones
and HRR from the union, the average from the canonical id alone.

The parameter is now REQUIRED. Once both call sites were corrected nothing
depended on the default, and a caller that cannot name the strap it means
should not be quietly handed the canonical one — that silence is the whole
defect. Swift has no such parameter at all, so this also narrows the
shapes rather than widening them.

Also worth recording, since a tester will try it: `AppViewModel.deviceId`
snapshots `NoopApplication.activeDeviceId`, which is `by lazy` — so it is
fixed for the process. Switching straps needs an app restart before HR
reads follow. That is pre-existing and applies identically to the chart,
which reads the same value; this change does not introduce it and does not
widen it, but the fill now shares the limitation rather than sidestepping
it with a constant.

Suite 4308/0, assembleFullDebug, doc lint.
ryanbr added a commit that referenced this pull request Aug 25, 2026
Reported by @bartmuskala: three Health Connect cycling sessions rendering three
different pages. Two showed "AVG –" beside a populated HR curve, a PEAK, a LOW, a
full zone split labelled "From strap HR", and — on one — a complete HR-recovery
block. Every one of those is derived from the samples the average claimed not to
have.

The chart, zones and recovery resolve their read key through
`workoutHrDeviceIds(source, rowDeviceId, activeStrapId = vm.deviceId)`, which for
an imported row is `importedSourceIdsFor(active)` = active ∪ canonical.
`fillWorkoutHrFromStrap` took a `strapDeviceId` defaulting to the canonical id and
BOTH UI call sites left it defaulted, so the fill resolved the canonical id ALONE.
On an install whose strap banks under a non-canonical id — a re-add, or a 5/MG's
transient CB-UUID pairing (#1193) — the graph found the trace and the fill did not.

The history names the regression precisely. `strapDeviceId` and its canonical
default came from #77, the original imported-HR feature. The union resolver came
later, in #857 — "one HR device-id rule for the chart, zones, Avg HR and HRR".
That commit wired the fill to the new resolver but kept passing it the #77-era
parameter, so the one rule ran on two inputs and Avg HR was the one surface that
never got it. It is also #836/#928 for the same reporter: that fix made the
resolver return the active union precisely so a re-added strap stops leaving Avg
HR and Effort blank, and this call site handed it a constant instead.

Both call sites now pass the active strap, and the parameter is REQUIRED — nothing
depended on the default once they were corrected, and a caller that cannot name
the strap it means should not be quietly handed the canonical one. Swift has no
such parameter at all (`reconcileWorkoutHrWithTrace` reads the Repository's active
deviceId directly), so this brings Android to the reference implementation rather
than changing shared behaviour.

The parameter doc claimed the canonical default was deliberate, on the grounds
that which strap was worn during an imported session is undetermined on a MULTI-
strap install. True, and unresolved by this — but it made the common SINGLE-strap
case wrong, and the union with its first-wins merge is exactly what the chart
already applies to the same window. Rewritten to say that rather than the opposite.

Test pins the asymmetry rather than the call site, which is a ViewModel hop and
unreachable from a Robolectric-free suite: the canonical constant resolves to a
STRICT subset that cannot see a non-canonical strap's samples.

Verified: Android suite 4308 green, assembleFullDebug, doc lint.

Two limits worth recording. The reporter attached no strap log, so their active id
being non-canonical is likely (a 5.0, given #1193) but unconfirmed — the fix
restores the documented display == graph == zones == effort invariant regardless
and can only widen the id set, never narrow it. And `AppViewModel.deviceId`
snapshots a `by lazy` value, so a strap switch needs an app restart before reads
follow; that is pre-existing, applies identically to the chart, and diverges from
Swift, whose read id is re-pointed on every registry change (#814).
ryanbr added a commit that referenced this pull request Aug 25, 2026
A WHOOP 4.0 field log banks two battery samples of 81% thirty seconds
before the strap's own report says 39.2, then stays near 39 for the rest of
the session. Those two are the only samples following a "Reading standard
Battery Level (0x2A19)" line, which appears exactly twice and never again
once the strap is identified.

The 4.0's 0x2A19 is a stub rather than a state of charge (#77), but both
readers asked the family question the wrong way round — `!= WHOOP4` —
against a connectedFamily that defaults to WHOOP4, is written only at
service discovery, and was never cleared per-connection, so between links
it held the PREVIOUS strap's family. Asking that of a guess answers "use
the standard characteristic" for a 4.0. It is the #171 shape the family
resolver's own docs warn about: a family question answered by a
fall-through rather than by evidence.

Not cosmetic — banked samples feed the discharge-slope estimate (#713), so
a phantom 81% at session start reaches the "time left" readout and lands in
the history the open 4.0 drain work reads.

Clear the establishment state per-connection alongside the other flags in
reset(), and route both readers through one pure decision requiring
positive evidence: defer while unknown, custom command for a 4.0, standard
characteristic only for a link positively established as non-4.0.

Deferring is safe: the battery is polled about every 60s, and the
post-connect refresh sits inside an already-established 5/MG branch behind
a 1.5s delay. reset() runs only in connectToDevice() and teardown, never
between discovery and use.

Also corrected the comment claiming that characteristic is a "constant
100". It returned 81 here — plausible enough to pass any range check, which
is why the source and not the value has to be the gate.

Android only. Swift has the identical gate shape but feeds it selectedModel,
which has no generic case, defaults to .whoop4 (the safe branch), and is set
by the service-filtered scan or adopt that reached the strap. The one
residual — willRestoreState with a stale persisted model — is unevidenced
and left for its own issue.

Verified: app-build green both legs; Android build-and-test green; 4 new JVM
tests, one sweeping every (established, family) pair to assert exactly one
reaches the standard characteristic; doc_comment_lint and i18n_audit clean.
Not strap-verified — the 81/39.2 pair above is the reproduction.
DX23876 added a commit to DX23876/noop that referenced this pull request Oct 16, 2026
…from the strap trace

A workout recorded while the strap link was down is saved without average,
peak or Effort, though the strap hands its own trace over at the next sync.
Average and peak already followed that trace at read time (ryanbr#77/ryanbr#499); the
Effort stayed empty. The analysis pass now stores all three for recorded or
entered workouts saved without heart rate, through the same resolver and
fill table as the Apple Health fill, in their own namespace, laid over the
row at read time. Recorded in docs/fork/decisions.md.

Analysis migration required: no. New fills for rows that had no heart rate;
nothing already scored changes.
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.

2 participants