Merge pull request #209 from kizniche/fix-stale-mqtt-radio-values

Fix Community MQTT publishing stale firmware_version and model
This commit is contained in:
Jack Kingsman
2026-04-20 19:48:11 -07:00
committed by GitHub
2 changed files with 102 additions and 13 deletions

View File

@@ -477,7 +477,21 @@ 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()
# Prefer the always-fresh radio_manager fields (populated on every reconnect by
# radio_lifecycle) over the per-module _cached_device_info, which was only
# cleared on module restart and therefore served stale firmware versions after
# a radio firmware update. Fall back to _fetch_device_info() for older firmware
# where device_info_loaded is False.
if radio_manager.device_info_loaded:
raw_ver = radio_manager.firmware_version or "unknown"
fw_build = radio_manager.firmware_build or ""
fw_str = f"{raw_ver} (Build: {fw_build})" if fw_build else f"{raw_ver}"
device_info = {
"model": radio_manager.device_model or "unknown",
"firmware_version": fw_str,
}
else:
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)

View File

@@ -812,16 +812,14 @@ class TestLwtAndStatusPublish:
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "TestNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "v2.2.2"
mock_radio.firmware_build = "2025-01-15"
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}
),
@@ -852,6 +850,82 @@ class TestLwtAndStatusPublish:
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
assert payload["stats"] == {"battery_mv": 4200}
@pytest.mark.asyncio
async def test_publish_status_uses_fallback_fetch_when_device_info_not_loaded(self):
"""When device_info_loaded is False, _fetch_device_info() should be called as fallback."""
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "OldNode"}
mock_radio.device_info_loaded = False
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": "LegacyBoard", "firmware_version": "v2"},
) as mock_fetch,
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm/0-x"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
mock_fetch.assert_awaited_once()
payload = mock_publish.call_args[0][1]
assert payload["model"] == "LegacyBoard"
assert payload["firmware_version"] == "v2"
@pytest.mark.asyncio
async def test_publish_status_reflects_updated_firmware_version_after_reconnect(self):
"""After firmware update + radio reconnect, the published firmware_version must be fresh.
This is a regression test for the stale-cache bug: previously _cached_device_info
was never cleared between reconnects, so a radio firmware update was invisible to
the Community MQTT status payload until the fanout module itself restarted.
"""
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "MyNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "1.14.1"
mock_radio.firmware_build = ""
async def _publish_once(radio_mock):
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", radio_mock),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RT/0-x"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_pub,
):
await pub._publish_status(settings)
return mock_pub.call_args[0][1]
first_payload = await _publish_once(mock_radio)
assert first_payload["firmware_version"] == "1.14.1"
# Simulate firmware update: radio reboots, radio_lifecycle refreshes the manager fields
mock_radio.firmware_version = "1.15.0"
second_payload = await _publish_once(mock_radio)
assert second_payload["firmware_version"] == "1.15.0", (
"Expected updated firmware version after reconnect; stale cache bug would return v1.14.1"
)
def test_lwt_and_online_share_same_topic(self):
"""LWT and on-connect status should use the same topic path."""
pub = CommunityMqttPublisher()
@@ -896,6 +970,7 @@ class TestLwtAndStatusPublish:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
with (
patch("app.keystore.get_public_key", return_value=public_key),
@@ -1252,18 +1327,16 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "TestNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "v2.2.2"
mock_radio.firmware_build = "2025-01-15"
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.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch(
@@ -1294,6 +1367,7 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
with (
patch("app.keystore.get_public_key", return_value=public_key),
@@ -1326,6 +1400,7 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
before = time.monotonic()