Add radio health &c. to fanout bus

This commit is contained in:
Jack Kingsman
2026-04-10 14:31:45 -07:00
parent 3bd756ee4e
commit 1e22a21445
10 changed files with 445 additions and 49 deletions
+160
View File
@@ -101,6 +101,9 @@ class StubModule(FanoutModule):
super().__init__("stub", {})
self.message_calls: list[dict] = []
self.raw_calls: list[dict] = []
self.contact_calls: list[dict] = []
self.telemetry_calls: list[dict] = []
self.health_calls: list[dict] = []
self._status = "connected"
async def start(self) -> None:
@@ -115,6 +118,15 @@ class StubModule(FanoutModule):
async def on_raw(self, data: dict) -> None:
self.raw_calls.append(data)
async def on_contact(self, data: dict) -> None:
self.contact_calls.append(data)
async def on_telemetry(self, data: dict) -> None:
self.telemetry_calls.append(data)
async def on_health(self, data: dict) -> None:
self.health_calls.append(data)
@property
def status(self) -> str:
return self._status
@@ -301,6 +313,113 @@ class TestFanoutManagerDispatch:
assert statuses["test-id"]["last_error"] == "ConnectionError: broker down"
# ---------------------------------------------------------------------------
# New event dispatch (contact, telemetry, health)
# ---------------------------------------------------------------------------
class TestFanoutManagerNewEventDispatch:
@pytest.mark.asyncio
async def test_broadcast_contact_dispatches_to_all_modules(self):
manager = FanoutManager()
mod = StubModule()
manager._modules["test-id"] = (mod, {})
await manager.broadcast_contact({"public_key": "aabb", "name": "Alice"})
assert len(mod.contact_calls) == 1
assert mod.contact_calls[0]["public_key"] == "aabb"
@pytest.mark.asyncio
async def test_broadcast_contact_ignores_scope(self):
"""Contact dispatch is unconditional — scope doesn't affect it."""
manager = FanoutManager()
mod = StubModule()
manager._modules["test-id"] = (mod, {"messages": "none", "raw_packets": "none"})
await manager.broadcast_contact({"public_key": "aabb"})
assert len(mod.contact_calls) == 1
@pytest.mark.asyncio
async def test_broadcast_telemetry_dispatches_to_all_modules(self):
manager = FanoutManager()
mod = StubModule()
manager._modules["test-id"] = (mod, {})
await manager.broadcast_telemetry(
{"public_key": "ccdd", "battery_volts": 4.1, "timestamp": 1000}
)
assert len(mod.telemetry_calls) == 1
assert mod.telemetry_calls[0]["battery_volts"] == 4.1
@pytest.mark.asyncio
async def test_broadcast_health_fanout_dispatches_to_all_modules(self):
manager = FanoutManager()
mod = StubModule()
manager._modules["test-id"] = (mod, {})
await manager.broadcast_health_fanout({"connected": True, "noise_floor_dbm": -112})
assert len(mod.health_calls) == 1
assert mod.health_calls[0]["connected"] is True
@pytest.mark.asyncio
async def test_new_events_do_not_affect_message_or_raw(self):
"""Verify new dispatch paths are independent of message/raw."""
manager = FanoutManager()
mod = StubModule()
manager._modules["test-id"] = (mod, {"messages": "all", "raw_packets": "all"})
await manager.broadcast_contact({"public_key": "aabb"})
await manager.broadcast_telemetry({"public_key": "ccdd", "battery_volts": 3.8})
await manager.broadcast_health_fanout({"connected": False})
assert len(mod.message_calls) == 0
assert len(mod.raw_calls) == 0
assert len(mod.contact_calls) == 1
assert len(mod.telemetry_calls) == 1
assert len(mod.health_calls) == 1
@pytest.mark.asyncio
async def test_base_module_no_ops_do_not_raise(self):
"""Default FanoutModule no-ops accept data without error."""
manager = FanoutManager()
class MinimalModule(FanoutModule):
@property
def status(self) -> str:
return "connected"
mod = MinimalModule("test", {})
manager._modules["test-id"] = (mod, {})
# Should not raise — base class no-ops silently accept
await manager.broadcast_contact({"public_key": "aabb"})
await manager.broadcast_telemetry({"public_key": "ccdd"})
await manager.broadcast_health_fanout({"connected": True})
@pytest.mark.asyncio
async def test_error_in_one_module_does_not_block_others(self):
manager = FanoutManager()
bad_mod = StubModule()
async def fail(data):
raise RuntimeError("boom")
bad_mod.on_contact = fail
good_mod = StubModule()
manager._modules["bad"] = (bad_mod, {})
manager._modules["good"] = (good_mod, {})
await manager.broadcast_contact({"public_key": "aabb"})
assert len(good_mod.contact_calls) == 1
# ---------------------------------------------------------------------------
# Repository tests
# ---------------------------------------------------------------------------
@@ -476,6 +595,47 @@ class TestBroadcastEventRealtime:
mock_ws.broadcast.assert_called_once()
mock_fm.broadcast_message.assert_called_once()
@pytest.mark.asyncio
async def test_contact_event_dispatches_to_fanout(self):
"""broadcast_event for 'contact' should trigger fanout contact dispatch."""
from app.websocket import broadcast_event
with (
patch("app.websocket.ws_manager") as mock_ws,
patch("app.fanout.manager.fanout_manager") as mock_fm,
):
mock_ws.broadcast = AsyncMock()
mock_fm.broadcast_contact = AsyncMock()
broadcast_event("contact", {"public_key": "aabb"}, realtime=True)
import asyncio
await asyncio.sleep(0)
mock_ws.broadcast.assert_called_once()
mock_fm.broadcast_contact.assert_called_once()
@pytest.mark.asyncio
async def test_contact_event_skipped_when_not_realtime(self):
"""broadcast_event('contact', ..., realtime=False) should skip fanout."""
from app.websocket import broadcast_event
with (
patch("app.websocket.ws_manager") as mock_ws,
patch("app.fanout.manager.fanout_manager") as mock_fm,
):
mock_ws.broadcast = AsyncMock()
broadcast_event("contact", {"public_key": "aabb"}, realtime=False)
import asyncio
await asyncio.sleep(0)
mock_ws.broadcast.assert_called_once()
mock_fm.broadcast_contact.assert_not_called()
# ---------------------------------------------------------------------------
# Webhook module unit tests
+76 -24
View File
@@ -17,11 +17,12 @@ class TestRadioStatsSamplingLoop:
sample_calls = 0
sleep_calls = 0
async def fake_sample() -> None:
async def fake_sample():
nonlocal sample_calls
sample_calls += 1
if sample_calls == 1:
raise RuntimeError("boom")
return {}
async def fake_sleep(_seconds: int) -> None:
nonlocal sleep_calls
@@ -29,10 +30,14 @@ class TestRadioStatsSamplingLoop:
if sleep_calls >= 2:
raise asyncio.CancelledError()
mock_fanout = MagicMock()
mock_fanout.broadcast_health_fanout = AsyncMock()
with (
patch.object(radio_stats, "_sample_all_stats", side_effect=fake_sample),
patch.object(radio_stats.asyncio, "sleep", side_effect=fake_sleep),
patch.object(radio_stats.logger, "exception") as mock_exception,
patch("app.fanout.manager.fanout_manager", mock_fanout),
):
with pytest.raises(asyncio.CancelledError):
await radio_stats._stats_sampling_loop()
@@ -43,11 +48,11 @@ class TestRadioStatsSamplingLoop:
@pytest.mark.asyncio
async def test_broadcasts_health_every_cycle(self):
"""The loop should push a WS health broadcast after every iteration."""
"""The loop should push a WS health broadcast and fanout after every iteration."""
sleep_calls = 0
async def fake_sample() -> None:
pass # no-op; just testing that broadcast fires
async def fake_sample():
return {}
async def fake_sleep(_seconds: int) -> None:
nonlocal sleep_calls
@@ -55,36 +60,88 @@ class TestRadioStatsSamplingLoop:
if sleep_calls >= 2:
raise asyncio.CancelledError()
mock_fanout = MagicMock()
mock_fanout.broadcast_health_fanout = AsyncMock()
with (
patch.object(radio_stats, "_sample_all_stats", side_effect=fake_sample),
patch.object(radio_stats.asyncio, "sleep", side_effect=fake_sleep),
patch("app.websocket.broadcast_health") as mock_broadcast,
patch("app.fanout.manager.fanout_manager", mock_fanout),
):
with pytest.raises(asyncio.CancelledError):
await radio_stats._stats_sampling_loop()
assert mock_broadcast.call_count == 2
assert mock_fanout.broadcast_health_fanout.call_count == 2
@pytest.mark.asyncio
async def test_fanout_receives_enriched_payload(self):
"""The health fanout payload should include radio identity + stats."""
sleep_calls = 0
fake_snapshot = {
"timestamp": 1700000000,
"battery_mv": 4100,
"uptime_secs": 3600,
"noise_floor": -118,
"last_rssi": -85,
"last_snr": 9.5,
"tx_air_secs": 100,
"rx_air_secs": 200,
"packets": {"recv": 500, "sent": 250},
}
async def fake_sample():
return dict(fake_snapshot)
async def fake_sleep(_seconds: int) -> None:
nonlocal sleep_calls
sleep_calls += 1
raise asyncio.CancelledError()
mock_fanout = MagicMock()
mock_fanout.broadcast_health_fanout = AsyncMock()
with (
patch.object(radio_stats, "_sample_all_stats", side_effect=fake_sample),
patch.object(radio_stats.asyncio, "sleep", side_effect=fake_sleep),
patch("app.websocket.broadcast_health"),
patch("app.fanout.manager.fanout_manager", mock_fanout),
patch.object(radio_stats, "radio_manager") as mock_rm,
):
mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.meshcore = MagicMock()
mock_rm.meshcore.self_info = {"public_key": "aabbccddeeff", "name": "MyRadio"}
with pytest.raises(asyncio.CancelledError):
await radio_stats._stats_sampling_loop()
payload = mock_fanout.broadcast_health_fanout.call_args[0][0]
assert payload["connected"] is True
assert payload["public_key"] == "aabbccddeeff"
assert payload["name"] == "MyRadio"
assert payload["battery_mv"] == 4100
assert payload["noise_floor_dbm"] == -118
assert payload["packets_recv"] == 500
class TestSampleAllStats:
@pytest.mark.asyncio
async def test_clears_cache_when_disconnected(self):
"""Stats cache should be empty when radio is disconnected."""
radio_stats._latest_stats = {"old": "data"}
async def test_returns_empty_when_disconnected(self):
"""Should return empty dict when radio is disconnected."""
with patch.object(radio_stats, "radio_manager") as mock_rm:
mock_rm.is_connected = False
await radio_stats._sample_all_stats()
result = await radio_stats._sample_all_stats()
assert radio_stats._latest_stats == {}
assert result == {}
@pytest.mark.asyncio
async def test_partial_stats_still_records_available_data(self):
"""If core stats return ERROR but radio/packet stats succeed, noise floor
is still sampled and available fields are cached."""
is still sampled and available fields are returned."""
from meshcore import EventType
radio_stats._latest_stats = {}
radio_stats._noise_floor_samples.clear()
core_event = _make_event(EventType.ERROR, {"reason": "unsupported"})
@@ -122,9 +179,8 @@ class TestSampleAllStats:
with patch.object(radio_stats, "radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.radio_operation = MagicMock(return_value=mock_ctx)
await radio_stats._sample_all_stats()
snapshot = await radio_stats._sample_all_stats()
snapshot = radio_stats._latest_stats
# Core fields missing (ERROR), but radio + packet fields present
assert "battery_mv" not in snapshot
assert snapshot["noise_floor"] == -118
@@ -134,10 +190,9 @@ class TestSampleAllStats:
@pytest.mark.asyncio
async def test_all_stats_succeed(self):
"""All three stats commands succeed — full snapshot cached."""
"""All three stats commands succeed — full snapshot returned."""
from meshcore import EventType
radio_stats._latest_stats = {}
radio_stats._noise_floor_samples.clear()
core_event = _make_event(
@@ -178,21 +233,18 @@ class TestSampleAllStats:
with patch.object(radio_stats, "radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.radio_operation = MagicMock(return_value=mock_ctx)
await radio_stats._sample_all_stats()
snapshot = await radio_stats._sample_all_stats()
snapshot = radio_stats._latest_stats
assert snapshot["battery_mv"] == 4100
assert snapshot["noise_floor"] == -120
assert snapshot["packets"]["sent"] == 250
assert len(radio_stats._noise_floor_samples) == 1
@pytest.mark.asyncio
async def test_all_errors_clears_cache(self):
"""If every stats command returns ERROR, cache is empty."""
async def test_all_errors_returns_empty(self):
"""If every stats command returns ERROR, result is empty."""
from meshcore import EventType
radio_stats._latest_stats = {"old": "stale"}
error = _make_event(EventType.ERROR, {"reason": "unsupported"})
mock_mc = AsyncMock()
@@ -207,6 +259,6 @@ class TestSampleAllStats:
with patch.object(radio_stats, "radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.radio_operation = MagicMock(return_value=mock_ctx)
await radio_stats._sample_all_stats()
snapshot = await radio_stats._sample_all_stats()
assert radio_stats._latest_stats == {}
assert snapshot == {}