From 9cd6d7bf149798021fec072159577f149fea5e9d Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:17:36 -0400 Subject: [PATCH] feat(radio): report disconnected USB and TCP modem links --- repeater/config.py | 49 +++++++++++- .../data_acquisition/storage_collector.py | 5 +- repeater/main.py | 5 ++ repeater/web/openapi.yaml | 5 ++ tests/test_modem_status.py | 80 +++++++++++++++++++ ...est_storage_collector_ws_stats_throttle.py | 16 ++++ 6 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/test_modem_status.py diff --git a/repeater/config.py b/repeater/config.py index 6cc13e1..a281bc1 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -140,6 +140,52 @@ class BaselineCrcCounterRadio: return 0 +def disconnected_modems(config: dict, radio) -> list[str]: + """Return configured USB/TCP modem labels whose live transport is down. + + Core's TCP modem can begin in deferred-connect mode (begin() returns True), + so initialization status alone cannot report connectivity. Do not probe the + modem here: stats and WebSocket beats must stay cheap and non-invasive. + """ + root = getattr(radio, "_radio", radio) + fabric = getattr(root, "fabric", None) + radios = getattr(fabric, "radios", None) + entries = config.get("radios") + if isinstance(entries, list) and entries: + configured = [ + (str(e.get("id") or e.get("radio_id") or ""), e) for e in entries if isinstance(e, dict) + ] + physicals = radios if isinstance(radios, dict) else {} + else: + configured = [("", config)] + physicals = { + "": next(iter(radios.values())) if isinstance(radios, dict) and radios else root + } + + down = [] + for radio_id, entry in configured: + kind = entry.get("radio_type", config.get("radio_type")) + if kind == "pymc_tcp": + kind = "modem_tcp" + elif kind == "pymc_usb": + kind = "modem_usb" + if kind not in ("modem_tcp", "modem_usb"): + continue + device = physicals.get(radio_id, root if isinstance(root, NullRadio) else None) + device = getattr(device, "_radio", device) + if isinstance(device, NullRadio): + connected = False + elif kind == "modem_tcp": + sock = getattr(device, "_sock", ...) + connected = sock is not None if sock is not ... else None + else: + event = getattr(device, "_connected_event", None) + connected = event.is_set() if event is not None else None + if connected is False: + down.append(f"{radio_id}: {kind}" if radio_id else kind) + return down + + def resolve_storage_dir( config: Dict[str, Any], *, @@ -750,7 +796,8 @@ def get_radio_for_board(board_config: dict): ) try: - radio.begin() + if radio.begin() is False: + raise RuntimeError("USB modem did not connect or accept radio configuration") except Exception as e: raise RuntimeError(f"Failed to initialize modem_usb radio: {e}") from e diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index 1c66fbe..09600a3 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -3,7 +3,7 @@ import concurrent.futures import logging import threading import time -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional from repeater.config import build_radio_profiles, resolve_storage_dir @@ -122,6 +122,7 @@ class StorageCollector: # Wired by the daemon once the advert helper exists; returns the # rate-limit stats dict the sidebar's advert tier reads. self.advert_stats_getter = None + self.modem_status_getter: Callable[[], list[str]] | None = None self._last_noise_floor_dbm: Optional[float] = None self._stats_broadcast_seq = 0 self._ws_stats_broadcast_interval_sec: float = 5.0 @@ -423,6 +424,8 @@ class StorageCollector: ), "mode": self.config.get("repeater", {}).get("mode", "forward"), } + if self.modem_status_getter is not None: + system_stats["modem_disconnected"] = self.modem_status_getter() airtime_stats = _node_airtime_stats(self.repeater_handler) if airtime_stats: system_stats["utilization_percent"] = airtime_stats["utilization_percent"] diff --git a/repeater/main.py b/repeater/main.py index 82c7882..26629da 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -25,6 +25,7 @@ from repeater.companion.utils import ( from repeater.config import ( NullRadio, build_radio_stack, + disconnected_modems, load_config, resolve_storage_dir, save_config, @@ -601,6 +602,9 @@ class RepeaterDaemon: self.repeater_handler.storage.advert_stats_getter = ( self.advert_helper.get_rate_limit_stats ) + self.repeater_handler.storage.modem_status_getter = lambda: disconnected_modems( + self.config, self.radio + ) # Set up discovery handler if enabled allow_discovery = self.config.get("repeater", {}).get("allow_discovery", True) @@ -1505,6 +1509,7 @@ class RepeaterDaemon: stats["sensors"] = self.sensor_manager.get_summary() stats["radio_status"] = self.radio_status + stats["modem_disconnected"] = disconnected_modems(self.config, self.radio) if self.radio_error: stats["radio_error"] = self.radio_error diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 8b3d3a1..cca7b71 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -323,6 +323,11 @@ paths: core_version: type: string example: "0.5.0" + modem_disconnected: + type: array + description: Configured USB/TCP radio labels whose live modem link is down; empty when connected or not configured. + items: + type: string /gps: get: diff --git a/tests/test_modem_status.py b/tests/test_modem_status.py new file mode 100644 index 0000000..66284e9 --- /dev/null +++ b/tests/test_modem_status.py @@ -0,0 +1,80 @@ +from threading import Event +from types import SimpleNamespace + +import pytest + +from repeater.config import ( + BaselineCrcCounterRadio, + NullRadio, + disconnected_modems, + get_radio_for_board, +) +from repeater.main import RepeaterDaemon + + +def test_tcp_deferred_connect_disconnect_and_recovery(): + config = {"radio_type": "modem_tcp"} + transport = SimpleNamespace(_sock=None, crc_error_count=0) + radio = BaselineCrcCounterRadio(transport) + daemon = RepeaterDaemon(config, radio=radio) + assert daemon.get_stats()["modem_disconnected"] == ["modem_tcp"] + transport._sock = object() + assert daemon.get_stats()["modem_disconnected"] == [] + transport._sock = None + assert daemon.get_stats()["modem_disconnected"] == ["modem_tcp"] + + +def test_usb_disconnection_recovery_and_failed_init(): + event = Event() + radio = SimpleNamespace(_connected_event=event) + config = {"radio_type": "modem_usb"} + assert disconnected_modems(config, radio) == ["modem_usb"] + event.set() + assert disconnected_modems(config, radio) == [] + event.clear() + assert disconnected_modems(config, radio) == ["modem_usb"] + assert disconnected_modems(config, NullRadio()) == ["modem_usb"] + + +def test_fabric_only_reports_disconnected_modems(): + config = { + "radios": [ + {"id": "local", "radio_type": "sx1262_ch341"}, + {"id": "backhaul", "radio_type": "modem_tcp"}, + {"id": "usb", "radio_type": "pymc_usb"}, + ] + } + fabric = SimpleNamespace( + radios={ + "local": object(), + "backhaul": SimpleNamespace(_sock=None), + "usb": SimpleNamespace(_connected_event=Event()), + } + ) + assert disconnected_modems(config, SimpleNamespace(fabric=fabric)) == [ + "backhaul: modem_tcp", + "usb: modem_usb", + ] + fabric.radios["backhaul"]._sock = object() + fabric.radios["usb"]._connected_event.set() + assert disconnected_modems(config, SimpleNamespace(fabric=fabric)) == [] + assert disconnected_modems({"radio_type": "sx1262_ch341"}, NullRadio()) == [] + assert disconnected_modems(config, NullRadio()) == [ + "backhaul: modem_tcp", + "usb: modem_usb", + ] + + +def test_usb_begin_false_is_not_reported_as_initialized(monkeypatch): + pytest.importorskip("openhop_core.hardware.usb_radio") + + class UnavailableUSB: + def __init__(self, **kwargs): + pass + + def begin(self): + return False + + monkeypatch.setattr("openhop_core.hardware.usb_radio.USBLoRaRadio", UnavailableUSB) + with pytest.raises(RuntimeError, match="USB modem did not connect"): + get_radio_for_board({"radio_type": "modem_usb", "modem_usb": {"port": "/dev/missing"}}) diff --git a/tests/test_storage_collector_ws_stats_throttle.py b/tests/test_storage_collector_ws_stats_throttle.py index 1b8e88f..e8810b7 100644 --- a/tests/test_storage_collector_ws_stats_throttle.py +++ b/tests/test_storage_collector_ws_stats_throttle.py @@ -85,6 +85,22 @@ def test_broadcast_stats_once_queries_and_broadcasts(): assert payload["system_stats"]["mode"] == "forward" +def test_modem_status_is_in_every_vitals_beat_and_clears_on_reconnect(): + collector = _make_collector() + down = ["modem_tcp"] + collector.modem_status_getter = lambda: list(down) + collector._broadcast_stats_once() + assert collector.websocket_broadcast_stats.call_args.args[0]["system_stats"][ + "modem_disconnected" + ] == ["modem_tcp"] + down.clear() + collector._broadcast_stats_once() + assert ( + collector.websocket_broadcast_stats.call_args.args[0]["system_stats"]["modem_disconnected"] + == [] + ) + + def test_broadcast_decimates_the_packet_stats_aggregate(): collector = _make_collector()