feat(radio): report disconnected USB and TCP modem links

This commit is contained in:
yellowcooln committed 2026-09-23 12:17:36 -04:00
1 parent c079560e51
commit 9cd6d7bf14
6 files changed
+158 -2

No files matched your search

+48 -1
View File
@@ -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
@@ -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"]
+5
View File
@@ -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
+5
View File
@@ -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:
+80
View File
@@ -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"}})
@@ -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()