Files
meshcore-bot/modules/flood_scope.py
T
agessaman 9b29ba4261 refactor(flood_scope): share the config lookups behind every scope resolver
MeshMessage.effective_outgoing_flood_scope, CommandManager's per-channel and override readers and its config_section branch, and BaseServicePlugin.get_mesh_flood_scope each scanned config.ini themselves. modules/flood_scope.py now owns those reads: channel_scope_entry (stripped flood_scope.<channel>, None with no entry), outgoing_override, and section_flood_scope (normalized, None when empty). Callers keep their own contracts on top: MeshMessage still reports raw values, CommandManager still normalizes, and None still means 'apply the override later' in resolve_channel_send_scope. MeshMessage.is_global_flood_scope delegates to is_global_marker, the identical predicate. The scope matrix golden file is unchanged.
2026-10-01 01:59:03 -07:00

195 lines
8.0 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
from typing import Any
# 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
# ── Config lookups shared by every send path ───────────────────────────────
#
# These return what config.ini says, not a decision. Callers keep their own
# contracts on top: MeshMessage reports raw values, CommandManager normalizes
# them, and the send path falls back to the override last.
def normalize_channel_for_scope(channel: str) -> str:
"""Channel name as matched against ``flood_scope.<channel>`` keys."""
return channel.strip().removeprefix("#").lower()
def channel_scope_entry(config: Any, channel: str | None) -> str | None:
"""Stripped value of ``[Channels] flood_scope.<channel>``, or None with no entry.
Matching ignores case and a leading ``#`` on both sides. An entry may hold a
global marker; that is returned as-is (an explicit "send this channel
globally").
"""
if not channel or not config.has_section("Channels"):
return None
channel_key = normalize_channel_for_scope(channel)
for key, value in config.items("Channels"):
if key.startswith("flood_scope.") and normalize_channel_for_scope(key[len("flood_scope."):]) == channel_key:
return (value or "").strip()
return None
def outgoing_override(config: Any) -> str:
"""Stripped ``[Channels] outgoing_flood_scope_override``, or ``""`` when unset."""
if config.has_section("Channels") and config.has_option("Channels", "outgoing_flood_scope_override"):
return (config.get("Channels", "outgoing_flood_scope_override") or "").strip()
return ""
def section_flood_scope(config: Any, section: str | None) -> str | None:
"""Normalized ``flood_scope`` from a plugin/service section, or None when unset or empty."""
if not section or not config.has_section(section):
return None
raw = (config.get(section, "flood_scope", fallback="") or "").strip()
return normalize_scope_name(raw) if raw else None