Fix firmware-compatible unscoped flood scope handling

This commit is contained in:
Björkan
2026-06-28 01:23:19 +02:00
parent ce4946351f
commit 55020a5e22
11 changed files with 134 additions and 21 deletions
+8 -5
View File
@@ -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
+2 -1
View File
@@ -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)
+32
View File
@@ -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])
+3 -4
View File
@@ -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",
+2 -1
View File
@@ -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)
+46
View File
@@ -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()
+8 -1
View File
@@ -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):
+3
View File
@@ -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()
+14
View File
@@ -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("*") == ""
+10 -8
View File
@@ -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):
+6 -1
View File
@@ -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: