diff --git a/repeater/main.py b/repeater/main.py index 0eb5ea4..773b842 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -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), diff --git a/tests/test_companion_battery_mv.py b/tests/test_companion_battery_mv.py new file mode 100644 index 0000000..4e34290 --- /dev/null +++ b/tests/test_companion_battery_mv.py @@ -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