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: