Skip to content

charging: stop pack presence from overriding the strap gauge - #1945

Merged
ryanbr merged 5 commits into
mainfrom
docs/1935-charging-conflation
Sep 7, 2026
Merged

ryanbr merged 5 commits into
mainfrom
docs/1935-charging-conflation

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 7, 2026 •

Copy link
Copy Markdown
Owner

Started as option A from #1935, document the conflation rather than split the
state. Re-reviewing the doc before merging showed the argument that option A
rested on was wrong, so this now carries the fix as well.

What the re-review found

The doc claimed a spurious charging = true was bounded and self-healing: the
next BATTERY_LEVEL overwrites it from the strap's own gauge, so at worst it is
wrong for about one battery cadence. That is true on iOS. It was not true on
Android, because BATTERY_LEVEL was not the only writer.

The pushed pack-info event (109) also wrote charging = true, keyed on pack
PRESENCE plus a plausible SoC. displayable is present && socPct in 0.0..100.0,
so a flat pack qualifies, and the decoder's own hardware-confirmed note says the
strap volunteers 109 every couple of minutes while a pack is attached. That
outruns the ~8 min BATTERY_LEVEL that would clear the flag. So the gauge never
got the last word: a pack attached and not charging read as charging for its
whole attachment, not for one cadence.

That is not only a wrong pill. idleThrottleActive reads the flag and gates
three levers: the low-battery offload cadence, the GATT connection-priority
throttle, and the continuous-capture pause behind the user's own "Pause HRV
capture" percentage. A strap on a flat or badly seated pack kept background
capture running at low battery after the wearer asked for it to stop.

iOS was never affected. There the pack record is log-only and Test Centre gated,
so nothing but the gauge writes the flag.

The fix

109 now publishes packSocPct alone. That is what the anti-staleness half
actually wanted, and the neighbouring doc already calls a non-null packSocPct
the pack-attached signal.

BATTERY_PACK_CONNECTED(21) still sets the flag on the attach edge, so the
latency win that #1826 added survives, and BATTERY_LEVEL can now correct it.
A missed attach edge costs at most one battery cadence of lag and is then right,
rather than being fast and possibly wrong. Android ends up doing what iOS
already did.

The rule both platforms now state at the field: an EDGE may set this flag
(7, 21, 22), a repeating presence signal may not, or the gauge can no longer
correct it.

Tests

One new test, pinned against the source, because the write lives inline in a
GATT callback no JVM test can construct. It is anchored on the handler's own
info.displayable && soc != null guard, which is unique in the file. Anchoring
on the decode call or the event constant instead swallowed 400k characters and
passed on the pre-fix source, so the anchor is checked rather than assumed.

Verified as a negative control: re-adding charging = true to that one line
fails the test, restoring it passes. Full Android suite 5504 green, doc-comment
gate clean.

Swift is doc-only, so CI is the check there.

Not included

The charging flag still means "attached or charging" on the 21 edge, which is
the original conflation #1935 raised. That stays documented rather than split:
21 leads CHARGING_ON(7) by up to ~17 s in captures, and with the gauge able to
correct the flag again, the window is genuinely one cadence.

One thing remains unmeasured, and it is noted in the code rather than glossed:
how often 109 repeats on a pack that is attached but flat. The fix does not
depend on it, since a repeating presence write is wrong regardless, but the
severity of what was there before does.

The doc on this flag said it came from BATTERY_LEVEL events. That is where its
AUTHORITY comes from, but on a 5/MG it is also set by BATTERY_PACK_CONNECTED(21)
and cleared by BATTERY_PACK_REMOVED(22), which the doc did not mention at all. 21
means a pack was attached, not that charging began, so on a depleted pack or a
poor contact the flag reads true while nothing charges.

That state is bounded, and the bound is the reason to document rather than split:
it does not last until 22, because the next live BATTERY_LEVEL rewrites the flag
from the strap's own gauge, roughly one battery cadence later. Splitting the
state across both platforms, or making the throttle wait for a rising gauge,
would cost every honest attach to close an edge that already closes itself.

Two corrections to my own reading while writing this. The consequence is not one
lever but three: idleThrottleActive gates the low-battery offload cadence, the
GATT connection-priority throttle, AND the continuous-capture pause behind the
user's own "Pause HRV capture" percentage. So a pack that never charges can keep
background capture running at low battery after the user asked for it to stop.
That is a larger surface than the offload cadence alone, and it is the part worth
knowing. batteryPollDue reads the flag too, polling every tick rather than every
other, which is harmless.

The note lives on the flag, with a pointer from the throttle predicate on both
platforms, because that predicate is where someone reasoning about a trend check
would be reading and the reason not to add one is not visible from there.

Comments only. Android suite green, doc-comment gate clean.
The bound is the next live BATTERY_LEVEL, but that rewrite is suppressed
while an offload replays, so a long history sync holds a stale flag for
its duration plus a cadence. Android gates it explicitly, iOS structurally.
The claim that a stale true lasts about one battery cadence is wrong on
Android. Pack-info event 109 rewrites charging=true on pack PRESENCE every
couple of minutes, which outruns the ~8 min BATTERY_LEVEL that would clear
it, so a flat or badly seated pack holds the flag true until removal. iOS
keeps the pack record log-only, so its bound is real. Also drops a doc link
to lowPowerThrottleActive, which is an iOS-only symbol.
Event 109 says a pack is ATTACHED and how full it is, never that current
is flowing, but it wrote charging=true on presence as an anti-staleness
half for a missed attach edge. It repeats every couple of minutes, so it
outran the ~8 min BATTERY_LEVEL that corrects the flag from the strap's
own gauge: a flat or badly seated pack read as charging for its whole
attachment instead of self-correcting, which also held off the three
levers behind idleThrottleActive, the user's own capture pause included.

It now publishes packSocPct alone, which is what the anti-staleness job
wanted. BATTERY_PACK_CONNECTED(21) still lights the pill on the attach
edge, so the latency win survives and the gauge gets the last word, which
is what iOS already did. A missed attach edge now costs at most one
battery cadence of lag and is then right.

The rule both platforms follow: an EDGE may set the flag, a repeating
presence signal may not. Pinned against the source, since the write lives
in a GATT callback no JVM test can construct.
@ryanbr ryanbr changed the title charging: document that it means "pack attached", and what that reaches charging: stop pack presence from overriding the strap gauge Sep 7, 2026
@ryanbr
ryanbr merged commit e2a68f3 into main Sep 7, 2026
5 checks passed
@ryanbr
ryanbr deleted the docs/1935-charging-conflation branch September 7, 2026 00:55
ryanbr added a commit that referenced this pull request Sep 7, 2026
Option 1 from #1948, and the investigation made it bigger than the issue said.

## What was happening

The keep-alive polled battery commands the 5/MG send allowlist rejects before
they leave the app, under a comment claiming the pack "rides the SAME cadence as
the strap's own gauge".

Android sent `GET_BATTERY_PACK_INFO`, which the allowlist admits only while a
user-initiated probe is in flight:

```
12:31:48  send(GET_BATTERY_PACK_INFO) skipped — no WHOOP 5/MG framing for this command yet
12:32:48  send(GET_BATTERY_PACK_INFO) skipped — no WHOOP 5/MG framing for this command yet
```

40 of them in 40 minutes of one capture: one wasted call and one skip line per
minute.

**iOS was worse than #1948 recorded.** The allowlist has no clause for
`.getBatteryLevel` either, so the whole block was dead rather than half of it:
TWO sends and two skip lines per tick. The issue said "one refused command per
platform"; that was wrong and is corrected there.

A re-review then narrowed WHO pays it. `keepAliveMayRun` refuses an unbonded
5/MG outright, so the tick never fires there and only a BONDED 5/MG was paying.
That correction strengthens the case rather than weakening it, and is why the
comment now argues from co-residence: the same `didBond` that lets this tick run
has already fired `enableLiveNotifications` a few lines above, which drives the
`0x2A19` read a 5/MG's percent comes from. The read and the refused send were
always on the same tick, so dropping the send cannot strand the reading.

## The change

Android drops the pack poll from the WHOOP5 keep-alive branch. iOS makes that
whole battery block WHOOP 4.0 only, which matches `refreshBattery`, already
guarded `if selectedModel.deviceFamily == .whoop4` for the same command.

Nothing is lost:

- the strap's PERCENT comes from the `0x2A19` read both platforms already drive
  off this same keep-alive, on the same tick the removed send rode (iOS throttled
  to `whoop5BatteryReadMinIntervalSeconds`)
- the PACK's charge comes from the pushed pack-info event (109), that flag's only
  writer since #1945
- Android's user-initiated probe path is untouched, and remains how the hardware
  question ("does a 5/MG answer 151 at all?") gets asked

On iOS this leaves opcode 151 with no sender at all, because there is no iOS twin
of that probe. That is not a loss: it restores exactly what `FrameRouter` already
records for its own decoder, "nothing sent the command, so the decoder had no
caller". Noted at the call site so the asymmetry is not rediscovered.

The comments now say what actually happens instead of describing a cadence that
never ran.

## Tests

Both sends live in keep-alive bodies no unit test can drive, so both are pinned
against the source. The Android guard is scoped to the WHOOP5 branch and also
asserts the WHOOP4 poll beside it survives, since this is a 5/MG-only removal
rather than a change to battery polling.

Verified as a NEGATIVE CONTROL rather than assumed: re-adding the Android send
fails the guard, restoring it passes. The Swift guard needed a fix a re-review
caught before CI did: it asserted the block contains no `getBatteryPackInfo`
without stripping comments, while the comment above it names that symbol to
explain why it is not sent, so it would have failed on its own rationale. It
judges the code only now, the rule the Kotlin twin already followed. The Swift guard normalises whitespace on
both sides, because a Swift multiline literal strips indentation to its closing
delimiter and so can never carry the source's own leading spaces — a raw
comparison would have failed for a reason unrelated to the guard.

Android suite 5521 green, doc-comment gate clean. The Swift guard DOES run on
this PR: `app-build.yml` triggers on `Strand/**` and its macOS leg has a
`Test Strand` step, so `StrandTests` executes in CI here.

Worth flagging separately: that path filter lists `Strand/**`, `StrandiOS*/**`,
`Packages/**` and `project.yml`, but NOT `StrandTests/**`. A PR touching only
app-target tests therefore builds and runs nothing, and a broken test file would
merge unverified to fail on the next PR that happens to touch `Strand/**`. This
PR is covered because it edits `BLEManager.swift` too.

## Risk

About as low as it gets: these are sends the allowlist already refused. The
visible effect is less pointless traffic and quieter captures — an iOS Test
Centre connection capture on a 5/MG was carrying roughly two wasted lines a
minute.
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