Merge pull request #307 from Bjorkan/fixRegions. Closes #303.

Fix unscoped flood-scope handling
This commit is contained in:
Jack Kingsman
2026-07-08 15:51:50 -07:00
committed by GitHub
20 changed files with 432 additions and 55 deletions
+6 -1
View File
@@ -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,
+4
View File
@@ -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
+24 -5
View File
@@ -1,19 +1,38 @@
"""Helpers for normalizing MeshCore flood-scope / region names."""
# 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:
"""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
+21 -3
View File
@@ -13,7 +13,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
@@ -55,7 +55,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 '*'."
)
)
@@ -348,7 +354,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")
+4 -1
View File
@@ -282,13 +282,16 @@ 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, 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)
+60
View File
@@ -0,0 +1,60 @@
"""Firmware-compatible flood-scope command helpers."""
import logging
from typing import Any
from meshcore import EventType
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
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:
- 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)
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:
"""Tell the radio to send following flood packets unscoped until mode 0."""
return await mc.commands.send(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
+21 -6
View File
@@ -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,
@@ -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,
@@ -160,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
@@ -184,7 +197,9 @@ 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, 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",
@@ -313,8 +328,8 @@ 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, fw_ver=radio_manager.firmware_ver_code
)
if restore_result is not None and restore_result.type == EventType.ERROR:
logger.warning(
+19 -13
View File
@@ -67,19 +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 set_flood_scope)
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository
app_settings = await AppSettingsRepository.get()
scope = normalize_region_scope(app_settings.flood_scope)
try:
await mc.commands.set_flood_scope(scope if scope else "")
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)
# 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.
@@ -100,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
@@ -127,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"]
@@ -140,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):
@@ -205,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:
@@ -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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
@@ -46,7 +57,9 @@ export function ChannelFloodScopeOverrideModal({
<DialogTitle>Regional Override</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -54,8 +67,7 @@ export function ChannelFloodScopeOverrideModal({
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
<div className="font-medium">{roomName}</div>
<div className="mt-1 text-muted-foreground">
Current regional override:{' '}
{currentOverride ? stripRegionScopePrefix(currentOverride) : 'none'}
Current setting: {currentOverrideLabel}
</div>
</div>
@@ -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`}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
onSetOverride(UNSCOPED_OVERRIDE_MARKER);
onClose();
}}
>
Always send {roomName} unscoped (ignore global region)
</Button>
<Button
type="button"
@@ -95,7 +118,7 @@ export function ChannelFloodScopeOverrideModal({
onClose();
}}
>
Do not use region routing for {roomName}
Use global region setting for {roomName}
</Button>
</div>
</DialogFooter>
@@ -1163,7 +1163,9 @@ export function SettingsRadioSection({
<p className="text-[0.8125rem] text-muted-foreground">
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.
</p>
</div>
+6 -6
View File
@@ -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(() => {
@@ -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(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[channel]}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
/>
);
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, '*');
});
});
+9
View File
@@ -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;
+20
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from meshcore import EventType
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
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()
# 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")
mc.commands.send.assert_not_awaited()
@pytest.mark.asyncio
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, "", 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("scope", [None, " ", "0", "*"])
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, 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
+13 -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,12 @@ class TestPostConnectSetupOrdering:
mock_mc = MagicMock()
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="")
@@ -999,7 +1008,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()
+35
View File
@@ -0,0 +1,35 @@
from app.region_scope import (
UNSCOPED_OVERRIDE_MARKER,
is_unscoped,
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("*") == ""
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) == ""
+39 -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())
@@ -662,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(
@@ -669,10 +672,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
@@ -687,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(
@@ -694,11 +698,38 @@ 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_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):
+8 -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:
@@ -140,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
@@ -166,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
@@ -178,7 +182,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: