From a3f8ec18e71153f01f2501a345415d07ccc35bc4 Mon Sep 17 00:00:00 2001 From: skjegg Date: Sun, 30 Aug 2026 12:15:05 -0400 Subject: [PATCH] fix(companion): report battery and storage to companion clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit fixed battery_mv in the STATS_TYPE_CORE frame, but that is not the path companion apps use to display battery. CMD_GET_BATT_AND_STORAGE goes through `CompanionFrameServer._get_batt_and_storage()`, an openhop_core extension hook documented as "Default: all zeros" that the repeater never overrode — so apps showed 0% regardless of what any sensor reported. Override it in the repeater's frame server: battery from the same sensor lookup added for core stats, storage from the filesystem holding the configured storage_dir. Both are wired in from main.py alongside stats_getter. Found by deploying the previous fix and observing that Home Assistant showed the modem battery correctly while the phone app still showed 0% — two different paths, only one of which had been fixed. --- repeater/companion/frame_server.py | 37 +++++++++++++++++++++++++++++- repeater/main.py | 12 ++++++++++ tests/test_companion_battery_mv.py | 37 ++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 7076fec..80260eb 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -10,7 +10,9 @@ from __future__ import annotations import asyncio import logging -from typing import Optional +import os +import shutil +from typing import Callable, Optional from openhop_core.companion.constants import RESP_CODE_NO_MORE_MESSAGES from openhop_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer @@ -38,6 +40,8 @@ class CompanionFrameServer(_BaseFrameServer): local_hash: Optional[int] = None, stats_getter=None, control_handler=None, + batt_getter: Optional[Callable[[], int]] = None, + storage_dir: Optional[str] = None, ): super().__init__( bridge=bridge, @@ -53,6 +57,37 @@ class CompanionFrameServer(_BaseFrameServer): control_handler=control_handler, ) self.sqlite_handler = sqlite_handler + self.batt_getter = batt_getter + self.storage_dir = storage_dir + + def _get_batt_and_storage(self) -> tuple[int, int, int]: + """Report battery millivolts and storage usage to companion clients. + + The base-class hook returns zeros, so companion apps show no battery and + no storage for a Python repeater. Battery comes from whichever sensor + plug-in publishes one (an attached UPS HAT, or the battery of an + openHop modem the repeater drives); storage is the filesystem holding + the repeater's data directory. + """ + millivolts = 0 + if self.batt_getter is not None: + try: + millivolts = int(self.batt_getter() or 0) + except Exception: # pragma: no cover - never break the companion link + logger.debug("battery lookup failed", exc_info=True) + millivolts = 0 + if millivolts < 0 or millivolts > 0xFFFF: + millivolts = 0 + + used_kb = total_kb = 0 + if self.storage_dir: + try: + usage = shutil.disk_usage(self.storage_dir) + total_kb = min(usage.total // 1024, 0xFFFFFFFF) + used_kb = min((usage.total - usage.free) // 1024, 0xFFFFFFFF) + except OSError: + logger.debug("storage lookup failed for %s", self.storage_dir, exc_info=True) + return millivolts, used_kb, total_kb async def start(self) -> None: """Start persistence before accepting companion client connections.""" diff --git a/repeater/main.py b/repeater/main.py index 773b842..800a13d 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -957,6 +957,8 @@ class RepeaterDaemon: sqlite_handler=sqlite_handler, local_hash=self.local_hash, stats_getter=self._get_companion_stats, + batt_getter=self._companion_battery_mv, + storage_dir=self._companion_storage_dir(), control_handler=( self.discovery_helper.control_handler if self.discovery_helper else None ), @@ -1198,6 +1200,8 @@ class RepeaterDaemon: sqlite_handler=sqlite_handler, local_hash=self.local_hash, stats_getter=self._get_companion_stats, + batt_getter=self._companion_battery_mv, + storage_dir=self._companion_storage_dir(), control_handler=( self.discovery_helper.control_handler if self.discovery_helper else None ), @@ -1441,6 +1445,14 @@ class RepeaterDaemon: return stats + def _companion_storage_dir(self) -> "str | None": + """Filesystem path companion clients should see storage figures for.""" + try: + path = (self.config.get("storage", {}) or {}).get("storage_dir") + return path if path and os.path.isdir(path) else None + except Exception: # pragma: no cover - defensive + return None + def _companion_battery_mv(self) -> int: """Battery voltage in mV for the companion core-stats frame. diff --git a/tests/test_companion_battery_mv.py b/tests/test_companion_battery_mv.py index 4e34290..de93acb 100644 --- a/tests/test_companion_battery_mv.py +++ b/tests/test_companion_battery_mv.py @@ -76,3 +76,40 @@ def test_never_raises_when_the_sensor_manager_misbehaves(): daemon = RepeaterDaemon(_base_config(), radio=object()) daemon.sensor_manager = SimpleNamespace(get_summary=boom) assert daemon._companion_battery_mv() == 0 + + +def test_frame_server_reports_battery_and_storage(tmp_path): + """The companion BATT_AND_STORAGE hook must return real values, not zeros.""" + from repeater.companion.frame_server import CompanionFrameServer + + fs = CompanionFrameServer.__new__(CompanionFrameServer) + fs.batt_getter = lambda: 4221 + fs.storage_dir = str(tmp_path) + + mv, used_kb, total_kb = fs._get_batt_and_storage() + assert mv == 4221 + assert total_kb > 0 + assert 0 <= used_kb <= total_kb + + +def test_frame_server_battery_survives_a_broken_getter(tmp_path): + from repeater.companion.frame_server import CompanionFrameServer + + fs = CompanionFrameServer.__new__(CompanionFrameServer) + + def boom(): + raise RuntimeError("sensor exploded") + + fs.batt_getter = boom + fs.storage_dir = str(tmp_path) + mv, _, _ = fs._get_batt_and_storage() + assert mv == 0 + + +def test_frame_server_without_getters_returns_zeros(): + from repeater.companion.frame_server import CompanionFrameServer + + fs = CompanionFrameServer.__new__(CompanionFrameServer) + fs.batt_getter = None + fs.storage_dir = None + assert fs._get_batt_and_storage() == (0, 0, 0)