fix(companion): report real battery voltage in core stats

`_get_companion_stats` hardcoded `battery_mv: 0`, so every MeshCore companion
client connected to a Python repeater showed no battery, even when a sensor
plug-in was reporting one. The protocol side is already complete: the frame
server packs `battery_mv` into the STATS_TYPE_CORE frame and clients request it
via CMD_GET_BATT_AND_STORAGE.

Surface the first sensor reading that publishes a usable battery voltage,
accepting either `battery_voltage_mv` or `battery_voltage_v`. This covers an
attached UPS HAT as well as the battery of an openHop modem the repeater is
driving, which is the case that prompted this: a solar-powered modem whose
battery was invisible to the companion app right up until it went flat.

Returns 0 when no sensor reports a battery, which is what clients already
expect from a mains-powered node, and the lookup never raises — stats must
not be able to take down the companion connection.
This commit is contained in:
skjegg committed 2026-08-30 08:54:36 -04:00
1 parent ca3d4ecb8f
commit 0a2eeea8ee
2 files changed
+125 -1

No files matched your search

+47 -1
View File
@@ -1441,6 +1441,52 @@ class RepeaterDaemon:
return stats
def _companion_battery_mv(self) -> int:
"""Battery voltage in mV for the companion core-stats frame.
MeshCore companion clients show the battery of the device they are
connected to. A Python repeater has no battery of its own, but a sensor
plug-in often reports one -- an attached UPS HAT, or the battery of an
openHop modem the repeater is driving -- so surface the first sensor
publishing a usable voltage instead of always reporting 0.
Returns 0 when nothing is available, which is what clients already
expect for a mains-powered node.
"""
if not self.sensor_manager:
return 0
try:
readings = (self.sensor_manager.get_summary() or {}).get("readings") or []
except Exception: # pragma: no cover - defensive; stats must never raise
logger.debug("companion battery lookup failed", exc_info=True)
return 0
for reading in readings:
if not isinstance(reading, dict) or not reading.get("ok"):
continue
data = reading.get("data")
if not isinstance(data, dict):
continue
millivolts = data.get("battery_voltage_mv")
if millivolts is None:
volts = data.get("battery_voltage_v")
if volts is None:
continue
try:
millivolts = float(volts) * 1000.0
except (TypeError, ValueError):
continue
try:
millivolts = round(float(millivolts))
except (TypeError, ValueError):
continue
# The core-stats frame packs battery_mv as an unsigned 16-bit int.
if 0 < millivolts <= 0xFFFF:
return millivolts
return 0
async def _get_companion_stats(self, stats_type: int) -> dict:
"""Return stats dict for companion CMD_GET_STATS (format expected by frame_server + meshcore_py)."""
from repeater.companion.constants import (
@@ -1459,7 +1505,7 @@ class RepeaterDaemon:
queue_len += getattr(getattr(bridge, "message_queue", None), "count", 0) or 0
if stats_type == STATS_TYPE_CORE:
return {
"battery_mv": 0,
"battery_mv": self._companion_battery_mv(),
"uptime_secs": uptime_secs,
"errors": 0,
"queue_len": min(255, queue_len),
+78
View File
@@ -0,0 +1,78 @@
"""Companion core-stats battery reporting.
The MeshCore companion frame packs ``battery_mv``; clients show it as the
connected device's battery. The repeater used to hardcode 0, so companion apps
always showed no battery even when a sensor plug-in was reporting one.
"""
from types import SimpleNamespace
from repeater.main import RepeaterDaemon
def _base_config():
return {
"repeater": {
"node_name": "node-test",
"mode": "forward",
"latitude": 1.0,
"longitude": 2.0,
},
"logging": {"level": "INFO"},
}
def _daemon(readings=None):
daemon = RepeaterDaemon(_base_config(), radio=object())
daemon.sensor_manager = (
None if readings is None else SimpleNamespace(get_summary=lambda: {"readings": readings})
)
return daemon
def _reading(name, data, ok=True):
return {"name": name, "type": name, "ok": ok, "timestamp": "t", "data": data}
def test_returns_zero_without_a_sensor_manager():
assert _daemon()._companion_battery_mv() == 0
def test_reads_battery_voltage_mv():
daemon = _daemon([_reading("modem", {"battery_voltage_mv": 4213})])
assert daemon._companion_battery_mv() == 4213
def test_derives_millivolts_from_volts():
daemon = _daemon([_reading("modem", {"battery_voltage_v": 3.72})])
assert daemon._companion_battery_mv() == 3720
def test_skips_failed_and_batteryless_readings():
daemon = _daemon(
[
_reading("broken", {"battery_voltage_mv": 4100}, ok=False),
_reading("system-health", {"cpu_percent": 12.0}),
_reading("modem", {"battery_voltage_mv": 3980}),
]
)
assert daemon._companion_battery_mv() == 3980
def test_ignores_out_of_range_and_unparseable_values():
for data in (
{"battery_voltage_mv": 0},
{"battery_voltage_mv": 70000},
{"battery_voltage_mv": "n/a"},
{"battery_voltage_v": None},
):
assert _daemon([_reading("modem", data)])._companion_battery_mv() == 0
def test_never_raises_when_the_sensor_manager_misbehaves():
def boom():
raise RuntimeError("sensor manager exploded")
daemon = RepeaterDaemon(_base_config(), radio=object())
daemon.sensor_manager = SimpleNamespace(get_summary=boom)
assert daemon._companion_battery_mv() == 0