mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add community mqtt stats reporting
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user