mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-03 23:42:56 +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
|
||||
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
|
||||
|
||||
+24
-5
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user