From 25089930f13d352f250032795aee91adb331f317 Mon Sep 17 00:00:00 2001 From: Kizniche Date: Mon, 20 Apr 2026 21:47:38 -0400 Subject: [PATCH] fIX Community MQTT publishing stale firmware_version and model --- app/fanout/community_mqtt.py | 16 +++++- tests/test_community_mqtt.py | 99 +++++++++++++++++++++++++++++++----- 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index 8aa0d1b..2d616a8 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -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) diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index a8b5182..04e5e99 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -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()