Skip to content

ble: light the charging pill on a 5/MG, which listens for events nobody sends it - #1826

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
Zebsi235:feat/mg-charging-pill
Sep 2, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
Zebsi235:feat/mg-charging-pill

Conversation

@Zebsi235

@Zebsi235 Zebsi235 commented Sep 2, 2026

Copy link
Copy Markdown

What this PR does

The Live-screen charging bolt is often dark while a 5/MG is demonstrably sitting on its battery pack.

The pill is driven from CHARGING_ON(7) / CHARGING_OFF(8) and the BATTERY_LEVEL charging bit. On a 5/MG none of those is a dependable source: the strap reads battery from the standard 0x2A19 characteristic — a bare percentage byte with no charge bit — so the flag rests entirely on catching an edge. Miss the edge and there is no level-based source to recover from.

Meanwhile BATTERY_PACK_CONNECTED(21) and BATTERY_PACK_REMOVED(22) are declared in EventNumber and handled nowhere at all.

This handles them, alongside the existing 7/8 branch. Five lines of behaviour.

The evidence

WHOOP MG, fw 50.39.1.0, two attach/detach cycles with every pushed event logged:

01:40:51  BATTERY_PACK_CONNECTED(21)      <- pack attached
01:40:51  CHARGING_ON(7)
01:41:06  CHARGING_OFF(8)
01:41:11  BATTERY_PACK_REMOVED(22)        <- pack removed
01:42:46  BATTERY_PACK_CONNECTED(21)      <- attached again
01:42:47  CHARGING_ON(7)
01:43:43  CHARGING_OFF(8)
01:43:48  BATTERY_PACK_REMOVED(22)        <- removed again

Both families fire, and 21/22 lead. They are simply the ones nothing was listening to.

This ADDS a source, it does not replace one

  • the 7/8 branch is untouched
  • a WHOOP 4.0 is unaffected — it never sends 21/22
  • no new opcode, no CommandNumber case, no send-allowlist entry, no polling, no wire traffic at all

The replay gate is load-bearing here

The new branch sits inside the existing shouldApplyChargingFromBatteryEvent(replayedOffload) gate, and that matters more than it might look. The strap replays these edges during the next historical offload with byte-identical payloads:

01:40:51 live CHARGING_ON  707d0000  ->  replayed 01:43:00  707d0000
01:41:06 live CHARGING_OFF b87e0000  ->  replayed 01:43:00  b87e0000
01:42:47 live CHARGING_ON  cc0c0000  ->  replayed 01:43:02  cc0c0000

An accepted replay would switch the pill back on minutes after the pack was physically removed. Verified in the same capture: the replayed copies are correctly suppressed by the existing gate, so the new branch inherits the right behaviour rather than needing its own.

A correction to the record

An earlier draft of this work claimed "a WHOOP MG never emits CHARGING_ON/CHARGING_OFF", drawn from a census over a single detach/reattach cycle that logged zero of them.

That was wrong, and the comment in the diff says so rather than leaving it buried. CHARGING_ON trails event 21 by about 17 seconds — long enough for a short capture window to miss it. A negative result from one cycle was read as a property of the firmware.

The change is unaffected either way: 21/22 fire reliably and lead 7/8, so handling them improves the pill's latency. It does not repair an absent signal, and this PR does not claim it does.

Type of change

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

How it was tested

On hardware. WHOOP MG, hw WS50_r00, fw 50.39.1.0 (DIS-attested), no subscription, Pixel 9 Pro / Android 15, local full debug build. Two deliberate pack attach/detach cycles with every pushed event logged; the trace above is from that run. The pill tracked both cycles, updating in the same second as the physical attach. The replayed copies arrived ~2 minutes later and did not move it.

Android unit tests, full suite. ./gradlew testFullDebugUnitTest on Windows 11 / JDK 17, compileSdk 35, against main @ d6d84cc5: 5,039 tests across 614 classes, 0 failures, 0 errors (10 skipped, pre-existing). Counted from the JUnit XML with the results directory cleared beforehand, so the numbers come from a real run rather than an up-to-date task.

No new build warnings.

Tools/doc_comment_lint.py: not run to completion locally — it fails with a PermissionError reading a SwiftPM artifact under Packages/StrandAnalytics/.build/ on the SMB share this checkout lives on, unrelated to the change. This diff adds no /** */ doc comments, only inline //, so its per-file count is unchanged. Left to source-hygiene.yml.

Not run: swift test. No Apple machine here, and this diff touches no Swift.

What this does NOT claim

  • One device, one firmware. Everything above is a WHOOP MG on 50.39.1.0. Nothing here says another strap or firmware behaves the same.
  • It does not make the pill correct on a 5/MG in every case. charging is still cleared on every disconnect and there is no level-based source to re-read it from, so a pack attached while the app is disconnected is still missed until the next edge. That is a real remaining gap and this PR does not close it.
  • present on a pack is not the same as actively charging — a full or dead pack is attached but not charging. The edges are what the pill has always used, and that limitation is unchanged.
  • Nothing about the Apple side: Strand/BLE/FrameRouter.swift handles 7/8 and not 21/22, so it has the same gap. Deliberately left out — there is no Swift toolchain here to verify it, and it belongs in its own PR.

Checklist

  • Swift package tests pass for any package I touched — n/a, no Swift touched
  • Android unit tests pass (./gradlew testFullDebugUnitTest, full suite)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — n/a, no UI
  • 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 or any secrets/keystores

Related issues

None. Does not close anything.

The event logging that produced the trace above is #1825, which is independent of this change — this does not depend on it, but that PR is what makes the capture reproducible.

…nobody sends it

The Live-screen charging bolt is often dark while a 5/MG is demonstrably on its
battery pack. The pill is driven from CHARGING_ON(7) / CHARGING_OFF(8) and the
BATTERY_LEVEL charging bit. On a 5/MG none of those is a dependable source: the
strap reads battery from the standard 0x2A19 characteristic, a bare percentage
byte with no charge bit, so the flag rests entirely on catching an edge.

Meanwhile BATTERY_PACK_CONNECTED(21) and BATTERY_PACK_REMOVED(22) are declared
in EventNumber and handled nowhere at all.

Measured on WHOOP MG fw 50.39.1.0 over two attach/detach cycles with every pushed
event logged:

    01:40:51  BATTERY_PACK_CONNECTED(21)      <- attach
    01:40:51  CHARGING_ON(7)
    01:41:06  CHARGING_OFF(8)
    01:41:11  BATTERY_PACK_REMOVED(22)        <- detach

Both families fire, and 21/22 lead. So this ADDS a source for the same flag
rather than replacing one: the 7/8 branch is untouched, and a WHOOP 4.0 is
unaffected because it never sends 21/22.

The existing replayedOffload gate covers the new branch, and that matters: the
strap replays these edges during the next historical offload with byte-identical
payloads. An accepted replay would switch the pill back on minutes after the pack
came off. Verified: the replayed copies at 01:43:00 are correctly suppressed.

One correction to the record, since it appears in an earlier draft of this work:
a census over a single detach/reattach cycle saw no CHARGING_ON/CHARGING_OFF and
concluded an MG never sends them. That was wrong -- CHARGING_ON simply trails
event 21 by about 17 seconds, long enough for a short capture window to miss it.
@ryanbr
ryanbr merged commit ab9420e into ryanbr:main Sep 2, 2026
4 checks passed
@ryanbr

ryanbr commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Merged — thanks @Zebsi235. I checked the load-bearing claims against the tree rather than taking them on
trust, and they hold: 21/22 really were declared in EventNumber and handled nowhere, and the new branch
really does sit inside shouldApplyChargingFromBatteryEvent(replayedOffload) — which matters, because
your capture shows the strap replaying those edges with byte-identical payloads.

The self-correction about CHARGING_ON was the right call too. A negative result from one detach cycle
reading as a firmware property is an easy trap, and putting the retraction in the diff comment rather than
leaving it in a superseded draft is what stops the next person inheriting it.

Two follow-ups it prompted:

Apple twin — #1831. Same events, no replay guard: that router is live-only (Backfiller holds no
reference to it), so the gate that is load-bearing on Android would guard nothing there. The diff says so
explicitly, so it does not read as a missing guard.

If your capture happens to hold the raw frame bytes for a BATTERY_PACK_CONNECTED(21) and a
BATTERY_PACK_REMOVED(22), those two hex strings would let both platforms ship a real routing test.
Whoop5EventTests keeps to real captured frames only, and a synthetic fixture would prove the branch
matches a string I invented rather than what the strap sends. Entirely optional — the change stands
without it.

#1832. charging is one field serving both the pill and the low-battery throttle
(lowPowerThrottleActive is !charging && batteryPct <= threshold), so a pack that is attached but never
starts charging would hold the throttle off until it is removed. Not a problem with your change — a
property of the flag both platforms now share — but worth writing down before it surfaces as a battery
report nobody can explain.

ryanbr added a commit that referenced this pull request Sep 3, 2026
Apple twin of @Zebsi235's Android change in #1826.

BATTERY_PACK_CONNECTED(21) and BATTERY_PACK_REMOVED(22) are declared in the shared protocol schema and
were handled on neither platform. On a 5/MG they fire on every attach and detach and lead the
CHARGING_ON/OFF edges, so the charging pill responds when a pack goes on rather than waiting to catch a
later edge. A WHOOP 4.0 never sends them.

No replay guard here, deliberately: this router is live-only — Backfiller holds no reference to it — which
is why the CHARGING_ON/OFF branch above carries none either. Android's EVENT routing does see replays, so
its gate is load-bearing there and would guard nothing here.

Recorded on #1832: event 21 means a pack was ATTACHED, not that charging began, and `charging` also
disables the low-battery throttle. A pack that never starts charging holds that off until it is removed.
Unchanged here because both platforms now share that shape.
ryanbr added a commit to Zebsi235/noop that referenced this pull request Sep 3, 2026
Fourth time this session, and mine every time. The ryanbr#1833 block went in
between `/** Mask MAC addresses and WHOOP serials ... */` and the
redactStrapLogPii it documents, so that text bound to nothing and the
function took my block instead.

The gate reports a per-FILE count rather than line-keyed sites, so one new
stacked doc pushed WhoopBleClient.kt from its baselined 8 to 9 and listed all
nine — including eight pre-existing ones that were never the problem. That
reads like the branch broke something wholesale; it was one insertion.

Moved above the neighbour's doc, which is where an inserted declaration
belongs.

Also merged current main into this branch: it was 33 commits behind, which
predated the ryanbr#1826 pack-attach handler landing in the same EVENT block, so
merging now rather than at the merge button keeps the resolution visible.

Gate clean, compile clean, PiiRedactionTest 14/14.
ryanbr added a commit that referenced this pull request Sep 3, 2026
versionName/MARKETING_VERSION 11.1.0 -> 11.1.1, versionCode 418 -> 419,
CURRENT_PROJECT_VERSION 299 -> 300. 418 was the 11.1.0 RELEASE, so 419 clears
that high-water and the testing build can install over it.

Carries everything merged since the release, none of which has reached a
user yet:

- the clock perf regression from #1822, where ~13 one-time DateFormatters
  became computed properties running an ICU pattern build and a Bundle
  lookup per access (#1830)
- pack-attach events lighting the charging pill on a 5/MG, both platforms
  (#1826, #1831)
- the Test Centre event census (#1825)
- redaction for a strap serial that arrives as HEX inside a payload dump,
  both platforms (#1833). 11.1.0 can still print one through the alarm
  readback or the clock frame= dump, so this build is the first where
  "send me a strap log" is safe to ask again

The 11.1.0 startup report is NOT known to be addressed by any of this. The
clock fix is a real regression but was never established as its cause, and
this build changes nothing about that question - it just stops shipping the
regression.
@Zebsi235
Zebsi235 deleted the feat/mg-charging-pill branch September 6, 2026 13:20
ryanbr added a commit that referenced this pull request Sep 7, 2026
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.
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