fix(companion): report battery and storage to companion clients

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.
This commit is contained in:
skjegg committed 2026-08-30 12:18:52 -04:00
1 parent 0a2eeea8ee
commit a3f8ec18e7
3 files changed
+85 -1

No files matched your search

+36 -1
View File
@@ -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."""
+12
View File
@@ -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.
+37
View File
@@ -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)