mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 09:43:03 +02:00
Pluck in HA radio stats into the WS fanout endpoint
This commit is contained in:
@@ -127,6 +127,78 @@ class TestHealthEndpoint:
|
||||
assert data["radio_connected"] is False
|
||||
assert data["connection_info"] is None
|
||||
|
||||
def test_health_includes_radio_stats_when_available(self):
|
||||
"""Health endpoint includes cached radio stats snapshot."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
fake_stats = {
|
||||
"timestamp": 1700000000,
|
||||
"battery_mv": 4150,
|
||||
"uptime_secs": 3600,
|
||||
"noise_floor": -120,
|
||||
"last_rssi": -85,
|
||||
"last_snr": 9.5,
|
||||
"tx_air_secs": 100,
|
||||
"rx_air_secs": 200,
|
||||
"packets": {
|
||||
"recv": 500,
|
||||
"sent": 250,
|
||||
"flood_tx": 100,
|
||||
"direct_tx": 150,
|
||||
"flood_rx": 300,
|
||||
"direct_rx": 200,
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("app.routers.health.radio_manager") as mock_rm,
|
||||
patch("app.routers.health.get_latest_radio_stats", return_value=fake_stats),
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_rm.is_setup_in_progress = False
|
||||
mock_rm.is_setup_complete = True
|
||||
mock_rm.connection_desired = True
|
||||
mock_rm.is_reconnecting = False
|
||||
mock_rm.device_info_loaded = False
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
stats = response.json()["radio_stats"]
|
||||
assert stats["battery_mv"] == 4150
|
||||
assert stats["uptime_secs"] == 3600
|
||||
assert stats["noise_floor"] == -120
|
||||
assert stats["packets_recv"] == 500
|
||||
assert stats["packets_sent"] == 250
|
||||
|
||||
def test_health_radio_stats_null_when_no_data(self):
|
||||
"""Health endpoint returns null radio_stats when cache is empty."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with (
|
||||
patch("app.routers.health.radio_manager") as mock_rm,
|
||||
patch("app.routers.health.get_latest_radio_stats", return_value={}),
|
||||
):
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.connection_info = None
|
||||
mock_rm.is_setup_in_progress = False
|
||||
mock_rm.is_setup_complete = False
|
||||
mock_rm.connection_desired = True
|
||||
mock_rm.is_reconnecting = False
|
||||
mock_rm.device_info_loaded = False
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["radio_stats"] is None
|
||||
|
||||
|
||||
class TestDebugEndpoint:
|
||||
"""Test the debug support snapshot endpoint."""
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import radio_noise_floor
|
||||
|
||||
|
||||
class TestNoiseFloorSamplingLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_and_continues_after_unexpected_sample_exception(self):
|
||||
sample_calls = 0
|
||||
sleep_calls = 0
|
||||
|
||||
async def fake_sample() -> None:
|
||||
nonlocal sample_calls
|
||||
sample_calls += 1
|
||||
if sample_calls == 1:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def fake_sleep(_seconds: int) -> None:
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with (
|
||||
patch.object(radio_noise_floor, "sample_noise_floor_once", side_effect=fake_sample),
|
||||
patch.object(radio_noise_floor.asyncio, "sleep", side_effect=fake_sleep),
|
||||
patch.object(radio_noise_floor.logger, "exception") as mock_exception,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await radio_noise_floor._noise_floor_sampling_loop()
|
||||
|
||||
assert sample_calls == 2
|
||||
assert sleep_calls == 2
|
||||
mock_exception.assert_called_once()
|
||||
@@ -0,0 +1,212 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import radio_stats
|
||||
|
||||
|
||||
def _make_event(event_type, payload=None):
|
||||
return SimpleNamespace(type=event_type, payload=payload or {})
|
||||
|
||||
|
||||
class TestRadioStatsSamplingLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_and_continues_after_unexpected_sample_exception(self):
|
||||
sample_calls = 0
|
||||
sleep_calls = 0
|
||||
|
||||
async def fake_sample() -> None:
|
||||
nonlocal sample_calls
|
||||
sample_calls += 1
|
||||
if sample_calls == 1:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def fake_sleep(_seconds: int) -> None:
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
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,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await radio_stats._stats_sampling_loop()
|
||||
|
||||
assert sample_calls == 2
|
||||
assert sleep_calls == 2
|
||||
mock_exception.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcasts_health_every_cycle(self):
|
||||
"""The loop should push a WS health broadcast after every iteration."""
|
||||
sleep_calls = 0
|
||||
|
||||
async def fake_sample() -> None:
|
||||
pass # no-op; just testing that broadcast fires
|
||||
|
||||
async def fake_sleep(_seconds: int) -> None:
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
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,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await radio_stats._stats_sampling_loop()
|
||||
|
||||
assert mock_broadcast.call_count == 2
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
with patch.object(radio_stats, "radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
await radio_stats._sample_all_stats()
|
||||
|
||||
assert radio_stats._latest_stats == {}
|
||||
|
||||
@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."""
|
||||
from meshcore import EventType
|
||||
|
||||
radio_stats._latest_stats = {}
|
||||
radio_stats._noise_floor_samples.clear()
|
||||
|
||||
core_event = _make_event(EventType.ERROR, {"reason": "unsupported"})
|
||||
radio_event = _make_event(
|
||||
EventType.STATS_RADIO,
|
||||
{
|
||||
"noise_floor": -118,
|
||||
"last_rssi": -90,
|
||||
"last_snr": 8.0,
|
||||
"tx_air_secs": 10,
|
||||
"rx_air_secs": 20,
|
||||
},
|
||||
)
|
||||
packet_event = _make_event(
|
||||
EventType.STATS_PACKETS,
|
||||
{
|
||||
"recv": 100,
|
||||
"sent": 50,
|
||||
"flood_tx": 20,
|
||||
"direct_tx": 30,
|
||||
"flood_rx": 60,
|
||||
"direct_rx": 40,
|
||||
},
|
||||
)
|
||||
|
||||
mock_mc = AsyncMock()
|
||||
mock_mc.commands.get_stats_core = AsyncMock(return_value=core_event)
|
||||
mock_mc.commands.get_stats_radio = AsyncMock(return_value=radio_event)
|
||||
mock_mc.commands.get_stats_packets = AsyncMock(return_value=packet_event)
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_mc)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
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 = radio_stats._latest_stats
|
||||
# Core fields missing (ERROR), but radio + packet fields present
|
||||
assert "battery_mv" not in snapshot
|
||||
assert snapshot["noise_floor"] == -118
|
||||
assert snapshot["packets"]["recv"] == 100
|
||||
# Noise floor history was still appended
|
||||
assert len(radio_stats._noise_floor_samples) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_stats_succeed(self):
|
||||
"""All three stats commands succeed — full snapshot cached."""
|
||||
from meshcore import EventType
|
||||
|
||||
radio_stats._latest_stats = {}
|
||||
radio_stats._noise_floor_samples.clear()
|
||||
|
||||
core_event = _make_event(
|
||||
EventType.STATS_CORE,
|
||||
{"battery_mv": 4100, "uptime_secs": 7200, "errors": 0, "queue_len": 2},
|
||||
)
|
||||
radio_event = _make_event(
|
||||
EventType.STATS_RADIO,
|
||||
{
|
||||
"noise_floor": -120,
|
||||
"last_rssi": -85,
|
||||
"last_snr": 9.5,
|
||||
"tx_air_secs": 100,
|
||||
"rx_air_secs": 200,
|
||||
},
|
||||
)
|
||||
packet_event = _make_event(
|
||||
EventType.STATS_PACKETS,
|
||||
{
|
||||
"recv": 500,
|
||||
"sent": 250,
|
||||
"flood_tx": 100,
|
||||
"direct_tx": 150,
|
||||
"flood_rx": 300,
|
||||
"direct_rx": 200,
|
||||
},
|
||||
)
|
||||
|
||||
mock_mc = AsyncMock()
|
||||
mock_mc.commands.get_stats_core = AsyncMock(return_value=core_event)
|
||||
mock_mc.commands.get_stats_radio = AsyncMock(return_value=radio_event)
|
||||
mock_mc.commands.get_stats_packets = AsyncMock(return_value=packet_event)
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_mc)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
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 = 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."""
|
||||
from meshcore import EventType
|
||||
|
||||
radio_stats._latest_stats = {"old": "stale"}
|
||||
|
||||
error = _make_event(EventType.ERROR, {"reason": "unsupported"})
|
||||
|
||||
mock_mc = AsyncMock()
|
||||
mock_mc.commands.get_stats_core = AsyncMock(return_value=error)
|
||||
mock_mc.commands.get_stats_radio = AsyncMock(return_value=error)
|
||||
mock_mc.commands.get_stats_packets = AsyncMock(return_value=error)
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_mc)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
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()
|
||||
|
||||
assert radio_stats._latest_stats == {}
|
||||
@@ -450,11 +450,10 @@ class TestStatisticsEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):
|
||||
noise_floor_history = {
|
||||
"sample_interval_seconds": 300,
|
||||
"sample_interval_seconds": 60,
|
||||
"coverage_seconds": 1800,
|
||||
"latest_noise_floor_dbm": -119,
|
||||
"latest_timestamp": 1_700_000_000,
|
||||
"supported": True,
|
||||
"samples": [
|
||||
{"timestamp": 1_699_998_200, "noise_floor_dbm": -121},
|
||||
{"timestamp": 1_700_000_000, "noise_floor_dbm": -119},
|
||||
@@ -463,7 +462,7 @@ class TestStatisticsEndpoint:
|
||||
|
||||
with patch(
|
||||
"app.routers.statistics.get_noise_floor_history",
|
||||
new=AsyncMock(return_value=noise_floor_history),
|
||||
return_value=noise_floor_history,
|
||||
):
|
||||
response = await client.get("/api/statistics")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user