diff --git a/app/fanout/mqtt_ha.py b/app/fanout/mqtt_ha.py index 6a6469d..7df8af8 100644 --- a/app/fanout/mqtt_ha.py +++ b/app/fanout/mqtt_ha.py @@ -81,6 +81,15 @@ _REPEATER_SENSORS: list[dict[str, Any]] = [ "unit": None, "precision": 0, }, + { + "field": "recv_errors", + "name": "RX Errors", + "object_id": "recv_errors", + "device_class": None, + "state_class": "total_increasing", + "unit": None, + "precision": 0, + }, { "field": "uptime_seconds", "name": "Uptime", diff --git a/app/models.py b/app/models.py index d9aac3b..46d128a 100644 --- a/app/models.py +++ b/app/models.py @@ -540,6 +540,7 @@ class RepeaterStatusResponse(BaseModel): flood_dups: int = Field(description="Duplicate flood packets") direct_dups: int = Field(description="Duplicate direct packets") full_events: int = Field(description="Full event queue count") + recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors") telemetry_history: list["TelemetryHistoryEntry"] = Field( default_factory=list, description="Recent telemetry history snapshots" ) diff --git a/app/radio_sync.py b/app/radio_sync.py index 032e109..01d4d02 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -1821,6 +1821,7 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool: "flood_dups": status.get("flood_dups", 0), "direct_dups": status.get("direct_dups", 0), "full_events": status.get("full_evts", 0), + "recv_errors": status.get("recv_errors"), } # Best-effort LPP sensor fetch — failure here does not fail the overall diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index a2dba2e..a1e1031 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -133,6 +133,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse: flood_dups=status.get("flood_dups", 0), direct_dups=status.get("direct_dups", 0), full_events=status.get("full_evts", 0), + recv_errors=status.get("recv_errors"), ) # Record to telemetry history as a JSON blob (best-effort) diff --git a/app/routers/rooms.py b/app/routers/rooms.py index f075089..745ae51 100644 --- a/app/routers/rooms.py +++ b/app/routers/rooms.py @@ -78,6 +78,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse: flood_dups=status.get("flood_dups", 0), direct_dups=status.get("direct_dups", 0), full_events=status.get("full_evts", 0), + recv_errors=status.get("recv_errors"), ) diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index 2a55f85..3bbd6c4 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -17,7 +17,12 @@ import type { TelemetryHistoryEntry, TelemetryLppSensor, Contact } from '../../t const MAX_TRACKED = 8; -type BuiltinMetric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds'; +type BuiltinMetric = + | 'battery_volts' + | 'noise_floor_dbm' + | 'packets' + | 'recv_errors' + | 'uptime_seconds'; interface MetricConfig { label: string; @@ -29,6 +34,7 @@ const BUILTIN_METRIC_CONFIG: Record = { battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' }, noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' }, packets: { label: 'Packets', unit: '', color: '#0ea5e9' }, + recv_errors: { label: 'RX Errors', unit: '', color: '#ef4444' }, uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' }, }; @@ -154,6 +160,7 @@ export function TelemetryHistoryPane({ noise_floor_dbm: d.noise_floor_dbm, packets_received: d.packets_received, packets_sent: d.packets_sent, + recv_errors: d.recv_errors ?? undefined, uptime_seconds: d.uptime_seconds, }; // Flatten LPP sensors into the point, converting units as needed diff --git a/frontend/src/components/repeater/RepeaterTelemetryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryPane.tsx index 7440116..65ef1c1 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryPane.tsx @@ -91,6 +91,9 @@ export function TelemetryPane({ label="Duplicates" value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`} /> + {data.recv_errors != null && ( + + )} diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 295ee38..d651d52 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -438,6 +438,7 @@ describe('RepeaterDashboard', () => { flood_dups: 1, direct_dups: 0, full_events: 0, + recv_errors: 5, telemetry_history: [], }; @@ -707,6 +708,7 @@ describe('RepeaterDashboard', () => { flood_dups: 1, direct_dups: 0, full_events: 0, + recv_errors: null, telemetry_history: [liveEntry], }; @@ -742,6 +744,7 @@ describe('RepeaterDashboard', () => { flood_dups: 1, direct_dups: 0, full_events: 0, + recv_errors: null, telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }], }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bd6c573..3db952e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -438,6 +438,7 @@ export interface RepeaterStatusResponse { flood_dups: number; direct_dups: number; full_events: number; + recv_errors: number | null; telemetry_history: TelemetryHistoryEntry[]; } diff --git a/tests/test_mqtt_ha.py b/tests/test_mqtt_ha.py index e34dfce..663c4d9 100644 --- a/tests/test_mqtt_ha.py +++ b/tests/test_mqtt_ha.py @@ -125,7 +125,7 @@ class TestRadioDiscovery: class TestRepeaterDiscovery: def test_produces_sensor_per_field(self): configs = _repeater_discovery_configs("mc", "ccdd11223344", "Rep1", "aabb") - assert len(configs) == 7 # matches _REPEATER_SENSORS length + assert len(configs) == 8 # matches _REPEATER_SENSORS length topics = [t for t, _ in configs] assert "homeassistant/sensor/meshcore_ccdd11223344/battery_voltage/config" in topics diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index c66d2f0..d482b89 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -722,6 +722,7 @@ class TestRepeaterStatus: "flood_dups": 10, "direct_dups": 5, "full_evts": 0, + "recv_errors": 42, } ) @@ -741,6 +742,7 @@ class TestRepeaterStatus: assert response.uptime_seconds == 86400 assert response.sent_flood == 100 assert response.recv_direct == 700 + assert response.recv_errors == 42 @pytest.mark.asyncio async def test_504_on_timeout(self, test_db): diff --git a/tests/test_repeater_telemetry.py b/tests/test_repeater_telemetry.py index 4a86579..97cc2fd 100644 --- a/tests/test_repeater_telemetry.py +++ b/tests/test_repeater_telemetry.py @@ -31,6 +31,7 @@ SAMPLE_STATUS = { "flood_dups": 5, "direct_dups": 2, "full_events": 0, + "recv_errors": None, } diff --git a/tests/test_room_routes.py b/tests/test_room_routes.py index 6738940..ff08d05 100644 --- a/tests/test_room_routes.py +++ b/tests/test_room_routes.py @@ -135,6 +135,7 @@ class TestRoomStatus: "flood_dups": 2, "direct_dups": 1, "full_evts": 0, + "recv_errors": 7, } ) @@ -147,6 +148,7 @@ class TestRoomStatus: assert response.battery_volts == 4.025 assert response.packets_received == 80 assert response.recv_direct == 73 + assert response.recv_errors == 7 @pytest.mark.asyncio async def test_room_acl_maps_entries(self, test_db):