mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
feat(radio): report disconnected USB and TCP modem links
This commit is contained in:
1 parent
c079560e51
commit
9cd6d7bf14
6 files changed
+158
-2
No files matched your search
+48
-1
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user