From 55020a5e22d93f949455cfd2ff0934001a811dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= Date: Sun, 28 Jun 2026 01:23:19 +0200 Subject: [PATCH 1/4] Fix firmware-compatible unscoped flood scope handling --- app/region_scope.py | 13 +++++--- app/routers/settings.py | 3 +- app/services/flood_scope.py | 32 +++++++++++++++++++ app/services/message_send.py | 7 ++-- app/services/radio_lifecycle.py | 3 +- tests/test_flood_scope_service.py | 46 +++++++++++++++++++++++++++ tests/test_radio.py | 9 +++++- tests/test_radio_lifecycle_service.py | 3 ++ tests/test_region_scope.py | 14 ++++++++ tests/test_send_messages.py | 18 ++++++----- tests/test_settings_router.py | 7 +++- 11 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 app/services/flood_scope.py create mode 100644 tests/test_flood_scope_service.py create mode 100644 tests/test_region_scope.py diff --git a/app/region_scope.py b/app/region_scope.py index 681d4cf..d97cb8b 100644 --- a/app/region_scope.py +++ b/app/region_scope.py @@ -1,19 +1,22 @@ """Helpers for normalizing MeshCore flood-scope / region names.""" +_UNSCOPED_SENTINELS = {"", "0", "*"} + def normalize_region_scope(scope: str | None) -> str: """Normalize a user-facing region scope into MeshCore's internal form. - Region names are now user-facing plain strings like ``Esperance``. - Internally, MeshCore still expects hashtag-style names like ``#Esperance``. + Region names are user-facing plain strings like ``Esperance``. Internally, + MeshCore still expects hashtag-style names like ``#Esperance``. - Backward compatibility: - - blank/None stays disabled (`""`) + Backward compatibility / firmware parity: + - blank/None stays unscoped (``""``) + - ``"0"`` and ``"*"`` also mean explicit unscoped/plain flood - existing leading ``#`` is preserved """ stripped = (scope or "").strip() - if not stripped: + if stripped in _UNSCOPED_SENTINELS: return "" if stripped.startswith("#"): return stripped diff --git a/app/routers/settings.py b/app/routers/settings.py index 1100786..b6782df 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -282,13 +282,14 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: # Apply flood scope to radio immediately if changed if flood_scope_changed: + from app.services.flood_scope import set_radio_flood_scope from app.services.radio_runtime import radio_runtime as radio_manager if radio_manager.is_connected: try: scope = result.flood_scope async with radio_manager.radio_operation("set_flood_scope") as mc: - await mc.commands.set_flood_scope(scope if scope else "") + await set_radio_flood_scope(mc, scope) logger.info("Applied flood_scope=%r to radio", scope or "(disabled)") except Exception as e: logger.warning("Failed to apply flood_scope to radio: %s", e) diff --git a/app/services/flood_scope.py b/app/services/flood_scope.py new file mode 100644 index 0000000..2e4fada --- /dev/null +++ b/app/services/flood_scope.py @@ -0,0 +1,32 @@ +"""Firmware-compatible flood-scope command helpers.""" + +from typing import Any + +from meshcore import EventType +from meshcore.packets import CommandType + +from app.region_scope import normalize_region_scope + +SET_FLOOD_SCOPE_MODE_UNSCOPED = 1 +FORCE_UNSCOPED_FRAME = bytes([CommandType.SET_FLOOD_SCOPE.value, SET_FLOOD_SCOPE_MODE_UNSCOPED]) + + +async def set_radio_flood_scope(mc, scope: str | None) -> Any: + """Apply the standing radio flood-scope state. + + A non-empty scope is delegated to meshcore_py's mode-0 ``set_flood_scope``. + An empty scope means explicit unscoped/plain flood and must use firmware's + mode-1 command. Sending a mode-0 all-zero key would fall back to the radio's + configured default scope on firmware-compatible companions. + """ + + normalized_scope = normalize_region_scope(scope) + if normalized_scope: + return await mc.commands.set_flood_scope(normalized_scope) + return await force_radio_unscoped(mc) + + +async def force_radio_unscoped(mc) -> Any: + """Tell the radio to send following flood packets unscoped until mode 0.""" + + return await mc.commands.send(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]) diff --git a/app/services/message_send.py b/app/services/message_send.py index 68d3543..cc21fc0 100644 --- a/app/services/message_send.py +++ b/app/services/message_send.py @@ -19,6 +19,7 @@ from app.repository import ( MessageRepository, ) from app.services import dm_ack_tracker +from app.services.flood_scope import set_radio_flood_scope from app.services.messages import ( BroadcastFn, broadcast_message, @@ -184,7 +185,7 @@ async def send_channel_message_with_effective_scope( desired_scope or "(unscoped)", channel.name, ) - override_result = await mc.commands.set_flood_scope(desired_scope) + override_result = await set_radio_flood_scope(mc, desired_scope) if override_result is not None and override_result.type == EventType.ERROR: logger.warning( "Failed to apply flood_scope %r for %s: %s", @@ -313,9 +314,7 @@ async def send_channel_message_with_effective_scope( restored = False for attempt in range(3): try: - restore_result = await mc.commands.set_flood_scope( - baseline_scope if baseline_scope else "" - ) + restore_result = await set_radio_flood_scope(mc, baseline_scope) if restore_result is not None and restore_result.type == EventType.ERROR: logger.warning( "Attempt %d/3: failed to restore flood_scope after sending to %s: %s", diff --git a/app/services/radio_lifecycle.py b/app/services/radio_lifecycle.py index afcaa90..6696c21 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -71,11 +71,12 @@ async def run_post_connect_setup(radio_manager) -> None: # may not support set_flood_scope) from app.region_scope import normalize_region_scope from app.repository import AppSettingsRepository + from app.services.flood_scope import set_radio_flood_scope app_settings = await AppSettingsRepository.get() scope = normalize_region_scope(app_settings.flood_scope) try: - await mc.commands.set_flood_scope(scope if scope else "") + await set_radio_flood_scope(mc, scope) logger.info("Applied flood_scope=%r", scope or "(disabled)") except Exception as exc: logger.warning("set_flood_scope failed (firmware may not support it): %s", exc) diff --git a/tests/test_flood_scope_service.py b/tests/test_flood_scope_service.py new file mode 100644 index 0000000..597b4e0 --- /dev/null +++ b/tests/test_flood_scope_service.py @@ -0,0 +1,46 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from meshcore import EventType + +from app.services.flood_scope import FORCE_UNSCOPED_FRAME, set_radio_flood_scope + + +@pytest.mark.asyncio +async def test_set_radio_flood_scope_uses_meshcore_scope_for_regions(): + mc = MagicMock() + mc.commands.set_flood_scope = AsyncMock(return_value="ok") + mc.commands.send = AsyncMock() + + result = await set_radio_flood_scope(mc, "Esperance") + + assert result == "ok" + mc.commands.set_flood_scope.assert_awaited_once_with("#Esperance") + mc.commands.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_set_radio_flood_scope_empty_uses_firmware_unscoped_mode(): + mc = MagicMock() + mc.commands.set_flood_scope = AsyncMock() + mc.commands.send = AsyncMock(return_value="ok") + + result = await set_radio_flood_scope(mc, "") + + assert result == "ok" + mc.commands.send.assert_awaited_once_with(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]) + mc.commands.set_flood_scope.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", [None, " ", "0", "*"]) +async def test_set_radio_flood_scope_unscoped_sentinels_use_firmware_mode(scope): + mc = MagicMock() + mc.commands.set_flood_scope = AsyncMock() + mc.commands.send = AsyncMock(return_value="ok") + + result = await set_radio_flood_scope(mc, scope) + + assert result == "ok" + mc.commands.send.assert_awaited_once_with(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]) + mc.commands.set_flood_scope.assert_not_awaited() diff --git a/tests/test_radio.py b/tests/test_radio.py index ef8a9df..63ebaf5 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -6,8 +6,11 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest +from meshcore import EventType from serial.serialutil import SerialException +from app.services.flood_scope import FORCE_UNSCOPED_FRAME + class TestRadioManagerConnect: """Test that connect() dispatches to the correct transport.""" @@ -977,6 +980,7 @@ class TestPostConnectSetupOrdering: mock_mc = MagicMock() mock_mc.start_auto_message_fetching = AsyncMock() mock_mc.commands.set_flood_scope = AsyncMock() + mock_mc.commands.send = AsyncMock() rm._meshcore = mock_mc mock_settings = AppSettings(flood_scope="") @@ -999,7 +1003,10 @@ class TestPostConnectSetupOrdering: ): await rm.post_connect_setup() - mock_mc.commands.set_flood_scope.assert_awaited_once_with("") + mock_mc.commands.send.assert_awaited_once_with( + FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR] + ) + mock_mc.commands.set_flood_scope.assert_not_awaited() @pytest.mark.asyncio async def test_message_polling_starts_hourly_audit_by_default(self): diff --git a/tests/test_radio_lifecycle_service.py b/tests/test_radio_lifecycle_service.py index 5b99214..3b0dbee 100644 --- a/tests/test_radio_lifecycle_service.py +++ b/tests/test_radio_lifecycle_service.py @@ -94,6 +94,7 @@ class TestRunPostConnectSetup: initial_mc = MagicMock() initial_mc.commands.send_device_query = AsyncMock(return_value=None) initial_mc.commands.set_flood_scope = AsyncMock(return_value=None) + initial_mc.commands.send = AsyncMock(return_value=None) initial_mc._reader = MagicMock() initial_mc._reader.handle_rx = AsyncMock() initial_mc.start_auto_message_fetching = AsyncMock() @@ -103,6 +104,7 @@ class TestRunPostConnectSetup: return_value=MagicMock(payload={"max_channels": 8}) ) replacement_mc.commands.set_flood_scope = AsyncMock(return_value=None) + replacement_mc.commands.send = AsyncMock(return_value=None) replacement_mc._reader = MagicMock() replacement_mc._reader.handle_rx = AsyncMock() replacement_mc.start_auto_message_fetching = AsyncMock() @@ -168,6 +170,7 @@ class TestRunPostConnectSetup: ) ) mc.commands.set_flood_scope = AsyncMock(return_value=None) + mc.commands.send = AsyncMock(return_value=None) mc._reader = MagicMock() mc._reader.handle_rx = AsyncMock() mc.start_auto_message_fetching = AsyncMock() diff --git a/tests/test_region_scope.py b/tests/test_region_scope.py new file mode 100644 index 0000000..e493c53 --- /dev/null +++ b/tests/test_region_scope.py @@ -0,0 +1,14 @@ +from app.region_scope import normalize_region_scope + + +def test_normalize_region_scope_preserves_regions(): + assert normalize_region_scope("Esperance") == "#Esperance" + assert normalize_region_scope("#Esperance") == "#Esperance" + + +def test_normalize_region_scope_unscoped_sentinels(): + assert normalize_region_scope(None) == "" + assert normalize_region_scope("") == "" + assert normalize_region_scope(" ") == "" + assert normalize_region_scope("0") == "" + assert normalize_region_scope("*") == "" diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index 150782f..e0b7c99 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -26,6 +26,7 @@ from app.routers.messages import ( send_direct_message, ) from app.services import dm_ack_tracker +from app.services.flood_scope import FORCE_UNSCOPED_FRAME from app.services.message_send import NO_RADIO_RESPONSE_AFTER_SEND_DETAIL @@ -67,6 +68,7 @@ def _make_mc(name="TestNode"): mc.self_info = {"name": name} mc.commands = MagicMock() mc.commands.set_flood_scope = AsyncMock(return_value=_make_radio_result()) + mc.commands.send = AsyncMock(return_value=_make_radio_result()) mc.commands.send_msg = AsyncMock(return_value=_make_radio_result()) mc.commands.send_chan_msg = AsyncMock(return_value=_make_radio_result()) mc.commands.add_contact = AsyncMock(return_value=_make_radio_result()) @@ -669,10 +671,10 @@ class TestOutgoingChannelBroadcast: ) await send_channel_message(request) - # Apply the region, then restore the (empty) baseline. - assert mc.commands.set_flood_scope.await_args_list == [ - call("#Region"), - call(""), + # Apply the region, then restore the empty baseline via explicit unscoped mode. + assert mc.commands.set_flood_scope.await_args_list == [call("#Region")] + assert mc.commands.send.await_args_list == [ + call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]) ] @pytest.mark.asyncio @@ -694,11 +696,11 @@ class TestOutgoingChannelBroadcast: ) await send_channel_message(request) - # Explicit unscoped: set empty scope, then restore the global baseline. - assert mc.commands.set_flood_scope.await_args_list == [ - call(""), - call("#Baseline"), + # Explicit unscoped uses firmware mode 1, then restores the global baseline. + assert mc.commands.send.await_args_list == [ + call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]) ] + assert mc.commands.set_flood_scope.await_args_list == [call("#Baseline")] @pytest.mark.asyncio async def test_send_channel_msg_aborts_when_override_apply_fails(self, test_db): diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py index 50c59b3..0e7fe99 100644 --- a/tests/test_settings_router.py +++ b/tests/test_settings_router.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from fastapi import HTTPException +from meshcore import EventType from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert from app.repository import AppSettingsRepository, ContactRepository @@ -16,6 +17,7 @@ from app.routers.settings import ( toggle_tracked_telemetry, update_settings, ) +from app.services.flood_scope import FORCE_UNSCOPED_FRAME class TestUpdateSettings: @@ -178,7 +180,10 @@ class TestUpdateSettings: with patch("app.radio.radio_manager", mock_rm): await update_settings(AppSettingsUpdate(flood_scope="")) - mock_mc.commands.set_flood_scope.assert_awaited_once_with("") + mock_mc.commands.send.assert_awaited_once_with( + FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR] + ) + mock_mc.commands.set_flood_scope.assert_not_awaited() class TestToggleFavorite: From 2118c83bc403d85ad5be77c16d9cd7d0ec431147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= Date: Fri, 3 Jul 2026 19:02:25 +0200 Subject: [PATCH 2/4] Stabilize app startup hash test timeout --- frontend/src/test/appStartupHash.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 5cc6e47..c5b15d9 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -154,10 +154,10 @@ const publicChannel = { // contacts, unreads) that must all settle before conversation selection // renders. This suite passes in isolation, but under the full parallel suite // (~70 files) CPU contention can stretch that startup well past RTL's 1000ms -// default — and even past vitest's 5000ms test timeout — for any waitFor in -// this file. It's starvation, not a hang, so give this file generous headroom -// on both timeouts: a healthy render still settles in ~100ms (nothing is -// slowed), only a starved run waits longer. +// default — and occasionally past 10s on GitHub's shared runners — for any +// waitFor in this file. It's starvation, not a hang, so give this file generous +// headroom on both timeouts: a healthy render still settles in ~100ms (nothing +// is slowed), only a starved run waits longer. // // These MUST run at module scope, not in beforeAll: vitest bakes each test's // timeout in when it() registers the test (during collection, before any @@ -167,8 +167,8 @@ const publicChannel = { // 10s but the test killed at 5s) that flaked under load. vi.setConfig is scoped // to this file (each file runs in its own isolated worker), so other files are // unaffected. -vi.setConfig({ testTimeout: 15000 }); -configure({ asyncUtilTimeout: 10000 }); +vi.setConfig({ testTimeout: 45000 }); +configure({ asyncUtilTimeout: 30000 }); describe('App startup hash resolution', () => { beforeEach(() => { From 30e9a77cdcbf2b8cdeecf0e5ffebc22e273cdf9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= Date: Fri, 3 Jul 2026 19:07:55 +0200 Subject: [PATCH 3/4] Clarify flood scope setup warning --- app/services/radio_lifecycle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/radio_lifecycle.py b/app/services/radio_lifecycle.py index 6696c21..1dc60d6 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -68,7 +68,7 @@ async def run_post_connect_setup(radio_manager) -> None: await sync_radio_time(mc) # Apply flood scope from settings (best-effort; older firmware - # may not support set_flood_scope) + # may not support the relevant flood-scope command) from app.region_scope import normalize_region_scope from app.repository import AppSettingsRepository from app.services.flood_scope import set_radio_flood_scope @@ -79,7 +79,7 @@ async def run_post_connect_setup(radio_manager) -> None: await set_radio_flood_scope(mc, scope) logger.info("Applied flood_scope=%r", scope or "(disabled)") except Exception as exc: - logger.warning("set_flood_scope failed (firmware may not support it): %s", exc) + logger.warning("Failed to apply configured flood scope to radio: %s", exc) # Query path hash mode support (best-effort; older firmware won't report it). # If the library's parsed payload is missing path_hash_mode (e.g. stale From 6edd2927b15cc969ad25cd8b1e2f98bcf58cca62 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 8 Jul 2026 15:45:58 -0700 Subject: [PATCH 4/4] Gate scoping-safety on firmware ref, and and make per-channel override tristate. --- app/models.py | 7 ++- app/radio.py | 4 ++ app/region_scope.py | 18 +++++++- app/routers/channels.py | 24 ++++++++-- app/routers/settings.py | 4 +- app/services/flood_scope.py | 42 ++++++++++++++--- app/services/message_send.py | 26 ++++++++--- app/services/radio_lifecycle.py | 33 ++++++++------ .../ChannelFloodScopeOverrideModal.tsx | 39 ++++++++++++---- .../settings/SettingsRadioSection.tsx | 4 +- .../src/test/chatHeaderKeyVisibility.test.tsx | 28 +++++++++++- frontend/src/utils/regionScope.ts | 9 ++++ tests/test_channels_router.py | 20 +++++++++ tests/test_flood_scope_service.py | 45 ++++++++++++++++--- tests/test_radio.py | 5 +++ tests/test_region_scope.py | 23 +++++++++- tests/test_send_messages.py | 29 ++++++++++++ tests/test_settings_router.py | 2 + 18 files changed, 313 insertions(+), 49 deletions(-) diff --git a/app/models.py b/app/models.py index a2315c3..0532b30 100644 --- a/app/models.py +++ b/app/models.py @@ -343,7 +343,12 @@ class Channel(BaseModel): on_radio: bool = False flood_scope_override: str | None = Field( default=None, - description="Per-channel outbound flood scope override (null = use global app setting)", + description=( + "Per-channel outbound flood scope override, tri-state: null = inherit the " + "global app setting; '*' (UNSCOPED_OVERRIDE_MARKER) = force unscoped/plain " + "flood even over a scoped global; a region name (e.g. '#Esperance') = scope " + "this channel." + ), ) path_hash_mode_override: int | None = Field( default=None, diff --git a/app/radio.py b/app/radio.py index a9b19e2..d0101ae 100644 --- a/app/radio.py +++ b/app/radio.py @@ -171,6 +171,9 @@ class RadioManager: self.device_model: str | None = None self.firmware_build: str | None = None self.firmware_version: str | None = None + # Companion protocol version (FIRMWARE_VER_CODE). Gates version-dependent + # host commands such as the mode-1 unscoped flood-scope frame (ver 12+). + self.firmware_ver_code: int | None = None self.max_channels: int = 40 self.path_hash_mode: int = 0 self.path_hash_mode_supported: bool = False @@ -217,6 +220,7 @@ class RadioManager: self.device_model = None self.firmware_build = None self.firmware_version = None + self.firmware_ver_code = None self.max_channels = 40 self.path_hash_mode = 0 self.path_hash_mode_supported = False diff --git a/app/region_scope.py b/app/region_scope.py index d97cb8b..36a136d 100644 --- a/app/region_scope.py +++ b/app/region_scope.py @@ -1,6 +1,22 @@ """Helpers for normalizing MeshCore flood-scope / region names.""" -_UNSCOPED_SENTINELS = {"", "0", "*"} +# Canonical persisted marker for "force unscoped/plain flood". Stored verbatim in +# the per-channel ``flood_scope_override`` column to mean "this channel is unscoped +# even if a global region is set" — distinct from NULL, which means "inherit global". +UNSCOPED_OVERRIDE_MARKER = "*" + +# All values that denote explicit unscoped/plain flood, matching firmware parity. +_UNSCOPED_SENTINELS = {"", "0", UNSCOPED_OVERRIDE_MARKER} + + +def is_unscoped(scope: str | None) -> bool: + """True if ``scope`` denotes an explicit unscoped/plain-flood request. + + Note: an empty string counts as unscoped here. Callers that need to treat + blank as "no opinion / inherit" (e.g. the channel-override API) must check for + blank *before* calling this. + """ + return (scope or "").strip() in _UNSCOPED_SENTINELS def normalize_region_scope(scope: str | None) -> str: diff --git a/app/routers/channels.py b/app/routers/channels.py index 3091620..402dd77 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -14,7 +14,7 @@ from app.channel_constants import ( from app.decoder import parse_packet, try_decrypt_packet_with_channel_key from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender from app.packet_processor import create_message_from_decrypted -from app.region_scope import normalize_region_scope +from app.region_scope import UNSCOPED_OVERRIDE_MARKER, is_unscoped, normalize_region_scope from app.repository import ChannelRepository, MessageRepository, RawPacketRepository from app.websocket import broadcast_event, broadcast_success @@ -56,7 +56,13 @@ class BulkCreateHashtagChannelsResponse(BaseModel): class ChannelFloodScopeOverrideRequest(BaseModel): flood_scope_override: str = Field( - description="Blank clears the override; non-empty values temporarily override flood scope" + description=( + "Tri-state channel override. Blank clears the override (inherit the global " + "scope); '*' forces unscoped/plain flood even when a global region is set; " + "any other value scopes the channel to that region. Note the deliberate " + "asymmetry vs. the send layer: here blank means 'inherit', so an explicit " + "unscoped request must use '*'." + ) ) @@ -345,7 +351,19 @@ async def set_channel_flood_scope_override( if not channel: raise HTTPException(status_code=404, detail="Channel not found") - override = normalize_region_scope(request.flood_scope_override) or None + # Tri-state persisted override: + # blank -> None: clear the override, inherit the global scope + # "*" / "0" -> canonical unscoped marker: force unscoped even over a global + # region name -> "#Region": scope this channel + # NOTE: at this (channel-override) layer blank means "clear/inherit", so we must + # check for blank *before* is_unscoped() (which also treats "" as unscoped). + raw_override = (request.flood_scope_override or "").strip() + if raw_override == "": + override: str | None = None + elif is_unscoped(raw_override): + override = UNSCOPED_OVERRIDE_MARKER + else: + override = normalize_region_scope(raw_override) updated = await ChannelRepository.update_flood_scope_override(channel.key, override) if not updated: raise HTTPException(status_code=500, detail="Failed to update flood-scope override") diff --git a/app/routers/settings.py b/app/routers/settings.py index b6782df..3c45002 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -289,7 +289,9 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: try: scope = result.flood_scope async with radio_manager.radio_operation("set_flood_scope") as mc: - await set_radio_flood_scope(mc, scope) + await set_radio_flood_scope( + mc, scope, fw_ver=radio_manager.firmware_ver_code + ) logger.info("Applied flood_scope=%r to radio", scope or "(disabled)") except Exception as e: logger.warning("Failed to apply flood_scope to radio: %s", e) diff --git a/app/services/flood_scope.py b/app/services/flood_scope.py index 2e4fada..900ab87 100644 --- a/app/services/flood_scope.py +++ b/app/services/flood_scope.py @@ -1,5 +1,6 @@ """Firmware-compatible flood-scope command helpers.""" +import logging from typing import Any from meshcore import EventType @@ -7,23 +8,50 @@ from meshcore.packets import CommandType from app.region_scope import normalize_region_scope +logger = logging.getLogger(__name__) + SET_FLOOD_SCOPE_MODE_UNSCOPED = 1 FORCE_UNSCOPED_FRAME = bytes([CommandType.SET_FLOOD_SCOPE.value, SET_FLOOD_SCOPE_MODE_UNSCOPED]) +# CMD_SET_FLOOD_SCOPE_KEY mode 1 (the firmware ``send_unscoped`` flag) is companion +# firmware ver 12+. On older firmware the mode-1 frame is rejected, so we fall back +# to resetting the scope override (mode 0, zero key), which makes the radio use its +# configured default scope. True "unscoped while a default scope is set" is not +# achievable pre-v12. +FIRMWARE_VER_UNSCOPED_MODE = 12 -async def set_radio_flood_scope(mc, scope: str | None) -> Any: + +def firmware_supports_unscoped_mode(fw_ver: int | None) -> bool: + """Whether the radio's protocol version supports the mode-1 unscoped command.""" + return fw_ver is not None and fw_ver >= FIRMWARE_VER_UNSCOPED_MODE + + +async def set_radio_flood_scope(mc, scope: str | None, *, fw_ver: int | None = None) -> Any: """Apply the standing radio flood-scope state. - A non-empty scope is delegated to meshcore_py's mode-0 ``set_flood_scope``. - An empty scope means explicit unscoped/plain flood and must use firmware's - mode-1 command. Sending a mode-0 all-zero key would fall back to the radio's - configured default scope on firmware-compatible companions. - """ + A non-empty scope is delegated to meshcore_py's mode-0 ``set_flood_scope``. An + empty scope means explicit unscoped/plain flood: + - firmware >= 12: use the dedicated mode-1 command (``force_radio_unscoped``). + - older / unknown firmware: no dedicated unscoped command exists, so reset the + scope override (mode 0, zero key); the radio falls back to its configured + default scope. ``fw_ver=None`` (version unknown) is treated conservatively as + unsupported so we never emit a frame the radio might reject. + """ normalized_scope = normalize_region_scope(scope) if normalized_scope: return await mc.commands.set_flood_scope(normalized_scope) - return await force_radio_unscoped(mc) + + if firmware_supports_unscoped_mode(fw_ver): + return await force_radio_unscoped(mc) + + logger.debug( + "Radio fw_ver=%s < %d: no dedicated unscoped command; resetting scope override " + "(radio falls back to its configured default scope)", + fw_ver, + FIRMWARE_VER_UNSCOPED_MODE, + ) + return await mc.commands.set_flood_scope("") async def force_radio_unscoped(mc) -> Any: diff --git a/app/services/message_send.py b/app/services/message_send.py index cc21fc0..17a0775 100644 --- a/app/services/message_send.py +++ b/app/services/message_send.py @@ -11,7 +11,7 @@ from meshcore import EventType from app.models import ResendChannelMessageResponse from app.radio import RadioOperationBusyError -from app.region_scope import normalize_region_scope +from app.region_scope import is_unscoped, normalize_region_scope from app.repository import ( AppSettingsRepository, ChannelRepository, @@ -161,8 +161,20 @@ async def send_channel_message_with_effective_scope( back to the channel's persisted override. """ if isinstance(flood_scope_override, _ScopeUnset): - desired_scope = normalize_region_scope(channel.flood_scope_override) - scope_explicit = False + # Fall back to the channel's persisted override, which is tri-state: + # None -> inherit the global scope (leave radio untouched) + # unscoped marker ("*") -> force unscoped even over a scoped global + # region name -> scope this channel + channel_override = channel.flood_scope_override + if channel_override is None: + desired_scope = "" + scope_explicit = False + elif is_unscoped(channel_override): + desired_scope = "" + scope_explicit = True + else: + desired_scope = normalize_region_scope(channel_override) + scope_explicit = True else: desired_scope = normalize_region_scope(flood_scope_override) scope_explicit = True @@ -185,7 +197,9 @@ async def send_channel_message_with_effective_scope( desired_scope or "(unscoped)", channel.name, ) - override_result = await set_radio_flood_scope(mc, desired_scope) + override_result = await set_radio_flood_scope( + mc, desired_scope, fw_ver=radio_manager.firmware_ver_code + ) if override_result is not None and override_result.type == EventType.ERROR: logger.warning( "Failed to apply flood_scope %r for %s: %s", @@ -314,7 +328,9 @@ async def send_channel_message_with_effective_scope( restored = False for attempt in range(3): try: - restore_result = await set_radio_flood_scope(mc, baseline_scope) + restore_result = await set_radio_flood_scope( + mc, baseline_scope, fw_ver=radio_manager.firmware_ver_code + ) if restore_result is not None and restore_result.type == EventType.ERROR: logger.warning( "Attempt %d/3: failed to restore flood_scope after sending to %s: %s", diff --git a/app/services/radio_lifecycle.py b/app/services/radio_lifecycle.py index 1dc60d6..5c874cd 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -67,20 +67,6 @@ async def run_post_connect_setup(radio_manager) -> None: # Sync radio clock with system time await sync_radio_time(mc) - # Apply flood scope from settings (best-effort; older firmware - # may not support the relevant flood-scope command) - from app.region_scope import normalize_region_scope - from app.repository import AppSettingsRepository - from app.services.flood_scope import set_radio_flood_scope - - app_settings = await AppSettingsRepository.get() - scope = normalize_region_scope(app_settings.flood_scope) - try: - await set_radio_flood_scope(mc, scope) - logger.info("Applied flood_scope=%r", scope or "(disabled)") - except Exception as exc: - logger.warning("Failed to apply configured flood scope to radio: %s", exc) - # Query path hash mode support (best-effort; older firmware won't report it). # If the library's parsed payload is missing path_hash_mode (e.g. stale # .pyc on WSL2 Windows mounts), fall back to raw-frame extraction. @@ -101,6 +87,7 @@ async def run_post_connect_setup(radio_manager) -> None: radio_manager.device_model = None radio_manager.firmware_build = None radio_manager.firmware_version = None + radio_manager.firmware_ver_code = None radio_manager.max_channels = 40 radio_manager.path_hash_mode = 0 radio_manager.path_hash_mode_supported = False @@ -128,6 +115,7 @@ async def run_post_connect_setup(radio_manager) -> None: payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3 if payload_reports_device_info: radio_manager.device_info_loaded = True + radio_manager.firmware_ver_code = fw_ver if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int): radio_manager.path_hash_mode = payload["path_hash_mode"] @@ -141,6 +129,7 @@ async def run_post_connect_setup(radio_manager) -> None: fw_ver = raw[1] if len(raw) > 1 else 0 if fw_ver >= 3: radio_manager.device_info_loaded = True + radio_manager.firmware_ver_code = fw_ver if radio_manager.max_contacts is None and len(raw) >= 3: radio_manager.max_contacts = max(1, raw[2] * 2) if len(raw) >= 4 and not isinstance(payload_max_channels, int): @@ -206,6 +195,22 @@ async def run_post_connect_setup(radio_manager) -> None: finally: reader.handle_rx = _original_handle_rx + # Apply flood scope from settings (best-effort; older firmware may + # not support the mode-1 unscoped command). Done after the device + # query so radio_manager.firmware_ver_code is known and the unscoped + # path can pick the correct firmware command. + from app.region_scope import normalize_region_scope + from app.repository import AppSettingsRepository + from app.services.flood_scope import set_radio_flood_scope + + app_settings = await AppSettingsRepository.get() + scope = normalize_region_scope(app_settings.flood_scope) + try: + await set_radio_flood_scope(mc, scope, fw_ver=radio_manager.firmware_ver_code) + logger.info("Applied flood_scope=%r", scope or "(disabled)") + except Exception as exc: + logger.warning("Failed to apply configured flood scope to radio: %s", exc) + from app.config import settings as app_settings_config if app_settings_config.skip_post_connect_sync: diff --git a/frontend/src/components/ChannelFloodScopeOverrideModal.tsx b/frontend/src/components/ChannelFloodScopeOverrideModal.tsx index 11dbf71..85504d0 100644 --- a/frontend/src/components/ChannelFloodScopeOverrideModal.tsx +++ b/frontend/src/components/ChannelFloodScopeOverrideModal.tsx @@ -1,6 +1,10 @@ import { useEffect, useState } from 'react'; -import { stripRegionScopePrefix } from '../utils/regionScope'; +import { + UNSCOPED_OVERRIDE_MARKER, + isUnscopedMarker, + stripRegionScopePrefix, +} from '../utils/regionScope'; import { Button } from './ui/button'; import { Dialog, @@ -34,11 +38,18 @@ export function ChannelFloodScopeOverrideModal({ if (!open) { return; } - setRegion(stripRegionScopePrefix(currentOverride)); + // The unscoped marker isn't a region name, so start the input blank for it. + setRegion(isUnscopedMarker(currentOverride) ? '' : stripRegionScopePrefix(currentOverride)); }, [currentOverride, open]); const trimmedRegion = region.trim(); + const currentOverrideLabel = isUnscopedMarker(currentOverride) + ? 'unscoped (plain flood)' + : currentOverride + ? stripRegionScopePrefix(currentOverride) + : 'inherit global setting'; + return ( !isOpen && onClose()}> @@ -46,7 +57,9 @@ export function ChannelFloodScopeOverrideModal({ Regional Override Channel-level regional routing temporarily changes the radio flood scope before send and - restores it after. This can noticeably slow channel sends. + restores it after. This can noticeably slow channel sends. Choose one of three modes + below: scope to a region, force unscoped (plain flood, ignoring your global region), or + inherit the global setting. @@ -54,8 +67,7 @@ export function ChannelFloodScopeOverrideModal({
{roomName}
- Current regional override:{' '} - {currentOverride ? stripRegionScopePrefix(currentOverride) : 'none'} + Current setting: {currentOverrideLabel}
@@ -83,8 +95,19 @@ export function ChannelFloodScopeOverrideModal({ }} > {trimmedRegion.length > 0 - ? `Use ${trimmedRegion} region for ${roomName}` - : `Use region for ${roomName}`} + ? `Scope ${roomName} to ${trimmedRegion}` + : `Scope ${roomName} to a region`} + + diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index fc1cd2c..be51af0 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -1163,7 +1163,9 @@ export function SettingsRadioSection({

Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that region can forward the traffic, while repeaters configured to deny other regions may drop - it. Leave empty to disable. + it. Leave empty to send unscoped (plain flood). Forcing unscoped when a default scope is + configured on the radio requires firmware v12 or newer. Individual channels can override + this with their own region or an "always unscoped" setting.

diff --git a/frontend/src/test/chatHeaderKeyVisibility.test.tsx b/frontend/src/test/chatHeaderKeyVisibility.test.tsx index c6ec6d4..0de91bf 100644 --- a/frontend/src/test/chatHeaderKeyVisibility.test.tsx +++ b/frontend/src/test/chatHeaderKeyVisibility.test.tsx @@ -360,8 +360,34 @@ describe('ChatHeader key visibility', () => { expect(await screen.findByRole('dialog')).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('Region'), { target: { value: 'Esperance' } }); - fireEvent.click(screen.getByRole('button', { name: 'Use Esperance region for #flightless' })); + fireEvent.click(screen.getByRole('button', { name: 'Scope #flightless to Esperance' })); expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, 'Esperance'); }); + + it('forces the channel unscoped via the modal (issue #303)', async () => { + const key = 'CD'.repeat(16); + const channel = makeChannel(key, '#flightless', true); + const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' }; + const onSetChannelFloodScopeOverride = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByTitle('Set regional override')); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { + name: 'Always send #flightless unscoped (ignore global region)', + }) + ); + + expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, '*'); + }); }); diff --git a/frontend/src/utils/regionScope.ts b/frontend/src/utils/regionScope.ts index a6e3a41..64219ff 100644 --- a/frontend/src/utils/regionScope.ts +++ b/frontend/src/utils/regionScope.ts @@ -1,3 +1,12 @@ +// Canonical persisted marker meaning "force this channel unscoped / plain flood" +// (mirrors the backend UNSCOPED_OVERRIDE_MARKER). Distinct from null, which means +// "inherit the global scope". +export const UNSCOPED_OVERRIDE_MARKER = '*'; + +export function isUnscopedMarker(scope: string | null | undefined): boolean { + return scope === UNSCOPED_OVERRIDE_MARKER; +} + export function stripRegionScopePrefix(scope: string | null | undefined): string { if (!scope) return ''; return scope.startsWith('#') ? scope.slice(1) : scope; diff --git a/tests/test_channels_router.py b/tests/test_channels_router.py index be8ef37..1ac67bd 100644 --- a/tests/test_channels_router.py +++ b/tests/test_channels_router.py @@ -32,6 +32,26 @@ class TestChannelFloodScopeOverride: mock_broadcast.assert_called_once() assert mock_broadcast.call_args.args[0] == "channel" + @pytest.mark.asyncio + async def test_unscoped_marker_forces_channel_unscoped(self, test_db, client): + """'*' persists as the canonical unscoped marker (issue #303), distinct + from blank (which clears the override / inherits the global scope).""" + key = "DD" * 16 + await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True) + + with patch("app.routers.channels.broadcast_event"): + response = await client.post( + f"/api/channels/{key}/flood-scope-override", + json={"flood_scope_override": "*"}, + ) + + assert response.status_code == 200 + assert response.json()["flood_scope_override"] == "*" + + channel = await ChannelRepository.get_by_key(key) + assert channel is not None + assert channel.flood_scope_override == "*" + class TestCreateChannel: @pytest.mark.asyncio diff --git a/tests/test_flood_scope_service.py b/tests/test_flood_scope_service.py index 597b4e0..98959c8 100644 --- a/tests/test_flood_scope_service.py +++ b/tests/test_flood_scope_service.py @@ -3,7 +3,17 @@ from unittest.mock import AsyncMock, MagicMock import pytest from meshcore import EventType -from app.services.flood_scope import FORCE_UNSCOPED_FRAME, set_radio_flood_scope +from app.services.flood_scope import ( + FIRMWARE_VER_UNSCOPED_MODE, + FORCE_UNSCOPED_FRAME, + firmware_supports_unscoped_mode, + set_radio_flood_scope, +) + +# A protocol version that supports the mode-1 unscoped command (>= 12). +FW_SUPPORTS_UNSCOPED = FIRMWARE_VER_UNSCOPED_MODE +# A protocol version that predates the mode-1 command. +FW_NO_UNSCOPED = FIRMWARE_VER_UNSCOPED_MODE - 1 @pytest.mark.asyncio @@ -12,7 +22,8 @@ async def test_set_radio_flood_scope_uses_meshcore_scope_for_regions(): mc.commands.set_flood_scope = AsyncMock(return_value="ok") mc.commands.send = AsyncMock() - result = await set_radio_flood_scope(mc, "Esperance") + # Region path is version-independent. + result = await set_radio_flood_scope(mc, "Esperance", fw_ver=FW_NO_UNSCOPED) assert result == "ok" mc.commands.set_flood_scope.assert_awaited_once_with("#Esperance") @@ -20,12 +31,12 @@ async def test_set_radio_flood_scope_uses_meshcore_scope_for_regions(): @pytest.mark.asyncio -async def test_set_radio_flood_scope_empty_uses_firmware_unscoped_mode(): +async def test_set_radio_flood_scope_empty_uses_firmware_unscoped_mode_on_v12(): mc = MagicMock() mc.commands.set_flood_scope = AsyncMock() mc.commands.send = AsyncMock(return_value="ok") - result = await set_radio_flood_scope(mc, "") + result = await set_radio_flood_scope(mc, "", fw_ver=FW_SUPPORTS_UNSCOPED) assert result == "ok" mc.commands.send.assert_awaited_once_with(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]) @@ -34,13 +45,35 @@ async def test_set_radio_flood_scope_empty_uses_firmware_unscoped_mode(): @pytest.mark.asyncio @pytest.mark.parametrize("scope", [None, " ", "0", "*"]) -async def test_set_radio_flood_scope_unscoped_sentinels_use_firmware_mode(scope): +async def test_set_radio_flood_scope_unscoped_sentinels_use_firmware_mode_on_v12(scope): mc = MagicMock() mc.commands.set_flood_scope = AsyncMock() mc.commands.send = AsyncMock(return_value="ok") - result = await set_radio_flood_scope(mc, scope) + result = await set_radio_flood_scope(mc, scope, fw_ver=FW_SUPPORTS_UNSCOPED) assert result == "ok" mc.commands.send.assert_awaited_once_with(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]) mc.commands.set_flood_scope.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fw_ver", [None, FW_NO_UNSCOPED]) +async def test_set_radio_flood_scope_unscoped_falls_back_on_old_or_unknown_firmware(fw_ver): + """Pre-v12 (or unknown) firmware has no mode-1 command; reset scope via mode 0.""" + mc = MagicMock() + mc.commands.set_flood_scope = AsyncMock(return_value="ok") + mc.commands.send = AsyncMock() + + result = await set_radio_flood_scope(mc, "", fw_ver=fw_ver) + + assert result == "ok" + mc.commands.set_flood_scope.assert_awaited_once_with("") + mc.commands.send.assert_not_awaited() + + +def test_firmware_supports_unscoped_mode(): + assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE) is True + assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE + 1) is True + assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE - 1) is False + assert firmware_supports_unscoped_mode(None) is False diff --git a/tests/test_radio.py b/tests/test_radio.py index 63ebaf5..4b0a2c8 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -981,6 +981,11 @@ class TestPostConnectSetupOrdering: mock_mc.start_auto_message_fetching = AsyncMock() mock_mc.commands.set_flood_scope = AsyncMock() mock_mc.commands.send = AsyncMock() + # Flood scope is applied after the device query, so report a protocol + # version that supports the mode-1 unscoped command (>= 12). + device_query = MagicMock() + device_query.payload = {"fw ver": 13} + mock_mc.commands.send_device_query = AsyncMock(return_value=device_query) rm._meshcore = mock_mc mock_settings = AppSettings(flood_scope="") diff --git a/tests/test_region_scope.py b/tests/test_region_scope.py index e493c53..146cfed 100644 --- a/tests/test_region_scope.py +++ b/tests/test_region_scope.py @@ -1,4 +1,8 @@ -from app.region_scope import normalize_region_scope +from app.region_scope import ( + UNSCOPED_OVERRIDE_MARKER, + is_unscoped, + normalize_region_scope, +) def test_normalize_region_scope_preserves_regions(): @@ -12,3 +16,20 @@ def test_normalize_region_scope_unscoped_sentinels(): assert normalize_region_scope(" ") == "" assert normalize_region_scope("0") == "" assert normalize_region_scope("*") == "" + + +def test_is_unscoped(): + assert is_unscoped(None) is True + assert is_unscoped("") is True + assert is_unscoped(" ") is True + assert is_unscoped("0") is True + assert is_unscoped("*") is True + assert is_unscoped(UNSCOPED_OVERRIDE_MARKER) is True + assert is_unscoped("Esperance") is False + assert is_unscoped("#Esperance") is False + + +def test_unscoped_override_marker_is_recognized_and_not_a_region(): + # The canonical persisted marker must round-trip as unscoped, never as a region. + assert is_unscoped(UNSCOPED_OVERRIDE_MARKER) is True + assert normalize_region_scope(UNSCOPED_OVERRIDE_MARKER) == "" diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index e0b7c99..76064f9 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -664,6 +664,7 @@ class TestOutgoingChannelBroadcast: with ( patch("app.routers.messages.radio_manager.require_connected", return_value=mc), patch.object(radio_manager, "_meshcore", mc), + patch.object(radio_manager, "firmware_ver_code", 13), patch("app.routers.messages.broadcast_event"), ): request = SendChannelMessageRequest( @@ -689,6 +690,7 @@ class TestOutgoingChannelBroadcast: with ( patch("app.routers.messages.radio_manager.require_connected", return_value=mc), patch.object(radio_manager, "_meshcore", mc), + patch.object(radio_manager, "firmware_ver_code", 13), patch("app.routers.messages.broadcast_event"), ): request = SendChannelMessageRequest( @@ -702,6 +704,33 @@ class TestOutgoingChannelBroadcast: ] assert mc.commands.set_flood_scope.await_args_list == [call("#Baseline")] + @pytest.mark.asyncio + async def test_persisted_unscoped_channel_forces_plain_flood_over_scoped_global(self, test_db): + """A channel persistently marked unscoped ('*') sends unscoped even when the + companion has a global region set — the core of issue #303. No per-send + override is supplied, so this exercises the persisted-override path.""" + mc = _make_mc(name="MyNode") + chan_key = "d3" * 16 + await ChannelRepository.upsert(key=chan_key, name="#plain") + await ChannelRepository.update_flood_scope_override(chan_key, "*") + await AppSettingsRepository.update(flood_scope="Baseline") + + with ( + patch("app.routers.messages.radio_manager.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch.object(radio_manager, "firmware_ver_code", 13), + patch("app.routers.messages.broadcast_event"), + ): + # No per-send flood_scope_override: fall back to the channel's persisted "*". + request = SendChannelMessageRequest(channel_key=chan_key, text="hello") + await send_channel_message(request) + + # Force unscoped via mode 1, then restore the global region baseline. + assert mc.commands.send.await_args_list == [ + call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]) + ] + assert mc.commands.set_flood_scope.await_args_list == [call("#Baseline")] + @pytest.mark.asyncio async def test_send_channel_msg_aborts_when_override_apply_fails(self, test_db): mc = _make_mc(name="MyNode") diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py index 0e7fe99..f640758 100644 --- a/tests/test_settings_router.py +++ b/tests/test_settings_router.py @@ -142,6 +142,7 @@ class TestUpdateSettings: mock_rm = AsyncMock() mock_rm.is_connected = True mock_rm.meshcore = mock_mc + mock_rm.firmware_ver_code = 13 # supports mode-1 unscoped from contextlib import asynccontextmanager @@ -168,6 +169,7 @@ class TestUpdateSettings: mock_rm = AsyncMock() mock_rm.is_connected = True mock_rm.meshcore = mock_mc + mock_rm.firmware_ver_code = 13 # supports mode-1 unscoped from contextlib import asynccontextmanager