Files
MeshTerm/meshterm/services/battery_service.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

308 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The battery poller: keep the status bar's fuel gauge current, quietly.
A session-long task that reads the connected companion's battery voltage on a slow
cadence and caches a small :class:`BatteryReading` for the header to draw (see
:func:`~meshterm.ui.widgets.battery_cell`). Like the advert scheduler and the Watchtower
it holds no device subscription — each pass just checks whether a device is connected and
skips quietly otherwise — so it is indifferent to reconnects, ticking across a link drop
and resuming once the radio is back.
Which pack it reads is platform data (``Platform.battery``). On the **PicoCalc** the gauge is
the handheld's own — its BMS hangs off the keyboard MCU's I2C register block and the kernel
publishes it as a standard ``power_supply`` (:data:`_HOST_SUPPLY`), so both numbers are the
device's own ground truth: ``capacity`` is a real percent and ``status`` a real charging flag
(the MCU's battery register carries charge in its low bits and *charging* in its top one).
Nothing below is estimated there — no voltage curve, no trend.
The rest of this module is the **companion** path, where the radio gives far less. Two device
facts shape what it can report:
* Firmware exposes the pack as a **terminal voltage in millivolts**, not a percentage, so
the reading is turned into a state-of-charge estimate against a single-cell LiPo
discharge curve (:func:`battery_percent`). Devices with no battery gauge answer with no
usable level; those are reported as *absent* so the header shows nothing for them.
* The companion protocol exposes **no charging flag at all**. Charging is therefore *inferred*
from the terminal voltage — but a single-cell LiPo sags tens of millivolts under each LoRa
transmission and springs back when the radio idles, so the raw sample trend is mostly load
noise, not charge. The inference reads it robustly (the median of the sample window's older
half against its newer half, which discards those transient spikes) and calls charge only
on a large, *sustained* rise, dropping straight back the moment the pack goes flat. It is
still an estimate — the best the companion protocol allows — but one a discharging or
resting pack no longer trips. Where a device *does* expose a firmware charging flag over the
standard BLE Battery Level Status characteristic (no MeshCore build does today), that ground
truth is preferred and the inference only fills in for the rest — see
:meth:`~meshterm.core.connection.Device.get_hw_charging`.
"""
from __future__ import annotations
import asyncio
import time
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from statistics import median
from typing import TYPE_CHECKING
from ..platforms import get_platform
if TYPE_CHECKING:
from ..context import AppContext
#: Seconds between battery reads. A pack drifts slowly, so a coarse tick is plenty; it is
#: also fine enough that the charging trend (measured over :data:`_TREND_WINDOW_S`) has
#: several samples to work with.
POLL_S = 20.0
#: Millivolts at or above which a reading counts as a real battery. A device with no fuel
#: gauge answers with an empty frame or a zero level; anything below a badly-depleted
#: single cell is treated as *no battery present* and the gauge is hidden.
_BATTERY_PRESENT_FLOOR_MV = 1500
#: Recent-samples window (seconds) the charging trend is measured over.
_TREND_WINDOW_S = 180.0
#: Fewest samples the trend needs before it will call charging either way — about two minutes
#: at :data:`POLL_S`, enough to span the window and give each half a meaningful median. Below
#: this the verdict is simply *not charging*.
_TREND_MIN_SAMPLES = 6
#: Smoothed rise (millivolts: the newer half's median terminal voltage minus the older half's)
#: that *starts* a charging verdict, and the smaller rise it must stay above to *hold* one.
#: The start threshold clears the median-smoothed load-noise floor by a wide margin, so a
#: discharging or resting pack never trips it; the hold threshold lets a genuine, still-climbing
#: charge ride through the poll jitter without flicker, while a pack gone flat — topped off, or
#: unplugged — falls below it and drops straight back to *not charging*.
_CHARGING_RISE_ON_MV = 25
_CHARGING_RISE_HOLD_MV = 8
#: How many samples to retain — the trend window plus a little slack at the poll cadence.
_TREND_SAMPLES = int(_TREND_WINDOW_S / POLL_S) + 2
#: The PicoCalc's own battery gauge, a standard kernel ``power_supply`` driver
#: (confirmed present in P0: ``capacity``, ``status``, Li-ion, uevent format).
_HOST_SUPPLY = Path("/sys/class/power_supply/picocalc")
#: Single-cell LiPo terminal-voltage → state-of-charge lookup ``(millivolts, percent)``,
#: high to low. The discharge curve is far from a straight line — most of the usable charge
#: sits in a narrow band around 3.7–3.9 V — so a piecewise table read with linear
#: interpolation tracks it far better than a flat voltage-to-percent line. These are the
#: widely-used community/Battery-University rungs.
_LIPO_SOC: tuple[tuple[int, int], ...] = (
(4200, 100),
(4150, 95),
(4110, 90),
(4080, 85),
(4020, 80),
(3980, 75),
(3950, 70),
(3910, 65),
(3870, 60),
(3850, 55),
(3840, 50),
(3820, 45),
(3800, 40),
(3790, 35),
(3770, 30),
(3750, 25),
(3730, 20),
(3710, 15),
(3690, 10),
(3610, 5),
(3270, 0),
)
def battery_percent(millivolts: int) -> int:
"""Estimate a single-cell LiPo's state of charge (0–100%) from its terminal mV.
Reads the :data:`_LIPO_SOC` curve, interpolating linearly between the two rungs the
voltage falls between and clamping past either end.
Args:
millivolts: The pack's terminal voltage in millivolts.
Returns:
The estimated charge as a whole percent in ``[0, 100]``.
"""
mv = int(millivolts)
if mv >= _LIPO_SOC[0][0]:
return 100
if mv <= _LIPO_SOC[-1][0]:
return 0
for (v_hi, p_hi), (v_lo, p_lo) in zip(_LIPO_SOC, _LIPO_SOC[1:], strict=False):
if v_lo <= mv <= v_hi:
frac = (mv - v_lo) / (v_hi - v_lo)
return round(p_lo + (p_hi - p_lo) * frac)
return 0 # pragma: no cover - the clamps above cover the whole range
@dataclass(frozen=True)
class BatteryReading:
"""One cached battery snapshot for the header to draw.
Attributes:
millivolts: The pack's terminal voltage, as the firmware reported it.
percent: The state-of-charge estimate (see :func:`battery_percent`).
charging: Whether the pack appears to be taking charge (inferred from a sustained
rise in terminal voltage — the firmware exposes no charging flag).
"""
millivolts: int
percent: int
charging: bool
class BatteryService:
"""Polls the connected companion's battery and caches a reading for the header.
Interact through the async lifecycle methods (:meth:`start`, :meth:`stop`,
:meth:`aclose`) and read the latest snapshot with :meth:`reading`; everything else
happens on the loop's own schedule.
"""
def __init__(self, ctx: AppContext) -> None:
"""Initialize an idle poller bound to an application context.
Args:
ctx: The shared application context, read each pass for the connected device.
Nothing is touched until :meth:`start`.
"""
self._ctx = ctx
self._task: asyncio.Task | None = None
self._reading: BatteryReading | None = None
#: Recent ``(monotonic_time, millivolts)`` samples, for the charging trend.
self._history: deque[tuple[float, int]] = deque(maxlen=_TREND_SAMPLES)
@property
def active(self) -> bool:
"""Whether the poll loop is running."""
return self._task is not None and not self._task.done()
def reading(self) -> BatteryReading | None:
"""The latest battery snapshot, or ``None`` when unknown or no battery is present."""
return self._reading
async def start(self) -> None:
"""Start the poll loop. Idempotent; never blocks on the first read."""
if self.active:
return
self._task = asyncio.ensure_future(self._run())
self._ctx.log.info("battery poller started")
async def stop(self) -> None:
"""Stop the poll loop. Idempotent."""
task, self._task = self._task, None
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._ctx.log.info("battery poller stopped")
async def aclose(self) -> None:
"""Stop the loop at session end (an alias for :meth:`stop`)."""
await self.stop()
async def _run(self) -> None:
"""Read once at once, then tick forever: a best-effort pass, then sleep."""
while True:
try:
await self._poll()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - a bad read must not kill the loop
self._ctx.log.debug("battery poller: read failed: %s", exc)
await asyncio.sleep(POLL_S)
async def _poll(self) -> None:
"""One pass: read the pack, estimate charge, and re-derive the charging trend.
Which pack depends on the platform (``Platform.battery``): the PicoCalc's gauge
reports the *handheld's* cells through the kernel's standard ``power_supply``
driver — a percent and a real charging flag, no estimation needed — while the
regular platform polls the connected companion radio over the mesh link.
"""
if get_platform().battery == "host":
self._poll_host()
return
ctx = self._ctx
if not ctx.is_connected:
return
device = await ctx.device()
info = await device.get_battery()
mv = int(info.get("level") or 0)
if mv < _BATTERY_PRESENT_FLOOR_MV:
# No usable level — this companion has no battery gauge; report absence so the
# header draws nothing, and forget any stale trend from a different device.
self._reading = None
self._history.clear()
return
now = time.monotonic()
self._history.append((now, mv))
# Prefer a firmware-reported charging flag when the device exposes one (the standard
# BLE Battery Level Status characteristic); only where it can't — every MeshCore device
# today — fall back to inferring charge from the voltage trend.
hw_charging = await device.get_hw_charging()
charging = hw_charging if hw_charging is not None else self._charging(now)
self._reading = BatteryReading(
millivolts=mv,
percent=battery_percent(mv),
charging=charging,
)
def _poll_host(self) -> None:
"""Read the host's own pack from sysfs (the PicoCalc's ``picocalc`` supply).
The driver is a standard ``power_supply``: ``capacity`` is a true percent and
``status`` a real charging flag (verified in P0), so none of the companion
path's voltage-curve or trend estimation applies. An unreadable supply (driver
missing, permissions) reports *absent* and the header simply draws no gauge.
"""
supply = _HOST_SUPPLY
try:
percent = int((supply / "capacity").read_text().strip())
status = (supply / "status").read_text().strip()
except (OSError, ValueError):
self._reading = None
return
try: # informational only; the header draws percent + charging
mv = int((supply / "voltage_now").read_text().strip()) // 1000
except (OSError, ValueError):
mv = 0
self._reading = BatteryReading(
millivolts=mv,
percent=max(0, min(100, percent)),
charging=status == "Charging",
)
def _charging(self, now: float) -> bool:
"""Infer whether the pack is charging from its recent terminal-voltage trend.
The firmware exposes no charging flag, so the trend is the only signal — but a
single-cell LiPo's terminal voltage sags tens of millivolts under each transmission
and springs back when the radio idles, so the raw trend is mostly load noise.
Charging is read *robustly*: the median voltage of the window's older half is compared
against the median of its newer half, which discards those transient sag/recovery
spikes, and only a large, *sustained* rise counts as charge going in. The verdict
carries hysteresis — hard to start (:data:`_CHARGING_RISE_ON_MV`), then held while the
pack is still clearly climbing (:data:`_CHARGING_RISE_HOLD_MV`) — so a genuine charge
doesn't flicker, while a pack gone flat (topped off, or unplugged) drops straight back
to *not charging*.
Args:
now: The current monotonic time (the just-taken sample's timestamp).
Returns:
The charging estimate for this reading.
"""
charging = self._reading.charging if self._reading is not None else False
recent = [mv for t, mv in self._history if now - t <= _TREND_WINDOW_S]
if len(recent) < _TREND_MIN_SAMPLES:
return False
mid = len(recent) // 2
rise = median(recent[mid:]) - median(recent[:mid])
return rise >= (_CHARGING_RISE_HOLD_MV if charging else _CHARGING_RISE_ON_MV)