Files
MeshTerm/meshtools/core/connection.py
T
Jean-Pierre MartineauandClaude Opus 4.8 eba264385b Add passive mesh monitor (longitudinal advert/telemetry logger)
Adds a Device.listen capability (real + mock), an Observation/HeardNode
domain model, an observations table with Repository.record_observation /
heard_nodes aggregation, and a `monitor` tool that listens for a window and
logs every overheard advert/telemetry (SNR, RSSI, shared location) to the
database. This is the longitudinal substrate later features read back.

The real-hardware event-payload mapping is best-effort and, like the trace
mapping, flagged for validation against the firmware's event schema; the mock
device synthesizes plausible adverts so the tool and tests run without a radio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:48:17 -04:00

1253 lines
50 KiB
Python

"""Device connection abstraction.
Services and tools depend only on the :class:`Device` interface, never on the
``meshcore`` library directly. This keeps the algorithms testable and lets the
:class:`MockDevice` simulator stand in for real hardware during development.
Two implementations are provided:
* :class:`MeshCoreDevice` - wraps the async ``meshcore`` companion-protocol client.
* :class:`MockDevice` - a deterministic simulator with a physically plausible
SNR-vs-TX-power response, used by ``--mock`` and the test suite.
"""
from __future__ import annotations
import asyncio
import random
from abc import ABC, abstractmethod
from typing import Callable, Optional
from .models import Contact, Hop, Observation, TraceResult
#: Callback invoked with each :class:`Observation` as passive monitoring captures it.
ObservationCallback = Callable[[Observation], None]
TX_POWER_MIN = 1
TX_POWER_MAX = 22
#: Default range explored when tuning a *remote* repeater's transmit power. Remote nodes
#: (e.g. high-gain repeaters) typically run hotter than the local companion, so this band
#: differs from the local ``TX_POWER_MIN``/``TX_POWER_MAX`` clamp. Both bounds are
#: user-configurable (see :class:`~meshtools.core.config.Settings`).
REMOTE_TX_MIN = 12
REMOTE_TX_MAX = 28
class DeviceCommandError(RuntimeError):
"""A device command failed in a recoverable, user-facing way.
Raised for conditions worth reporting cleanly (no traceback) — chiefly the
companion's intermittent failure to answer a query in time. Callers may retry.
"""
class Device(ABC):
"""Abstract companion device exposing the operations MeshTools needs.
Implementations manage their own connection lifecycle and translate raw protocol
events into the domain models in :mod:`meshtools.core.models`.
"""
@abstractmethod
async def connect(self) -> None:
"""Open the connection to the device. Idempotent."""
@abstractmethod
async def disconnect(self) -> None:
"""Close the connection and release resources. Idempotent."""
@abstractmethod
async def get_self_info(self) -> dict:
"""Return identity and radio configuration of the connected device.
Returns:
A dict with at least ``name`` and, when available, ``tx_power`` and radio
parameters (``freq``, ``bw``, ``sf``, ``cr``).
"""
@abstractmethod
async def get_contacts(self) -> list[Contact]:
"""Return the device's known contacts.
Returns:
The list of :class:`Contact` records currently stored on the device.
"""
@abstractmethod
async def get_tx_power(self) -> Optional[int]:
"""Return the current TX power level, or ``None`` if unknown."""
@abstractmethod
async def set_tx_power(self, value: int) -> None:
"""Set the radio transmit power.
Args:
value: TX power level, clamped by the caller to the device's valid range.
"""
# -- remote administration (tuning a node we have admin rights on) -----------
@abstractmethod
async def admin_login(self, node: Contact, password: str) -> bool:
"""Authenticate as administrator on a remote node.
Args:
node: The contact to log in to. Its ``public_key`` addresses the node.
password: The node's admin password.
Returns:
``True`` if the node accepted the login, ``False`` otherwise (e.g. a wrong
password or no response).
"""
@abstractmethod
async def get_remote_tx_power(self, node: Contact) -> Optional[int]:
"""Read a remote (admin) node's current transmit power.
Args:
node: The contact to query (must already be logged in).
Returns:
The node's TX power in dBm, or ``None`` if it could not be read.
"""
@abstractmethod
async def set_remote_tx_power(self, node: Contact, value: int) -> None:
"""Set a remote (admin) node's transmit power.
Args:
node: The contact to adjust (must already be logged in).
value: TX power in dBm.
Raises:
DeviceCommandError: If the node rejected the command.
"""
@abstractmethod
async def run_trace(
self,
target: str,
*,
path: Optional[str] = None,
timeout: float = 10.0,
) -> TraceResult:
"""Run a single path trace to ``target`` and return per-hop SNR.
Args:
target: Name or key prefix of the destination node.
path: Optional explicit path to force, as a comma-separated string of
single-byte hex key prefixes (e.g. ``"3d,f2,3d"``). When ``None``
the device chooses the path.
timeout: Seconds to wait for the trace reply.
Returns:
A :class:`TraceResult`; ``success`` is ``False`` on timeout.
"""
# -- passive monitoring ------------------------------------------------------
@abstractmethod
async def listen(
self,
duration_s: float,
*,
on_observation: Optional["ObservationCallback"] = None,
) -> list[Observation]:
"""Passively capture packets the companion overhears for ``duration_s`` seconds.
Adverts (and, where the firmware surfaces them, telemetry frames) heard on the
air are returned as :class:`~meshtools.core.models.Observation` records with
whatever SNR/RSSI/location the radio reported. The radio is not asked to transmit;
it simply listens.
Args:
duration_s: How long to listen, in seconds.
on_observation: Optional callback invoked with each observation as it arrives
(e.g. to advance a live progress display).
Returns:
Every observation captured during the window, in arrival order.
"""
# -- configuration: extra reads ---------------------------------------------
@abstractmethod
async def get_tuning(self) -> dict:
"""Return radio tuning parameters.
Returns:
A dict with ``rx_delay`` and ``airtime_factor`` (both ints).
"""
@abstractmethod
async def get_path_hash_mode(self) -> int:
"""Return the current path-hash mode (experimental routing flag)."""
@abstractmethod
async def get_custom_vars(self) -> dict[str, str]:
"""Return the device's experimental custom key/value variables."""
@abstractmethod
async def get_channel(self, index: int) -> Optional[dict]:
"""Return one channel's configuration, or ``None`` if unset.
Args:
index: Zero-based channel slot.
Returns:
A dict with ``channel_idx``, ``channel_name`` and ``channel_secret``
(16 raw bytes), or ``None`` when the slot is empty.
"""
# -- configuration: settable values -----------------------------------------
@abstractmethod
async def set_name(self, name: str) -> None:
"""Set the node's advertised name."""
@abstractmethod
async def set_coords(self, lat: float, lon: float) -> None:
"""Set the node's advertised latitude/longitude (decimal degrees)."""
@abstractmethod
async def set_device_pin(self, pin: int) -> None:
"""Set the device's BLE pairing PIN."""
@abstractmethod
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None:
"""Set the core radio parameters.
Args:
freq: Frequency in MHz.
bw: Bandwidth in kHz.
sf: Spreading factor.
cr: Coding rate denominator (``5``-``8`` for 4/5-4/8).
"""
@abstractmethod
async def set_tuning(self, rx_delay: int, airtime_factor: int) -> None:
"""Set radio tuning parameters (RX delay and airtime budgeting factor)."""
@abstractmethod
async def set_manual_add_contacts(self, enabled: bool) -> None:
"""Set whether contacts must be added manually rather than automatically."""
@abstractmethod
async def set_adv_loc_policy(self, policy: int) -> None:
"""Set the advert location-sharing policy."""
@abstractmethod
async def set_multi_acks(self, value: int) -> None:
"""Set the multi-ack behavior flag."""
@abstractmethod
async def set_telemetry_modes(self, base: int, loc: int, env: int) -> None:
"""Set the three telemetry mode fields together (each ``0``-``3``)."""
@abstractmethod
async def set_path_hash_mode(self, mode: int) -> None:
"""Set the experimental path-hash routing mode."""
@abstractmethod
async def set_custom_var(self, key: str, value: str) -> None:
"""Set an experimental custom variable."""
@abstractmethod
async def set_channel(self, index: int, name: str, secret: Optional[bytes]) -> None:
"""Configure a channel slot.
Args:
index: Zero-based channel slot.
name: Channel name (a leading ``#`` derives the secret from the name).
secret: 16-byte shared secret, or ``None`` to derive it from ``name``.
"""
# -- configuration: actions -------------------------------------------------
@abstractmethod
async def set_time(self, epoch: int) -> None:
"""Set the device clock to a UNIX epoch timestamp."""
@abstractmethod
async def send_advert(self, flood: bool = False) -> None:
"""Broadcast an advertisement (``flood`` propagates it across the mesh)."""
@abstractmethod
async def reboot(self) -> None:
"""Reboot the device."""
@abstractmethod
async def export_private_key(self) -> str:
"""Export the device's private key as a hex string (sensitive)."""
@abstractmethod
async def import_private_key(self, key_hex: str) -> None:
"""Import a private key from a hex string (overwrites the device identity)."""
@abstractmethod
async def factory_reset(self) -> None:
"""Erase all device data and restore factory defaults (destructive)."""
async def __aenter__(self) -> "Device":
"""Enter the async context manager, connecting the device."""
await self.connect()
return self
async def __aexit__(self, *exc: object) -> None:
"""Exit the async context manager, disconnecting the device."""
await self.disconnect()
class MeshCoreDevice(Device):
"""A :class:`Device` backed by the ``meshcore`` serial companion client.
Note:
The trace mapping here follows the documented companion protocol (trace replies
carry per-hop SNR encoded as ``SNR * 4``) but should be validated against your
firmware version, as event payload field names vary between releases.
"""
def __init__(self, port: str, baudrate: int = 115200) -> None:
"""Initialize the device wrapper.
Args:
port: Serial port path (e.g. ``COM5`` or ``/dev/ttyUSB0``).
baudrate: Serial baud rate.
"""
self._port = port
self._baudrate = baudrate
self._mc = None # type: ignore[var-annotated] # meshcore.MeshCore
async def connect(self) -> None: # noqa: D102 - inherited docstring
if self._mc is not None:
return
try:
from meshcore import MeshCore # lazy import so --mock needs no hardware deps
except ImportError as exc: # pragma: no cover - environment-dependent
raise RuntimeError(
"The 'meshcore' library is required to talk to real hardware but is not "
"installed. Run `pip install -e .` (or `pip install meshcore`), or use "
"--mock for the simulator."
) from exc
self._mc = await MeshCore.create_serial(self._port, self._baudrate)
async def disconnect(self) -> None: # noqa: D102 - inherited docstring
if self._mc is None:
return
disconnect = getattr(self._mc, "disconnect", None)
if disconnect is not None:
await disconnect()
self._mc = None
def _require(self): # type: ignore[no-untyped-def]
"""Return the live client or raise if not connected."""
if self._mc is None:
raise RuntimeError("Device is not connected; call connect() first.")
return self._mc
async def get_self_info(self) -> dict: # noqa: D102 - inherited docstring
mc = self._require()
result = await mc.commands.send_appstart()
return dict(getattr(result, "payload", {}) or {})
async def _contacts_payload(
self, mc, *, retries: int = 3, delay: float = 0.5
) -> dict: # noqa: ANN001
"""Fetch the raw contacts map, retrying the transient "no event" blip.
The companion intermittently fails to emit the contacts event in time and
returns an error event instead of data (``no event received during contacts
retrieval``). That's a recoverable timing hiccup, so retry a few times with a
short backoff before surfacing a clean, actionable error.
Args:
mc: The connected ``MeshCore`` client.
retries: Number of extra attempts after the first.
delay: Seconds to wait between attempts.
Returns:
The contacts payload mapping (possibly empty).
Raises:
DeviceCommandError: If every attempt fails to retrieve contacts.
"""
reason = ""
for attempt in range(retries + 1):
event = await mc.commands.get_contacts()
if event is None or not getattr(event, "is_error", lambda: False)():
return dict(getattr(event, "payload", {}) or {})
payload = getattr(event, "payload", {}) or {}
reason = str(payload.get("reason", payload))
if attempt < retries:
await asyncio.sleep(delay)
raise DeviceCommandError(
f"could not read contacts from the radio ({reason}). "
"The companion didn't respond in time — this is usually transient; "
"retry, or power-cycle/reconnect the radio if it persists."
)
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
mc = self._require()
payload = await self._contacts_payload(mc)
contacts: list[Contact] = []
for name, info in payload.items():
info = info or {}
contacts.append(
Contact(
name=info.get("adv_name", name),
public_key=info.get("public_key", ""),
key_prefix=info.get("public_key", "")[:12],
)
)
return contacts
async def get_tx_power(self) -> Optional[int]: # noqa: D102 - inherited docstring
info = await self.get_self_info()
value = info.get("tx_power")
return int(value) if value is not None else None
async def set_tx_power(self, value: int) -> None: # noqa: D102 - inherited docstring
mc = self._require()
await mc.commands.set_tx_power(value)
@staticmethod
def _node_pubkey(node: Contact) -> str:
"""Return a contact's full public key for remote addressing.
Args:
node: The contact to address.
Returns:
The lowercased hex public key (``0x`` stripped).
Raises:
DeviceCommandError: If the contact carries no public key, so it cannot be
addressed for login/admin commands.
"""
pub = (node.public_key or "").lower().removeprefix("0x")
if not pub:
raise DeviceCommandError(
f"contact {node.name!r} has no public key on this device, so it can't be "
"logged in to for admin commands. Receive an advert from it first."
)
return pub
async def admin_login(self, node: Contact, password: str) -> bool: # noqa: D102
from meshcore import EventType
mc = self._require()
pub = self._node_pubkey(node)
event = await mc.commands.send_login_sync(pub, password)
# ``send_login_sync`` returns the LOGIN_SUCCESS event, or ``None``/an ERROR or
# LOGIN_FAILED event when the node refused (typically a wrong password).
if event is None:
return False
etype = getattr(event, "type", None)
if etype in (EventType.ERROR, EventType.LOGIN_FAILED):
return False
return True
async def _send_admin_cmd(self, node: Contact, cmd: str, *, timeout: float = 8.0):
"""Send a CLI command to a logged-in remote node and await its reply.
The companion acknowledges the send immediately (``MSG_SENT``); the node's
textual reply arrives later as a ``CONTACT_MSG_RECV`` event. We return that
reply text (or ``None`` if none arrived before ``timeout``).
Args:
node: The remote contact (must already be logged in).
cmd: The repeater CLI command, e.g. ``"set tx 20"``.
timeout: Seconds to wait for the node's reply.
Returns:
The reply text, or ``None`` if the node did not answer in time.
Raises:
DeviceCommandError: If the companion rejected the send outright.
"""
from meshcore import EventType
mc = self._require()
pub = self._node_pubkey(node)
sent = await mc.commands.send_cmd(pub, cmd)
if sent is not None and getattr(sent, "is_error", lambda: False)():
raise DeviceCommandError(
f"failed to send admin command {cmd!r} to {node.name!r}: "
f"{getattr(sent, 'payload', {})}"
)
reply = await mc.wait_for_event(EventType.CONTACT_MSG_RECV, timeout=timeout)
if reply is None:
return None
payload = getattr(reply, "payload", {}) or {}
return str(payload.get("text", payload.get("msg", "")))
async def get_remote_tx_power(self, node: Contact) -> Optional[int]: # noqa: D102
reply = await self._send_admin_cmd(node, "get tx")
return _parse_tx_reply(reply)
async def set_remote_tx_power(self, node: Contact, value: int) -> None: # noqa: D102
# The reply ("ok"/echoed value) is best-effort confirmation; absence isn't fatal
# since some firmware answers tersely or drops the ack under duty-cycle limits.
await self._send_admin_cmd(node, f"set tx {value}")
async def run_trace( # noqa: D102 - inherited docstring
self,
target: str,
*,
path: Optional[str] = None,
timeout: float = 10.0,
) -> TraceResult:
from meshcore import EventType # local import keeps mock path dependency-free
from ..services.trace_runner import path_hash_flags
mc = self._require()
tag = random.randint(0, 0xFFFFFFFF)
started = asyncio.get_event_loop().time()
# A trace packet has no destination field — it walks an explicit path of
# repeater hops. Send the path as raw bytes so any uniform hash width
# transmits; ``flags`` carries the path-hash mode (size - 1).
path_bytes: Optional[bytes] = None
flags = 0
if path:
hops = [h.strip() for h in path.split(",") if h.strip()]
path_bytes = bytes.fromhex("".join(hops))
flags = path_hash_flags(len(bytes.fromhex(hops[0]))) or 0
else:
# No forced path: build a path that ends at the target's own hash
# (prepending any learned ``out_path`` repeaters), since a trace only
# replies when its destination is the final hop. ``None`` only when the
# contact is unknown, leaving the trace to run path-less.
resolved = await self._trace_path_to_contact(mc, target)
if resolved is not None:
path_bytes, flags = resolved
await mc.commands.send_trace(auth_code=0, tag=tag, flags=flags, path=path_bytes)
event = await mc.wait_for_event(
EventType.TRACE_DATA,
attribute_filters={"tag": tag},
timeout=timeout,
)
elapsed_ms = (asyncio.get_event_loop().time() - started) * 1000.0
# The firmware addresses each hop by a hash of ``1 << flags`` bytes; record it
# so the summary can show node hashes at the width the command actually used.
hash_bytes = 1 << flags
if event is None:
return TraceResult(
target=target, success=False, round_trip_ms=None, path_hash_bytes=hash_bytes
)
payload = getattr(event, "payload", {}) or {}
hops = parse_trace_hops(payload)
return TraceResult(
target=target,
success=True,
hops=hops,
round_trip_ms=elapsed_ms,
tx_power=await self.get_tx_power(),
path_hash_bytes=hash_bytes,
raw=payload,
)
async def _trace_path_to_contact(
self, mc, target: str
) -> Optional[tuple[bytes, int]]: # noqa: ANN001
"""Resolve a target contact into a trace ``(path_bytes, flags)``.
A trace reply only comes back when the *destination's own hash* is the final
hop in the path — an empty/destination-less path is silently dropped (verified
on hardware: a direct neighbor answers a single-hop trace to its own hash but
not a path-less one). So we always end the path at the contact's key prefix,
prepending any learned repeater hops (``out_path``) ahead of it:
* direct neighbor / no learned route → just ``[destination]``;
* learned multi-hop route → ``[repeater…, destination]``.
Each hash is re-encoded at the trace's own width (``1 << flags``, only 1/2/4/8
bytes), collapsing a region's routing width (e.g. 3) to the widest representable
value (2). The firmware matches by hash prefix, so a narrower prefix still
addresses the same node.
Args:
mc: The connected ``MeshCore`` client.
target: Contact name (case-insensitive) or public-key prefix.
Returns:
``(path_bytes, flags)`` to walk, or ``None`` only when the contact is
unknown or carries no public key to address.
"""
from ..services.trace_runner import path_hash_flags
payload = await self._contacts_payload(mc)
needle = target.casefold()
for name, info in payload.items():
info = info or {}
adv = info.get("adv_name", name)
pub = (info.get("public_key", "") or "").lower().removeprefix("0x")
if adv.casefold() != needle and not pub.startswith(needle):
continue
if not pub:
return None # no key to address the trace's destination hop
# Routing hash width (bytes): the contact's stored mode, or — when it has
# no learned route (mode == -1) — our region's mode.
mode = int(info.get("out_path_hash_mode", -1))
if mode < 0:
try:
mode = int(await mc.commands.get_path_hash_mode())
except Exception:
mode = 2 # region default: 3-byte hashes
size = max(mode + 1, 1)
trace_size = max(s for s in (1, 2, 4, 8) if s <= size)
hops: list[bytes] = []
out_path = (info.get("out_path") or "").strip().lower().removeprefix("0x")
out_path_len = int(info.get("out_path_len", -1))
if 1 <= out_path_len <= 254 and out_path:
# Learned multi-hop route: walk each repeater, collapsed to trace width.
route = bytes.fromhex(out_path)[: out_path_len * size]
hops += [route[i * size : i * size + trace_size] for i in range(out_path_len)]
# Always finish at the destination's own hash so the target recognizes the
# trace and replies; for a direct neighbor this single hop is the whole path.
hops.append(bytes.fromhex(pub)[:trace_size])
return b"".join(hops), path_hash_flags(trace_size) or 0
return None
async def listen( # noqa: D102 - inherited docstring
self,
duration_s: float,
*,
on_observation: Optional[ObservationCallback] = None,
) -> list[Observation]:
from meshcore import EventType
mc = self._require()
subscribe = getattr(mc, "subscribe", None)
if subscribe is None: # pragma: no cover - depends on installed meshcore build
raise DeviceCommandError(
"this meshcore build doesn't expose event subscription, so passive "
"monitoring isn't available. Upgrade the 'meshcore' library."
)
observations: list[Observation] = []
def make_handler(kind: str): # type: ignore[no-untyped-def]
def handler(event) -> None: # noqa: ANN001
obs = observation_from_event(event, kind)
if obs is None:
return
observations.append(obs)
if on_observation is not None:
on_observation(obs)
return handler
# Subscribe to whichever advert/telemetry event types this firmware/library build
# exposes. The event payload field names below are best-effort and, like the trace
# mapping, should be validated against your firmware's event schema.
subs = []
for attr, kind in (
("ADVERTISEMENT", "advert"),
("ADVERT", "advert"),
("NEW_CONTACT", "advert"),
("TELEMETRY_RESPONSE", "telemetry"),
):
etype = getattr(EventType, attr, None)
if etype is not None:
subs.append(subscribe(etype, make_handler(kind)))
try:
await asyncio.sleep(duration_s)
finally:
for sub in subs:
unsub = getattr(sub, "unsubscribe", None)
if unsub is not None:
try:
unsub()
except Exception: # noqa: BLE001 - best-effort cleanup
pass
return observations
@staticmethod
def _ok(event): # type: ignore[no-untyped-def]
"""Return ``event`` if it succeeded, else raise its error payload.
Args:
event: The :class:`meshcore.events.Event` returned by a command.
Returns:
The same event, for convenient chaining.
Raises:
RuntimeError: If the device reported an error.
"""
if event is not None and getattr(event, "is_error", lambda: False)():
raise RuntimeError(f"device rejected command: {getattr(event, 'payload', {})}")
return event
async def get_tuning(self) -> dict: # noqa: D102 - inherited docstring
event = self._ok(await self._require().commands.get_tuning())
payload = getattr(event, "payload", {}) or {}
return {
"rx_delay": int(payload.get("rx_delay", 0)),
"airtime_factor": int(payload.get("airtime_factor", 0)),
}
async def get_path_hash_mode(self) -> int: # noqa: D102 - inherited docstring
return int(await self._require().commands.get_path_hash_mode())
async def get_custom_vars(self) -> dict[str, str]: # noqa: D102 - inherited docstring
event = self._ok(await self._require().commands.get_custom_vars())
return dict(getattr(event, "payload", {}) or {})
async def get_channel(self, index: int) -> Optional[dict]: # noqa: D102
event = self._ok(await self._require().commands.get_channel(index))
payload = getattr(event, "payload", {}) or {}
if not payload.get("channel_name"):
return None
return payload
async def set_name(self, name: str) -> None: # noqa: D102 - inherited docstring
self._ok(await self._require().commands.set_name(name))
async def set_coords(self, lat: float, lon: float) -> None: # noqa: D102
self._ok(await self._require().commands.set_coords(lat, lon))
async def set_device_pin(self, pin: int) -> None: # noqa: D102 - inherited docstring
self._ok(await self._require().commands.set_devicepin(pin))
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None: # noqa: D102
self._ok(await self._require().commands.set_radio(freq, bw, sf, cr))
async def set_tuning(self, rx_delay: int, airtime_factor: int) -> None: # noqa: D102
self._ok(await self._require().commands.set_tuning(rx_delay, airtime_factor))
async def set_manual_add_contacts(self, enabled: bool) -> None: # noqa: D102
self._ok(await self._require().commands.set_manual_add_contacts(enabled))
async def set_adv_loc_policy(self, policy: int) -> None: # noqa: D102
self._ok(await self._require().commands.set_advert_loc_policy(policy))
async def set_multi_acks(self, value: int) -> None: # noqa: D102 - inherited docstring
self._ok(await self._require().commands.set_multi_acks(value))
async def set_telemetry_modes(self, base: int, loc: int, env: int) -> None: # noqa: D102
mc = self._require()
result = self._ok(await mc.commands.send_appstart())
infos = dict(getattr(result, "payload", {}) or {})
infos["telemetry_mode_base"] = base
infos["telemetry_mode_loc"] = loc
infos["telemetry_mode_env"] = env
self._ok(await mc.commands.set_other_params_from_infos(infos))
async def set_path_hash_mode(self, mode: int) -> None: # noqa: D102
self._ok(await self._require().commands.set_path_hash_mode(mode))
async def set_custom_var(self, key: str, value: str) -> None: # noqa: D102
self._ok(await self._require().commands.set_custom_var(key, value))
async def set_channel(self, index: int, name: str, secret: Optional[bytes]) -> None: # noqa: D102
self._ok(await self._require().commands.set_channel(index, name, secret))
async def set_time(self, epoch: int) -> None: # noqa: D102 - inherited docstring
self._ok(await self._require().commands.set_time(epoch))
async def send_advert(self, flood: bool = False) -> None: # noqa: D102
self._ok(await self._require().commands.send_advert(flood))
async def reboot(self) -> None: # noqa: D102 - inherited docstring
await self._require().commands.reboot() # device reboots; no OK reply expected
async def export_private_key(self) -> str: # noqa: D102 - inherited docstring
event = self._ok(await self._require().commands.export_private_key())
payload = getattr(event, "payload", {}) or {}
key = payload.get("private_key")
if key is None:
raise RuntimeError("device did not return a private key (export may be disabled)")
return key.hex() if isinstance(key, (bytes, bytearray)) else str(key)
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
self._ok(await self._require().commands.import_private_key(bytes.fromhex(key_hex)))
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
mc = self._require()
token = await mc.commands.request_factory_reset()
self._ok(await mc.commands.confirm_factory_reset(token))
class MockDevice(Device):
"""A deterministic simulator implementing the full :class:`Device` interface.
The simulated SNR follows an inverted-U response to TX power: too low and the signal
sits in the noise floor, too high and the receiver saturates. This gives the TX
optimizer a realistic, unimodal-with-noise curve to converge on without hardware.
Attributes:
optimal_tx: The TX power at which the simulated link peaks.
"""
def __init__(
self,
seed: int = 1234,
optimal_tx: int = 14,
optimal_remote_tx: int = 20,
admin_password: str = "admin",
) -> None:
"""Initialize the simulator.
Args:
seed: RNG seed for reproducible measurement noise.
optimal_tx: Local TX power at which the simulated *bottleneck* SNR peaks.
optimal_remote_tx: Remote-node TX power at which the simulated SNR *at the
target* peaks (what the remote-admin optimizer converges on).
admin_password: Password the simulated remote nodes accept for admin login.
"""
self.optimal_tx = optimal_tx
self.optimal_remote_tx = optimal_remote_tx
self._admin_password = admin_password
self._rng = random.Random(seed)
self._tx_power = 20
self._connected = False
self._contacts = [
Contact(name="Yagi-Repeater", public_key=_mock_pub("a1b2c3d4"), key_prefix="a1b2c3d4"),
Contact(name="Local-Repeater", public_key=_mock_pub("b2c3d4e5"), key_prefix="b2c3d4e5"),
Contact(name="Observer-Bot", public_key=_mock_pub("c3d4e5f6"), key_prefix="c3d4e5f6"),
Contact(name="Alice", public_key=_mock_pub("d4e5f6a7"), key_prefix="d4e5f6a7"),
]
# Remote-admin simulation: which nodes we're "logged in" to, and each tuned
# node's transmit power keyed by full public key. ``_default_remote_tx`` is the
# assumed power before the optimizer first writes one.
self._admin_sessions: set[str] = set()
self._remote_tx: dict[str, int] = {}
self._default_remote_tx = 20
# Mutable configuration state, keyed exactly like the real SELF_INFO payload so the
# settings registry behaves identically on the simulator and on hardware.
self._info: dict = {
"name": "MockCompanion",
"adv_type": 1,
"max_tx_power": 22,
"public_key": "00" * 32,
"adv_lat": 0.0,
"adv_lon": 0.0,
"multi_acks": 0,
"adv_loc_policy": 0,
"telemetry_mode_base": 0,
"telemetry_mode_loc": 0,
"telemetry_mode_env": 0,
"manual_add_contacts": False,
"radio_freq": 869.525,
"radio_bw": 250.0,
"radio_sf": 11,
"radio_cr": 5,
"simulated": True,
}
self._tuning: dict = {"rx_delay": 0, "airtime_factor": 0}
self._path_hash_mode = 0
self._custom_vars: dict[str, str] = {}
self._channels: dict[int, dict] = {}
self._device_pin = 0
self._private_key = "11" * 32
async def connect(self) -> None: # noqa: D102 - inherited docstring
await asyncio.sleep(0)
self._connected = True
async def disconnect(self) -> None: # noqa: D102 - inherited docstring
self._connected = False
async def get_self_info(self) -> dict: # noqa: D102 - inherited docstring
return {**self._info, "tx_power": self._tx_power}
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
return list(self._contacts)
async def get_tx_power(self) -> Optional[int]: # noqa: D102 - inherited docstring
return self._tx_power
async def set_tx_power(self, value: int) -> None: # noqa: D102 - inherited docstring
self._tx_power = value
async def admin_login(self, node: Contact, password: str) -> bool: # noqa: D102
await asyncio.sleep(0)
if password != self._admin_password:
return False
self._admin_sessions.add(self._mock_key(node))
return True
async def get_remote_tx_power(self, node: Contact) -> Optional[int]: # noqa: D102
key = self._mock_key(node)
if key not in self._admin_sessions:
return None
return self._remote_tx.get(key, self._default_remote_tx)
async def set_remote_tx_power(self, node: Contact, value: int) -> None: # noqa: D102
key = self._mock_key(node)
if key not in self._admin_sessions:
raise DeviceCommandError(
f"not logged in to {node.name!r}; call admin_login first."
)
self._remote_tx[key] = value
@staticmethod
def _mock_key(node: Contact) -> str:
"""Return the lookup key for a remote node (its public key, else key prefix)."""
return (node.public_key or node.key_prefix or node.name).lower().removeprefix("0x")
def _remote_tx_for(self, hop_hex: str) -> Optional[int]:
"""Resolve the simulated remote TX power set on a forced-path hop, if any.
The optimizer stores a node's power under its full public key; a trace addresses
it by a shorter hash prefix, so match in either direction.
Args:
hop_hex: The forced-path hop hash (hex).
Returns:
The node's simulated TX power, or ``None`` if we never set one (i.e. this
hop isn't a node the optimizer is tuning).
"""
h = hop_hex.lower()
for key, tx in self._remote_tx.items():
if key.startswith(h) or h.startswith(key):
return tx
return None
async def get_tuning(self) -> dict: # noqa: D102 - inherited docstring
return dict(self._tuning)
async def get_path_hash_mode(self) -> int: # noqa: D102 - inherited docstring
return self._path_hash_mode
async def get_custom_vars(self) -> dict[str, str]: # noqa: D102 - inherited docstring
return dict(self._custom_vars)
async def get_channel(self, index: int) -> Optional[dict]: # noqa: D102
return self._channels.get(index)
async def set_name(self, name: str) -> None: # noqa: D102 - inherited docstring
self._info["name"] = name
async def set_coords(self, lat: float, lon: float) -> None: # noqa: D102
self._info["adv_lat"] = lat
self._info["adv_lon"] = lon
async def set_device_pin(self, pin: int) -> None: # noqa: D102 - inherited docstring
self._device_pin = pin
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None: # noqa: D102
self._info.update(radio_freq=freq, radio_bw=bw, radio_sf=sf, radio_cr=cr)
async def set_tuning(self, rx_delay: int, airtime_factor: int) -> None: # noqa: D102
self._tuning = {"rx_delay": rx_delay, "airtime_factor": airtime_factor}
async def set_manual_add_contacts(self, enabled: bool) -> None: # noqa: D102
self._info["manual_add_contacts"] = enabled
async def set_adv_loc_policy(self, policy: int) -> None: # noqa: D102
self._info["adv_loc_policy"] = policy
async def set_multi_acks(self, value: int) -> None: # noqa: D102 - inherited docstring
self._info["multi_acks"] = value
async def set_telemetry_modes(self, base: int, loc: int, env: int) -> None: # noqa: D102
self._info.update(
telemetry_mode_base=base, telemetry_mode_loc=loc, telemetry_mode_env=env
)
async def set_path_hash_mode(self, mode: int) -> None: # noqa: D102
self._path_hash_mode = mode
async def set_custom_var(self, key: str, value: str) -> None: # noqa: D102
self._custom_vars[key] = value
async def set_channel(self, index: int, name: str, secret: Optional[bytes]) -> None: # noqa: D102
self._channels[index] = {
"channel_idx": index,
"channel_name": name,
"channel_secret": secret or (b"\x00" * 16),
}
async def set_time(self, epoch: int) -> None: # noqa: D102 - inherited docstring
self._info["clock"] = epoch
async def send_advert(self, flood: bool = False) -> None: # noqa: D102
await asyncio.sleep(0)
async def reboot(self) -> None: # noqa: D102 - inherited docstring
await asyncio.sleep(0)
async def export_private_key(self) -> str: # noqa: D102 - inherited docstring
return self._private_key
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
self._private_key = bytes.fromhex(key_hex).hex() # validates hex, normalizes
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
self._custom_vars.clear()
self._channels.clear()
async def listen( # noqa: D102 - inherited docstring
self,
duration_s: float,
*,
on_observation: Optional[ObservationCallback] = None,
) -> list[Observation]:
# Synthesize plausible adverts from the known contacts, cycling through them.
# Two of them ("…Repeater") advertise a fixed location so location-aware features
# (e.g. the coverage map) have data. The wall-clock duration is honored only
# loosely — the simulator emits a count proportional to it and paces minimally so
# tests stay fast.
repeats = max(1, round(duration_s / 5.0))
locations = {
"Yagi-Repeater": (45.5019, -73.5674),
"Local-Repeater": (45.4768, -73.5990),
}
observations: list[Observation] = []
for r in range(repeats):
for c in self._contacts:
kind = "telemetry" if (r + len(observations)) % 4 == 3 else "advert"
lat_lon = locations.get(c.name)
obs = Observation(
node=c.key_prefix or c.public_key[:12],
name=c.name,
kind=kind,
snr=round(self._rng.gauss(6.0, 3.0), 1),
rssi=round(self._rng.gauss(-95.0, 8.0), 1),
lat=lat_lon[0] if lat_lon else None,
lon=lat_lon[1] if lat_lon else None,
)
observations.append(obs)
if on_observation is not None:
on_observation(obs)
await asyncio.sleep(0)
return observations
def _expected_snr(self, hop_index: int) -> float:
"""Model SNR for a hop as an inverted-U in TX power plus distance falloff.
Args:
hop_index: Zero-based hop position; deeper hops are weaker.
Returns:
The noise-free expected SNR in dB for the current TX power.
"""
# Inverted parabola peaking at ``optimal_tx``; ~10 dB swing across the range.
span = (TX_POWER_MAX - TX_POWER_MIN) / 2
offset = (self._tx_power - self.optimal_tx) / span
peak = 8.0 - 10.0 * (offset**2)
return peak - 2.5 * hop_index
def _expected_remote_snr(self, remote_tx: int) -> float:
"""Model the SNR the target receives from the admin node it sits behind.
An inverted-U in the admin node's TX power: too low and the target barely hears
it, too high and the target's front end saturates. The peak sits at
``optimal_remote_tx`` so the remote-admin optimizer has a unimodal-with-noise
curve to converge on.
Args:
remote_tx: The admin node's transmit power.
Returns:
The noise-free expected SNR in dB at the target.
"""
# Curvature is steep enough that the band edges fall below the ~-12 dB drop
# threshold, so traces start failing there — giving the optimizer a real
# reliability gradient (not just an SNR one) to honor reliability-first.
span = (REMOTE_TX_MAX - REMOTE_TX_MIN) / 2
offset = (remote_tx - self.optimal_remote_tx) / span
return 9.0 - 24.0 * (offset**2)
async def run_trace( # noqa: D102 - inherited docstring
self,
target: str,
*,
path: Optional[str] = None,
timeout: float = 10.0,
) -> TraceResult:
await asyncio.sleep(0.05) # mimic radio latency so progress bars are visible
forced = [h for h in path.split(",") if h.strip()] if path else None
# Mirror the real device: the path-hash width is the byte length of a forced hop.
hash_bytes = len(bytes.fromhex(forced[0])) if forced else None
depth = len(forced) if forced else self._rng.randint(1, 3)
hops: list[Hop] = []
for i in range(depth):
# Each hop's SNR reflects the node that transmitted *into* it (hop i-1). If
# that node is one the optimizer has tuned a remote TX on, model the link from
# its power — so the hop arriving at the target tracks the admin node we're
# tuning, wherever the target sits in a there-and-back path. Otherwise fall
# back to the local TX-power model.
prev_tx = self._remote_tx_for(forced[i - 1]) if forced is not None and i >= 1 else None
if prev_tx is not None:
expected = self._expected_remote_snr(prev_tx)
else:
expected = self._expected_snr(i)
snr = expected + self._rng.gauss(0, 1.2) # measurement noise
node = forced[i] if forced else f"hop{i}"
hops.append(Hop(index=i, node=node, snr=round(snr, 1)))
# Very weak links occasionally drop the whole trace, judged on the bottleneck hop.
bottleneck = min((h.snr for h in hops), default=-99)
success = bottleneck > -12 or self._rng.random() > 0.1
# The trace reply returns to us: firmware appends the local device as a final
# hash-less hop (``node=None``). Mirror that so the origin/destination framing
# naturally has our device at both ends of the path. The return link is modeled
# as symmetric to the first outbound hop, so it never alters the bottleneck SNR.
if hops:
hops.append(Hop(index=depth, node=None, snr=hops[0].snr))
return TraceResult(
target=target,
success=success,
hops=hops if success else [],
round_trip_ms=round(self._rng.uniform(120, 480), 1) if success else None,
tx_power=self._tx_power,
path_hash_bytes=hash_bytes,
)
def parse_trace_hops(payload: dict) -> list[Hop]:
"""Extract per-hop SNR from a ``TRACE_DATA`` event payload.
meshcore parses a trace reply into ``payload["path"]`` — a list of nodes, each a
dict with a repeater ``"hash"`` and its ``"snr"`` (already in dB, signed-byte / 4).
The final node is the local device and carries an ``"snr"`` but no ``"hash"``.
Args:
payload: The ``TRACE_DATA`` event payload.
Returns:
The hops in path order; nodes without an SNR reading are skipped.
"""
hops: list[Hop] = []
for node in payload.get("path") or []:
if not isinstance(node, dict) or node.get("snr") is None:
continue
hops.append(Hop(index=len(hops), node=node.get("hash"), snr=float(node["snr"])))
return hops
def observation_from_event(event, kind: str) -> Optional[Observation]: # noqa: ANN001
"""Map a meshcore advert/telemetry event into an :class:`Observation`.
The companion reports a node identifier, optionally a name and shared location, and
the SNR/RSSI of the reception. Field names vary across firmware and library versions,
so several common spellings are tried for each value. Like the trace mapping this is
best-effort and should be validated against your firmware's event schema.
Args:
event: A meshcore event (anything exposing a ``payload`` mapping).
kind: The observation class to tag the record with (e.g. ``advert``).
Returns:
The parsed :class:`Observation`, or ``None`` if the payload carried no node id.
"""
payload = dict(getattr(event, "payload", {}) or {})
node = (
payload.get("public_key")
or payload.get("pubkey")
or payload.get("hash")
or payload.get("key_prefix")
)
if not node:
return None
node = str(node).lower().removeprefix("0x")[:12]
lat = payload.get("adv_lat", payload.get("lat"))
lon = payload.get("adv_lon", payload.get("lon"))
return Observation(
node=node,
name=payload.get("adv_name") or payload.get("name"),
kind=kind,
snr=_as_float(payload.get("snr")),
rssi=_as_float(payload.get("rssi")),
lat=_as_float(lat) if lat else None,
lon=_as_float(lon) if lon else None,
raw=payload,
)
def _as_float(value: object) -> Optional[float]:
"""Best-effort float conversion, returning ``None`` on missing/garbage values."""
if value is None:
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _mock_pub(prefix: str) -> str:
"""Build a 32-byte mock public key from a short hex prefix (simulator only).
Args:
prefix: Leading hex digits identifying the node.
Returns:
A 64-hex-character (32-byte) key beginning with ``prefix``.
"""
return prefix + "0" * (64 - len(prefix))
def _parse_tx_reply(reply: Optional[str]) -> Optional[int]:
"""Extract a TX-power integer from a repeater's ``get tx`` reply text.
Repeater firmware answers tersely and inconsistently across versions (e.g.
``"tx: 20"``, ``"TX power = 20 dBm"``, or just ``"20"``), so pull the first signed
integer out of the reply rather than matching a fixed format.
Args:
reply: The node's reply text, or ``None`` if it did not answer.
Returns:
The parsed TX power, or ``None`` if the reply was empty or carried no number.
"""
if not reply:
return None
import re
match = re.search(r"-?\d+", reply)
return int(match.group()) if match else None
def clamp_tx_power(value: int) -> int:
"""Clamp a TX power value to the supported range.
Args:
value: Requested TX power level.
Returns:
``value`` constrained to ``[TX_POWER_MIN, TX_POWER_MAX]``.
"""
return max(TX_POWER_MIN, min(TX_POWER_MAX, value))
def make_device(
*,
mock: bool,
port: Optional[str],
baudrate: int = 115200,
mock_optimal_tx: int = 14,
) -> Device:
"""Construct the appropriate :class:`Device` for the current invocation.
Args:
mock: When ``True`` return a :class:`MockDevice` simulator.
port: Serial port for a real device. Required unless ``mock`` is set.
baudrate: Serial baud rate for a real device.
mock_optimal_tx: Peak TX power for the simulator.
Returns:
A connected-on-enter :class:`Device` instance.
Raises:
ValueError: If a real device is requested without a serial port.
"""
if mock:
return MockDevice(optimal_tx=mock_optimal_tx)
if not port:
raise ValueError(
"No serial port configured. Pass --port, set a profile, or use --mock."
)
return MeshCoreDevice(port=port, baudrate=baudrate)