Skip to content

5/MG battery-pack poll is refused on every keepalive tick, on both platforms #1948

Closed

Description

@ryanbr

The 5/MG keepalive polls GET_BATTERY_PACK_INFO (151) every other tick, but the
send allowlist can never admit it on that path, so the call is refused every
time. Seen 40 times in 40 minutes of a Test Centre capture on an MG:

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
...
13:11:37  send(GET_BATTERY_PACK_INFO) skipped — no WHOOP 5/MG framing for this command yet

Why it can never pass

Android admits 151 only while a user-initiated probe is in flight:

!(cmd == CommandNumber.GET_BATTERY_PACK_INFO &&
    _batteryPackProbe.value == WAITING_BATTERY_PACK_PROBE) &&

The keepalive is not a probe, so the periodic send at the WHOOP5 branch of the
keep-alive tick is refused on every tick.

iOS is the same call with less around it: BLEManager.swift sends
.getBatteryPackInfo on the same cadence, and its allowlist has no clause for
that command at all
. FrameRouter says as much about its own decoder:

until now nothing sent the command, so the decoder had no caller and the
offsets have never been seen against a live strap

What it costs

Small but real: one refused command and one log line per minute per platform,
and a Test Centre battery capture on a 5/MG carries a wasted line every minute.

The bigger cost is the comment. Both platforms carry this at the call site:

The 5/MG battery pack rides the SAME cadence as the strap's own gauge, the
same question about the same physical thing, and a second timer would only be
a second thing to get wrong.

It never rides anything. On a 5/MG, packSocPct can only come from the pushed
pack-info event (109), which is now its only writer following #1945.

Options

  1. Drop the periodic send on both platforms and let 109 be the documented single
    source, correcting the comment.
  2. Admit 151 on the cadence path, if a repeating poll of that opcode is actually
    wanted, which would need the same hardware question answered that the probe
    gate was written to ask.

Option 1 matches what the code already does in practice. Option 2 is a
behaviour change on a read-only opcode that has never been sent on a cadence to
any strap.

Found while reading an MG capture for an unrelated bond question.

Activity

  1. ryanbr commented on Sep 7, 2026

    @ryanbr
    OwnerAuthor

    Correcting the cost above: I wrote "one refused command and one log line per
    minute per platform". That is right for Android and wrong for iOS.

    The Swift allowlist has no clause for .getBatteryLevel either, not just for
    .getBatteryPackInfo. Both are sent from the same batteryPollDue block, so a
    5/MG paid TWO dead sends and two skip lines per tick there, and the whole block
    was dead rather than half of it.

    Neither is needed: the strap's percent comes from the 0x2A19 read that
    enableLiveNotifications drives off the same keep-alive, and refreshBattery
    already guards the same command with if selectedModel.deviceFamily == .whoop4,
    so the fix follows a pattern the file had already established.

    Option 1 is up in #1951, both platforms.

  2. ryanbr commented on Sep 7, 2026

    @ryanbr
    OwnerAuthor

    One more narrowing, on who actually paid that cost.

    keepAliveMayRun refuses an unbonded 5/MG outright, so the tick never fires
    there and the dead sends were only ever paid by a BONDED 5/MG. My note above
    said the block was dead on a 5/MG without qualifying which, which overstates it
    for exactly the straps #1635 is about.

    It makes the removal easier to justify rather than harder: the same didBond
    that lets the tick run has already fired enableLiveNotifications a few lines
    above, and that is what 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.

  3. ryanbr commented on Sep 9, 2026

    @ryanbr
    OwnerAuthor

    Landed in ad7e625ba: a 5/MG is no longer asked for battery it refuses to answer, so the dead sends and their skip lines are gone from the keepalive tick. Guarded on both platforms, by Whoop5BatteryPollGuardTests and ChargingAndReleaseTest.

    Shipped in testing build 451 onward.

    Recording the narrowing from the comments above, since it is the part worth keeping: keepAliveMayRun already refuses an unbonded 5/MG, so the dead sends were only ever paid by a BONDED one. The original note did not qualify which, and overstated the cost for exactly the straps #1635 is about.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions