Files
meshcore-bot/modules/flood_scope.py
T
agessaman 16cce92c46 feat(region): set region scopes from the Radio page (#283)
Region scopes had to be hand-edited in config.ini, and the radio's own default
region could not be set from the bot at all. Both now live on the Radio page.

Node Settings gains a Default Region Scope field beside the path hash size,
which is the firmware's own setting — NodePrefs.default_scope_name and
default_scope_key, the region the radio falls back to for any send the bot does
not scope itself. Firmware that has no such command is reported as not having
answered rather than shown as an empty field, and a stored key that is not the
stored name's hash is flagged, because the radio routes by the key and the name
beside it is only a label. A build-flag default stores the bare name while
hashing the '#' form, so the comparison normalizes first.

Clearing it does not go through the meshcore library, because none of its reset
paths can: set_default_flood_scope(None) raises on len(None), "" earns
ILLEGAL_ARG from the firmware, and "*" only works because the frame is padded by
character count rather than by the name it wrote, landing one byte short of the
length the firmware reads as "a scope follows". The firmware's own contract for
clearing is a bare CMD_SET_DEFAULT_FLOOD_SCOPE, so that is what goes out. The
same padding bug is why a device scope name must be ASCII: a multi-byte
character displaces the transport key out of its field.

A separate Region Scopes card covers the bot's side — [Channels] flood_scopes as
an explicit choice between "reply whatever the scope" and an allowlist, and
outgoing_flood_scope_override. It writes config.ini, queues a hot reload, then
polls that reload and reports what the bot did with it, including saying plainly
when nothing picked it up. Per-channel flood_scope.<channel> entries are listed
read-only, since they are the reason a channel can ignore the default.

The two settings interact in a way worth stating on the page: once the bot sends
a scoped message it restores with set_flood_scope("*"), which leaves the radio in
forced-unscoped mode, so the device default stops applying until the bot scopes
another send.

Also from building it:

- flood_scopes left in [Bot] is surfaced rather than hidden. CommandManager
  still honours it when [Channels] is empty, so a page that ignored it would
  show "replies to every scope" while the bot enforced an allowlist — and
  saving "off" hands the allowlist straight back to [Bot], which the banner now
  says instead of claiming the old key stops mattering.
- outgoing_flood_scope_override = none is read as global flood on the send path,
  as it already was everywhere else. send_channel_message tested the raw value
  against a fixed tuple, so the lowercase spelling became the region "#none".
- Scope normalization moved to modules/flood_scope.py so the viewer, a separate
  process, can validate a typed name without importing the bot's command
  machinery. CommandManager delegates to it.
- Dark mode named only .border, so a .border-start divider drew Bootstrap's
  light #dee2e6 onto a dark card. All four directional utilities now match.
2026-09-16 07:59:10 -07:00

151 lines
6.1 KiB
Python

"""Regional flood scope ("region code") names, shared by the bot and the viewer.
A MeshCore client with a region set sends channel messages as TC_FLOOD, whose
transport code confines them to repeaters holding that region's key. The key is
derived from the scope *name*, so the name is the whole setting: ``west`` and
``#west`` are the same region, and everything hashes the leading-``#`` form.
``[Channels] flood_scopes`` and ``[Channels] outgoing_flood_scope_override``
are edited from the web viewer, which runs in its own process and must not
import the bot's command machinery to check a name the operator typed. So the
normalization the bot has always used, and the rules for what survives a trip
through ``config.ini``, live here and both sides call in.
"""
from __future__ import annotations
from hashlib import sha256
# Scope values that mean "no region" — an ordinary FLOOD every repeater
# rebroadcasts. Empty is how config.ini spells "unset".
GLOBAL_MARKERS = ("", "*", "0", "None")
# A scope name is hashed whole, so there is no protocol limit; this is a sanity
# bound on what the UI will write into a comma-separated INI value.
MAX_SCOPE_NAME_LENGTH = 64
# The companion firmware stores its own default scope in a fixed 31-byte field
# and rejects a name that does not fit with its terminator
# (NodePrefs.default_scope_name, and the n > 0 && n < 31 check in
# CMD_SET_DEFAULT_FLOOD_SCOPE). That is 30 bytes of name, '#' included.
MAX_DEVICE_SCOPE_NAME_LENGTH = 30
# Characters that do not survive the round trip. ',' separates flood_scopes
# entries; '%' is read as an interpolation token by every later configparser
# read of the section; a newline ends the key's line and re-parses the rest as
# INI. '#' is the scope marker itself and only belongs at the front.
_FORBIDDEN_IN_NAME = {",", "%", "#"}
def normalize_scope_name(scope: str) -> str:
"""Return scope with '#' prepended if it is a non-global named region without one."""
if scope in GLOBAL_MARKERS or scope.lower() == "none":
if scope.lower() == "none":
return "None"
return scope
if not scope.startswith("#"):
return "#" + scope
return scope
def is_global_marker(scope: str) -> bool:
"""True when ``scope`` means global flood rather than a named region."""
return scope in GLOBAL_MARKERS or scope.lower() == "none"
def validate_scope_name(scope: str) -> str:
"""Return the canonical form of one scope, or raise ``ValueError``.
Global markers come back canonicalised (``none`` → ``None``); a named
region comes back with its leading ``#``.
"""
raw = str(scope or "").strip()
if is_global_marker(raw):
return normalize_scope_name(raw)
normalized = normalize_scope_name(raw)
body = normalized[1:]
if not body.strip():
raise ValueError("A region scope needs a name after the '#'")
if len(normalized) > MAX_SCOPE_NAME_LENGTH:
raise ValueError(
f"Region scope {normalized!r} is longer than {MAX_SCOPE_NAME_LENGTH} characters"
)
for char in body:
if ord(char) < 32 or ord(char) == 127:
raise ValueError(f"Region scope {raw!r} contains a control character")
if char in _FORBIDDEN_IN_NAME:
raise ValueError(
f"Region scope {raw!r} cannot contain {char!r} — "
"',' separates scopes, '%' breaks config.ini, and '#' only leads"
)
return normalized
def parse_scope_list(raw: str) -> list[str]:
"""Split a comma-separated ``flood_scopes`` value into its raw entries."""
return [part.strip() for part in str(raw or "").split(",") if part.strip()]
def split_allowlist(raw: str) -> tuple[list[str], bool]:
"""Return ``(named_scopes, allow_global)`` for a ``flood_scopes`` value.
Mirrors ``CommandManager._load_flood_scope_keys``: global markers do not
become keys, they just opt unscoped FLOOD through the allowlist. Duplicates
collapse so the UI shows the list the bot actually matches against.
"""
named: list[str] = []
allow_global = False
for entry in parse_scope_list(raw):
normalized = normalize_scope_name(entry)
if is_global_marker(normalized):
allow_global = True
elif normalized not in named:
named.append(normalized)
return named, allow_global
def format_scope_list(scopes: list[str] | tuple[str, ...]) -> str:
"""Join scopes into the comma-separated form ``flood_scopes`` expects."""
return ", ".join(scopes)
def scope_key_hex(scope: str) -> str:
"""The 16-byte transport key a named scope hashes to, as hex.
SHA-256 over the '#'-prefixed name, truncated to 16 bytes — the same
derivation as the firmware's TransportKeyStore::getAutoKeyFor and the
meshcore library, so a key read back from a radio can be compared with the
name stored beside it.
"""
normalized = normalize_scope_name(str(scope or "").strip())
if is_global_marker(normalized):
return ""
return sha256(normalized.encode("utf-8")).hexdigest()[:32]
def validate_device_scope_name(scope: str) -> str:
"""Return the canonical form of a scope the radio can store, or raise.
Stricter than :func:`validate_scope_name` on two counts, both of them the
firmware's and the library's, not ours: the name has to fit the radio's
31-byte field, and it has to be ASCII. The library pads the name frame by
character count while encoding it as UTF-8, so a multi-byte character
pushes the transport key past the field it belongs in and the radio stores
a key that is not the one the name hashes to.
"""
canonical = validate_scope_name(scope)
if is_global_marker(canonical):
return canonical
encoded = canonical.encode("utf-8")
if not canonical.isascii():
raise ValueError(
f"Region scope {canonical!r} must be ASCII to be stored on the radio"
)
if len(encoded) > MAX_DEVICE_SCOPE_NAME_LENGTH:
raise ValueError(
f"Region scope {canonical!r} is longer than "
f"{MAX_DEVICE_SCOPE_NAME_LENGTH} characters, which is all the radio stores"
)
return canonical