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

Fix unscoped flood-scope handling
This commit is contained in:
Jack Kingsman
2026-07-08 15:51:50 -07:00
committed by GitHub
20 changed files with 432 additions and 55 deletions
+6 -1
View File
@@ -343,7 +343,12 @@ class Channel(BaseModel):
on_radio: bool = False
flood_scope_override: str | None = Field(
default=None,
description="Per-channel outbound flood scope override (null = use global app setting)",
description=(
"Per-channel outbound flood scope override, tri-state: null = inherit the "
"global app setting; '*' (UNSCOPED_OVERRIDE_MARKER) = force unscoped/plain "
"flood even over a scoped global; a region name (e.g. '#Esperance') = scope "
"this channel."
),
)
path_hash_mode_override: int | None = Field(
default=None,
+4
View File
@@ -171,6 +171,9 @@ class RadioManager:
self.device_model: str | None = None
self.firmware_build: str | None = None
self.firmware_version: str | None = None
# Companion protocol version (FIRMWARE_VER_CODE). Gates version-dependent
# host commands such as the mode-1 unscoped flood-scope frame (ver 12+).
self.firmware_ver_code: int | None = None
self.max_channels: int = 40
self.path_hash_mode: int = 0
self.path_hash_mode_supported: bool = False
@@ -217,6 +220,7 @@ class RadioManager:
self.device_model = None
self.firmware_build = None
self.firmware_version = None
self.firmware_ver_code = None
self.max_channels = 40
self.path_hash_mode = 0
self.path_hash_mode_supported = False
+24 -5
View File
@@ -1,19 +1,38 @@
"""Helpers for normalizing MeshCore flood-scope / region names."""
# Canonical persisted marker for "force unscoped/plain flood". Stored verbatim in
# the per-channel ``flood_scope_override`` column to mean "this channel is unscoped
# even if a global region is set" — distinct from NULL, which means "inherit global".
UNSCOPED_OVERRIDE_MARKER = "*"
# All values that denote explicit unscoped/plain flood, matching firmware parity.
_UNSCOPED_SENTINELS = {"", "0", UNSCOPED_OVERRIDE_MARKER}
def is_unscoped(scope: str | None) -> bool:
"""True if ``scope`` denotes an explicit unscoped/plain-flood request.
Note: an empty string counts as unscoped here. Callers that need to treat
blank as "no opinion / inherit" (e.g. the channel-override API) must check for
blank *before* calling this.
"""
return (scope or "").strip() in _UNSCOPED_SENTINELS
def normalize_region_scope(scope: str | None) -> str:
"""Normalize a user-facing region scope into MeshCore's internal form.
Region names are now user-facing plain strings like ``Esperance``.
Internally, MeshCore still expects hashtag-style names like ``#Esperance``.
Region names are user-facing plain strings like ``Esperance``. Internally,
MeshCore still expects hashtag-style names like ``#Esperance``.
Backward compatibility:
- blank/None stays disabled (`""`)
Backward compatibility / firmware parity:
- blank/None stays unscoped (``""``)
- ``"0"`` and ``"*"`` also mean explicit unscoped/plain flood
- existing leading ``#`` is preserved
"""
stripped = (scope or "").strip()
if not stripped:
if stripped in _UNSCOPED_SENTINELS:
return ""
if stripped.startswith("#"):
return stripped
+21 -3
View File
@@ -13,7 +13,7 @@ from app.channel_constants import (
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.packet_processor import create_message_from_decrypted
from app.region_scope import normalize_region_scope
from app.region_scope import UNSCOPED_OVERRIDE_MARKER, is_unscoped, normalize_region_scope
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_event, broadcast_success
@@ -55,7 +55,13 @@ class BulkCreateHashtagChannelsResponse(BaseModel):
class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope"
description=(
"Tri-state channel override. Blank clears the override (inherit the global "
"scope); '*' forces unscoped/plain flood even when a global region is set; "
"any other value scopes the channel to that region. Note the deliberate "
"asymmetry vs. the send layer: here blank means 'inherit', so an explicit "
"unscoped request must use '*'."
)
)
@@ -348,7 +354,19 @@ async def set_channel_flood_scope_override(
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
override = normalize_region_scope(request.flood_scope_override) or None
# Tri-state persisted override:
# blank -> None: clear the override, inherit the global scope
# "*" / "0" -> canonical unscoped marker: force unscoped even over a global
# region name -> "#Region": scope this channel
# NOTE: at this (channel-override) layer blank means "clear/inherit", so we must
# check for blank *before* is_unscoped() (which also treats "" as unscoped).
raw_override = (request.flood_scope_override or "").strip()
if raw_override == "":
override: str | None = None
elif is_unscoped(raw_override):
override = UNSCOPED_OVERRIDE_MARKER
else:
override = normalize_region_scope(raw_override)
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
+4 -1
View File
@@ -282,13 +282,16 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
# Apply flood scope to radio immediately if changed
if flood_scope_changed:
from app.services.flood_scope import set_radio_flood_scope
from app.services.radio_runtime import radio_runtime as radio_manager
if radio_manager.is_connected:
try:
scope = result.flood_scope
async with radio_manager.radio_operation("set_flood_scope") as mc:
await mc.commands.set_flood_scope(scope if scope else "")
await set_radio_flood_scope(
mc, scope, fw_ver=radio_manager.firmware_ver_code
)
logger.info("Applied flood_scope=%r to radio", scope or "(disabled)")
except Exception as e:
logger.warning("Failed to apply flood_scope to radio: %s", e)
+60
View File
@@ -0,0 +1,60 @@
"""Firmware-compatible flood-scope command helpers."""
import logging
from typing import Any
from meshcore import EventType
from meshcore.packets import CommandType
from app.region_scope import normalize_region_scope
logger = logging.getLogger(__name__)
SET_FLOOD_SCOPE_MODE_UNSCOPED = 1
FORCE_UNSCOPED_FRAME = bytes([CommandType.SET_FLOOD_SCOPE.value, SET_FLOOD_SCOPE_MODE_UNSCOPED])
# CMD_SET_FLOOD_SCOPE_KEY mode 1 (the firmware ``send_unscoped`` flag) is companion
# firmware ver 12+. On older firmware the mode-1 frame is rejected, so we fall back
# to resetting the scope override (mode 0, zero key), which makes the radio use its
# configured default scope. True "unscoped while a default scope is set" is not
# achievable pre-v12.
FIRMWARE_VER_UNSCOPED_MODE = 12
def firmware_supports_unscoped_mode(fw_ver: int | None) -> bool:
"""Whether the radio's protocol version supports the mode-1 unscoped command."""
return fw_ver is not None and fw_ver >= FIRMWARE_VER_UNSCOPED_MODE
async def set_radio_flood_scope(mc, scope: str | None, *, fw_ver: int | None = None) -> Any:
"""Apply the standing radio flood-scope state.
A non-empty scope is delegated to meshcore_py's mode-0 ``set_flood_scope``. An
empty scope means explicit unscoped/plain flood:
- firmware >= 12: use the dedicated mode-1 command (``force_radio_unscoped``).
- older / unknown firmware: no dedicated unscoped command exists, so reset the
scope override (mode 0, zero key); the radio falls back to its configured
default scope. ``fw_ver=None`` (version unknown) is treated conservatively as
unsupported so we never emit a frame the radio might reject.
"""
normalized_scope = normalize_region_scope(scope)
if normalized_scope:
return await mc.commands.set_flood_scope(normalized_scope)
if firmware_supports_unscoped_mode(fw_ver):
return await force_radio_unscoped(mc)
logger.debug(
"Radio fw_ver=%s < %d: no dedicated unscoped command; resetting scope override "
"(radio falls back to its configured default scope)",
fw_ver,
FIRMWARE_VER_UNSCOPED_MODE,
)
return await mc.commands.set_flood_scope("")
async def force_radio_unscoped(mc) -> Any:
"""Tell the radio to send following flood packets unscoped until mode 0."""
return await mc.commands.send(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
+21 -6
View File
@@ -11,7 +11,7 @@ from meshcore import EventType
from app.models import ResendChannelMessageResponse
from app.radio import RadioOperationBusyError
from app.region_scope import normalize_region_scope
from app.region_scope import is_unscoped, normalize_region_scope
from app.repository import (
AppSettingsRepository,
ChannelRepository,
@@ -19,6 +19,7 @@ from app.repository import (
MessageRepository,
)
from app.services import dm_ack_tracker
from app.services.flood_scope import set_radio_flood_scope
from app.services.messages import (
BroadcastFn,
broadcast_message,
@@ -160,8 +161,20 @@ async def send_channel_message_with_effective_scope(
back to the channel's persisted override.
"""
if isinstance(flood_scope_override, _ScopeUnset):
desired_scope = normalize_region_scope(channel.flood_scope_override)
scope_explicit = False
# Fall back to the channel's persisted override, which is tri-state:
# None -> inherit the global scope (leave radio untouched)
# unscoped marker ("*") -> force unscoped even over a scoped global
# region name -> scope this channel
channel_override = channel.flood_scope_override
if channel_override is None:
desired_scope = ""
scope_explicit = False
elif is_unscoped(channel_override):
desired_scope = ""
scope_explicit = True
else:
desired_scope = normalize_region_scope(channel_override)
scope_explicit = True
else:
desired_scope = normalize_region_scope(flood_scope_override)
scope_explicit = True
@@ -184,7 +197,9 @@ async def send_channel_message_with_effective_scope(
desired_scope or "(unscoped)",
channel.name,
)
override_result = await mc.commands.set_flood_scope(desired_scope)
override_result = await set_radio_flood_scope(
mc, desired_scope, fw_ver=radio_manager.firmware_ver_code
)
if override_result is not None and override_result.type == EventType.ERROR:
logger.warning(
"Failed to apply flood_scope %r for %s: %s",
@@ -313,8 +328,8 @@ async def send_channel_message_with_effective_scope(
restored = False
for attempt in range(3):
try:
restore_result = await mc.commands.set_flood_scope(
baseline_scope if baseline_scope else ""
restore_result = await set_radio_flood_scope(
mc, baseline_scope, fw_ver=radio_manager.firmware_ver_code
)
if restore_result is not None and restore_result.type == EventType.ERROR:
logger.warning(
+19 -13
View File
@@ -67,19 +67,6 @@ async def run_post_connect_setup(radio_manager) -> None:
# Sync radio clock with system time
await sync_radio_time(mc)
# Apply flood scope from settings (best-effort; older firmware
# may not support set_flood_scope)
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository
app_settings = await AppSettingsRepository.get()
scope = normalize_region_scope(app_settings.flood_scope)
try:
await mc.commands.set_flood_scope(scope if scope else "")
logger.info("Applied flood_scope=%r", scope or "(disabled)")
except Exception as exc:
logger.warning("set_flood_scope failed (firmware may not support it): %s", exc)
# Query path hash mode support (best-effort; older firmware won't report it).
# If the library's parsed payload is missing path_hash_mode (e.g. stale
# .pyc on WSL2 Windows mounts), fall back to raw-frame extraction.
@@ -100,6 +87,7 @@ async def run_post_connect_setup(radio_manager) -> None:
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.firmware_ver_code = None
radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
@@ -127,6 +115,7 @@ async def run_post_connect_setup(radio_manager) -> None:
payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3
if payload_reports_device_info:
radio_manager.device_info_loaded = True
radio_manager.firmware_ver_code = fw_ver
if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int):
radio_manager.path_hash_mode = payload["path_hash_mode"]
@@ -140,6 +129,7 @@ async def run_post_connect_setup(radio_manager) -> None:
fw_ver = raw[1] if len(raw) > 1 else 0
if fw_ver >= 3:
radio_manager.device_info_loaded = True
radio_manager.firmware_ver_code = fw_ver
if radio_manager.max_contacts is None and len(raw) >= 3:
radio_manager.max_contacts = max(1, raw[2] * 2)
if len(raw) >= 4 and not isinstance(payload_max_channels, int):
@@ -205,6 +195,22 @@ async def run_post_connect_setup(radio_manager) -> None:
finally:
reader.handle_rx = _original_handle_rx
# Apply flood scope from settings (best-effort; older firmware may
# not support the mode-1 unscoped command). Done after the device
# query so radio_manager.firmware_ver_code is known and the unscoped
# path can pick the correct firmware command.
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository
from app.services.flood_scope import set_radio_flood_scope
app_settings = await AppSettingsRepository.get()
scope = normalize_region_scope(app_settings.flood_scope)
try:
await set_radio_flood_scope(mc, scope, fw_ver=radio_manager.firmware_ver_code)
logger.info("Applied flood_scope=%r", scope or "(disabled)")
except Exception as exc:
logger.warning("Failed to apply configured flood scope to radio: %s", exc)
from app.config import settings as app_settings_config
if app_settings_config.skip_post_connect_sync: