Files
Jean-Pierre MartineauandClaude Opus 5.5 ffe31dac36 Docstrings and comments of meshterm/core are in ASD-STE100
The prose of each docstring and comment in the core package now follows
ASD-STE100 Simplified Technical English, with Canadian spelling: short
sentences, simple verb tenses, no dashes or semicolons, and one word for
one concept (docs/development/writing-style.md has the rules and the
glossary). The meaning and each reason stay. The code does not change:
the AST of each file, without its docstrings, is the same as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:29:03 -04:00

322 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Region scopes: the maths that connects a flood to the region into which it was sent.
A MeshCore repeater can get a list of the *regions* that it carries (``region put yul``,
``region allowf yul``). When a flood is *scoped* to a region, only the repeaters that
carry that region relay it. The scope is in the packet as a **transport code**. A
``TRANSPORT_FLOOD`` packet (route type 0, which the library calls ``TC_FLOOD``) has two
little-endian ``uint16`` codes between its header and its path. The first code is
HMAC-SHA256(region key, payload_type_byte || payload)[0:2]
with ``0x0000`` and ``0xFFFF`` reserved (they become ``0x0001`` and ``0xFFFE``). The
second code is reserved, and at this time it is always zero. The key of a region is the
first 16 bytes of ``SHA-256("#" + name)``, with the name as its UTF-8 bytes. The firmware
stores names bare, and puts the ``#`` at the start when it derives the key
(``RegionMap::getTransportKeysFor``, ``TransportKeyStore``). Each companion app does the
same.
Two results of this design have an effect on all the code that uses this module:
* **A code names no region.** It is a keyed hash of the payload of the packet. Thus the
only way to find the region of a scoped packet is to try the known region names, and to
find the name whose key makes the same code (:func:`resolve`). If a packet is scoped to
a region that nobody here has named, it shows as *scoped, region unknown*. This result
is honest, and it stays until somebody here names that region.
* **The code does not change on the way.** The hash includes neither the header nor the
path. Thus the copy of each relay carries the code of the sender without a change. One
message has one scope, also when it arrived by many different relays.
A repeater never filters a direct packet by region. Thus a scope is important only for a
flood. The codes of a ``TC_DIRECT`` packet are the ``{0, 0}`` "to nowhere" pair that a
contact share uses, not a region.
This module sets the words, because the UI uses them everywhere. A **region** is a named
area that a repeater carries. The **scope** of a flood is the region to which the flood
is limited. A plain flood is **unscoped**. The region list of a repeater shows this as
the wildcard ``*``.
"""
from __future__ import annotations
import hashlib
import hmac
from collections.abc import Iterable
from dataclasses import dataclass
#: The wildcard: "every region", that is, unscoped. Repeaters list it in this form, and
#: the explicit unscoped override of the send scope takes it in this form.
WILDCARD = "*"
#: The longest region name, in UTF-8 bytes. The name slot of the firmware is 31 bytes with
#: the terminating NUL (``RegionEntry.name[31]``, and the 31-byte field for the default
#: scope in the companion protocol). Also, ``CMD_SET_DEFAULT_FLOOD_SCOPE`` refuses a
#: length outside 1–30.
MAX_NAME_BYTES = 30
#: The width of the transport key of a region, in bytes.
KEY_BYTES = 16
#: The route types that carry transport codes, with the names that the meshcore library
#: uses (``ROUTE_TYPENAMES``): a scoped flood and its direct equivalent.
SCOPED_ROUTES = frozenset({"TC_FLOOD", "TC_DIRECT"})
#: The only route types that a repeater filters by region: floods, scoped or not. A
#: repeater never filters a direct packet by region, so a direct packet has no scope to
#: show.
FLOOD_ROUTES = frozenset({"FLOOD", "TC_FLOOD"})
class RegionNameError(ValueError):
"""A region name that the firmware refuses, with a reason that the user can act on."""
def normalize(name: str) -> str:
"""A region name as the firmware stores and shows it: trimmed, with no ``#`` at the start.
Apps and older firmware wrote names as ``#yul``. From firmware 1.12, the names are
stored bare, and the ``#`` is only for the key derivation. The case is kept, because
the hash is over the exact bytes. Thus ``YUL`` and ``yul`` are two different regions.
Args:
name: The name as the user typed it, as a repeater listed it, or as it was read
from a device.
Returns:
The bare name (it can be empty).
"""
return name.strip().removeprefix("#").strip()
def validate(name: str) -> str:
"""Normalize a region name, and refuse a name that the firmware refuses.
Args:
name: The name as typed.
Returns:
The bare, valid name.
Raises:
RegionNameError: If the name is empty, too long, private, or the wildcard.
"""
bare = normalize(name)
if not bare:
raise RegionNameError("a region needs a name")
if bare == WILDCARD:
raise RegionNameError("* is every region — leave the scope empty for unscoped")
if bare.startswith("$"):
# A private region gets its key from a hardware keystore that the firmware does
# not have yet (``RegionMap::loadKeysFor`` is a TODO). Thus no repeater can match
# a private region.
raise RegionNameError("$ regions are private and no firmware can match one yet")
if len(bare.encode("utf-8")) > MAX_NAME_BYTES:
raise RegionNameError(f"a region name is at most {MAX_NAME_BYTES} bytes")
if any(ch.isspace() or ch == "," for ch in bare):
# A repeater lists its regions with commas between them, and it splits its CLI
# input at whitespace. Thus a repeater can store a name that has a comma or a
# space, but it can never list that name or address it again.
raise RegionNameError("a region name has no spaces or commas")
return bare
def region_key(name: str) -> bytes:
"""The 16-byte transport key of a region, as the firmware derives it.
Args:
name: The region name, with or without the ``#`` at its start.
Returns:
``SHA-256("#" + bare name)[:16]``, with the name as UTF-8.
"""
return hashlib.sha256(("#" + normalize(name)).encode("utf-8")).digest()[:KEY_BYTES]
def transport_code(key: bytes, body: bytes) -> int:
"""The transport code that a packet carries when it is scoped under ``key``.
Args:
key: The 16-byte transport key of the region (:func:`region_key`).
body: The HMAC input: the payload type byte, then the payload
(:func:`scope_body`).
Returns:
The 16-bit code. If the code is one of the two reserved values, it moves one step
away from that value.
"""
code = int.from_bytes(hmac.new(key, body, hashlib.sha256).digest()[:2], "little")
if code == 0x0000:
return 0x0001
if code == 0xFFFF:
return 0xFFFE
return code
def scope_body(payload_type: int, payload: bytes) -> bytes:
"""The bytes from which a transport code is calculated: the payload type, then the payload.
Args:
payload_type: The 4-bit payload type of the header (``GRP_TXT`` is 5, ``ADVERT``
4, …).
payload: The payload of the packet: all the bytes after the path.
Returns:
``bytes([payload_type]) + payload``.
"""
return bytes([payload_type & 0x0F]) + bytes(payload)
def parse_codes(hex4: str | None) -> tuple[int, int] | None:
"""Split the 4-byte transport-codes field of a packet into its two little-endian codes.
Args:
hex4: The field as the meshcore library gives it (``transport_code``, 8 hex
digits), or ``None`` when the packet had no such field.
Returns:
``(code0, code1)``, or ``None`` for a field that is absent or malformed.
"""
if not hex4:
return None
try:
raw = bytes.fromhex(str(hex4))
except ValueError:
return None
if len(raw) != 4:
return None
return int.from_bytes(raw[0:2], "little"), int.from_bytes(raw[2:4], "little")
@dataclass(frozen=True, slots=True)
class Scope:
"""What a received packet tells about the region into which it was flooded.
Attributes:
state: ``"unscoped"``: a plain flood that each repeater can relay. ``"scoped"``:
a scoped flood whose region is :attr:`region`. ``"unknown"``: a scoped flood
whose code matches no region that MeshTerm knows.
region: The bare region name for a resolved scope, else ``None``.
code: The first transport code of the packet (4 lowercase hex digits), for a
scoped packet.
"""
state: str
region: str | None = None
code: str | None = None
@property
def scoped(self) -> bool:
"""Whether the flood was scoped, resolved or not."""
return self.state != "unscoped"
#: The only unscoped answer. All callers share it, because a Scope is immutable.
UNSCOPED = Scope("unscoped")
def resolve(names: Iterable[str], body: bytes, code: int) -> str | None:
"""The first known region whose key makes ``code`` from ``body``.
Two different regions can have the same 16-bit code for one payload (one chance in
65 534 for each pair). The firmware has the same ambiguity: a repeater that carries
both regions relays the packet under the region that it lists first. Here also, the
answer is the first match in the order of ``names``. The callers put the most probable
names first.
Args:
names: The candidate region names (bare or with a ``#`` prefix).
body: The HMAC input (:func:`scope_body`).
code: The first transport code of the packet.
Returns:
The bare name that matches, or ``None``.
"""
for name in names:
bare = normalize(name)
if bare and bare != WILDCARD and transport_code(region_key(bare), body) == code:
return bare
return None
def frame_scope(raw: dict, names: Iterable[str]) -> Scope | None:
"""The scope of the raw payload of a received packet, or ``None`` if it has no scope.
The function reads the route type and the transport codes that the meshcore library
parses from an RX-log packet (or that the repository restores for a replayed packet).
It also reads the HMAC input. The HMAC input is made live from
``payload_type``/``pkt_payload``, or restored as ``scope_body``.
Args:
raw: The raw payload mapping of the packet.
names: The region names against which to resolve a scoped packet.
Returns:
:data:`UNSCOPED` for a plain flood, a scoped :class:`Scope` for a transport flood,
and ``None`` for a direct packet or a packet whose route type was never kept. A
repeater never filters these packets by region, so they have no scope to show.
"""
route = str(raw.get("route_typename") or "").upper()
if route == "FLOOD":
return UNSCOPED
if route != "TC_FLOOD":
return None
codes = parse_codes(raw.get("transport_code"))
if codes is None:
return Scope("unknown")
code0 = codes[0]
body = raw_scope_body(raw)
region = resolve(names, body, code0) if body is not None else None
return Scope("scoped" if region else "unknown", region, f"{code0:04x}")
def raw_scope_body(raw: dict) -> bytes | None:
"""The HMAC input for the raw payload of a packet, live or restored from the history.
Args:
raw: The raw payload mapping of the packet.
Returns:
The body bytes, or ``None`` when there are no live fields and no stored body.
"""
stored = raw.get("scope_body")
if stored:
try:
return bytes.fromhex(str(stored))
except ValueError:
return None
payload_type = raw.get("payload_type")
payload = raw.get("pkt_payload")
if payload_type is None or payload is None or int(payload_type) < 0:
return None
if isinstance(payload, str):
try:
payload = bytes.fromhex(payload)
except ValueError:
return None
return scope_body(int(payload_type), bytes(payload))
def parse_region_list(text: str | None) -> list[str]:
"""The regions that a repeater names in its reply to the ``REGIONS`` anonymous request.
The reply is a list of the regions for which the repeater relays floods, with commas
between them. If the repeater also relays unscoped floods, ``*`` comes first (firmware
``RegionMap::exportNamesTo``).
Args:
text: The text of the reply (the meshcore library removes the clock that comes
before it).
Returns:
The bare names in the given order. The wildcard stays as ``*``, and the function
removes blank names.
"""
if not text:
return []
names: list[str] = []
for part in str(text).replace("\x00", "").split(","):
bare = normalize(part)
if bare and bare not in names:
names.append(bare)
return names