Skip to content

Battery: anchor SoC to AXP192 coulomb counter to stop the unplug-jump#10

Open
SnowWarri0r wants to merge 4 commits into
anthropics:mainfrom
SnowWarri0r:feat/battery-soc
Open

Battery: anchor SoC to AXP192 coulomb counter to stop the unplug-jump#10
SnowWarri0r wants to merge 4 commits into
anthropics:mainfrom
SnowWarri0r:feat/battery-soc

Conversation

@SnowWarri0r
Copy link
Copy Markdown

Problem

The reference firmware reports battery percentage from a single linear
formula on instantaneous voltage:

int pct = (vBat_mV - 3200) / 10;  // 3.20V → 0%, 4.20V → 100%

That formula has two well-known weaknesses on this hardware. Both produced
visible-and-confusing-to-the-user readings on my stick:

  1. BLE/LCD load yanks the measured voltage 100–300 mV below the
    open-circuit value.
    The same physical SoC reads ~10–20 percentage
    points lower while busy than while idle, and the indicator wobbles in
    sync with whatever the stick is doing.

  2. Charging polarization inflates the measured voltage by 50–100 mV.
    Pull USB and the reading drops 5–15 percentage points instantly,
    despite no actual charge having moved. From cc-buddy-bridge's daemon
    log:

    00:42  63%  (charging)
    00:43  64%
    00:44  64%      ← USB unplugged here
    00:46  55%      ← same SoC, different reading
    

Both surfaces — the BLE status ack consumed by desktop UIs and the
on-device DEVICE info page — share the same formula and the same wobble.

Fix

Switch the primary SoC signal from voltage-direct to the AXP192's
hardware coulomb counter (already exposed by the M5StickC Plus library
as M5.Axp.EnableCoulombcounter() / GetCoulombData()).

New battery namespace in src/battery.h:

  • setup(): enable the coulomb counter.
  • loop(): sample voltage at 1 Hz into a 60-slot ring buffer.
  • First-time anchor: when the buffer fills, take its median (load is
    low at boot, so this is the cleanest OCV reading available) and look
    it up in a 12-point LiPo OCV-SOC table. Freeze that pct + the current
    coulomb value as the anchor.
  • Steady state: SoC = anchor_pct + (coulomb_now - coulomb_anchor) / 120 mAh × 100, clamped to 0–100. The coulomb counter is unaffected
    by polarization or transient load — it counts actual charge in/out.
  • Re-anchor: any time vBat ≥ 4.10 V and |I| < 30 mA, the cell
    is genuinely topped up. Re-anchor to 100% to correct any drift the
    coulomb counter accumulated over weeks of use.
  • Light 8-sample median on top of derived pct catches single-tick
    AXP register glitches without flattening real charge motion.

xfer.h:status and the on-device DEVICE info page (main.cpp:_drawDevicePage)
both call battery::percent() — same SoC value visible from both
surfaces.

Cost

  • RAM: +~600 bytes (60-slot float voltage ring + 8-slot int pct ring)
  • Flash: +~1 KB
  • CPU: ~5 µs per loop tick (1 Hz throttled)

Testing

I flashed this to my own M5StickC Plus and watched the same daemon log
column over the same time window:

Before (linear voltage formula):
   pct jumps 9–20 percentage points on USB plug/unplug events
   pct drifts 2–4 points from BLE bursts even with no real charge motion

After (coulomb counter anchored to OCV):
   pct stays stable across plug/unplug events (±0–1)
   pct moves at the actual charge rate (~+1%/min while charging at default current)
   re-anchor on full top-up keeps long-term drift bounded

Scope

Only battery.h (new), main.cpp (call battery::begin() and
battery::poll(), swap inline pct calc for battery::percent()), and
xfer.h (same swap). No protocol changes — the wire format is unchanged,
the bat.pct field just becomes meaningful.

CONTRIBUTING.md fit

Per CONTRIBUTING.md "What we will take": fixes for bugs that make the
reference not work as a reference. A battery indicator that swings 9 %
on USB unplug isn't a working reference of how this should look.

The reference firmware reported battery percentage as a naive
linear interpolation: pct = (vBat_mV - 3200) / 10. Two issues
combine to make this almost useless on a desk pet under typical
BLE + display load:

  1. LiPo voltage drops 100-300 mV under load. The same actual
     state-of-charge reads ~20% lower while busy than while idle,
     and jumps that much when load disappears (e.g., USB unplug).
     The current daemon log shows readings hopping between 31% and
     51% from one minute to the next.

  2. The real LiPo discharge curve is far from linear — it's
     nearly flat across the middle 30-80% band and steep at the
     extremes. The linear formula has roughly the right endpoints
     but is wrong everywhere in between.

Replace with a small battery module that:

  - Samples vBat at 1 Hz into a 30-slot ring buffer
  - Returns the median voltage (rejects single-tick spikes from
    the BLE radio TX bursts)
  - Maps that smoothed voltage through a 12-point OCV-SOC table
    sized for a typical 1S LiPo

Pure passive measurement — no coulomb counter, no NVS state, no
calibration cycles. ~600 bytes of flash, 128 bytes of RAM for the
ring buffer, ~5 µs per loop tick (1 Hz throttled).

xfer.h's status response now calls battery::percent() instead of
inlining the formula. Behaviour: SoC moves slowly and roughly
monotonically across genuine charge/discharge transitions; load
fluctuations no longer make the indicator jitter.
Watching the output for ~10 minutes after the initial commit, the
30-sample window still let through ±2% wiggles — physically real
(charging current isn't perfectly constant; load varies over 1 minute
windows), but visually noticeable on the hud's 8-segment progress bar
where each segment is 12.5%, so a 2% wiggle near a segment boundary
flips the bar one notch.

60 samples halves the rate at which a single new sample can drag the
median across a percentile threshold. The OCV table's midpoint plateau
(40–70%) is where the LiPo curve is flattest and our smoothing helps
most.
…ary)

Voltage-only SoC has a fundamental limit on this hardware: charging
current inflates the measured voltage by 50–100 mV via charge
polarization. Pull USB and the same physical SoC reads ~10–15
percentage points lower. We confirmed this reproducibly: 64% while
charging dropped to 55% the instant USB came out, no real charge moved.

The AXP192 has a hardware coulomb counter (charge-mAh and
discharge-mAh accumulators clocked off the current-sense ADC). M5's
library exposes it as Axp.GetCoulombData() returning net mAh. That's
the right primary signal — it tracks actual charge in/out regardless
of polarization or load.

New algorithm:
  1. setup(): EnableCoulombcounter().
  2. Sample voltage at 1 Hz into a 30-slot ring (unchanged from prior
     commit). This gives us a clean OCV reading at the moment the
     buffer first fills (load is low at boot).
  3. Anchor: at first full window, set SoC = OCV(median(voltage)) and
     freeze the coulomb-counter value at that point.
  4. From then on: SoC = anchor_pct + (coulomb_now - coulomb_anchor) /
     120 mAh × 100. Bounded to 0–100.
  5. Re-anchor to 100% any time vBat ≥ 4.10 and |I| < 30 mA — this
     happens whenever the battery genuinely tops up, and corrects any
     coulomb-counter drift accumulated over weeks.
  6. Light 8-sample median on top of derived pct catches AXP register
     glitches without flattening genuine charge/discharge motion.

The unplug step-down should now be invisible: coulomb_now barely
changes across an unplug event (no real mAh moved), so derived SoC
stays put even though voltage drops 50–100 mV.
The DEVICE info screen (info page 3) had an inline copy of the same
naive (vBat-3200)/10 formula that xfer.h used for status acks. Switched
xfer.h to battery::percent() in a previous commit but missed this one,
so users watching the stick's own info page still saw the unplug jump.

Now both surfaces (BLE status response + on-device info page) get the
same coulomb-counter-anchored SoC.
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