Skip to content

charging conflates "pack attached" with "actually charging", and the low-battery throttle reads it #1935

Closed

Description

@Zebsi235

Flagged by @ryanbr in the #1831 merge note ("worth its own issue if it should be split"); opening it as the issue that note asked for, so the decision has a home. Nothing here is urgent and nothing is broken in the normal case.

What #1826 / #1831 do

On a 5/MG, BATTERY_PACK_CONNECTED(21) sets charging = true and BATTERY_PACK_REMOVED(22) clears it, on both platforms. That was a latency win: 21 leads CHARGING_ON(7) by under a second in one captured cycle and by ~17 s in another, so the pill responds when the pack goes on rather than when charging happens to start.

The edge

21 means a pack was attached. 7 means charging began. They differ when a pack is attached but never charges — a depleted pack, or a poor contact. Then 21 fires, 7 never does, and charging stays true until 22.

Two consequences:

  1. The pill reads charging while nothing is charging. Cosmetic.
  2. lowPowerThrottleActive = thresholdPct > 0 && !charging && batteryPct <= thresholdPct, so a true charging disables the low-battery throttle. A strap at low battery on a dead pack stops stretching its offload interval — the opposite of what that gate exists to do — for as long as the pack stays on.

The second is the one that matters, and it is behaviour outside the Live screen.

What is measured, and what is not

Measured (MG, fw 50.39.1.0, #1825 census): 21 and 22 fire on every attach/detach; 7 follows 21 when the pack is charging; the strap replays all of these during the next offload with byte-identical payloads, and the replay gate correctly suppresses them.

Not measured: a pack that is attached and does not charge. I have not produced that case on hardware, so the size of the edge is unknown. It may be rare enough not to matter.

Options, for your call

  • A — leave it. Document the conflation in charging's doc comment. Cheapest; accepts the throttle edge as rare.
  • B — split the state. A second field (packAttached or similar) set by 21/22, with charging kept on 7/8 plus BATTERY_LEVEL's charge bit. The pill can show either; the throttle reads only charging. Needs both platforms at once to stay in step, and needs a decision about what the pill shows during the 21→7 gap.
  • C — narrow the throttle only. Keep charging as is for the pill; have lowPowerThrottleActive additionally require a rising batteryPct trend (the strap's own gauge, already sampled every ~30 s) before treating charging as true. Level-based, cannot go stale, no new event handling — but it delays the throttle release by a couple of samples after a genuine attach.

I lean C if the edge turns out to be real, because it fixes the consequence that matters without touching the pill or adding state — but I have not measured the edge, so A is entirely defensible.

Happy to do the hardware run that would size it (attach a pack that is known-flat, watch whether 7 ever arrives) if that helps decide.

Activity

  1. ryanbr commented on Sep 7, 2026

    @ryanbr
    Owner

    Good issue, and the throttle consequence is the right thing to have led with — that predicate is exactly as you quote it, and the Swift doc says "Charging never engages" in as many words.

    Going with A, but for a different reason than the one on offer, because one premise does not hold:

    Then 21 fires, 7 never does, and charging stays true until 22.

    It stays true until the next live BATTERY_LEVEL. That event carries the strap's own charging bit and writes charging unconditionally on both platforms — Kotlin at the shouldApplyChargingFromBatteryEvent branch, Swift in FrameRouter. It is a level signal from the gauge, not an edge, arriving "~every 8 min on captured links" per the comment beside it. So a stale true from 21 self-corrects at the next battery event.

    That bounds the exposure to: threshold configured, strap at or below it, a pack attached that never charges, and within ~8 minutes of the attach. Then the throttle stays off for that window and re-engages by itself.

    Which is what rules out C. Requiring a rising battery trend would delay throttle release on every genuine attach, for everyone, permanently — and at roughly 1pp per 8 minutes the gauge needs several samples to establish a trend, so that delay is not small. It trades a bounded, self-healing edge for an unbounded, always-on one. B is a real improvement and the conflation is worth naming, but two-platform state plus a decision about the 21→7 gap is a lot for an eight-minute window.

    So: document it, and the doc should correct the "until 22" reading specifically, since that premise is what makes B and C look necessary. I will put that up.

    On the offered hardware run — thanks, but I would skip it. It would tell us whether 7 ever arrives on a flat pack, which is genuinely interesting, and it no longer changes this decision either way.

    Worth saying: you flagged the throttle rather than stopping at the pill, said plainly what you had not measured, and asked instead of shipping. The only thing missed is a level signal that lives nowhere near the event handling this is about.

  2. ryanbr commented on Sep 7, 2026

    @ryanbr
    Owner

    Correcting myself: your "stays true until 22" reading was right on Android, and
    the premise I replaced it with was wrong.

    I said the next live BATTERY_LEVEL writes the flag unconditionally, so the
    exposure is bounded by about one battery cadence. 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
    hardware-confirmed note has the strap volunteering 109 every couple of minutes
    while a pack is attached. That outruns the ~8 min BATTERY_LEVEL, so the gauge
    never got the last word and the flag stayed true toward 22, exactly as you
    described it.

    So the throttle consequence you led with was larger than I credited, not one
    cadence at low battery but the whole attachment, with the user's own capture
    pause among the three levers held off.

    iOS was never affected: the pack record is log-only there, so nothing but the
    gauge writes the flag. That parity gap is what hid this from me, since the
    reasoning I checked was the Swift one.

    #1945 now carries the change rather than only the doc. 109 publishes
    packSocPct alone, which is what the anti-staleness half wanted;
    BATTERY_PACK_CONNECTED(21) still lights the pill on the attach edge, so the
    latency win you measured for #1826 survives and the gauge can correct it again.
    The rule now stated at the field on both platforms: an EDGE may set this flag,
    a repeating presence signal may not.

    On the hardware run I talked you out of, I would take it after all if you still
    have the flat pack. One thing stays unmeasured: how often 109 repeats on a pack
    that is attached but flat. Nothing here depends on it, since a repeating
    presence write is wrong either way, but that is the measurement that would have
    caught this, and I told you it would not change anything.

  3. ryanbr commented on Sep 7, 2026

    @ryanbr
    Owner

    Landed in #1945: the pack-info event now publishes the pack SoC alone, so the strap's own gauge gets the last word again. Thanks @Zebsi235 — your original reading of this was the correct one.

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