mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Fix unscoped flood-scope handling
This commit is contained in:
+6
-1
@@ -343,7 +343,12 @@ class Channel(BaseModel):
|
|||||||
on_radio: bool = False
|
on_radio: bool = False
|
||||||
flood_scope_override: str | None = Field(
|
flood_scope_override: str | None = Field(
|
||||||
default=None,
|
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(
|
path_hash_mode_override: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ class RadioManager:
|
|||||||
self.device_model: str | None = None
|
self.device_model: str | None = None
|
||||||
self.firmware_build: str | None = None
|
self.firmware_build: str | None = None
|
||||||
self.firmware_version: 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.max_channels: int = 40
|
||||||
self.path_hash_mode: int = 0
|
self.path_hash_mode: int = 0
|
||||||
self.path_hash_mode_supported: bool = False
|
self.path_hash_mode_supported: bool = False
|
||||||
@@ -217,6 +220,7 @@ class RadioManager:
|
|||||||
self.device_model = None
|
self.device_model = None
|
||||||
self.firmware_build = None
|
self.firmware_build = None
|
||||||
self.firmware_version = None
|
self.firmware_version = None
|
||||||
|
self.firmware_ver_code = None
|
||||||
self.max_channels = 40
|
self.max_channels = 40
|
||||||
self.path_hash_mode = 0
|
self.path_hash_mode = 0
|
||||||
self.path_hash_mode_supported = False
|
self.path_hash_mode_supported = False
|
||||||
|
|||||||
+24
-5
@@ -1,19 +1,38 @@
|
|||||||
"""Helpers for normalizing MeshCore flood-scope / region names."""
|
"""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:
|
def normalize_region_scope(scope: str | None) -> str:
|
||||||
"""Normalize a user-facing region scope into MeshCore's internal form.
|
"""Normalize a user-facing region scope into MeshCore's internal form.
|
||||||
|
|
||||||
Region names are now user-facing plain strings like ``Esperance``.
|
Region names are user-facing plain strings like ``Esperance``. Internally,
|
||||||
Internally, MeshCore still expects hashtag-style names like ``#Esperance``.
|
MeshCore still expects hashtag-style names like ``#Esperance``.
|
||||||
|
|
||||||
Backward compatibility:
|
Backward compatibility / firmware parity:
|
||||||
- blank/None stays disabled (`""`)
|
- blank/None stays unscoped (``""``)
|
||||||
|
- ``"0"`` and ``"*"`` also mean explicit unscoped/plain flood
|
||||||
- existing leading ``#`` is preserved
|
- existing leading ``#`` is preserved
|
||||||
"""
|
"""
|
||||||
|
|
||||||
stripped = (scope or "").strip()
|
stripped = (scope or "").strip()
|
||||||
if not stripped:
|
if stripped in _UNSCOPED_SENTINELS:
|
||||||
return ""
|
return ""
|
||||||
if stripped.startswith("#"):
|
if stripped.startswith("#"):
|
||||||
return stripped
|
return stripped
|
||||||
|
|||||||
+21
-3
@@ -13,7 +13,7 @@ from app.channel_constants import (
|
|||||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||||
from app.packet_processor import create_message_from_decrypted
|
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.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||||
from app.websocket import broadcast_event, broadcast_success
|
from app.websocket import broadcast_event, broadcast_success
|
||||||
|
|
||||||
@@ -55,7 +55,13 @@ class BulkCreateHashtagChannelsResponse(BaseModel):
|
|||||||
|
|
||||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||||
flood_scope_override: str = Field(
|
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:
|
if not channel:
|
||||||
raise HTTPException(status_code=404, detail="Channel not found")
|
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)
|
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
|
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
|
||||||
|
|||||||
@@ -282,13 +282,16 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
|||||||
|
|
||||||
# Apply flood scope to radio immediately if changed
|
# Apply flood scope to radio immediately if changed
|
||||||
if flood_scope_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
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
if radio_manager.is_connected:
|
if radio_manager.is_connected:
|
||||||
try:
|
try:
|
||||||
scope = result.flood_scope
|
scope = result.flood_scope
|
||||||
async with radio_manager.radio_operation("set_flood_scope") as mc:
|
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)")
|
logger.info("Applied flood_scope=%r to radio", scope or "(disabled)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to apply flood_scope to radio: %s", e)
|
logger.warning("Failed to apply flood_scope to radio: %s", e)
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -11,7 +11,7 @@ from meshcore import EventType
|
|||||||
|
|
||||||
from app.models import ResendChannelMessageResponse
|
from app.models import ResendChannelMessageResponse
|
||||||
from app.radio import RadioOperationBusyError
|
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 (
|
from app.repository import (
|
||||||
AppSettingsRepository,
|
AppSettingsRepository,
|
||||||
ChannelRepository,
|
ChannelRepository,
|
||||||
@@ -19,6 +19,7 @@ from app.repository import (
|
|||||||
MessageRepository,
|
MessageRepository,
|
||||||
)
|
)
|
||||||
from app.services import dm_ack_tracker
|
from app.services import dm_ack_tracker
|
||||||
|
from app.services.flood_scope import set_radio_flood_scope
|
||||||
from app.services.messages import (
|
from app.services.messages import (
|
||||||
BroadcastFn,
|
BroadcastFn,
|
||||||
broadcast_message,
|
broadcast_message,
|
||||||
@@ -160,8 +161,20 @@ async def send_channel_message_with_effective_scope(
|
|||||||
back to the channel's persisted override.
|
back to the channel's persisted override.
|
||||||
"""
|
"""
|
||||||
if isinstance(flood_scope_override, _ScopeUnset):
|
if isinstance(flood_scope_override, _ScopeUnset):
|
||||||
desired_scope = normalize_region_scope(channel.flood_scope_override)
|
# Fall back to the channel's persisted override, which is tri-state:
|
||||||
scope_explicit = False
|
# 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:
|
else:
|
||||||
desired_scope = normalize_region_scope(flood_scope_override)
|
desired_scope = normalize_region_scope(flood_scope_override)
|
||||||
scope_explicit = True
|
scope_explicit = True
|
||||||
@@ -184,7 +197,9 @@ async def send_channel_message_with_effective_scope(
|
|||||||
desired_scope or "(unscoped)",
|
desired_scope or "(unscoped)",
|
||||||
channel.name,
|
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:
|
if override_result is not None and override_result.type == EventType.ERROR:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to apply flood_scope %r for %s: %s",
|
"Failed to apply flood_scope %r for %s: %s",
|
||||||
@@ -313,8 +328,8 @@ async def send_channel_message_with_effective_scope(
|
|||||||
restored = False
|
restored = False
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
restore_result = await mc.commands.set_flood_scope(
|
restore_result = await set_radio_flood_scope(
|
||||||
baseline_scope if baseline_scope else ""
|
mc, baseline_scope, fw_ver=radio_manager.firmware_ver_code
|
||||||
)
|
)
|
||||||
if restore_result is not None and restore_result.type == EventType.ERROR:
|
if restore_result is not None and restore_result.type == EventType.ERROR:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -67,19 +67,6 @@ async def run_post_connect_setup(radio_manager) -> None:
|
|||||||
# Sync radio clock with system time
|
# Sync radio clock with system time
|
||||||
await sync_radio_time(mc)
|
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).
|
# 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
|
# 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.
|
# .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.device_model = None
|
||||||
radio_manager.firmware_build = None
|
radio_manager.firmware_build = None
|
||||||
radio_manager.firmware_version = None
|
radio_manager.firmware_version = None
|
||||||
|
radio_manager.firmware_ver_code = None
|
||||||
radio_manager.max_channels = 40
|
radio_manager.max_channels = 40
|
||||||
radio_manager.path_hash_mode = 0
|
radio_manager.path_hash_mode = 0
|
||||||
radio_manager.path_hash_mode_supported = False
|
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
|
payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3
|
||||||
if payload_reports_device_info:
|
if payload_reports_device_info:
|
||||||
radio_manager.device_info_loaded = True
|
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):
|
if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int):
|
||||||
radio_manager.path_hash_mode = payload["path_hash_mode"]
|
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
|
fw_ver = raw[1] if len(raw) > 1 else 0
|
||||||
if fw_ver >= 3:
|
if fw_ver >= 3:
|
||||||
radio_manager.device_info_loaded = True
|
radio_manager.device_info_loaded = True
|
||||||
|
radio_manager.firmware_ver_code = fw_ver
|
||||||
if radio_manager.max_contacts is None and len(raw) >= 3:
|
if radio_manager.max_contacts is None and len(raw) >= 3:
|
||||||
radio_manager.max_contacts = max(1, raw[2] * 2)
|
radio_manager.max_contacts = max(1, raw[2] * 2)
|
||||||
if len(raw) >= 4 and not isinstance(payload_max_channels, int):
|
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:
|
finally:
|
||||||
reader.handle_rx = _original_handle_rx
|
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
|
from app.config import settings as app_settings_config
|
||||||
|
|
||||||
if app_settings_config.skip_post_connect_sync:
|
if app_settings_config.skip_post_connect_sync:
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { Button } from './ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -34,11 +38,18 @@ export function ChannelFloodScopeOverrideModal({
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
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]);
|
}, [currentOverride, open]);
|
||||||
|
|
||||||
const trimmedRegion = region.trim();
|
const trimmedRegion = region.trim();
|
||||||
|
|
||||||
|
const currentOverrideLabel = isUnscopedMarker(currentOverride)
|
||||||
|
? 'unscoped (plain flood)'
|
||||||
|
: currentOverride
|
||||||
|
? stripRegionScopePrefix(currentOverride)
|
||||||
|
: 'inherit global setting';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
<DialogContent className="sm:max-w-[520px]">
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
@@ -46,7 +57,9 @@ export function ChannelFloodScopeOverrideModal({
|
|||||||
<DialogTitle>Regional Override</DialogTitle>
|
<DialogTitle>Regional Override</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Channel-level regional routing temporarily changes the radio flood scope before send and
|
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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -54,8 +67,7 @@ export function ChannelFloodScopeOverrideModal({
|
|||||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||||
<div className="font-medium">{roomName}</div>
|
<div className="font-medium">{roomName}</div>
|
||||||
<div className="mt-1 text-muted-foreground">
|
<div className="mt-1 text-muted-foreground">
|
||||||
Current regional override:{' '}
|
Current setting: {currentOverrideLabel}
|
||||||
{currentOverride ? stripRegionScopePrefix(currentOverride) : 'none'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,8 +95,19 @@ export function ChannelFloodScopeOverrideModal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{trimmedRegion.length > 0
|
{trimmedRegion.length > 0
|
||||||
? `Use ${trimmedRegion} region for ${roomName}`
|
? `Scope ${roomName} to ${trimmedRegion}`
|
||||||
: `Use region for ${roomName}`}
|
: `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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -95,7 +118,7 @@ export function ChannelFloodScopeOverrideModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Do not use region routing for {roomName}
|
Use global region setting for {roomName}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1163,7 +1163,9 @@ export function SettingsRadioSection({
|
|||||||
<p className="text-[0.8125rem] text-muted-foreground">
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
|
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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ const publicChannel = {
|
|||||||
// contacts, unreads) that must all settle before conversation selection
|
// contacts, unreads) that must all settle before conversation selection
|
||||||
// renders. This suite passes in isolation, but under the full parallel suite
|
// 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
|
// (~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
|
// default — and occasionally past 10s on GitHub's shared runners — for any
|
||||||
// this file. It's starvation, not a hang, so give this file generous headroom
|
// waitFor in this file. It's starvation, not a hang, so give this file generous
|
||||||
// on both timeouts: a healthy render still settles in ~100ms (nothing is
|
// headroom on both timeouts: a healthy render still settles in ~100ms (nothing
|
||||||
// slowed), only a starved run waits longer.
|
// is slowed), only a starved run waits longer.
|
||||||
//
|
//
|
||||||
// These MUST run at module scope, not in beforeAll: vitest bakes each test's
|
// 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
|
// 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
|
// 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
|
// to this file (each file runs in its own isolated worker), so other files are
|
||||||
// unaffected.
|
// unaffected.
|
||||||
vi.setConfig({ testTimeout: 15000 });
|
vi.setConfig({ testTimeout: 45000 });
|
||||||
configure({ asyncUtilTimeout: 10000 });
|
configure({ asyncUtilTimeout: 30000 });
|
||||||
|
|
||||||
describe('App startup hash resolution', () => {
|
describe('App startup hash resolution', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -360,8 +360,34 @@ describe('ChatHeader key visibility', () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||||
fireEvent.change(screen.getByLabelText('Region'), { target: { value: 'Esperance' } });
|
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');
|
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 {
|
export function stripRegionScopePrefix(scope: string | null | undefined): string {
|
||||||
if (!scope) return '';
|
if (!scope) return '';
|
||||||
return scope.startsWith('#') ? scope.slice(1) : scope;
|
return scope.startsWith('#') ? scope.slice(1) : scope;
|
||||||
|
|||||||
@@ -32,6 +32,26 @@ class TestChannelFloodScopeOverride:
|
|||||||
mock_broadcast.assert_called_once()
|
mock_broadcast.assert_called_once()
|
||||||
assert mock_broadcast.call_args.args[0] == "channel"
|
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:
|
class TestCreateChannel:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -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
@@ -6,8 +6,11 @@ import asyncio
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from meshcore import EventType
|
||||||
from serial.serialutil import SerialException
|
from serial.serialutil import SerialException
|
||||||
|
|
||||||
|
from app.services.flood_scope import FORCE_UNSCOPED_FRAME
|
||||||
|
|
||||||
|
|
||||||
class TestRadioManagerConnect:
|
class TestRadioManagerConnect:
|
||||||
"""Test that connect() dispatches to the correct transport."""
|
"""Test that connect() dispatches to the correct transport."""
|
||||||
@@ -977,6 +980,12 @@ class TestPostConnectSetupOrdering:
|
|||||||
mock_mc = MagicMock()
|
mock_mc = MagicMock()
|
||||||
mock_mc.start_auto_message_fetching = AsyncMock()
|
mock_mc.start_auto_message_fetching = AsyncMock()
|
||||||
mock_mc.commands.set_flood_scope = 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
|
rm._meshcore = mock_mc
|
||||||
|
|
||||||
mock_settings = AppSettings(flood_scope="")
|
mock_settings = AppSettings(flood_scope="")
|
||||||
@@ -999,7 +1008,10 @@ class TestPostConnectSetupOrdering:
|
|||||||
):
|
):
|
||||||
await rm.post_connect_setup()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_message_polling_starts_hourly_audit_by_default(self):
|
async def test_message_polling_starts_hourly_audit_by_default(self):
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class TestRunPostConnectSetup:
|
|||||||
initial_mc = MagicMock()
|
initial_mc = MagicMock()
|
||||||
initial_mc.commands.send_device_query = AsyncMock(return_value=None)
|
initial_mc.commands.send_device_query = AsyncMock(return_value=None)
|
||||||
initial_mc.commands.set_flood_scope = 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 = MagicMock()
|
||||||
initial_mc._reader.handle_rx = AsyncMock()
|
initial_mc._reader.handle_rx = AsyncMock()
|
||||||
initial_mc.start_auto_message_fetching = AsyncMock()
|
initial_mc.start_auto_message_fetching = AsyncMock()
|
||||||
@@ -103,6 +104,7 @@ class TestRunPostConnectSetup:
|
|||||||
return_value=MagicMock(payload={"max_channels": 8})
|
return_value=MagicMock(payload={"max_channels": 8})
|
||||||
)
|
)
|
||||||
replacement_mc.commands.set_flood_scope = AsyncMock(return_value=None)
|
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 = MagicMock()
|
||||||
replacement_mc._reader.handle_rx = AsyncMock()
|
replacement_mc._reader.handle_rx = AsyncMock()
|
||||||
replacement_mc.start_auto_message_fetching = 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.set_flood_scope = AsyncMock(return_value=None)
|
||||||
|
mc.commands.send = AsyncMock(return_value=None)
|
||||||
mc._reader = MagicMock()
|
mc._reader = MagicMock()
|
||||||
mc._reader.handle_rx = AsyncMock()
|
mc._reader.handle_rx = AsyncMock()
|
||||||
mc.start_auto_message_fetching = AsyncMock()
|
mc.start_auto_message_fetching = AsyncMock()
|
||||||
|
|||||||
@@ -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) == ""
|
||||||
@@ -26,6 +26,7 @@ from app.routers.messages import (
|
|||||||
send_direct_message,
|
send_direct_message,
|
||||||
)
|
)
|
||||||
from app.services import dm_ack_tracker
|
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
|
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.self_info = {"name": name}
|
||||||
mc.commands = MagicMock()
|
mc.commands = MagicMock()
|
||||||
mc.commands.set_flood_scope = AsyncMock(return_value=_make_radio_result())
|
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_msg = AsyncMock(return_value=_make_radio_result())
|
||||||
mc.commands.send_chan_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())
|
mc.commands.add_contact = AsyncMock(return_value=_make_radio_result())
|
||||||
@@ -662,6 +664,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
with (
|
with (
|
||||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
|
patch.object(radio_manager, "firmware_ver_code", 13),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
request = SendChannelMessageRequest(
|
request = SendChannelMessageRequest(
|
||||||
@@ -669,10 +672,10 @@ class TestOutgoingChannelBroadcast:
|
|||||||
)
|
)
|
||||||
await send_channel_message(request)
|
await send_channel_message(request)
|
||||||
|
|
||||||
# Apply the region, then restore the (empty) baseline.
|
# Apply the region, then restore the empty baseline via explicit unscoped mode.
|
||||||
assert mc.commands.set_flood_scope.await_args_list == [
|
assert mc.commands.set_flood_scope.await_args_list == [call("#Region")]
|
||||||
call("#Region"),
|
assert mc.commands.send.await_args_list == [
|
||||||
call(""),
|
call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -687,6 +690,7 @@ class TestOutgoingChannelBroadcast:
|
|||||||
with (
|
with (
|
||||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
|
patch.object(radio_manager, "firmware_ver_code", 13),
|
||||||
patch("app.routers.messages.broadcast_event"),
|
patch("app.routers.messages.broadcast_event"),
|
||||||
):
|
):
|
||||||
request = SendChannelMessageRequest(
|
request = SendChannelMessageRequest(
|
||||||
@@ -694,11 +698,38 @@ class TestOutgoingChannelBroadcast:
|
|||||||
)
|
)
|
||||||
await send_channel_message(request)
|
await send_channel_message(request)
|
||||||
|
|
||||||
# Explicit unscoped: set empty scope, then restore the global baseline.
|
# Explicit unscoped uses firmware mode 1, then restores the global baseline.
|
||||||
assert mc.commands.set_flood_scope.await_args_list == [
|
assert mc.commands.send.await_args_list == [
|
||||||
call(""),
|
call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
|
||||||
call("#Baseline"),
|
|
||||||
]
|
]
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_channel_msg_aborts_when_override_apply_fails(self, test_db):
|
async def test_send_channel_msg_aborts_when_override_apply_fails(self, test_db):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from meshcore import EventType
|
||||||
|
|
||||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert
|
from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert
|
||||||
from app.repository import AppSettingsRepository, ContactRepository
|
from app.repository import AppSettingsRepository, ContactRepository
|
||||||
@@ -16,6 +17,7 @@ from app.routers.settings import (
|
|||||||
toggle_tracked_telemetry,
|
toggle_tracked_telemetry,
|
||||||
update_settings,
|
update_settings,
|
||||||
)
|
)
|
||||||
|
from app.services.flood_scope import FORCE_UNSCOPED_FRAME
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateSettings:
|
class TestUpdateSettings:
|
||||||
@@ -140,6 +142,7 @@ class TestUpdateSettings:
|
|||||||
mock_rm = AsyncMock()
|
mock_rm = AsyncMock()
|
||||||
mock_rm.is_connected = True
|
mock_rm.is_connected = True
|
||||||
mock_rm.meshcore = mock_mc
|
mock_rm.meshcore = mock_mc
|
||||||
|
mock_rm.firmware_ver_code = 13 # supports mode-1 unscoped
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -166,6 +169,7 @@ class TestUpdateSettings:
|
|||||||
mock_rm = AsyncMock()
|
mock_rm = AsyncMock()
|
||||||
mock_rm.is_connected = True
|
mock_rm.is_connected = True
|
||||||
mock_rm.meshcore = mock_mc
|
mock_rm.meshcore = mock_mc
|
||||||
|
mock_rm.firmware_ver_code = 13 # supports mode-1 unscoped
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -178,7 +182,10 @@ class TestUpdateSettings:
|
|||||||
with patch("app.radio.radio_manager", mock_rm):
|
with patch("app.radio.radio_manager", mock_rm):
|
||||||
await update_settings(AppSettingsUpdate(flood_scope=""))
|
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:
|
class TestToggleFavorite:
|
||||||
|
|||||||
Reference in New Issue
Block a user