From 662e84adbea496372f25da04187204ea401b32e7 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 3 Mar 2026 09:17:57 -0800 Subject: [PATCH] Add community mqtt stats reporting --- app/AGENTS_MQTT.md | 38 +++ app/community_mqtt.py | 157 +++++++++++- app/mqtt_base.py | 5 + tests/test_community_mqtt.py | 480 ++++++++++++++++++++++++++++++++++- 4 files changed, 669 insertions(+), 11 deletions(-) diff --git a/app/AGENTS_MQTT.md b/app/AGENTS_MQTT.md index 5667504..b922635 100644 --- a/app/AGENTS_MQTT.md +++ b/app/AGENTS_MQTT.md @@ -48,6 +48,7 @@ loop: ├─ Connect with aiomqtt.Client ├─ Set connected=True, broadcast success toast via _on_connected() ├─ Wait in 60s intervals: + │ ├─ _on_periodic_wake(elapsed) → subclass hook (e.g., periodic status republish) │ ├─ Settings version changed? → break, reconnect with new settings │ ├─ _should_break_wait()? → break (e.g., JWT expiry) │ └─ Otherwise keep waiting (paho-mqtt handles keepalive internally) @@ -71,6 +72,7 @@ loop: | `_pre_connect(settings)` | `return True` | Async setup before connect; return `False` to retry | | `_should_break_wait(elapsed)` | `return False` | Force reconnect while connected (e.g., token renewal) | | `_on_not_configured()` | no-op | Called repeatedly while waiting for configuration | +| `_on_periodic_wake(elapsed)` | no-op | Called every ~60s while connected (e.g., periodic status republish) | ### Lifecycle Methods @@ -184,6 +186,42 @@ The community broker authenticates via Ed25519-signed JWT tokens. **Token lifetime:** 24 hours. The `_should_break_wait()` hook forces a reconnect at the 23-hour mark to renew before expiry. +### Status Messages + +On connect and every 5 minutes thereafter, the community publisher sends a retained status message to `meshcore/{IATA}/{PUBKEY}/status` with device info and radio telemetry: + +```json +{ + "status": "online", + "timestamp": "2024-01-15T10:30:00.000000", + "origin": "NodeName", + "origin_id": "PUBKEY_HEX_UPPER", + "client": "RemoteTerm (github.com/...)", + "model": "T-Deck", + "firmware_version": "v2.2.2 (Build: 2025-01-15)", + "radio": "915.0MHz BW250.0 SF10 CR8", + "client_version": "RemoteTerm/2.4.0", + "stats": { + "battery_mv": 4200, + "uptime_secs": 3600, + "errors": 0, + "queue_len": 0, + "noise_floor": -120, + "last_rssi": -85, + "last_snr": 10.5, + "tx_air_secs": 42, + "rx_air_secs": 150 + } +} +``` + +- `model` and `firmware_version` are fetched once per connection via `send_device_query()` (requires firmware version >= 3) +- `radio` is formatted from `self_info` radio parameters (freq, BW, SF, CR) +- `client_version` is read from Python package metadata (`remoteterm-meshcore`) +- `stats` is fetched from `get_stats_core()` + `get_stats_radio()` every 5 minutes; omitted if firmware doesn't support stats commands +- All radio queries use `blocking=False` — if the radio is busy, cached values are used. No user-facing operations are ever blocked. +- LWT (Last Will and Testament) publishes `{"status": "offline", ...}` on the same topic with retain + ### Packet Formatting `_format_raw_packet()` converts raw packet broadcast data into the meshcore-packet-capture JSON format: diff --git a/app/community_mqtt.py b/app/community_mqtt.py index f6acfe3..485ec3c 100644 --- a/app/community_mqtt.py +++ b/app/community_mqtt.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio import base64 import hashlib +import importlib.metadata import json import logging import re @@ -36,6 +37,10 @@ _CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)" _TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp) _TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours +# Periodic status republish interval (matches meshcore-packet-capture reference) +_STATS_REFRESH_INTERVAL = 300 # 5 minutes +_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s + # Ed25519 group order _L = 2**252 + 27742317777372353535851937790883648493 _IATA_RE = re.compile(r"^[A-Z]{3}$") @@ -259,6 +264,33 @@ def _build_status_topic(settings: AppSettings, pubkey_hex: str) -> str: return f"meshcore/{iata}/{pubkey_hex}/status" +def _build_radio_info() -> str: + """Format the radio parameters string from self_info, or 'unknown'.""" + from app.radio import radio_manager + + try: + if radio_manager.meshcore and radio_manager.meshcore.self_info: + info = radio_manager.meshcore.self_info + freq = info.get("radio_freq") + bw = info.get("radio_bw") + sf = info.get("radio_sf") + cr = info.get("radio_cr") + if freq is not None and bw is not None and sf is not None and cr is not None: + return f"{freq}MHz BW{bw} SF{sf} CR{cr}" + except Exception: + pass + return "unknown" + + +def _get_client_version() -> str: + """Return a client version string like 'RemoteTerm/2.4.0'.""" + try: + version = importlib.metadata.version("remoteterm-meshcore") + return f"RemoteTerm/{version}" + except Exception: + return "RemoteTerm/unknown" + + class CommunityMqttPublisher(BaseMqttPublisher): """Manages the community MQTT connection and publishes raw packets.""" @@ -269,9 +301,19 @@ class CommunityMqttPublisher(BaseMqttPublisher): def __init__(self) -> None: super().__init__() self._key_unavailable_warned: bool = False + self._cached_device_info: dict[str, str] | None = None + self._cached_stats: dict[str, Any] | None = None + self._stats_supported: bool | None = None + self._last_stats_fetch: float = 0.0 + self._last_status_publish: float = 0.0 async def start(self, settings: AppSettings) -> None: self._key_unavailable_warned = False + self._cached_device_info = None + self._cached_stats = None + self._stats_supported = None + self._last_stats_fetch = 0.0 + self._last_status_publish = 0.0 await super().start(settings) def _on_not_configured(self) -> None: @@ -340,8 +382,96 @@ class CommunityMqttPublisher(BaseMqttPublisher): broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT return ("Community MQTT connected", f"{broker_host}:{broker_port}") - async def _on_connected_async(self, settings: AppSettings) -> None: - """Publish a retained online status message after connecting.""" + async def _fetch_device_info(self) -> dict[str, str]: + """Fetch firmware model/version from the radio (cached for the connection).""" + if self._cached_device_info is not None: + return self._cached_device_info + + from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager + + fallback = {"model": "unknown", "firmware_version": "unknown"} + try: + async with radio_manager.radio_operation( + "community_stats_device_info", blocking=False + ) as mc: + event = await mc.commands.send_device_query() + from meshcore.events import EventType + + if event.type == EventType.DEVICE_INFO: + fw_ver = event.payload.get("fw ver", 0) + if fw_ver >= 3: + model = event.payload.get("model", "unknown") or "unknown" + ver = event.payload.get("ver", "unknown") or "unknown" + fw_build = event.payload.get("fw_build", "") or "" + fw_str = f"v{ver} (Build: {fw_build})" if fw_build else f"v{ver}" + self._cached_device_info = { + "model": model, + "firmware_version": fw_str, + } + else: + # Old firmware — cache what we can + self._cached_device_info = { + "model": "unknown", + "firmware_version": f"v{fw_ver}" if fw_ver else "unknown", + } + return self._cached_device_info + except (RadioOperationBusyError, RadioDisconnectedError): + pass + except Exception as e: + logger.debug("Community MQTT: device info fetch failed: %s", e) + + # Don't cache transient failures — allow retry on next status publish + return fallback + + async def _fetch_stats(self) -> dict[str, Any] | None: + """Fetch core + radio stats from the radio (best-effort, cached).""" + if self._stats_supported is False: + return self._cached_stats + + now = time.monotonic() + if ( + now - self._last_stats_fetch + ) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None: + return self._cached_stats + + from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager + + try: + async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc: + from meshcore.events import EventType + + result: dict[str, Any] = {} + + core_event = await mc.commands.get_stats_core() + if core_event.type == EventType.ERROR: + logger.info("Community MQTT: firmware does not support stats commands") + self._stats_supported = False + return self._cached_stats + if core_event.type == EventType.STATS_CORE: + result.update(core_event.payload) + + radio_event = await mc.commands.get_stats_radio() + if radio_event.type == EventType.ERROR: + logger.info("Community MQTT: firmware does not support stats commands") + self._stats_supported = False + return self._cached_stats + if radio_event.type == EventType.STATS_RADIO: + result.update(radio_event.payload) + + if result: + self._cached_stats = result + self._last_stats_fetch = now + return self._cached_stats + + except (RadioOperationBusyError, RadioDisconnectedError): + pass + except Exception as e: + logger.debug("Community MQTT: stats fetch failed: %s", e) + + return self._cached_stats + + async def _publish_status(self, settings: AppSettings, *, refresh_stats: bool = True) -> None: + """Build and publish the enriched retained status message.""" from app.keystore import get_public_key from app.radio import radio_manager @@ -355,16 +485,37 @@ class CommunityMqttPublisher(BaseMqttPublisher): if radio_manager.meshcore and radio_manager.meshcore.self_info: device_name = radio_manager.meshcore.self_info.get("name", "") + device_info = await self._fetch_device_info() + stats = await self._fetch_stats() if refresh_stats else self._cached_stats + status_topic = _build_status_topic(settings, pubkey_hex) - payload = { + payload: dict[str, Any] = { "status": "online", "timestamp": datetime.now().isoformat(), "origin": device_name or "MeshCore Device", "origin_id": pubkey_hex, "client": _CLIENT_ID, + "model": device_info.get("model", "unknown"), + "firmware_version": device_info.get("firmware_version", "unknown"), + "radio": _build_radio_info(), + "client_version": _get_client_version(), } + if stats: + payload["stats"] = stats await self.publish(status_topic, payload, retain=True) + self._last_status_publish = time.monotonic() + + async def _on_connected_async(self, settings: AppSettings) -> None: + """Publish a retained online status message after connecting.""" + await self._publish_status(settings) + + async def _on_periodic_wake(self, elapsed: float) -> None: + if not self._settings: + return + now = time.monotonic() + if (now - self._last_status_publish) >= _STATS_REFRESH_INTERVAL: + await self._publish_status(self._settings, refresh_stats=True) def _on_error(self) -> tuple[str, str]: return ( diff --git a/app/mqtt_base.py b/app/mqtt_base.py index 57e0102..ead961f 100644 --- a/app/mqtt_base.py +++ b/app/mqtt_base.py @@ -131,6 +131,10 @@ class BaseMqttPublisher(ABC): """ return # no-op by default + async def _on_periodic_wake(self, elapsed: float) -> None: + """Called every ~60s while connected. Subclasses may override.""" + return + # ── Connection loop ──────────────────────────────────────────────── async def _connection_loop(self) -> None: @@ -189,6 +193,7 @@ class BaseMqttPublisher(ABC): await asyncio.wait_for(self._version_event.wait(), timeout=60) except asyncio.TimeoutError: elapsed = time.monotonic() - connect_time + await self._on_periodic_wake(elapsed) if self._should_break_wait(elapsed): break continue diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index 669f9db..49a9db1 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -1,7 +1,9 @@ """Tests for community MQTT publisher.""" import json -from unittest.mock import MagicMock, patch +import time +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch import nacl.bindings import pytest @@ -9,13 +11,16 @@ import pytest from app.community_mqtt import ( _CLIENT_ID, _DEFAULT_BROKER, + _STATS_REFRESH_INTERVAL, CommunityMqttPublisher, _base64url_encode, + _build_radio_info, _build_status_topic, _calculate_packet_hash, _ed25519_sign_expanded, _format_raw_packet, _generate_jwt_token, + _get_client_version, community_mqtt_broadcast, ) from app.models import AppSettings @@ -475,9 +480,7 @@ class TestLwtAndStatusPublish: @pytest.mark.asyncio async def test_on_connected_async_publishes_online_status(self): - """_on_connected_async should publish a retained online status.""" - from unittest.mock import AsyncMock - + """_on_connected_async should publish a retained online status with enriched fields.""" pub = CommunityMqttPublisher() private_key, public_key = _make_test_keys() pubkey_hex = public_key.hex().upper() @@ -493,6 +496,17 @@ class TestLwtAndStatusPublish: with ( patch("app.keystore.get_public_key", return_value=public_key), patch("app.radio.radio_manager", mock_radio), + patch.object( + pub, + "_fetch_device_info", + new_callable=AsyncMock, + return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"}, + ), + patch.object( + pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200} + ), + patch("app.community_mqtt._build_radio_info", return_value="915.0MHz BW250.0 SF10 CR8"), + patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/2.4.0"), patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish, ): await pub._on_connected_async(settings) @@ -509,6 +523,11 @@ class TestLwtAndStatusPublish: assert payload["origin_id"] == pubkey_hex assert payload["client"] == _CLIENT_ID assert "timestamp" in payload + assert payload["model"] == "T-Deck" + assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)" + assert payload["radio"] == "915.0MHz BW250.0 SF10 CR8" + assert payload["client_version"] == "RemoteTerm/2.4.0" + assert payload["stats"] == {"battery_mv": 4200} def test_lwt_and_online_share_same_topic(self): """LWT and on-connect status should use the same topic path.""" @@ -533,8 +552,6 @@ class TestLwtAndStatusPublish: @pytest.mark.asyncio async def test_on_connected_async_skips_when_no_public_key(self): """_on_connected_async should no-op when public key is unavailable.""" - from unittest.mock import AsyncMock - pub = CommunityMqttPublisher() settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX") @@ -549,8 +566,6 @@ class TestLwtAndStatusPublish: @pytest.mark.asyncio async def test_on_connected_async_uses_fallback_device_name(self): """Should use 'MeshCore Device' when radio name is unavailable.""" - from unittest.mock import AsyncMock - pub = CommunityMqttPublisher() _, public_key = _make_test_keys() settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX") @@ -561,9 +576,458 @@ class TestLwtAndStatusPublish: with ( patch("app.keystore.get_public_key", return_value=public_key), patch("app.radio.radio_manager", mock_radio), + patch.object( + pub, + "_fetch_device_info", + new_callable=AsyncMock, + return_value={"model": "unknown", "firmware_version": "unknown"}, + ), + patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None), + patch("app.community_mqtt._build_radio_info", return_value="unknown"), + patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/unknown"), patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish, ): await pub._on_connected_async(settings) payload = mock_publish.call_args[0][1] assert payload["origin"] == "MeshCore Device" + + +def _mock_radio_operation(mc_mock): + """Create a mock async context manager for radio_operation.""" + + @asynccontextmanager + async def _op(*args, **kwargs): + yield mc_mock + + return _op + + +class TestFetchDeviceInfo: + @pytest.mark.asyncio + async def test_success_fw_ver_3(self): + """Should extract model and firmware_version from DEVICE_INFO with fw ver >= 3.""" + from meshcore.events import Event, EventType + + pub = CommunityMqttPublisher() + mc_mock = MagicMock() + mc_mock.commands.send_device_query = AsyncMock( + return_value=Event( + EventType.DEVICE_INFO, + {"fw ver": 3, "model": "T-Deck", "ver": "2.2.2", "fw_build": "2025-01-15"}, + ) + ) + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = _mock_radio_operation(mc_mock) + result = await pub._fetch_device_info() + + assert result["model"] == "T-Deck" + assert result["firmware_version"] == "v2.2.2 (Build: 2025-01-15)" + # Should be cached + assert pub._cached_device_info == result + + @pytest.mark.asyncio + async def test_fw_ver_below_3_caches_old_version(self): + """Should cache old firmware version string when fw ver < 3.""" + from meshcore.events import Event, EventType + + pub = CommunityMqttPublisher() + mc_mock = MagicMock() + mc_mock.commands.send_device_query = AsyncMock( + return_value=Event(EventType.DEVICE_INFO, {"fw ver": 2}) + ) + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = _mock_radio_operation(mc_mock) + result = await pub._fetch_device_info() + + assert result["model"] == "unknown" + assert result["firmware_version"] == "v2" + # Should be cached (firmware doesn't change mid-connection) + assert pub._cached_device_info == result + + @pytest.mark.asyncio + async def test_error_returns_fallback_not_cached(self): + """Should return unknowns when device query returns ERROR, without caching.""" + from meshcore.events import Event, EventType + + pub = CommunityMqttPublisher() + mc_mock = MagicMock() + mc_mock.commands.send_device_query = AsyncMock(return_value=Event(EventType.ERROR, {})) + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = _mock_radio_operation(mc_mock) + result = await pub._fetch_device_info() + + assert result["model"] == "unknown" + assert result["firmware_version"] == "unknown" + # Should NOT be cached — allows retry on next status publish + assert pub._cached_device_info is None + + @pytest.mark.asyncio + async def test_radio_busy_returns_fallback_not_cached(self): + """Should return unknowns when radio is busy, without caching.""" + from app.radio import RadioOperationBusyError + + pub = CommunityMqttPublisher() + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = MagicMock(side_effect=RadioOperationBusyError("busy")) + result = await pub._fetch_device_info() + + assert result["model"] == "unknown" + assert result["firmware_version"] == "unknown" + # Should NOT be cached — allows retry when radio becomes available + assert pub._cached_device_info is None + + @pytest.mark.asyncio + async def test_cached_result_returned_on_second_call(self): + """Should return cached result without re-querying the radio.""" + pub = CommunityMqttPublisher() + pub._cached_device_info = {"model": "T-Deck", "firmware_version": "v2.2.2"} + + # No radio mock needed — should return cached + result = await pub._fetch_device_info() + assert result["model"] == "T-Deck" + + @pytest.mark.asyncio + async def test_no_fw_build_omits_build_suffix(self): + """When fw_build is empty, firmware_version should just be 'vX.Y.Z'.""" + from meshcore.events import Event, EventType + + pub = CommunityMqttPublisher() + mc_mock = MagicMock() + mc_mock.commands.send_device_query = AsyncMock( + return_value=Event( + EventType.DEVICE_INFO, + {"fw ver": 3, "model": "Heltec", "ver": "1.0.0", "fw_build": ""}, + ) + ) + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = _mock_radio_operation(mc_mock) + result = await pub._fetch_device_info() + + assert result["firmware_version"] == "v1.0.0" + + +class TestFetchStats: + @pytest.mark.asyncio + async def test_success_merges_core_and_radio(self): + """Should merge STATS_CORE and STATS_RADIO payloads.""" + from meshcore.events import Event, EventType + + pub = CommunityMqttPublisher() + mc_mock = MagicMock() + mc_mock.commands.get_stats_core = AsyncMock( + return_value=Event( + EventType.STATS_CORE, + {"battery_mv": 4200, "uptime_secs": 3600, "errors": 0, "queue_len": 0}, + ) + ) + mc_mock.commands.get_stats_radio = AsyncMock( + return_value=Event( + EventType.STATS_RADIO, + { + "noise_floor": -120, + "last_rssi": -85, + "last_snr": 10.5, + "tx_air_secs": 42, + "rx_air_secs": 150, + }, + ) + ) + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = _mock_radio_operation(mc_mock) + result = await pub._fetch_stats() + + assert result is not None + assert result["battery_mv"] == 4200 + assert result["noise_floor"] == -120 + assert result["tx_air_secs"] == 42 + + @pytest.mark.asyncio + async def test_core_error_sets_stats_unsupported(self): + """Should set _stats_supported=False when STATS_CORE returns ERROR.""" + from meshcore.events import Event, EventType + + pub = CommunityMqttPublisher() + mc_mock = MagicMock() + mc_mock.commands.get_stats_core = AsyncMock(return_value=Event(EventType.ERROR, {})) + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = _mock_radio_operation(mc_mock) + result = await pub._fetch_stats() + + assert pub._stats_supported is False + assert result is None # no cached stats yet + + @pytest.mark.asyncio + async def test_radio_error_sets_stats_unsupported(self): + """Should set _stats_supported=False when STATS_RADIO returns ERROR.""" + from meshcore.events import Event, EventType + + pub = CommunityMqttPublisher() + mc_mock = MagicMock() + mc_mock.commands.get_stats_core = AsyncMock( + return_value=Event( + EventType.STATS_CORE, + {"battery_mv": 4200, "uptime_secs": 3600, "errors": 0, "queue_len": 0}, + ) + ) + mc_mock.commands.get_stats_radio = AsyncMock(return_value=Event(EventType.ERROR, {})) + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = _mock_radio_operation(mc_mock) + await pub._fetch_stats() + + assert pub._stats_supported is False + + @pytest.mark.asyncio + async def test_stats_unsupported_skips_radio(self): + """When _stats_supported=False, should return cached stats without radio call.""" + pub = CommunityMqttPublisher() + pub._stats_supported = False + pub._cached_stats = {"battery_mv": 4000} + + result = await pub._fetch_stats() + assert result == {"battery_mv": 4000} + + @pytest.mark.asyncio + async def test_cache_guard_prevents_refetch(self): + """Should return cached stats when within cache window.""" + pub = CommunityMqttPublisher() + pub._cached_stats = {"battery_mv": 4200} + pub._last_stats_fetch = time.monotonic() # Just fetched + + result = await pub._fetch_stats() + assert result == {"battery_mv": 4200} + + @pytest.mark.asyncio + async def test_radio_busy_returns_cached(self): + """Should return cached stats when radio is busy.""" + from app.radio import RadioOperationBusyError + + pub = CommunityMqttPublisher() + pub._cached_stats = {"battery_mv": 3900} + + with patch("app.radio.radio_manager") as mock_rm: + mock_rm.radio_operation = MagicMock(side_effect=RadioOperationBusyError("busy")) + result = await pub._fetch_stats() + + assert result == {"battery_mv": 3900} + + +class TestBuildRadioInfo: + def test_formatted_string(self): + """Should return formatted radio info string.""" + mock_radio = MagicMock() + mock_radio.meshcore = MagicMock() + mock_radio.meshcore.self_info = { + "radio_freq": 915.0, + "radio_bw": 250.0, + "radio_sf": 10, + "radio_cr": 8, + } + + with patch("app.radio.radio_manager", mock_radio): + result = _build_radio_info() + + assert result == "915.0MHz BW250.0 SF10 CR8" + + def test_fallback_when_no_meshcore(self): + """Should return 'unknown' when meshcore is None.""" + mock_radio = MagicMock() + mock_radio.meshcore = None + + with patch("app.radio.radio_manager", mock_radio): + result = _build_radio_info() + + assert result == "unknown" + + def test_fallback_when_self_info_missing_fields(self): + """Should return 'unknown' when self_info lacks radio fields.""" + mock_radio = MagicMock() + mock_radio.meshcore = MagicMock() + mock_radio.meshcore.self_info = {"name": "TestNode"} + + with patch("app.radio.radio_manager", mock_radio): + result = _build_radio_info() + + assert result == "unknown" + + +class TestGetClientVersion: + def test_returns_remoteterm_prefix(self): + """Should return 'RemoteTerm/...' string.""" + result = _get_client_version() + assert result.startswith("RemoteTerm/") + + def test_returns_version_from_metadata(self): + """Should use importlib.metadata to get version.""" + with patch("app.community_mqtt.importlib.metadata.version", return_value="1.2.3"): + result = _get_client_version() + assert result == "RemoteTerm/1.2.3" + + def test_fallback_on_error(self): + """Should return 'RemoteTerm/unknown' if metadata lookup fails.""" + with patch( + "app.community_mqtt.importlib.metadata.version", side_effect=Exception("not found") + ): + result = _get_client_version() + assert result == "RemoteTerm/unknown" + + +class TestPublishStatus: + @pytest.mark.asyncio + async def test_enriched_payload_fields(self): + """_publish_status should include all enriched fields.""" + pub = CommunityMqttPublisher() + _, public_key = _make_test_keys() + pubkey_hex = public_key.hex().upper() + settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX") + + mock_radio = MagicMock() + mock_radio.meshcore = MagicMock() + mock_radio.meshcore.self_info = {"name": "TestNode"} + + stats = {"battery_mv": 4200, "uptime_secs": 3600, "noise_floor": -120} + + with ( + patch("app.keystore.get_public_key", return_value=public_key), + patch("app.radio.radio_manager", mock_radio), + patch.object( + pub, + "_fetch_device_info", + new_callable=AsyncMock, + return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"}, + ), + patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats), + patch("app.community_mqtt._build_radio_info", return_value="915.0MHz BW250.0 SF10 CR8"), + patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/2.4.0"), + patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish, + ): + await pub._publish_status(settings) + + payload = mock_publish.call_args[0][1] + assert payload["status"] == "online" + assert payload["origin"] == "TestNode" + assert payload["origin_id"] == pubkey_hex + assert payload["client"] == _CLIENT_ID + assert payload["model"] == "T-Deck" + assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)" + assert payload["radio"] == "915.0MHz BW250.0 SF10 CR8" + assert payload["client_version"] == "RemoteTerm/2.4.0" + assert payload["stats"] == stats + + @pytest.mark.asyncio + async def test_stats_omitted_when_none(self): + """Should not include 'stats' key when stats are None.""" + pub = CommunityMqttPublisher() + _, public_key = _make_test_keys() + settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX") + + mock_radio = MagicMock() + mock_radio.meshcore = None + + with ( + patch("app.keystore.get_public_key", return_value=public_key), + patch("app.radio.radio_manager", mock_radio), + patch.object( + pub, + "_fetch_device_info", + new_callable=AsyncMock, + return_value={"model": "unknown", "firmware_version": "unknown"}, + ), + patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None), + patch("app.community_mqtt._build_radio_info", return_value="unknown"), + patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/unknown"), + patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish, + ): + await pub._publish_status(settings) + + payload = mock_publish.call_args[0][1] + assert "stats" not in payload + + @pytest.mark.asyncio + async def test_updates_last_status_publish(self): + """Should update _last_status_publish after publishing.""" + pub = CommunityMqttPublisher() + _, public_key = _make_test_keys() + settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX") + + mock_radio = MagicMock() + mock_radio.meshcore = None + + before = time.monotonic() + + with ( + patch("app.keystore.get_public_key", return_value=public_key), + patch("app.radio.radio_manager", mock_radio), + patch.object( + pub, + "_fetch_device_info", + new_callable=AsyncMock, + return_value={"model": "unknown", "firmware_version": "unknown"}, + ), + patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None), + patch("app.community_mqtt._build_radio_info", return_value="unknown"), + patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/unknown"), + patch.object(pub, "publish", new_callable=AsyncMock), + ): + await pub._publish_status(settings) + + assert pub._last_status_publish >= before + + @pytest.mark.asyncio + async def test_no_publish_key_returns_none(self): + """Should skip publish when public key is unavailable.""" + pub = CommunityMqttPublisher() + settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX") + + with ( + patch("app.keystore.get_public_key", return_value=None), + patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish, + ): + await pub._publish_status(settings) + + mock_publish.assert_not_called() + + +class TestPeriodicWake: + @pytest.mark.asyncio + async def test_skips_before_interval(self): + """Should not republish before _STATS_REFRESH_INTERVAL.""" + pub = CommunityMqttPublisher() + pub._settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX") + pub._last_status_publish = time.monotonic() # Just published + + with patch.object(pub, "_publish_status", new_callable=AsyncMock) as mock_ps: + await pub._on_periodic_wake(60.0) + + mock_ps.assert_not_called() + + @pytest.mark.asyncio + async def test_publishes_after_interval(self): + """Should republish after _STATS_REFRESH_INTERVAL elapsed.""" + pub = CommunityMqttPublisher() + pub._settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX") + pub._last_status_publish = time.monotonic() - _STATS_REFRESH_INTERVAL - 1 + + with patch.object(pub, "_publish_status", new_callable=AsyncMock) as mock_ps: + await pub._on_periodic_wake(360.0) + + mock_ps.assert_called_once_with(pub._settings, refresh_stats=True) + + @pytest.mark.asyncio + async def test_skips_when_no_settings(self): + """Should no-op when settings are None.""" + pub = CommunityMqttPublisher() + pub._settings = None + + with patch.object(pub, "_publish_status", new_callable=AsyncMock) as mock_ps: + await pub._on_periodic_wake(360.0) + + mock_ps.assert_not_called()