mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 01:33:01 +02:00
Add radio health &c. to fanout bus
This commit is contained in:
@@ -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
@@ -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 == {}
|
||||
|
||||
Reference in New Issue
Block a user