mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Gate scoping-safety on firmware ref, and and make per-channel override tristate.
This commit is contained in:
+6
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+17
-1
@@ -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:
|
||||
|
||||
+21
-3
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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, '*');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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) == ""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user