Files
MeshTerm/meshterm/core/connection.py
T
Jean-Pierre MartineauandClaude Opus 5.5 79137598cf Clock sync explains a radio whose clock is ahead instead of calling it malformed
MeshCore firmware sets its clock only forward: CMD_SET_DEVICE_TIME with an earlier time is answered ERR_CODE_ILLEGAL_ARG, which read as 'the device rejected the request as malformed'. Since 2026-09-24 the companion's clock has been ahead of this computer's (itself within 0.7 s of NTP), so every connect's sync was refused. set_clock now reads the radio's clock first: within a minute ahead is in sync and nothing is written; further ahead raises ClockAheadError, which says by how much and that a reboot resets it. The device layer also maps the firmware's refusal to that error, and the simulator's clock now only moves forward, like the firmware's.

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

5207 lines
246 KiB
Python
Raw 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
"""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 logging
import random
import re
import sys
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import TYPE_CHECKING
from . import transmit_gate
from .channels import CHANNEL_SLOT_PROBE_CAP
from .events import MeshEvent
from .frames import frame_addressing, trace_link_snrs
from .models import (
NODE_TYPE_CHAT,
NODE_TYPE_REPEATER,
Ack,
Contact,
Hop,
LoginResult,
Message,
NeighbourInfo,
Observation,
TraceResult,
advert_time,
utcnow,
)
from .region_sim import SimulatedRegionMap
from .regions import WILDCARD as REGION_WILDCARD
from .regions import normalize as normalize_region
from .regions import parse_region_list, region_key
from .regions import validate as validate_region
from .tracing import path_hash_flags, trace_timeout
from .transmit_lock import TransmitLock
if TYPE_CHECKING:
from .config import SpiWiring
from .discovery import DiscoveredDevice
#: Module logger; enable DEBUG on ``meshterm.core.connection`` to trace the message pump.
_log = logging.getLogger(__name__)
#: Callback invoked with each :class:`~meshterm.core.events.MeshEvent` the device emits
#: (an overheard packet, an inbound message, an acknowledgement).
EventCallback = Callable[[MeshEvent], None]
#: Zero-argument callable returned by :meth:`Device.subscribe_events` that stops the
#: subscription and releases its resources when invoked.
Unsubscribe = Callable[[], None]
#: How often the :class:`MockDevice` simulator emits a fresh burst of synthetic packets
#: while a passive-monitor subscription is open (seconds).
_MOCK_MONITOR_INTERVAL_S = 0.05
#: How often the real device's inbound-message pump sweeps for queued messages, as a
#: safety net for firmware that doesn't reliably push ``MESSAGES_WAITING`` (seconds).
_MESSAGE_POLL_INTERVAL_S = 3.0
#: Standard Bluetooth GATT Battery Service and its Battery Level Status characteristic (GATT
#: Specification Supplement, "Battery Level Status", 0x2BED — added in Battery Service 1.1).
#: Where a companion exposes these, the status characteristic carries a *firmware-reported*
#: charging flag — a ground truth the MeshCore companion protocol itself never provides (it
#: reports only a battery voltage). MeshCore firmware does not implement them today: its BLE
#: profile is just the Nordic UART pipe plus DFU, confirmed by dumping the GATT table. So this
#: path lies dormant behind a fail-safe fallback and lights up automatically only if a future
#: device ships the service. See :func:`charging_from_battery_level_status`.
_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"
_BATTERY_LEVEL_STATUS_UUID = "00002bed-0000-1000-8000-00805f9b34fb"
def charging_from_battery_level_status(data: bytes) -> bool | None:
"""Decode the charging state from a GATT *Battery Level Status* value (0x2BED).
Per the Bluetooth GATT Specification Supplement the characteristic opens with a 1-byte
flags field followed by a 16-bit little-endian *Power State* word; the two bits at offset
5 are the **Charge State** enum — 0 unknown, 1 charging, 2 discharging (active), 3
discharging (inactive). The fields after the word (identifier, battery level, additional
status) are optional and unread here, so only the first three bytes are required.
Args:
data: The raw characteristic value as read over GATT.
Returns:
``True`` when the pack reports it is charging, ``False`` when it reports discharging,
or ``None`` when the value is too short or the state is *unknown* — in which case the
caller should fall back to the voltage-trend inference.
"""
if len(data) < 3:
return None
power_state = int.from_bytes(data[1:3], "little")
charge_state = (power_state >> 5) & 0b11
if charge_state == 1:
return True
if charge_state in (2, 3):
return False
return None
#: Per-``get_msg`` timeout in the message pump, so a missing device reply can't wedge the
#: drain loop (seconds).
_MESSAGE_GET_TIMEOUT_S = 5.0
#: How many uncorrelated frames one :meth:`MeshCoreDevice.admin_login` may quote in its log
#: line. They are evidence for reading a failure afterwards, not a record to keep, and a busy
#: mesh can push a great many through the window — a handful names the fault, and the rest
#: would only bury the outcome they sit beside.
_LOGIN_STRAY_LOG_CAP = 4
#: How long a graceful ``meshcore`` client teardown may take before it is abandoned and the
#: transport is force-closed instead (seconds). A healthy disconnect completes in well under a
#: second; the bound exists because the library's dispatcher shutdown can deadlock — its
#: ``queue.join()`` never returns when two or more events (a routine serial RX burst) are
#: queued at the moment of stop, since the processor task exits after draining only one. Kept
#: comfortably under the interactive session's 5-second exit watchdog so even the forced path
#: finishes as a *clean* exit rather than an ``os._exit`` reap.
_DISCONNECT_TIMEOUT_S = 2.0
#: Bound on the forced transport close that follows an abandoned graceful teardown (seconds).
#: ``_DISCONNECT_TIMEOUT_S + _FORCE_DISCONNECT_TIMEOUT_S`` stays under the exit watchdog.
_FORCE_DISCONNECT_TIMEOUT_S = 1.5
#: Bound on the graceful half of closing a meshcore client whose own ``connect`` failed
#: (seconds). Deliberately short: this runs on a failure path the user is waiting through —
#: the startup probe that is about to raise "needs a PIN" — and a half-open client has no
#: session state worth draining. The forced transport close follows regardless.
_DISCARD_TIMEOUT_S = 2.0
#: Total attempts at opening the BLE link before its failure is surfaced. Opening a BLE
#: connection on Windows is intermittently flaky (a slow-advertising peripheral is missed by
#: bleak's internal lookup, or the link-layer connect races the just-finished discovery scan);
#: a single retry recovers the common case without meaningfully delaying a genuinely absent
#: device. This retries only the *link open* — never a rejected PIN and never a mesh transmit.
_BLE_CONNECT_ATTEMPTS = 2
#: Pause between BLE link-open attempts (seconds), giving the OS radio a beat to settle.
_BLE_CONNECT_RETRY_DELAY_S = 1.0
#: Attempts, and the wait between them, while macOS finishes a pairing it has just begun.
#: An unbonded subscribe to the companion's authenticated characteristic is what *triggers*
#: Passkey Entry there, so the very failure we catch is the signal that the OS has raised
#: its dialog — and the person now has to read a 6-digit code off the device and type it.
#: Giving up at once meant a correct PIN produced an error and only the *second* attempt
#: worked, the first having quietly done the bonding. Sized to cover a human typing a code
#: rather than a radio settling, and bounded so a cancelled dialog still fails in the end.
_BLE_MACOS_PAIRING_ATTEMPTS = 5
_BLE_MACOS_PAIRING_DELAY_S = 6.0
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:`~meshterm.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 ContactNotOnDeviceError(DeviceCommandError):
"""The companion has no contact matching the recipient, so it cannot address it.
Firmware addresses a direct message by looking the recipient up in *its own* contact
table (by a prefix of the public key) and answers ``ERR_CODE_NOT_FOUND`` when nothing
matches — the one send rejection with an obvious fix: put the contact back on the
device. A distinct :class:`DeviceCommandError` subclass so the chat screen can offer
exactly that (see :func:`~meshterm.ui.chat.open_chat`) while every other caller keeps
treating it as an ordinary command failure.
This is reachable for a contact MeshTerm itself listed, because the contact list a
screen sees is the union of the device's live table and the ones MeshTerm remembers
for it (see :mod:`meshterm.core.contact_store`): a contact the firmware has since
dropped still lists, and only the send finds out it is gone.
:meth:`Device.remove_contact` raises it for the same lookup against the same table,
where it means the opposite thing: nothing left to delete on the radio. A removal
therefore *reports* it and carries on dropping the contact from what MeshTerm
remembers, rather than failing and leaving a row the reader cannot get rid of.
Attributes:
contact: The recipient the device could not find.
"""
def __init__(self, contact: Contact) -> None:
"""Explain the rejection in terms of the contact the device could not find.
Args:
contact: The recipient the companion has no entry for.
"""
super().__init__(f"{contact.name} isn't in this device's contacts — add it back to send.")
self.contact = contact
class ClockAheadError(DeviceCommandError):
"""The radio's clock is ahead of the time being written, and firmware won't go back.
MeshCore's companion firmware sets its clock only *forward*: ``CMD_SET_DEVICE_TIME``
with a time earlier than the one it holds is answered ``ERR_CODE_ILLEGAL_ARG``
(``examples/companion_radio/MyMesh.cpp``), which the error table reads as "malformed" —
true of the argument, and no help to anyone. A clock a GPS fix, another app, or plain
drift carried past this computer's stays there until the radio reboots, so a set that
meets one is not a failure to retry but a fact to state.
Attributes:
ahead_s: How far ahead the radio's clock was, in seconds, or ``None`` where it was
not read (the firmware's refusal says only *that* it is ahead).
"""
def __init__(self, ahead_s: int | None) -> None:
"""Say how far ahead the clock is, and what does and does not move it.
Args:
ahead_s: Seconds ahead of the time written, or ``None`` when unknown.
"""
by = f" {_span(ahead_s)}" if ahead_s is not None else ""
super().__init__(
f"the radio's clock is{by} ahead of this computer's, and MeshCore firmware never "
"sets its clock back — rebooting the radio resets it"
)
self.ahead_s = ahead_s
def _span(seconds: int) -> str:
"""A duration as a person says it: ``3 s``, ``12 min``, ``5 h``, ``2 days``."""
seconds = abs(int(seconds))
if seconds < 120:
return f"{seconds} s"
if seconds < 2 * 3600:
return f"{seconds // 60} min"
if seconds < 2 * 86400:
return f"{seconds // 3600} h"
return f"{seconds // 86400} days"
class DeviceAuthenticationError(DeviceCommandError):
"""A Bluetooth companion refused the connection because it needs a pairing PIN/bond.
A distinct :class:`DeviceCommandError` subclass so callers can tell "this device needs a
PIN" apart from an ordinary command failure and offer to collect one: the interactive
picker opens a PIN dialog and retries, while the scripted CLI (which catches the base
class) prints the message and bails, since it can't prompt. The message already names the
fix (``--ble-pin`` and OS pairing).
The one exception covers several different failures — no PIN at all, a stale bond the OS
still believes in, a wrong PIN, a pairing the device itself refused — and each has its own
remedy, so the message names which one happened (see
:meth:`MeshCoreDevice._ble_auth_failure`) rather than one sentence covering all of them.
Attributes:
hint: The same diagnosis cut to one line for the PIN dialog, which re-asks under it;
empty when there is nothing to say beyond the dialog's own question.
"""
def __init__(self, message: str, *, hint: str = "") -> None:
"""Build the error.
Args:
message: The full, actionable sentence — what failed and what to do.
hint: The dialog-sized version of it (see the class attributes).
"""
super().__init__(message)
self.hint = hint
class FloodScopeError(DeviceCommandError):
"""The companion could not send under the scope a channel send asked for — so it didn't.
A scoped send is set-scope, send, restore (see :meth:`Device.send_channel_in_scope`),
and when the first step fails the message is **not** sent at all: going out unscoped
instead would reach every repeater the reader meant to keep it from, and going out
under the device's default scope would reach a region they never picked. Either would
be a silent substitute for what was asked. The message names the firmware each kind of
scope needs, since an old companion is by far the likeliest reason.
Attributes:
scope: The scope that was asked for — a region name, or ``*`` for unscoped.
"""
def __init__(self, scope: str, reason: str) -> None:
"""Explain which scope could not be set and why.
Args:
scope: The region name, or :data:`~meshterm.core.regions.WILDCARD`.
reason: The sentence to show (it already says nothing was sent).
"""
super().__init__(reason)
self.scope = scope
#: The companion firmware that first takes a session scope (``CMD_SET_FLOOD_SCOPE``), and
#: the one that first takes the explicit-unscoped override on it (``*``, sent as flag byte
#: ``0x01``) — before that release the flag frame is not understood, so an "unscoped" send
#: would fall back to the default scope instead.
SCOPE_FIRMWARE = (1, 10)
UNSCOPED_FIRMWARE = (1, 16)
#: The firmware that first has a persisted default scope (``CMD_SET_DEFAULT_FLOOD_SCOPE``).
#: On anything older a plain flood is unscoped by construction.
DEFAULT_SCOPE_FIRMWARE = (1, 15)
def firmware_version(info: dict | None) -> tuple[int, int, int] | None:
"""The companion's release as a comparable tuple, read from its device-query ``ver``.
Firmware reports its build as a string — ``v1.15.0``, ``1.16.0-dev``, a vendor's own
suffix — and only the leading ``major.minor[.patch]`` means anything here.
Args:
info: A :meth:`Device.get_device_info` payload.
Returns:
``(major, minor, patch)``, or ``None`` where nothing version-shaped was reported
(the simulator, or firmware predating the device query) — a caller then trusts the
command itself to say whether it is understood.
"""
match = _VERSION.search(str((info or {}).get("ver") or ""))
if match is None:
return None
return int(match.group(1)), int(match.group(2)), int(match.group(3) or 0)
#: The leading ``major.minor[.patch]`` of a firmware build string.
_VERSION = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?")
def _version_text(version: tuple[int, ...]) -> str:
"""``(1, 16)`` as ``1.16`` for a sentence."""
return ".".join(str(part) for part in version[:2])
def _scope_refusal(scope: str, exc: BaseException) -> str:
"""The sentence for a companion that refused to set a send scope.
Args:
scope: The region name, or ``*``.
exc: What the command raised, kept in the sentence for whoever is debugging it.
Returns:
A sentence naming the firmware the scope needs, and that nothing was sent.
"""
if scope == REGION_WILDCARD:
need = f"sending unscoped needs firmware {_version_text(UNSCOPED_FIRMWARE)} or newer"
else:
need = f"region scopes need firmware {_version_text(SCOPE_FIRMWARE)} or newer"
return f"The radio refused scope {scope} ({exc}) — {need}. Nothing was sent."
#: ``ERR_CODE_NOT_FOUND`` — the companion has no entry matching what a command addressed.
_ERR_NOT_FOUND = 2
#: ``ERR_CODE_ILLEGAL_ARG`` — an argument the firmware refuses; for ``CMD_SET_DEVICE_TIME``
#: specifically, a time earlier than the clock it already holds (see :class:`ClockAheadError`).
_ERR_ILLEGAL_ARG = 6
#: ``CMD_SET_AUTOADD_CONFIG`` — the auto-add bitmask, and (as an optional second byte, which
#: the ``meshcore`` library never sends) the hop limit.
_CMD_SET_AUTOADD_CONFIG = 58
#: Companion command that persists the default flood scope (firmware 1.15+), framed by
#: :meth:`MeshCoreDevice.set_default_flood_scope` itself rather than the library.
_CMD_SET_DEFAULT_FLOOD_SCOPE = 63
#: Companion command that sets (or clears, or forces off) the session flood scope
#: (firmware 1.10+; the force-unscoped mode 1.16+), framed by
#: :meth:`MeshCoreDevice.set_flood_scope` itself rather than the library.
_CMD_SET_FLOOD_SCOPE_KEY = 54
#: ``RESP_CODE_AUTOADD_CONFIG`` — the reply to a read of the auto-add configuration. Its
#: second payload byte is the hop limit, which the library's parser drops (it keeps only the
#: bitmask), so :meth:`MeshCoreDevice.get_autoadd_config` recognises the raw frame by this.
_RESP_AUTOADD_CONFIG = 25
#: Sentinel: no hop limit has been read alongside the last auto-add bitmask.
_UNREAD = object()
#: The client-repeat frequencies (MHz) the companion firmware allows when a board defines
#: none of its own (``repeat_freq_ranges`` in MeshCore's ``examples/companion_radio/MyMesh.cpp``).
_DEFAULT_REPEAT_FREQS: tuple[tuple[float, float], ...] = (
(433.0, 433.0),
(869.495, 869.495),
(918.0, 918.0),
)
#: How far a radio frequency may sit from an allowed repeat range and still be inside it
#: (MHz). Frequencies travel in kHz, so half of one is the whole rounding error.
_REPEAT_FREQ_TOLERANCE_MHZ = 0.0005
def repeat_freq_allowed(freq: float, ranges: Iterable[tuple[float, float]]) -> bool:
"""Whether ``freq`` (MHz) lies in one of the firmware's client-repeat ``ranges``.
The firmware's own ``isValidClientRepeatFreq``, in MHz: it refuses to turn relaying on
anywhere else.
"""
tol = _REPEAT_FREQ_TOLERANCE_MHZ
return any(low - tol <= float(freq) <= high + tol for low, high in ranges)
#: What each companion error code means, in a sentence that finishes "the device …".
#: The wire carries only the number and the library's ``ERR_CODE_*`` spelling (see
#: ``meshcore.events.ErrorMessages``), which is diagnostic text, not something to put in
#: front of a user — :func:`reject_reason` turns it into the sentence below.
_ERROR_REASONS = {
1: "the firmware doesn't support that command",
2: "the device has no contact with that key",
3: "the device's table is full",
4: "the device isn't in a state to do that",
5: "the device hit a storage error",
6: "the device rejected the request as malformed",
}
def _clip_utf8(text: str, limit: int) -> str:
"""Trim ``text`` to at most ``limit`` UTF-8 bytes, never splitting a character.
Firmware fields are byte-sized, not character-sized, so a name with an accent in it can
overrun a field it looks short enough for.
Args:
text: The value to fit.
limit: The field's size in bytes.
Returns:
``text`` itself when it fits, else its longest whole-character prefix that does.
"""
encoded = text.encode("utf-8")
if len(encoded) <= limit:
return text
return encoded[:limit].decode("utf-8", "ignore")
def error_code(result) -> int | None: # noqa: ANN001
"""Return the companion error code carried by a rejected command's event, if any.
Args:
result: The :class:`meshcore.events.Event` a command returned (or ``None``).
Returns:
The ``error_code`` from the event's payload, or ``None`` when the rejection carried
no code (an absent reply, or a payload shaped some other way).
"""
payload = getattr(result, "payload", {}) or {}
if not isinstance(payload, dict):
return None
try:
return int(payload["error_code"])
except (KeyError, TypeError, ValueError):
return None
def reject_reason(result) -> str: # noqa: ANN001
"""Explain a rejected command in one clause, for the end of a user-facing sentence.
A rejection reaches us as ``{'error_code': 2, 'code_string': 'ERR_CODE_NOT_FOUND'}``;
pasting that dict into a status line (which is what every raise site used to do) shows
the user a wire constant and leaves them no wiser. This maps the code to plain words,
falling back to the raw payload only for a rejection with no code we know.
Args:
result: The :class:`meshcore.events.Event` a command returned (or ``None``).
Returns:
A lowercase clause naming the cause, e.g. ``"the device's table is full"``.
"""
code = error_code(result)
if code in _ERROR_REASONS:
return _ERROR_REASONS[code]
if result is None:
return "the device didn't answer"
payload = getattr(result, "payload", {}) or {}
return f"the device rejected it ({payload})"
#: Exception class names that signal the link to the companion has dropped — the device was
#: unplugged, powered off, moved out of range, or its port/transport otherwise vanished — as
#: opposed to an ordinary command-level failure. Matched by name in :func:`is_connection_lost`
#: so the optional ``pyserial``/``bleak`` dependencies need not be imported here (neither is
#: installed on the ``--mock`` path). ``SerialException`` covers pyserial's read/write failures
#: (including the Windows ``ClearCommError``/``WriteFile`` variants); the ``Bleak*`` names cover
#: a Bluetooth link that dropped or a peripheral that went out of range; the ``OSError``
#: subclasses cover a link torn down at the OS layer.
_CONNECTION_LOST_TYPES = frozenset(
{
"SerialException",
"PortNotOpenError",
"ConnectionResetError",
"ConnectionAbortedError",
"BrokenPipeError",
"BleakError",
"BleakDeviceNotFoundError",
"BleakDBusError",
"BleakGATTError",
"BleakCharacteristicNotFoundError",
}
)
#: Lowercase message fragments that also indicate a dropped link, for exceptions raised as a
#: plain ``OSError``/``RuntimeError`` (whose type name alone isn't conclusive). Kept specific
#: enough not to fire on ordinary command timeouts.
_CONNECTION_LOST_HINTS = (
"device disconnected",
"device not configured",
"clearcommerror",
"the handle is invalid",
"the device does not recognize the command",
"readfile failed",
"writefile failed",
"port is closed",
"no such device",
"input/output error",
# BLE link-loss phrasings (bleak errors / meshcore BLE transport callback reasons).
"ble_transport_lost",
"ble_write_failed",
"ble_disconnect",
"not connected to a ble device",
"device is no longer connected",
)
def is_connection_lost(exc: BaseException) -> bool:
"""Return whether ``exc`` means the companion serial link has dropped.
Distinguishes a *lost connection* (the device was unplugged, powered off, or its serial
port vanished) from an ordinary command failure, so the interactive session can offer to
reconnect rather than merely report an error. The whole exception chain
(``__cause__``/``__context__``) is walked, matching by exception type name and message
text — see :data:`_CONNECTION_LOST_TYPES` / :data:`_CONNECTION_LOST_HINTS` — so the
optional ``pyserial`` dependency need not be imported here.
Args:
exc: The exception raised by a device operation.
Returns:
``True`` if the exception (or any it was raised from) looks like a dropped link.
"""
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
if type(current).__name__ in _CONNECTION_LOST_TYPES:
return True
text = str(current).lower()
if any(hint in text for hint in _CONNECTION_LOST_HINTS):
return True
current = current.__cause__ or current.__context__
return False
#: Message fragments a BLE stack uses when a characteristic can't be accessed without a bond —
#: i.e. the companion is PIN-protected and we're unpaired (or gave the wrong PIN). The GATT
#: subscribe fails with one of these rather than a dropped link, so they're handled as a
#: distinct, actionable "needs a PIN" case (see :func:`_is_ble_auth_error`) and never as
#: connection loss. Matched by text so ``bleak`` need not be imported here.
_BLE_AUTH_HINTS = (
"insufficient authentication",
"insufficient authorization",
"insufficient encryption",
"not paired",
# The pairing step itself failing, rather than the subscribe it guards: bleak's
# ``pair()`` ("Could not pair with device: …") and BlueZ's AuthenticationFailed. Without
# these a refused pairing fell through as an anonymous failure and was reported as a
# device that "didn't answer as a MeshCore device".
"could not pair",
"authentication failed",
"authenticationfailed",
# ATT 0x0E, "Unlikely Error": what a companion that has since been given a PIN answers
# the UART subscribe with over an *unauthenticated* bond left from when it was open —
# encryption succeeds on the old key, and the characteristic's MITM requirement is what
# refuses. Seen on a uConsole (BlueZ bond Authenticated=0). Only consulted while
# connecting, where it is the security layer talking; it routes the refusal to the
# stale-bond message and the PIN repair, which is exactly what heals it.
"unlikely error",
)
@dataclass(frozen=True)
class _BlePairing:
"""What the last Windows PIN-pairing attempt found, kept so a refusal can say why.
The pairing step is best-effort and returns a bool (see
:meth:`MeshCoreDevice._pair_ble_windows`), which is all the connect needs to decide
whether to retry — but a bool can't tell the reader whether the PIN was wrong, the device
refused, or Windows couldn't reach it, and those have different fixes.
Attributes:
outcome: ``"reused"`` (an existing bond was trusted), ``"paired"``, ``"absent"``
(Windows couldn't reach the device), ``"failed"`` (the ceremony returned a
non-success status), or ``"error"`` (the WinRT call raised).
status: The ceremony's status name, lowercase with spaces (``"authentication
failure"``), or the exception text for ``"error"``; empty otherwise.
"""
outcome: str
status: str = ""
def _ble_host() -> str | None:
"""The OS whose pairing MeshTerm runs itself, as a message names it; ``None`` elsewhere."""
if sys.platform == "win32":
return "Windows"
if sys.platform.startswith("linux"):
return "Linux"
return None
def _ble_forget(where: str) -> str:
"""How to forget a device's bond on this OS, as a clause a remedy can carry."""
if sys.platform.startswith("linux"):
return f"remove it with `bluetoothctl remove {where}`"
return "remove it in Settings > Bluetooth"
#: Pairing statuses that mean the PIN itself was wrong: WinRT's names, then BlueZ's (whose
#: AuthenticationFailed after the agent was asked for the PIN is a passkey mismatch).
_PAIRING_WRONG_PIN = frozenset(
{"authentication failure", "invalid ceremony data", "authentication failed"}
)
#: Pairing statuses that mean the device (not the PIN) turned the pairing down — busy with
#: another host, holding an old bond, or simply not answering in time.
_PAIRING_REFUSED = frozenset(
{
"connection rejected",
"too many connections",
"remote device has association",
"not ready to pair",
"rejected by handler",
"authentication timeout",
"protection level could not be met",
# BlueZ's spellings of the same refusals.
"authentication rejected",
"authentication canceled",
"connection attempt failed",
"in progress",
}
)
def _is_ble_auth_error(exc: BaseException) -> bool:
"""Return whether ``exc`` (or any it was raised from) is a BLE authentication rejection.
Walks the whole ``__cause__``/``__context__`` chain matching :data:`_BLE_AUTH_HINTS`, so a
``BleakGATTProtocolError`` wrapped by the meshcore transport is still recognized.
Args:
exc: The exception raised while opening the Bluetooth connection.
Returns:
``True`` if the failure is a missing/rejected pairing rather than a dropped link.
"""
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
text = str(current).lower()
if any(hint in text for hint in _BLE_AUTH_HINTS):
return True
current = current.__cause__ or current.__context__
return False
#: ``errno`` values a serial open fails with, by what they mean for the reader.
_ERRNO_DENIED = {1, 13} # EPERM, EACCES
_ERRNO_BUSY = {11, 16} # EAGAIN (pyserial's exclusive lock), EBUSY
_ERRNO_GONE = {2, 6, 19} # ENOENT, ENXIO, ENODEV
_ERRNO_IN_TEXT = re.compile(r"\[Errno (\d+)\]")
def _serial_open_message(port: str, exc: BaseException) -> str | None:
"""Name why a serial port wouldn't open, or ``None`` if the failure isn't one of those.
pyserial folds the OS error into a ``SerialException`` whose ``errno`` is usually unset,
so the chain is walked for a real ``OSError`` first and the ``[Errno N]`` in the text
second. Windows says "Access is denied" for a port another program holds, which is a
busy port there, not a permission.
Args:
port: The port, as the message names it.
exc: What opening it raised.
Returns:
The actionable sentence, or ``None`` to let the original error through.
"""
code: int | None = None
text = ""
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
text += " " + str(current).lower()
if code is None and isinstance(current, OSError) and current.errno:
code = current.errno
current = current.__cause__ or current.__context__
if code is None and (found := _ERRNO_IN_TEXT.search(text)):
code = int(found.group(1))
windows_busy = sys.platform == "win32" and ("access is denied" in text or code == 13)
if windows_busy or code in _ERRNO_BUSY or "resource busy" in text or "exclusively lock" in text:
modem = (
" On Linux this is often ModemManager, which probes new USB serial devices: "
"`sudo systemctl stop ModemManager` and try again."
if sys.platform.startswith("linux")
else ""
)
return (
f"{port} is in use by another program — the MeshCore app, a flasher, or a serial "
f"monitor. Close it and try again.{modem}"
)
if code in _ERRNO_DENIED or "permission denied" in text:
return (
f"no permission to open {port}. Your user needs to be in the group that owns it "
"(dialout on Ubuntu and Debian): `sudo usermod -aG dialout $USER`, then log out "
"and back in."
)
if code in _ERRNO_GONE or "cannot find the file" in text or "no such file" in text:
return f"{port} isn't there — the device was unplugged, or came back under another name."
return None
class _ConnectingClients(list):
"""Every bleak client a connecting ``BLEConnection`` reported a disconnect for.
Attributes:
connecting: Whether ``connect()`` is still running; while it is, a disconnect is
recorded here and *not* passed on to meshcore (see
:func:`_hold_disconnects_while_connecting`).
"""
connecting = True
def _hold_disconnects_while_connecting(connection) -> _ConnectingClients: # noqa: ANN001
"""Keep meshcore from dropping its bleak client while that client is still connecting.
On Linux, BlueZ answers a link attempt that failed to synchronise
(``le-connection-abort-by-local``, HCI 0x3e) and bleak retries it inside its own
``connect()`` — three to seven times per connect on a Raspberry Pi radio. Each failed
attempt fires the client's disconnect callback, and meshcore's
``BLEConnection.handle_disconnect`` answers by resetting ``self.client`` to what the
caller passed in, which is ``None``. bleak's next retry then succeeds on a client
nobody holds: meshcore's ``start_notify`` hits ``None``, its connect gives up as "not
established", and the live link is stranded. The companion, connected to us, stops
advertising, so the retry cannot find it and the reader is told it is out of range.
Measured against a companion on a uConsole with btmon: three runs out of three.
So while ``connect()`` runs, a disconnect is only recorded. bleak raises on its own if
the link really cannot be made, and once ``connect()`` returns, disconnects reach
meshcore as before, because the client keeps calling this same wrapper.
Args:
connection: The ``meshcore.BLEConnection`` about to connect.
Returns:
The record of clients seen, whose ``connecting`` flag the caller clears once
``connect()`` has returned or raised.
"""
seen = _ConnectingClients()
forward = getattr(connection, "handle_disconnect", None)
if forward is None: # a transport with no drop handler has nothing to hold back
return seen
def _handle_disconnect(client) -> None: # noqa: ANN001 - a bleak client
seen.append(client)
if seen.connecting:
_log.debug("BLE link attempt dropped while connecting; bleak retries it")
return
forward(client)
# BLEConnection hands ``self.handle_disconnect`` to each BleakClient it builds inside
# ``connect()``, so an instance attribute set now is the callback every client gets.
connection.handle_disconnect = _handle_disconnect
return seen
def _held_client(connection, seen: _ConnectingClients): # noqa: ANN001, ANN202
"""The bleak client to keep for teardown: the connection's, else the last one it lost."""
client = getattr(connection, "client", None)
if client is not None:
return client
return next((c for c in reversed(seen) if getattr(c, "is_connected", False)), None)
def serial_port_present(port: str) -> bool:
"""Return whether a serial port named ``port`` is currently enumerated by the OS.
This is the primary liveness signal for a mid-session unplug: the ``meshcore`` client
keeps serving cached data after the cable is pulled and never raises (verified on
hardware — a command still "succeeds", merely returning ``None``), so a failed command
can't be relied on to notice. The OS port list, by contrast, drops the device the moment
it is removed. Enumerating ports only reads the OS device table; it never opens the port,
so it is safe to poll against a companion another handle already holds open.
Args:
port: The serial port name the device was opened on (e.g. ``COM11`` or
``/dev/ttyUSB0``).
Returns:
``True`` if a port by that exact name is present (or if presence can't be
determined — pyserial missing or the query failed — so a mere lookup hiccup never
raises a false "disconnected" alarm).
"""
try:
from serial.tools import list_ports
except Exception: # noqa: BLE001 - pyserial absent (e.g. --mock env); can't tell, assume up
return True
try:
if any(info.device == port for info in list_ports.comports()):
return True
except Exception: # noqa: BLE001 - an enumeration failure must not fake a disconnect
return True
# A soldered platform-bus UART (e.g. an SoC port like ``/dev/ttyS1`` on the Luckfox Lyra)
# is never enumerated by pyserial's Linux ``comports()`` — yet its device node persists for
# exactly as long as the port exists. A USB serial node, by contrast, is removed from the
# filesystem the instant the cable is pulled. So an existing ``/dev`` character device is a
# sound presence signal that never masks a real unplug (and stays Windows-safe: COM names
# are not filesystem paths, so this branch is skipped there).
try:
import os
import stat
if port.startswith("/dev/") and os.path.exists(port):
return stat.S_ISCHR(os.stat(port).st_mode)
except Exception: # noqa: BLE001 - a stat hiccup must not fake a disconnect
return True
return False
class Device(ABC):
"""Abstract companion device exposing the operations MeshTerm needs.
Implementations manage their own connection lifecycle and translate raw protocol
events into the domain models in :mod:`meshterm.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."""
async def link_present(self) -> bool:
"""Return whether the underlying transport link is still up (best-effort).
A cheap, *non-invasive* liveness probe — it never transmits and never opens a new
handle — so the interactive session can poll it while the device is in use to notice
a mid-session drop (a serial cable pulled, a companion powered off, a BLE peripheral
out of range). Each transport implements it against the signal that actually reflects
its link state (OS port enumeration for serial, the BLE client's connection flag for
Bluetooth). It returns ``True`` whenever presence can't be determined, so a lookup
hiccup never fakes a disconnect.
Returns:
``True`` if the link appears up (or can't be checked), ``False`` if it is gone.
"""
return True
@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_device_info(self) -> dict:
"""Return the firmware's hardware/build identity for the connected device.
This is a *different* protocol frame from :meth:`get_self_info`: where self-info
reports the node's identity and radio tuning, this reports what the box actually is —
a ``model`` string (e.g. ``"Seeed Tracker T1000-E"``, matching a MeshCore firmware
``variant``), plus firmware ``ver``/``fw_build``. It is the *only* place the vendor and
model surface: BLE adverts carry no manufacturer data for these boards, most use a
randomized address with no IEEE OUI to look up, and no GATT Device Information Service
is exposed — so the model has to come from MeshCore's own application layer.
Best-effort: firmware predating the device-query frame answers with an empty payload
rather than erroring, so callers must treat a missing ``model`` as simply unknown.
Returns:
A dict with, when available, ``model``, ``ver``, and ``fw_build``; possibly empty.
"""
@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 add_contact(self, node: Contact) -> None:
"""Add (or update) a contact in the device's contact table.
The inverse of :meth:`remove_contact`, and the fix for a node MeshTerm knows but the
firmware has forgotten: a direct message is addressed by the *device's* own contact
entry, so a contact missing from its table can't be messaged at all
(:class:`ContactNotOnDeviceError`) until it is written back. Everything the entry
needs travels on the contact — its public key, name, type and last advertised
position — and it is added with no learned route, so the first message floods
exactly as it would for a freshly-heard node.
Args:
node: The contact to write; must carry a full public key.
Raises:
DeviceCommandError: If the contact carries no public key to address it by, or
the device rejected the write (a full contact table, most often).
"""
@abstractmethod
async def remove_contact(self, node: Contact) -> None:
"""Delete a contact from the device's contact table.
Addresses the contact by its public key, so it must carry one (a contact heard
as an advert always does). The node stays a *node* — its reception history and any
overheard traffic are untouched — it is only dropped from the device's list of
added, messageable contacts.
Args:
node: The contact to remove.
Raises:
ContactNotOnDeviceError: If the device holds no contact with that key — which
is not a failed removal but a removal with nothing left to do, so callers
drop it from what MeshTerm remembers and say so.
DeviceCommandError: If the contact carries no public key to address it by, or
the device rejected the removal for any other reason.
"""
@abstractmethod
async def get_tx_power(self) -> int | None:
"""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) -> LoginResult:
"""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:
A :class:`LoginResult`. It is truthy only for
:attr:`~LoginResult.ACCEPTED`, so a caller that just needs "am I in?" can
still write ``if not await device.admin_login(...)``; one that acts on the
failure must tell :attr:`~LoginResult.REFUSED` (the node said no — the
password is wrong) from :attr:`~LoginResult.NO_REPLY` (nothing came back —
the password is unproven, not disproven).
"""
@abstractmethod
async def send_remote_command(
self, node: Contact, command: str, *, timeout: float = 8.0
) -> str | None:
"""Send one CLI command to a logged-in remote node and await its text reply.
The generic remote-administration primitive: repeaters and room servers are
configured through their text CLI (``get``/``set``/``advert``/…) carried as
admin messages, and every higher-level remote operation is a spelling of this.
Requires an authenticated session (:meth:`admin_login` first) — firmware
silently ignores commands from strangers, which surfaces as a ``None`` reply.
Args:
node: The remote contact (must already be logged in).
command: The CLI command text, e.g. ``"set txdelay 5"``.
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.
"""
@abstractmethod
async def get_remote_tx_power(self, node: Contact) -> int | None:
"""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 fetch_neighbours(self, node: Contact) -> list[NeighbourInfo]:
"""Ask a remote node for its neighbour table: who it hears directly, and how well.
A second vantage point for the topology graph: every entry is a link
``node ↔ neighbour`` with SNR measured *at the remote node*, including nodes we
have never received anything from ourselves. Requires an authenticated session
(:meth:`admin_login` first) — firmware silently ignores the request from guests,
which surfaces here as a timeout.
Args:
node: The contact to query (must already be logged in).
Returns:
The reported neighbour entries; empty when the node's table is empty (a
normal answer — repeaters forget neighbours across reboots and only relearn
them as adverts arrive).
Raises:
DeviceCommandError: If the node never answered (not logged in, out of
reach, or firmware without neighbour tables).
"""
@abstractmethod
async def request_regions(self, node: Contact) -> list[str]:
"""Ask a repeater which regions it relays floods for (firmware 1.12+).
An anonymous request — no login — that a repeater answers only when it arrives
*direct or zero-hop*: a flooded copy is dropped (firmware gates ``REGIONS`` behind
``isRouteDirect()``), so the library pins a routeless contact to zero hops for the
exchange. In practice that means a neighbour, or a contact with a learned route.
It is also rate-limited on the repeater, so this is a one-shot question, never a
poll.
Args:
node: The repeater to ask.
Returns:
The region names it listed, bare, in its order — ``*`` first when it also
relays unscoped floods (see :func:`~meshterm.core.regions.parse_region_list`).
Raises:
DeviceCommandError: If the repeater never answered (out of direct reach, too
old, rate-limited, or not a repeater).
"""
@abstractmethod
async def run_trace(
self,
target: str,
*,
path: str | None = None,
timeout: float | None = None,
) -> 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 connection resolves one from the contact's learned route (the
firmware itself never routes a trace — an explicit path is all it
walks), falling back to path-less only for unknown targets.
timeout: Seconds to wait for the trace reply. ``None`` (the default) sizes the
wait to the route: a trace has to travel the whole path out and back, so a
long walk is given proportionally longer to come home
(:func:`~meshterm.core.tracing.trace_timeout`).
Returns:
A :class:`TraceResult`; ``success`` is ``False`` on timeout.
"""
# -- passive event stream ----------------------------------------------------
@abstractmethod
async def subscribe_events(self, on_event: EventCallback) -> Unsubscribe:
"""Begin streaming the device's unsolicited inbound events, without blocking.
Subscribes to everything the companion surfaces on its own: overheard adverts and
telemetry (as :attr:`~meshterm.core.events.EventKind.OBSERVATION` events), inbound
direct/channel text messages (:attr:`~meshterm.core.events.EventKind.MESSAGE`),
and delivery acknowledgements (:attr:`~meshterm.core.events.EventKind.ACK`). Each
is delivered to ``on_event`` as a :class:`~meshterm.core.events.MeshEvent` as it
arrives. Delivery continues in the background until the returned callable is
invoked to stop it; the radio is never asked to transmit, it only listens. This is
the primitive the always-on :class:`~meshterm.services.event_hub.EventHub` is
built on.
Note:
This carries only *unsolicited* events. Replies correlated to a request we
sent (a trace's ``TRACE_DATA``, a login result) are awaited by the issuing
command instead, so those flows work with or without a live subscription.
Args:
on_event: Callback invoked with each :class:`MeshEvent` as it is heard.
Returns:
A zero-argument callable that stops the stream and releases the subscription.
"""
# -- messaging ---------------------------------------------------------------
@abstractmethod
async def send_direct_message(self, contact: Contact, text: str) -> Ack | None:
"""Send a direct text message to a contact.
Args:
contact: The recipient; its ``public_key`` addresses the message.
text: The message body.
Returns:
The delivery :class:`Ack` if one arrived before the send timed out, else
``None`` (the message was handed to the radio but not yet acknowledged).
Raises:
DeviceCommandError: If the companion rejected the send outright.
"""
@abstractmethod
async def send_channel_message(self, index: int, text: str) -> None:
"""Broadcast a text message on a channel slot.
Args:
index: Zero-based channel slot to transmit on.
text: The message body.
Raises:
DeviceCommandError: If the companion rejected the send.
"""
# -- transmitting under a scope -----------------------------------------------------
@property
def transmit_lock(self) -> TransmitLock:
"""The lock every transmission holds while it hands its frame to the companion.
Created on first use rather than in ``__init__`` so an implementation need not
remember to chain up for it. See :mod:`~meshterm.core.transmit_lock` for why it
exists: a channel scope is a window on the companion's one session scope, and
nothing else may be sent through that window.
"""
lock = getattr(self, "_transmit_lock", None)
if lock is None:
lock = TransmitLock()
self._transmit_lock = lock
return lock
@asynccontextmanager
async def transmitting(self) -> AsyncIterator[None]:
"""Hold the transmit lock for one hand-over, repairing a scope a failed restore left.
Every send an implementation makes that could go out as a flood takes this around
the command that transmits — only that command: an ack or reply *wait* afterwards
is not a transmission and must not hold a scoped channel send up behind it.
A scoped send whose restore failed (see :meth:`send_channel_in_scope`) leaves the
companion's session scope pointing at a channel's region, where every later flood
would follow it. The first transmission to come through here afterwards puts it
back before it sends anything — and if the companion still won't take the reset,
the send goes ahead under the scope it had rather than not at all, logged, since
refusing every transmission from then on would be the worse failure.
"""
async with self.transmit_lock.held():
leaked = getattr(self, "_scope_leaked", None)
if leaked is not None and not getattr(self, "_scope_active", False):
try:
await self.set_flood_scope(None)
except Exception as exc: # noqa: BLE001 - see the docstring: send regardless
_log.warning("send scope %r is still set on the radio: %s", leaked, exc)
else:
self._scope_leaked = None
yield
async def send_channel_in_scope(self, index: int, text: str, scope: str | None) -> None:
"""Broadcast on a channel under a given scope, and leave the session scope as found.
The one way MeshTerm sends a channel message. ``None`` is the plain send, going out
under whatever the companion's default scope is; a region name or ``*`` is the
three-step window the firmware makes a per-channel scope out of — set the session
scope, send, restore it (``None``, back to the default) — all under
:attr:`transmit_lock`, so no other flood can go out between the steps.
What is refused is refused *before* anything is sent (:class:`FloodScopeError`):
a scoped message never quietly goes out unscoped, or under the default scope.
* A region name needs firmware :data:`SCOPE_FIRMWARE`; older firmware answers the
command with an error, which is taken as the refusal it is.
* ``*`` needs firmware :data:`UNSCOPED_FIRMWARE` to override a default scope.
Older firmware can still send unscoped when there is no default to override —
anything before :data:`DEFAULT_SCOPE_FIRMWARE` has none at all, and later ones
may have none set — so then the plain send *is* the unscoped one and goes out;
only a default that is actually set is refused.
Args:
index: Zero-based channel slot to transmit on.
text: The message body.
scope: A region name, :data:`~meshterm.core.regions.WILDCARD` for unscoped, or
``None`` for the device's default.
Raises:
FloodScopeError: If the companion can't send under ``scope``; nothing was sent.
~meshterm.core.regions.RegionNameError: If ``scope`` is not a region name the
firmware could hold.
DeviceCommandError: If the send itself was rejected.
"""
bare = normalize_region(scope) if scope else ""
if bare and bare != REGION_WILDCARD:
bare = validate_region(bare)
async with self.transmitting():
if not bare:
await self.send_channel_message(index, text)
return
if bare == REGION_WILDCARD and await self._unscoped_is_plain():
await self.send_channel_message(index, text)
return
try:
await self.set_flood_scope(bare)
except DeviceCommandError as exc:
raise FloodScopeError(bare, _scope_refusal(bare, exc)) from exc
self._scope_active = True
try:
await self.send_channel_message(index, text)
finally:
self._scope_active = False
try:
await self.set_flood_scope(None)
except Exception as exc: # noqa: BLE001 - the message went; repair next time
_log.warning("couldn't clear send scope %r after a channel send: %s", bare, exc)
self._scope_leaked = bare
async def _unscoped_is_plain(self) -> bool:
"""Whether a plain flood is already unscoped, so ``*`` needs no override — or refuse.
Only firmware too old to take the override is asked anything; firmware that reports
no version is trusted to answer the override command itself.
Returns:
``True`` when the plain send is unscoped; ``False`` when the override is
available and should be used.
Raises:
FloodScopeError: When a default scope is set and the firmware cannot override it.
"""
try:
version = firmware_version(await self.get_device_info())
except Exception: # noqa: BLE001 - an unreadable version is an unknown one
version = None
if version is None or version >= UNSCOPED_FIRMWARE:
return False
if version < DEFAULT_SCOPE_FIRMWARE:
return True
try:
default = await self.get_default_flood_scope()
except Exception: # noqa: BLE001 - can't see the default: can't promise unscoped
default = "?"
if not normalize_region(default or ""):
return True
raise FloodScopeError(
REGION_WILDCARD,
f"This radio floods everything under its default scope {default}, and only "
f"firmware {_version_text(UNSCOPED_FIRMWARE)} or newer can send one message "
"unscoped over it. Nothing was sent.",
)
# -- configuration: extra reads ---------------------------------------------
@abstractmethod
async def get_tuning(self) -> dict:
"""Return radio tuning parameters, in their real units.
Returns:
A dict with ``rx_delay`` (float seconds) and ``airtime_factor`` (float).
The firmware stores both as floats and moves them over the wire scaled
×1000; implementations undo that scaling so callers only ever see the
real values.
"""
@abstractmethod
async def get_autoadd_config(self) -> int | None:
"""Return the contact auto-add bitmask, or ``None`` if the firmware predates it.
The finer-grained sibling of :meth:`set_manual_add_contacts`: a bitmask of which
advert types the firmware adds to contacts automatically.
"""
async def get_autoadd_max_hops(self) -> int | None:
"""Return the auto-add hop limit, or ``None`` where the firmware doesn't report one.
The second byte of the firmware's auto-add configuration (``autoadd_max_hops``,
capped at 64). Not abstract: a device without the field has nothing to say, and the
snapshot leaves its row unread rather than failing.
"""
return None
async def get_allowed_repeat_freqs(self) -> list[tuple[float, float]]:
"""Return the frequency ranges (MHz, inclusive) client repeat may be enabled on.
The firmware refuses to relay outside them (``isValidClientRepeatFreq``), so the
editor can check before staging instead of after a refusal. Empty where the firmware
doesn't say — which means *unknown*, not *nowhere*.
"""
return []
@abstractmethod
async def get_default_flood_scope(self) -> str | None:
"""Return the persisted default flood scope's name (``""`` when unset).
Returns:
The ``#scope`` name limiting flood routing, an empty string when no scope is
configured, or ``None`` if the firmware predates flood scopes.
"""
@abstractmethod
async def get_time(self) -> int | None:
"""Return the device clock as a UNIX epoch timestamp, or ``None`` if unknown."""
@abstractmethod
async def get_battery(self) -> dict:
"""Return battery (and, when reported, storage) status.
Returns:
A dict with ``level`` (millivolts) and, on firmware that reports storage,
``used_kb``/``total_kb``. Empty when the read is unsupported.
"""
async def get_hw_charging(self) -> bool | None:
"""Return a firmware-reported charging flag, or ``None`` when the device has none.
The companion protocol carries only a battery voltage, so almost every device answers
``None`` and callers fall back to inferring charge from the voltage trend (see
:meth:`~meshterm.services.battery_service.BatteryService._charging`). A transport that
can read a real charging flag — a BLE device exposing the standard Battery Level Status
characteristic (0x2BED) — overrides this to return it. Best-effort by contract: it
never raises and never meaningfully blocks, so a caller may await it every poll.
"""
return None
@abstractmethod
async def get_stats(self) -> dict:
"""Return the firmware's core/radio/packet statistics, merged into one dict.
Each of the three stats frames is fetched best-effort — firmware predating one
simply contributes nothing — so callers get whatever subset exists: ``battery_mv``,
``uptime_secs``, ``errors``, ``queue_len`` (core); ``noise_floor``, ``last_rssi``,
``last_snr``, ``tx_air_secs``, ``rx_air_secs`` (radio); ``recv``, ``sent``,
``flood_tx``, ``direct_tx``, ``flood_rx``, ``direct_rx``, ``recv_errors`` (packets).
"""
@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) -> dict | None:
"""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.
"""
async def channel_capacity(self) -> int:
"""Discover how many channel slots this device exposes (read-only, non-destructive).
Reads slots from 0 upward until the firmware rejects an index. An *empty* slot is a
valid index and returns ``None`` without stopping the scan; only an out-of-range index
makes the firmware answer with an error, which surfaces here as an exception. The scan
is bounded by :data:`CHANNEL_SLOT_PROBE_CAP` so a device that never rejects an index
can't loop forever — in that case the cap itself is reported.
This only ever *reads* channel configuration, so it is safe to call against a live
device without disturbing its state.
Returns:
The number of addressable channel slots the firmware was built with.
"""
count = 0
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
await self.get_channel(idx)
except Exception: # noqa: BLE001 - a rejected index is how firmware signals its ceiling
break
count = idx + 1
return count
# -- 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, repeat: bool | None = None
) -> 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).
repeat: Whether the companion relays mesh traffic (client repeat, firmware v9+).
The firmware takes it as an optional trailing byte of this same command and
reads the byte's *absence* as off — so a caller changing any radio field on
firmware that reports it must restate it, or the change quietly stops the
relaying. ``None`` omits the byte, for firmware that predates it.
"""
@abstractmethod
async def set_tuning(self, rx_delay: float, airtime_factor: float) -> None:
"""Set radio tuning parameters, in their real units.
The firmware takes both fields in one command, so a caller changing one must
resend the other. Values are the real ones (``rx_delay`` in seconds, 0–20;
``airtime_factor`` a duty-cycle factor, 0–9); implementations apply the
protocol's ×1000 wire scaling. (The repeater-side TX delay factors are *not*
part of this command — companion firmware reads exactly these two fields and
ignores anything after them; those knobs are remote-CLI settings on repeaters.)
"""
@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_autoadd_config(self, flags: int, max_hops: int | None = None) -> None:
"""Set the contact auto-add bitmask (see :meth:`get_autoadd_config`), and its hop limit.
The firmware reads the hop limit as an optional second byte and leaves it where it
was when that byte is absent, so ``None`` changes the bitmask alone.
"""
@abstractmethod
async def set_default_flood_scope(self, scope: str) -> None:
"""Persist the default flood scope by name (empty string clears it).
The name is stored bare, as the firmware and the official apps store it; the
``#`` belongs only to deriving its 16-byte key (see :mod:`~meshterm.core.regions`).
"""
@abstractmethod
async def set_flood_scope(self, region: str | None) -> None:
"""Set the session send scope — what the next floods go out under, until changed.
The firmware's session override (``CMD_SET_FLOOD_SCOPE_KEY``): not persisted, reset
at boot, and applied to *every* flood the companion sends — channel messages, flood
DMs, acks, path returns, requests — ahead of the persisted default. There is no
per-channel scope in firmware, so a channel scope is this call made just before the
channel send (and undone after it).
Args:
region: A region name to scope to; :data:`~meshterm.core.regions.WILDCARD`
(``*``) to force unscoped even over a default scope (firmware 1.16+); or
``None`` to drop the override and fall back to the default scope.
"""
@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: bytes | None) -> 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.
Raises:
ClockAheadError: If the device's clock is already later than ``epoch`` —
MeshCore firmware only moves its clock forward.
"""
@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`` companion client (serial, BLE, or TCP).
The same wrapper serves every transport: which one it opens is chosen by ``transport``
(``"serial"`` opens ``port``; ``"ble"`` opens ``address``; ``"tcp"`` opens
``host``:``tcp_port``). Everything above the connection — commands, event mapping, trace
parsing — is transport-agnostic, so only :meth:`connect` and :meth:`link_present` differ
between them.
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 | None = None,
baudrate: int = 115200,
connect_timeout: float | None = None,
*,
transport: str = "serial",
address: str | None = None,
pin: str | None = None,
ble_device: object | None = None,
host: str | None = None,
tcp_port: int | None = None,
) -> None:
"""Initialize the device wrapper.
Args:
port: Serial port path (e.g. ``COM5`` or ``/dev/ttyUSB0``); serial transport only.
baudrate: Serial baud rate.
connect_timeout: Handshake timeout (seconds) for the initial connection, passed
to the client as its default command timeout. ``None`` uses the ``meshcore``
library default (~15s). The startup smoke test sets a short value so a
non-responsive port is rejected quickly instead of blocking on the full
default handshake window.
transport: ``"serial"`` (default), ``"ble"``, or ``"tcp"``.
address: Bluetooth address (e.g. ``AA:BB:CC:DD:EE:FF``); BLE transport only.
pin: Optional BLE pairing PIN, when the peripheral requires one (BLE only).
ble_device: The ``bleak.BLEDevice`` the discovery scan already found at
``address``, when available (BLE only). Passing it lets the connect open the
peripheral directly instead of re-discovering it by address — on Windows a
bare-address connect runs a fresh internal scan that intermittently misses a
slow-advertising companion, which is the main source of flaky BLE startups.
Typed ``object`` so ``bleak`` need not be imported on non-BLE paths.
host: Hostname or IP of a network companion; TCP transport only.
tcp_port: TCP port the network companion listens on; TCP transport only.
"""
self._port = port
self._baudrate = baudrate
self._connect_timeout = connect_timeout
self._transport = transport
self._address = address
self._pin = pin
#: What the last Windows PIN-pairing attempt found (``None`` when none ran), so an
#: authentication refusal can name the failure rather than guess at it.
self._ble_pairing: _BlePairing | None = None
self._ble_device = ble_device
self._host = host
self._tcp_port = tcp_port
self._mc = None # type: ignore[var-annotated] # meshcore.MeshCore
#: The ``bleak.BleakClient`` behind a BLE link, kept because ``meshcore`` lets go of
#: its own reference the moment the peripheral vanishes — see :meth:`_release_ble_client`
#: for why nothing else can close it, and what that costs when it stays open.
self._ble_client = None # type: ignore[var-annotated] # bleak.BleakClient
# Serializes channel reads. The meshcore library's get_channel waits for "the next
# CHANNEL_INFO event" with no correlation to the index it asked for, and the dispatcher
# fans that event to *every* in-flight waiter — so two concurrent reads both resolve on
# the first response and one caller silently gets the other's channel. Holding this lock
# keeps at most one channel read outstanding, so the response is unambiguously ours.
self._channel_read_lock = asyncio.Lock()
#: Set once we've noted a device exposing the standard BLE Battery Service, so the
#: "using its charging flag" log fires a single time per session rather than each poll.
self._logged_bas = False
@property
def transport(self) -> str:
"""Which transport this device connects over (``"serial"``, ``"ble"``, or ``"tcp"``)."""
return self._transport
@property
def endpoint(self) -> str | None:
"""The connection endpoint: ``host:port`` for TCP, the BLE address, else the port."""
if self._transport == "tcp":
return f"{self._host}:{self._tcp_port}"
return self._address if self._transport == "ble" else self._port
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
if self._transport == "ble":
self._mc = await self._create_ble(MeshCore)
elif self._transport == "tcp":
try:
self._mc = await MeshCore.create_tcp(
self._host, self._tcp_port, default_timeout=self._connect_timeout
)
except (OSError, asyncio.TimeoutError) as exc:
# The socket couldn't be opened — host unreachable, connection refused, name
# not resolved, or the connect timed out. That's not a MeshCore-level failure,
# so translate it into a clean, actionable message (naming the host:port and
# the things to check) rather than letting a raw socket traceback escape.
raise DeviceCommandError(self._no_response_message()) from exc
else:
try:
self._mc = await MeshCore.create_serial(
self._port, self._baudrate, default_timeout=self._connect_timeout
)
except Exception as exc: # noqa: BLE001 - named when recognised, else re-raised
# The port refused to open at all — which says nothing about whether a
# companion is on it, and used to be reported as if it had said exactly that.
message = _serial_open_message(self._port or "the serial port", exc)
if message is None:
raise
_log.warning("couldn't open %s: %s", self._port, exc)
raise DeviceCommandError(message) from exc
# ``create_*`` returns ``None`` (after cleaning up its own connection) when the node
# never answers the identity handshake — i.e. the endpoint isn't a MeshCore companion.
# Surface that as a clean, recoverable error rather than leaving a half-open device
# whose next command fails with a confusing "not connected".
if self._mc is None:
raise DeviceCommandError(self._no_response_message())
# A short ``connect_timeout`` is only meant to bound the initial identity handshake
# (so a dead endpoint is rejected quickly). Now that we're connected, restore the
# library's normal per-command timeout so the rest of the session isn't rushed.
if self._connect_timeout is not None:
commands = getattr(self._mc, "commands", None)
default = getattr(commands, "DEFAULT_TIMEOUT", None)
if commands is not None and default is not None:
commands.default_timeout = default
async def _create_ble(self, mesh_core): # type: ignore[no-untyped-def]
"""Open the BLE companion connection, pairing with a PIN first on Windows.
``auto_reconnect`` is deliberately left off: MeshTerm drives reconnection itself (the
same reconnect dialog the serial path uses), so the meshcore client should surface a
dropped link promptly via ``is_connected`` rather than silently retrying underneath us.
Before opening the link we establish an *authenticated* pairing ourselves when a PIN is
supplied (see :meth:`_pair_ble_windows`). This is essential on Windows: bleak's
``pair()`` only performs the "Just Works" ceremony (``CONFIRM_ONLY``) and never enters a
passkey, so a PIN-protected companion bonds *without authentication* and then rejects
the GATT subscribe on its authenticated UART characteristic — a correct PIN is reported
as "rejected" and the device can never connect. Running the WinRT ProvidePin ceremony
ourselves creates the authenticated bond the characteristic requires; once bonded, the
OS keeps the bond and later reconnects need no PIN at all. The step is a harmless no-op
on other platforms, when no PIN is set, or when the device is already bonded.
Args:
mesh_core: The imported ``meshcore.MeshCore`` class.
Returns:
The connected ``MeshCore`` client, or ``None`` if the peripheral never answered.
"""
async with self._ble_pairing_agent():
await self._pair_ble(force=False)
return await self._open_ble(mesh_core, allow_repair=True)
def _ble_pairing_agent(self) -> AbstractAsyncContextManager[object]:
"""Answer BlueZ's own pairing requests for this device while connecting (Linux only).
See :func:`meshterm.core.bluez.answering` for why: BlueZ starts a pairing of its own
when the subscribe is refused, and with nobody to answer it the connect stalls 30 s.
An instance hook, so tests can keep the system bus out of it.
"""
from . import bluez
if sys.platform.startswith("linux") and bluez.is_mac(self._address or ""):
return bluez.answering(self._address or "", self._pin)
return nullcontext()
async def _open_ble(self, mesh_core, *, allow_repair: bool): # type: ignore[no-untyped-def]
"""Open the meshcore BLE client, translating auth failures and healing stale bonds.
Args:
mesh_core: The imported ``meshcore.MeshCore`` class.
allow_repair: Whether a GATT authentication failure may trigger one unpair-and-
re-pair-with-PIN retry (Windows only). Set ``False`` on that retry so a genuine
wrong-PIN can't loop.
Returns:
The connected ``MeshCore`` client, or ``None`` if the peripheral never answered.
Raises:
DeviceCommandError: If ``bleak`` is missing (with install guidance).
DeviceAuthenticationError: If the companion needs a pairing PIN we don't have or
that was rejected.
"""
try:
return await self._create_ble_with_retry(mesh_core)
except ImportError as exc:
raise DeviceCommandError(
"Bluetooth support requires the 'bleak' package, which isn't installed. "
"Run `pip install -e .` (or `pip install bleak`), use a USB device, or "
"run with --mock."
) from exc
except Exception as exc: # noqa: BLE001 - translate auth failures; re-raise the rest
# A PIN-protected companion accepts the link-layer connection but rejects the
# GATT subscribe with an authentication error ("Insufficient Authentication" /
# "Insufficient Encryption" / "not paired"). That's not a dropped link — it's a
# missing bond — so surface a clean, actionable message instead of a raw traceback
# (which is what a bare BleakGATTProtocolError would produce). Anything else
# propagates unchanged so genuine link-loss still flows to is_connection_lost.
if not _is_ble_auth_error(exc):
raise
# On Windows this can also happen with the *right* PIN when a stale, unauthenticated
# "Just Works" bond from an older attempt is in the way: is_paired is true so the
# ProvidePin step above was skipped, yet the bond can't unlock the characteristic.
# Clear it, pair with the PIN, and retry the connect exactly once before giving up.
if allow_repair and await self._pair_ble(force=True):
return await self._open_ble(mesh_core, allow_repair=False)
# On macOS the same rejection means the opposite thing: it is not the end of a
# pairing attempt but the *start* of one, because touching the authenticated
# characteristic is the only way to make CoreBluetooth pair at all. The OS is
# putting its Passkey dialog up as we unwind. Wait for the person to answer it.
if allow_repair and sys.platform == "darwin":
return await self._open_ble_after_macos_pairing(mesh_core, exc)
raise await self._ble_auth_failure() from exc
async def _open_ble_after_macos_pairing(self, mesh_core, cause: BaseException): # type: ignore[no-untyped-def]
"""Re-open the link while macOS is running the Passkey dialog it just raised.
CoreBluetooth exposes no pairing API — Apple's model is that a peripheral pairs
*implicitly* when something touches a characteristic that requires encryption. The
companion firmware puts its UART characteristic at ENC+MITM precisely so that
happens (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on
ESP32), so the subscribe fails with "Insufficient Authentication" *and* that
failure is what makes macOS ask for the code. The GATT operation is already dead by
then; the bond it started arrives seconds later, once a human has typed six digits.
Failing there reported an error for a pairing that was in fact succeeding, and the
device connected on the next attempt — the first having silently done the work. So
retry rather than give up, for long enough to cover the typing.
Args:
mesh_core: The imported ``meshcore.MeshCore`` class.
cause: The authentication failure that opened the dialog, chained onto the
final error if the pairing never completes.
Returns:
The connected ``MeshCore`` client, or ``None`` if a later attempt opened the
transport but the peripheral never answered the identity handshake.
Raises:
DeviceAuthenticationError: If every attempt was still refused — the dialog was
dismissed, or the code entered was wrong.
"""
for attempt in range(_BLE_MACOS_PAIRING_ATTEMPTS):
await asyncio.sleep(_BLE_MACOS_PAIRING_DELAY_S)
try:
return await self._open_ble(mesh_core, allow_repair=False)
except DeviceAuthenticationError:
_log.debug(
"BLE bond with %s not established yet (attempt %d/%d)",
self._address,
attempt + 1,
_BLE_MACOS_PAIRING_ATTEMPTS,
)
raise await self._ble_auth_failure() from cause
async def _create_ble_with_retry(self, mesh_core): # type: ignore[no-untyped-def]
"""Open the owned BLE client, retrying the transport-level failures that are transient.
The meshcore client raises a bare ``ConnectionError`` when the *link itself* could
not be opened — the peripheral wasn't found during bleak's internal lookup, or the
link-layer connect timed out. On Windows both are routinely transient: a companion
advertising on a slow interval is easily missed by a single scan window, and a
connect attempted right after the discovery scan can race the radio. Users learned
to work around it by re-selecting the device, which is nothing but a manual retry —
so retry here, briefly, before surfacing the failure. The already-discovered
``BLEDevice`` (when the picker's scan produced one) is passed through so the client
connects to it directly instead of re-discovering the address.
Only ``ConnectionError`` is retried: a PIN/bond rejection or any other GATT failure
propagates unchanged on the first attempt so the auth handling in :meth:`_open_ble`
(and a genuine wrong-PIN) is never looped. Each attempt builds its own client through
:meth:`_connect_owned_ble`, which closes it before letting any failure out — a
retried attempt therefore starts from a released link, never a leaked one.
Args:
mesh_core: The imported ``meshcore.MeshCore`` class.
Returns:
The connected ``MeshCore`` client, or ``None`` if the transport connected but
the peripheral never answered the identity handshake.
Raises:
DeviceCommandError: If every attempt failed to open the link — said as such,
because this is the one failure where the device was never reached at all,
and "didn't answer as a MeshCore device" sent people looking at the firmware.
"""
last_exc: ConnectionError | None = None
for attempt in range(_BLE_CONNECT_ATTEMPTS):
if attempt:
await asyncio.sleep(_BLE_CONNECT_RETRY_DELAY_S)
try:
return await self._connect_owned_ble(mesh_core)
except ConnectionError as exc:
_log.debug(
"BLE link to %s failed to open (attempt %d/%d): %s",
self._address,
attempt + 1,
_BLE_CONNECT_ATTEMPTS,
exc,
)
last_exc = exc
assert last_exc is not None # the loop always runs; only ConnectionError falls through
where = self._address or "the selected Bluetooth device"
_log.warning("BLE link to %s never opened: %s", where, last_exc)
raise DeviceCommandError(
f"couldn't open a Bluetooth link to {where} ({_BLE_CONNECT_ATTEMPTS} tries) — it "
"wasn't found, or didn't accept the connection. It may be out of range, powered "
"off, or connected to a phone or another computer (a companion takes one "
"connection at a time)."
) from last_exc
async def _connect_owned_ble(self, mesh_core): # type: ignore[no-untyped-def]
"""Build the meshcore BLE client here and connect it, so we own its teardown.
``MeshCore.create_ble`` assembles a client, calls ``connect()`` on it, and hands it
back *only on success* — so a connect that **raises** leaves that client, and the
bleak link it has already opened, orphaned inside the library with no reference we
could close. That is not a hypothetical: a PIN-protected companion answers the
unbonded notify-subscribe with a GATT authentication error, which is raised from
deep inside ``connect()`` after bleak has brought the link up. Our own
:meth:`disconnect` then does nothing (``_mc`` was never assigned), Windows holds the
ACL link for the life of the process, and the peripheral — still believing it has a
peer — **stops advertising**. The PIN dialog that opens next therefore asks for a
code it can no longer deliver: the retry can't find the device, and the user reads a
correct PIN being refused. Assembling the same two objects here costs three lines and
keeps the handle, so every exit puts the link back down.
Both public failure shapes are covered, matching what ``create_ble`` does on the one
it bothers to handle: a raise (closed, then re-raised) and a ``None`` from the
identity handshake (closed, then reported as "not a companion").
Args:
mesh_core: The imported ``meshcore.MeshCore`` class.
Returns:
The connected ``MeshCore`` client, or ``None`` if the transport opened but the
peripheral never answered the identity handshake.
Raises:
Exception: Whatever ``connect`` raised — but not before the link is closed.
"""
from meshcore import BLEConnection
# Withhold the PIN on macOS. Handed one, ``BLEConnection.connect`` calls bleak's
# ``client.pair()``, and CoreBluetooth has no pairing API at all — the macOS
# backend raises ``NotImplementedError`` outright — whereupon the library
# disconnects and re-raises, so supplying a *correct* PIN is what breaks the
# connection. Apple's model is that pairing is the OS's to run, not ours: the
# companion firmware puts its UART characteristic at ENC+MITM
# (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on ESP32), so
# the unbonded subscribe below is answered with "Insufficient Authentication",
# and macOS reacts by running Passkey Entry and prompting for the code itself.
# Saying nothing here is therefore what *lets* a PIN-protected companion bond;
# the OS keeps the bond, and later connections need no PIN. Windows and Linux do
# expose explicit pairing, and keep it (see :meth:`_pair_ble_windows`).
# Linux is the same story from the other side: bleak's BlueZ ``pair()`` ignores the
# PIN and pairs through whatever system agent there is (a desktop dialog, or nothing
# at all), so MeshTerm pairs there itself beforehand (see :meth:`_pair_ble_bluez`).
# Handing bleak the PIN would only start a second pairing we can't answer.
pin = None if sys.platform == "darwin" or sys.platform.startswith("linux") else self._pin
connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin)
seen = _hold_disconnects_while_connecting(connection)
mc = mesh_core(
connection,
default_timeout=self._connect_timeout,
auto_reconnect=False,
)
try:
started = await mc.connect()
except BaseException:
self._ble_client = _held_client(connection, seen)
await MeshCoreDevice._discard_meshcore(mc)
await self._release_ble_client()
raise
finally:
seen.connecting = False
# Take our own reference to the bleak client now, while ``BLEConnection`` still holds
# one. It drops it the instant the peripheral goes away, and by then nothing else can
# reach the object that needs closing (see :meth:`_release_ble_client`).
self._ble_client = _held_client(connection, seen)
if started is None:
await MeshCoreDevice._discard_meshcore(mc)
await self._release_ble_client()
return None
return mc
@staticmethod
async def _discard_meshcore(mc) -> None: # type: ignore[no-untyped-def]
"""Close a client whose ``connect`` didn't complete. Bounded, and never raises.
The graceful ``mc.disconnect()`` is **not sufficient on its own here**, and that is
the whole subtlety of this path. ``ConnectionManager.disconnect`` closes the
transport only ``if self._is_connected`` — a flag it sets *after*
``connection.connect()`` returns. A connect that raised (the GATT authentication
error, thrown from ``start_notify`` well after bleak brought the link up) never got
that far, so the manager is certain there is nothing to close while ``BLEConnection``
is still holding a live, connected ``BleakClient``. Calling only the graceful path
therefore looks like a teardown and leaks the link anyway — which is exactly how the
first attempt at this fix still left the peripheral off the air.
So both halves run: the graceful call first (it stops the dispatcher and cancels any
reconnect task), then the transport closed directly, which is what actually drops the
link. ``BLEConnection.disconnect`` re-checks ``client.is_connected``, so the second
close is a no-op whenever the first one did the job.
Shielded on purpose. The other way into this method is a probe whose ``wait_for``
expired and cancelled the handshake mid-flight; a plain ``await`` would then be
cancelled itself the moment it suspended, abandoning the very teardown it was called
to perform and leaking exactly the link this exists to close. Shielding lets the
close finish on its own while the cancellation continues to propagate to our caller.
Args:
mc: A half-open ``meshcore.MeshCore`` client.
"""
async def _close() -> None:
try:
await asyncio.wait_for(mc.disconnect(), timeout=_DISCARD_TIMEOUT_S)
except Exception as exc: # noqa: BLE001 - the forced close is the real one
_log.debug("graceful discard failed (%s); forcing the transport close", exc)
await MeshCoreDevice._force_close_transport(mc)
closing = asyncio.ensure_future(_close())
try:
await asyncio.shield(closing)
except BaseException as exc: # noqa: BLE001 - teardown of a doomed client
# Includes CancelledError: swallowed here only so the caller's own exception
# (or cancellation) is the one that propagates. ``closing`` runs on regardless.
_log.debug("discarding a half-open BLE client: %s", exc)
@staticmethod
async def _force_close_transport(mc) -> None: # type: ignore[no-untyped-def]
"""Cancel the dispatcher and close the raw transport directly. Best-effort, silent.
The escape hatch from both library traps: a dispatcher stop that deadlocks on its own
``queue.join()``, and a connection manager that refuses to close a transport it never
recorded as connected. Reaching past both is what actually releases the serial port or
the BLE link.
Args:
mc: The ``meshcore.MeshCore`` client to tear down.
"""
try:
stop = getattr(mc, "stop", None)
if stop is not None:
stop()
except Exception as exc: # noqa: BLE001 - best-effort force-stop
_log.debug("dispatcher force-stop failed: %s", exc)
try:
raw = getattr(getattr(mc, "connection_manager", None), "connection", None)
if raw is not None:
await asyncio.wait_for(raw.disconnect(), timeout=_FORCE_DISCONNECT_TIMEOUT_S)
except Exception as exc: # noqa: BLE001 - the link may already be gone
_log.debug("forced transport close failed: %s", exc)
async def _pair_ble(self, *, force: bool) -> bool:
"""Pair with the PIN through the OS, where MeshTerm has to (Windows, Linux).
One door for :meth:`_create_ble` and its stale-bond repair, so the connect path is the
same everywhere and only the ceremony differs. macOS has no pairing API and pairs on
its own (see :meth:`_open_ble_after_macos_pairing`).
Args:
force: Tear down an existing bond and pair afresh (see :meth:`_pair_ble_windows`).
Returns:
``True`` if an authenticated bond exists afterward.
"""
if not self._pin:
return False
if sys.platform == "win32":
return await self._pair_ble_windows(force=force)
if sys.platform.startswith("linux"):
return await self._pair_ble_bluez(force=force)
return False
async def _pair_ble_bluez(self, *, force: bool) -> bool:
"""Pair through BlueZ with our own agent answering the PIN (Linux only).
See :mod:`meshterm.core.bluez` for why bleak can't: BlueZ asks a pairing *agent* for
the passkey, and bleak registers none. Records what it found in ``_ble_pairing``, as
the Windows ceremony does, so a refusal can say which step failed.
Args:
force: Remove an existing bond first — the heal for one the device has lost.
Returns:
``True`` if an authenticated bond exists afterward.
"""
from . import bluez
if not self._pin or not bluez.is_mac(self._address or ""):
return False
outcome, status = await bluez.pair(self._address or "", self._pin, force=force)
self._ble_pairing = _BlePairing(outcome, status)
if outcome in ("failed", "error"):
_log.warning("BLE PIN pairing with %s failed: %s", self._address, status)
else:
_log.debug("BLE PIN pairing with %s: %s", self._address, outcome)
return outcome in ("paired", "reused")
async def _pair_ble_windows(self, *, force: bool) -> bool:
"""Establish an authenticated BLE bond via the WinRT ProvidePin ceremony (Windows only).
This is the one place a Bluetooth passkey is actually delivered to the peripheral.
bleak's own ``pair()`` on Windows is hardcoded to the ``CONFIRM_ONLY`` ("Just Works")
ceremony and never sends a PIN, so a companion that demands passkey pairing can't be
bonded through bleak at all — its authenticated UART characteristic keeps rejecting the
notify subscribe with *Insufficient Authentication*. We instead run the ``PROVIDE_PIN``
ceremony directly against WinRT (the same one the Windows "Add device" dialog uses),
handing it :attr:`_pin`, which yields the ``ENCRYPTION_AND_AUTHENTICATION`` bond the
characteristic needs. Windows persists the bond, so subsequent sessions reconnect with
no PIN required.
Best-effort and self-contained: it returns a bool rather than raising, and swallows any
error (winrt projection absent, device out of range, API quirk) so the caller simply
falls through to the normal connect — whose auth-error translation still yields the
right message. A no-op (returns ``False``) off Windows, when no PIN is set, or when the
address isn't a parseable MAC.
Args:
force: When ``False``, an existing bond is trusted and reused (the fast path). When
``True``, any existing bond is torn down first and re-created with the PIN — used
to heal a stale, unauthenticated "Just Works" bond that a plain reconnect can't.
Returns:
``True`` if an authenticated bond exists afterward (freshly paired or already
bonded), ``False`` otherwise.
"""
if sys.platform != "win32" or not self._pin:
return False
address = self._ble_address_int(self._address or "")
if address is None:
return False
try:
from winrt.windows.devices.bluetooth import BluetoothLEDevice
from winrt.windows.devices.enumeration import (
DevicePairingKinds,
DevicePairingProtectionLevel,
DevicePairingResultStatus,
)
except Exception as exc: # noqa: BLE001 - winrt projection unavailable; fall through
_log.debug("BLE PIN pairing unavailable (winrt import failed): %s", exc)
return False
# Every exit records what it found in ``_ble_pairing``, so a refusal further on can
# say which of these it was — the bool alone is only enough to decide on a retry.
device = None
try:
device = await BluetoothLEDevice.from_bluetooth_address_async(address)
if device is None:
self._ble_pairing = _BlePairing("absent")
return False # out of range / not connectable right now
pairing = device.device_information.pairing
if pairing.is_paired:
if not force:
self._ble_pairing = _BlePairing("reused")
return True # trust the existing (authenticated) bond — fast path
# Tear the stale bond down, then re-fetch: the pairing object is a snapshot and
# won't reflect the unpair, so a fresh device_information is needed to re-pair.
await pairing.unpair_async()
MeshCoreDevice._close_ble_device(device) # release the pre-unpair handle
device = await BluetoothLEDevice.from_bluetooth_address_async(address)
if device is None:
self._ble_pairing = _BlePairing("absent")
return False
pairing = device.device_information.pairing
custom = pairing.custom
pin = self._pin
def _provide_pin(_sender, args) -> None: # noqa: ANN001 - winrt callback
# The peripheral asked for a passkey; hand it the one we were given.
args.accept_with_pin(pin)
token = custom.add_pairing_requested(_provide_pin)
try:
result = await custom.pair_with_protection_level_async(
DevicePairingKinds.PROVIDE_PIN,
DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION,
)
finally:
custom.remove_pairing_requested(token)
status = int(result.status)
ok = status in (
int(DevicePairingResultStatus.PAIRED),
int(DevicePairingResultStatus.ALREADY_PAIRED),
)
name = str(getattr(result.status, "name", status)).lower().replace("_", " ")
if ok:
self._ble_pairing = _BlePairing("paired")
_log.debug("BLE ProvidePin pairing for %s: %s", self._address, name)
else:
# A warning, not debug: this is the line that tells a wrong PIN from a device
# that refused to pair, and the default log level would otherwise hide it.
self._ble_pairing = _BlePairing("failed", name)
_log.warning("BLE PIN pairing with %s failed: %s", self._address, name)
return ok
except Exception as exc: # noqa: BLE001 - best-effort; caller falls through on False
self._ble_pairing = _BlePairing("error", str(exc))
_log.warning("BLE PIN pairing with %s raised: %s", self._address, exc)
return False
finally:
MeshCoreDevice._close_ble_device(device)
@staticmethod
def _ble_address_int(address: str) -> int | None:
"""Parse a ``AA:BB:CC:DD:EE:FF`` (or dash-separated) MAC into the ulong WinRT wants.
Args:
address: The Bluetooth address string bleak reported for the device.
Returns:
The 48-bit address as an int, or ``None`` if it isn't a 12-hex-digit MAC (e.g. a
CoreBluetooth UUID on macOS, where this pairing path doesn't apply anyway).
"""
cleaned = address.replace(":", "").replace("-", "").strip()
if len(cleaned) != 12:
return None
try:
return int(cleaned, 16)
except ValueError:
return None
@staticmethod
def _close_ble_device(device) -> None: # noqa: ANN001 - winrt BluetoothLEDevice
"""Release a WinRT ``BluetoothLEDevice`` handle, dropping the OS's link to the peripheral.
Every ``BluetoothLEDevice.from_bluetooth_address_async`` hands back an ``IClosable`` that
pins the operating system's ACL connection to the radio open for as long as the object is
alive. Unpairing removes the *bond* but never tears down that *link* — the link only goes
away when the last handle to it closes. If we leak the handle, Windows reports the device
as still "connected" (just unpaired), the peripheral never sees a clean disconnect, and it
refuses to re-pair until it is power-cycled. So every helper that opens one of these must
close it, even on the error paths.
Best-effort and silent: a missing ``close`` projection or a double-close is not worth
surfacing during teardown.
"""
try:
if device is not None:
device.close()
except Exception as exc: # noqa: BLE001 - releasing a handle must never raise
_log.debug("BLE device handle close failed: %s", exc)
@staticmethod
async def is_ble_paired(address: str) -> bool:
"""Whether the OS holds a bond for the BLE peripheral at ``address`` (Windows, Linux).
The read-only companion to :meth:`_pair_ble_windows` / :meth:`unpair_ble`: it asks
WinRT whether an OS-level pairing exists, so the UI can decide whether an "unpair"
affordance is meaningful (a device bonded with a PIN) or moot (an open companion that
never bonded, or a serial link). It reflects the *OS bond*, not MeshTerm's remembered
record — the two are independent — and holds true across sessions even when this run
supplied no PIN, because Windows persists the bond.
Best-effort and self-contained: returns ``False`` (rather than raising) off Windows,
when the winrt projection is unavailable, when the address isn't a parseable MAC, or on
any WinRT hiccup — so a caller can treat it as a plain "is there anything to unpair?".
Args:
address: The Bluetooth MAC (``AA:BB:CC:DD:EE:FF`` or dash-separated) to query.
Returns:
``True`` only when Windows reports a live bond for the device.
"""
if sys.platform.startswith("linux"):
from . import bluez
return bluez.is_mac(address or "") and await bluez.is_paired(address)
if sys.platform != "win32":
return False
addr = MeshCoreDevice._ble_address_int(address or "")
if addr is None:
return False
try:
from winrt.windows.devices.bluetooth import BluetoothLEDevice
except Exception as exc: # noqa: BLE001 - winrt projection unavailable; nothing to unpair
_log.debug("BLE pairing query unavailable (winrt import failed): %s", exc)
return False
device = None
try:
device = await BluetoothLEDevice.from_bluetooth_address_async(addr)
if device is None:
return False
return bool(device.device_information.pairing.is_paired)
except Exception as exc: # noqa: BLE001 - a status hiccup is not a bond
_log.debug("BLE pairing query failed for %s: %s", address, exc)
return False
finally:
MeshCoreDevice._close_ble_device(device)
@staticmethod
async def unpair_ble(address: str) -> bool:
"""Drop the OS-level bond for the BLE peripheral at ``address`` (Windows and Linux).
The inverse of :meth:`_pair_ble_windows`: it removes the persisted
``ENCRYPTION_AND_AUTHENTICATION`` bond so the next connection has to re-run the PIN
ceremony from scratch — the "forget this pairing" primitive behind the quit dialog's
*Unpair & quit*. It touches only the OS bond, never MeshTerm's remembered-device record,
which is deliberately left intact (the device keeps its friendly name and stays in the
picker; it just asks for its PIN again next time).
Call it only *after* the companion link is torn down — you can't cleanly drop a bond that
an open connection is still using. Best-effort and self-contained: returns a bool rather
than raising, and no-ops (returns ``False``) off Windows, when winrt is unavailable, when
the address isn't a MAC, or when there is no bond to remove.
Args:
address: The Bluetooth MAC (``AA:BB:CC:DD:EE:FF`` or dash-separated) to unpair.
Returns:
``True`` if a bond was removed, ``False`` if there was nothing to unpair or the
attempt failed.
"""
if sys.platform.startswith("linux"):
from . import bluez
return bluez.is_mac(address or "") and await bluez.unpair(address)
if sys.platform != "win32":
return False
addr = MeshCoreDevice._ble_address_int(address or "")
if addr is None:
return False
try:
from winrt.windows.devices.bluetooth import BluetoothLEDevice
from winrt.windows.devices.enumeration import DeviceUnpairingResultStatus
except Exception as exc: # noqa: BLE001 - winrt projection unavailable; fall through
_log.debug("BLE unpair unavailable (winrt import failed): %s", exc)
return False
device = None
try:
device = await BluetoothLEDevice.from_bluetooth_address_async(addr)
if device is None:
return False
pairing = device.device_information.pairing
if not pairing.is_paired:
return False # nothing bonded — treat as a no-op success-of-intent
result = await pairing.unpair_async()
status = int(result.status)
ok = status == int(DeviceUnpairingResultStatus.UNPAIRED)
_log.debug("BLE unpair for %s: status=%d ok=%s", address, status, ok)
return ok
except Exception as exc: # noqa: BLE001 - best-effort teardown; never crash exit
_log.debug("BLE unpair attempt failed for %s: %s", address, exc)
return False
finally:
# Closing the handle is what actually drops the OS's link to the peripheral; without
# it the device stays "connected" after the unpair and won't re-pair until rebooted.
MeshCoreDevice._close_ble_device(device)
async def _ble_os_bonded(self) -> bool:
"""Whether the OS holds a bond for this device — asked only to explain a refusal.
An instance hook over :meth:`is_ble_paired` so tests can answer it without the OS.
"""
return await MeshCoreDevice.is_ble_paired(self._address or "")
async def _ble_auth_failure(self) -> DeviceAuthenticationError:
"""Name *which* authentication failure this was, and what fixes it.
The GATT refusal looks the same on the wire whatever caused it, but the causes don't
share a remedy, and one sentence covering all of them sent people to the wrong one — a
stale Windows bond, say, was reported as "requires a PIN" to someone whose device
Windows plainly showed as paired. So the error is built from what is actually known:
* macOS — the OS runs the pairing in its own dialog; say so.
* No PIN, and the OS holds a bond — the bond is stale (the device was reflashed,
reset, or given a new PIN since), and re-pairing is the fix.
* No PIN, no bond — the device needs its PIN.
* A PIN, and MeshTerm's own pairing (WinRT on Windows, a BlueZ agent on Linux)
reported a status — a wrong PIN, a device that refused to pair, or some other
status, each named.
* A PIN, the pairing succeeded, and the device still refused — it is holding an old
bond for this computer.
* A PIN and nothing more known — the PIN was rejected.
The OS is named in the message (Windows, Linux) because each has its own place to
forget a bond, and that is half of most of these remedies. Every case is also logged,
so a log sent in from another machine says which it was.
Returns:
The error to raise, carrying a one-line :attr:`~DeviceAuthenticationError.hint`
for the PIN dialog.
"""
where = self._address or "the selected Bluetooth device"
# The OS bond changes the message in exactly one case, so it is asked only there.
bonded = _ble_host() is not None and not self._pin and await self._ble_os_bonded()
message, hint = self._ble_auth_diagnosis(where, bonded)
_log.warning("BLE connect to %s refused: %s", where, message)
return DeviceAuthenticationError(message, hint=hint)
def _ble_auth_diagnosis(self, where: str, os_bonded: bool) -> tuple[str, str]:
"""The message and dialog hint for :meth:`_ble_auth_failure` (see there for the cases).
Args:
where: The device, as the message names it.
os_bonded: Whether the OS holds a bond (asked only when no PIN was given).
Returns:
``(message, hint)``.
"""
if sys.platform == "darwin":
# macOS collects the code itself, in its own dialog, so --ble-pin is not the
# remedy here and naming it would send the reader somewhere that cannot help.
return (
f"{where} was not paired. macOS asks for the pairing code in its own dialog "
"rather than through MeshTerm — enter the 6-digit code shown on the device "
"(or in the MeshCore app) when it appears, and the bond is remembered for "
"next time. If no dialog appeared, check that Bluetooth is allowed for this "
"terminal in System Settings > Privacy & Security > Bluetooth.",
"",
)
host = _ble_host() or "The system"
forget = _ble_forget(where)
if not self._pin:
if os_bonded:
return (
f"{host} has {where} paired, but the device refused that pairing — it is "
"probably out of date (the device was reflashed, reset, or given a new PIN "
f"since). Pass --ble-pin <PIN> and MeshTerm will pair it again, or {forget} "
"and reconnect.",
f"{host}'s saved pairing was refused — the PIN pairs it again.",
)
once = (
" On Windows you may also need to pair the device once in Settings > Bluetooth "
"before it will connect."
if sys.platform == "win32"
else ""
)
return (
f"{where} requires a Bluetooth pairing PIN. Pass it with --ble-pin <PIN> (the "
f"6-digit code shown on the device or in the MeshCore app).{once}",
"",
)
pairing = self._ble_pairing
outcome = pairing.outcome if pairing else ""
status = pairing.status if pairing else ""
if outcome == "failed" and status in _PAIRING_REFUSED:
return (
f"{where} refused to pair ({status}) — it may be connected to a phone or "
"another computer, or still holding an old pairing for this one. Disconnect "
"it there or restart it, then try again.",
"The device refused to pair — restart it and try again.",
)
if outcome == "failed" and status not in _PAIRING_WRONG_PIN:
return (
f"{host} couldn't pair with {where} ({status}). Restart the device, {forget}, "
"and try again.",
f"{host} couldn't pair ({status}).",
)
if outcome == "absent":
return (
f"{host} couldn't reach {where} to pair it — it may be out of range, asleep, "
"or connected to another device.",
f"{host} couldn't reach the device to pair it.",
)
if outcome == "error":
return (
f"{host}'s pairing call failed for {where}: {status}",
f"{host}'s pairing call failed — the log has the detail.",
)
if outcome in ("paired", "reused"):
return (
f"{host} paired with {where}, but the device still refuses the connection — "
"it is probably holding an old pairing for this computer. Restart the device, "
f"{forget}, and try again.",
"Paired, but still refused — restart the device and retry.",
)
return (
f"{where} rejected the Bluetooth PIN — it needs pairing and the PIN provided "
"wasn't accepted. Double-check the 6-digit code shown on the device (or in the "
"MeshCore app) and pass it with --ble-pin, then try again.",
"That PIN was rejected — check the code and try again.",
)
def _no_response_message(self) -> str:
"""A clean, recoverable error for an endpoint that didn't answer as a companion."""
if self._transport == "ble":
# The link opened (a link that didn't has its own message), so range and power
# are not the question here — what answered is.
where = self._address or "the selected Bluetooth device"
return (
f"connected to {where} over Bluetooth, but it never answered as a MeshCore "
"companion — it may be running repeater or room server firmware, or be busy "
"with another app."
)
if self._transport == "tcp":
where = self.endpoint or "the selected network device"
return (
f"no response from a MeshCore companion at {where}; check the host and port, "
"and that the device is powered on, reachable on the network, and not already "
"connected to another client."
)
# ModemManager opens without locking, so the port opens fine and the handshake is
# what it spoils — which makes this, not the open error, where it has to be named.
modem = (
" On Linux, ModemManager may be probing it: `sudo systemctl stop ModemManager` "
"and try again."
if sys.platform.startswith("linux")
else ""
)
return (
f"no response from a MeshCore companion on {self._port}; it may not be a "
f"MeshCore device, or it may be powered off or in use by another program.{modem}"
)
async def link_present(self) -> bool: # noqa: D102 - inherited docstring
if self._mc is None:
return True # not connected yet / already torn down — nothing to declare lost
if self._transport in ("ble", "tcp"):
# The meshcore client flips ``is_connected`` to False the moment the transport
# drops — bleak's disconnect callback for BLE, a broken socket for TCP — so this is
# the network/Bluetooth analogue of the serial port-enumeration check: a cheap,
# non-transmitting liveness read.
try:
return bool(self._mc.is_connected)
except Exception: # noqa: BLE001 - a status hiccup must not fake a disconnect
return True
if not self._port:
return True # no port recorded (shouldn't happen once connected) — can't tell
return serial_port_present(self._port)
async def disconnect(self) -> None:
"""Close the connection and release resources. Idempotent, and bounded in time.
The graceful ``meshcore`` teardown is given :data:`_DISCONNECT_TIMEOUT_S` to finish.
That bound matters: the library's dispatcher stop awaits ``queue.join()``, but its
processor task exits after handling at most one event once stopped — so with two or
more events queued at that instant (a routine advert/RX-log burst on a live mesh)
the join deadlocks and a quit would hang until the exit watchdog force-kills the
process. When the graceful path doesn't return in time it is cancelled and the
teardown is forced instead: the dispatcher task is cancelled synchronously and the
raw transport is closed directly (also bounded), so the port/link is still released.
Over Bluetooth the ``bleak`` client is then closed directly as well, because none of
the above reaches it once the peripheral is the thing that went away — see
:meth:`_release_ble_client`.
"""
if self._mc is None:
await self._release_ble_client() # a link that dropped before we ever tore it down
return
mc, self._mc = self._mc, None
disconnect = getattr(mc, "disconnect", None)
if disconnect is None:
await self._release_ble_client()
return
try:
await asyncio.wait_for(disconnect(), timeout=_DISCONNECT_TIMEOUT_S)
except asyncio.TimeoutError:
_log.debug("graceful disconnect timed out; forcing transport teardown")
# Forced teardown: cancel the (possibly wedged) dispatcher task without awaiting
# the deadlocked join, then close the underlying transport so the serial port /
# BLE link is actually released. Best-effort — nothing here may block the exit.
await MeshCoreDevice._force_close_transport(mc)
except Exception as exc: # noqa: BLE001 - teardown must not raise; fall to forced path
_log.debug("graceful disconnect failed (%s); forcing transport teardown", exc)
await MeshCoreDevice._force_close_transport(mc)
await self._release_ble_client()
async def _release_ble_client(self) -> None:
"""Close the ``bleak`` client itself, whatever the library believes about its state.
The one teardown step that cannot be delegated, and the reason a Bluetooth session
could not be rebuilt after the companion was switched off. Every layer above declines
to act once the *peripheral* is what went away, each for its own locally-sensible
reason:
* ``BLEConnection.handle_disconnect`` — bleak's own dropped-link callback — restores
the connection's fields to what the caller originally passed, which sets
``self.client`` back to ``None``. The live client object is simply let go of.
* ``BLEConnection.disconnect`` then guards on ``self.client and
self.client.is_connected``, so it has nothing to close and does nothing.
* ``ConnectionManager.disconnect`` guards on ``self._is_connected``, which its own
drop handler already cleared, so it does nothing either.
* :meth:`_force_close_transport` reaches past both — but only as far as that same
``BLEConnection``, whose ``disconnect`` is the no-op above.
So on a drop nobody ever calls ``BleakClient.disconnect()``, and on Windows the WinRT
``BluetoothLEDevice`` and its GATT session stay open for the life of the process. The
next connect to that address is then served a broken service table: the link comes up,
and subscribing to the UART characteristic fails with *"Characteristic
6E400003-… was not found!"* — a device that is advertising, healthy, and unreachable
for as long as the app keeps running. A fresh process connects to it perfectly.
Hence our own reference, taken at connect (see :meth:`_connect_owned_ble`) while the
library still has one to give, and closed here **unconditionally** — never guarded on
``is_connected``, since the case that matters is precisely the one where it is already
``False``. Bounded and silent: the link is already gone, and a teardown may not raise
or hang. Idempotent, and a no-op on serial and TCP, which have no client to hold.
"""
client, self._ble_client = self._ble_client, None
if client is None:
return
disconnect = getattr(client, "disconnect", None)
if disconnect is None: # pragma: no cover - every bleak client has one
return
try:
await asyncio.wait_for(disconnect(), timeout=_FORCE_DISCONNECT_TIMEOUT_S)
except Exception as exc: # noqa: BLE001 - the link is already gone; best-effort
_log.debug("releasing the bleak client failed: %s", exc)
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()
info = dict(getattr(result, "payload", {}) or {})
# The firmware sends TX power as a signed byte (it accepts down to -9 dBm) and the
# library reads it unsigned, so a negative power would arrive as 247 and up.
power = info.get("tx_power")
if isinstance(power, int) and power > 127:
info["tx_power"] = power - 256
return info
async def get_device_info(self) -> dict: # noqa: D102 - inherited docstring
mc = self._require()
try:
result = await mc.commands.send_device_query()
except Exception: # noqa: BLE001 - older firmware lacks the query; unknown, not fatal
return {}
return dict(getattr(result, "payload", {}) or {})
#: How long the contacts stream may go quiet before a read is called failed (seconds).
#: The library's own ``get_contacts`` arms one future for the *whole* dump and never
#: re-arms it, so a table that takes longer than its five seconds to stream fails with
#: "no event received" however healthy the radio is — which is what a node holding four
#: hundred contacts does over Bluetooth. What actually means "the companion stopped
#: answering" is a gap *between* records, so that is what this times: the dump may take
#: as long as it takes, for as long as it keeps arriving.
_CONTACTS_IDLE_S = 6.0
async def _contacts_payload(
self, mc, *, retries: int = 3, delay: float = 0.5, idle: float | None = None
) -> dict: # noqa: ANN001
"""Fetch the raw contacts map, retrying a read that stalls or is refused.
The companion intermittently refuses or drops a contacts request — a recoverable
timing hiccup — so the read is attempted a few times with a short backoff before a
clean, actionable error is raised. One attempt is :meth:`_stream_contacts`.
Args:
mc: The connected ``MeshCore`` client.
retries: Number of extra attempts after the first.
delay: Seconds to wait between attempts.
idle: Seconds of silence that end an attempt (default :data:`_CONTACTS_IDLE_S`).
Returns:
The contacts payload mapping (possibly empty).
Raises:
DeviceCommandError: If every attempt fails to retrieve contacts.
"""
gap = self._CONTACTS_IDLE_S if idle is None else idle
reason = ""
for attempt in range(retries + 1):
payload, reason = await self._stream_contacts(mc, gap)
if payload is not None:
return 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 _stream_contacts(self, mc, idle: float) -> tuple[dict | None, str]: # noqa: ANN001
"""Run one contacts read, ending it on *silence* rather than on a deadline.
The contacts table arrives as one ``NEXT_CONTACT`` frame per record and a closing
``CONTACTS`` frame holding the whole map. A dump is therefore not one answer that is
either late or on time, it is a stream, and the only thing that distinguishes a slow
big table from a radio that has stopped talking is how long it has been since the
last record. So each record restarts the clock and only ``idle`` seconds of quiet
end the attempt — which is the whole of the fix for a four-hundred-contact node,
whose dump simply takes longer than any fixed deadline the library would allow it.
Args:
mc: The connected ``MeshCore`` client.
idle: Seconds of silence that end the attempt.
Returns:
``(payload, "")`` on success, or ``(None, reason)`` describing how it ended.
"""
from meshcore import EventType
loop = asyncio.get_running_loop()
finished: asyncio.Future = loop.create_future()
arrived = asyncio.Event()
seen = False
refusal = ""
def on_record(event) -> None: # noqa: ANN001 - meshcore Event
nonlocal seen
seen = True
arrived.set()
def on_end(event) -> None: # noqa: ANN001 - meshcore Event
if not finished.done():
finished.set_result(event)
def on_error(event) -> None: # noqa: ANN001 - meshcore Event
# An ERROR frame carries no request id, so it is only ours while nothing else
# can have earned it: before the first record it is this request being refused
# (a companion too busy to serve a dump answers ERR_CODE_BAD_STATE), and after
# one it belongs to whatever else is on the link — a battery poll, a courier
# send — where treating it as ours is how a read that was working stopped.
nonlocal refusal
payload = getattr(event, "payload", {}) or {}
refusal = str(payload.get("reason", payload))
if not seen and not finished.done():
finished.set_result(None)
subscriptions = [
mc.subscribe(EventType.NEXT_CONTACT, on_record),
mc.subscribe(EventType.CONTACTS, on_end),
mc.subscribe(EventType.ERROR, on_error),
]
try:
await mc.commands.get_contacts_async()
while not finished.done():
arrived.clear()
ticking = asyncio.ensure_future(arrived.wait())
try:
await asyncio.wait(
{finished, ticking},
timeout=idle,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
ticking.cancel()
if not finished.done() and not arrived.is_set():
break
finally:
for subscription in subscriptions:
subscription.unsubscribe()
event = finished.result() if finished.done() else None
if event is not None:
return dict(getattr(event, "payload", {}) or {}), ""
if refusal and not seen:
return None, refusal
if seen:
return None, "no event received during contacts retrieval — it stopped partway"
return None, "no event received during contacts retrieval"
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 {}
lat, lon = _contact_location(info)
contacts.append(
Contact(
name=info.get("adv_name", name),
public_key=info.get("public_key", ""),
key_prefix=info.get("public_key", "")[:12],
last_seen=advert_time(info.get("last_advert")),
node_type=_as_int(info.get("type", info.get("adv_type"))),
lat=lat,
lon=lon,
route_hops=_contact_route(info),
)
)
return contacts
#: How many bytes of a name the firmware's contact record holds (a 32-byte field).
_CONTACT_NAME_BYTES = 32
async def add_contact(self, node: Contact) -> None: # noqa: D102 - inherited docstring
mc = self._require()
pub = self._node_pubkey(node) # raises DeviceCommandError if it has no key
if len(pub) != 64:
raise DeviceCommandError(
f"{node.name} is known only by a key prefix, so it can't be added to the "
"device — receive an advert from it first."
)
# The library writes contacts through one add-or-update frame, from a record shaped
# exactly like the one a contacts read yields; a flood route (``out_path_len`` -1) is
# what a freshly-heard contact carries, and the device relearns a path from received
# traffic as usual.
record = {
"public_key": pub,
"type": int(node.node_type if node.node_type is not None else NODE_TYPE_CHAT),
"flags": 0,
"out_path": "",
"out_path_len": -1,
"out_path_hash_mode": 0,
"adv_name": _clip_utf8(node.name, self._CONTACT_NAME_BYTES),
"last_advert": int(node.last_seen.timestamp()) if node.last_seen else 0,
"adv_lat": float(node.lat or 0.0),
"adv_lon": float(node.lon or 0.0),
}
result = await mc.commands.add_contact(record)
if result is None or getattr(result, "is_error", lambda: False)():
raise DeviceCommandError(
f"couldn't add {node.name} to the device: {reject_reason(result)}"
)
async def remove_contact(self, node: Contact) -> None: # noqa: D102 - inherited docstring
mc = self._require()
pub = self._node_pubkey(node) # raises DeviceCommandError if it has no key
result = await mc.commands.remove_contact(pub)
if result is not None and getattr(result, "is_error", lambda: False)():
# A contact the firmware doesn't hold is the one rejection that isn't a
# failure: the list a screen removes from is the union of this table and the
# contacts MeshTerm remembers for the device, so the entry the reader is
# deleting may only ever have existed on our side. Same class the send path
# raises, for the same reason — the caller can finish the job.
if error_code(result) == _ERR_NOT_FOUND:
raise ContactNotOnDeviceError(node)
raise DeviceCommandError(
f"couldn't remove {node.name} from the device: {reject_reason(result)}"
)
async def get_tx_power(self) -> int | None: # 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()
# The firmware reads a signed byte; the library packs an unsigned int, which refuses a
# negative one. Two's complement is the same low byte, so -9 dBm survives the trip.
await mc.commands.set_tx_power(int(value) & 0xFFFFFFFF)
@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) -> LoginResult: # noqa: D102
from meshcore import EventType
mc = self._require()
pub = self._node_pubkey(node)
loop = asyncio.get_running_loop()
# Listen for the node's answer *before* transmitting, and keep listening across the
# whole exchange — the rule :meth:`run_trace` follows, for the same two reasons, and
# ``send_login_sync`` breaks both of them.
#
# It waits for LOGIN_SUCCESS and *only* LOGIN_SUCCESS, and it does not start waiting
# until its own send has returned. So:
#
# * A refusal — which firmware does send, as a LOGIN_FAILED frame the library parses
# and dispatches like any other event — is never waited for, and times out exactly
# like an unreachable node. Both halves of failure came back as the same ``None``.
# * Worse, for a node that is perfectly reachable: the send itself blocks until a
# MSG_SENT arrives, and the library correlates that acknowledgement by nothing but
# its event type — so a scheduled advert, a telemetry poll or the courier can
# consume ours and leave the send sitting on its own 15-second default. Every
# answer that lands during that stall is dispatched to no listener and dropped, and
# a login the repeater *accepted* is recorded as no reply. The mirror case is as
# bad: catching some other command's MSG_SENT takes its ``suggested_timeout`` with
# it, which for a neighbour is a second or two — nowhere near a multi-hop
# repeater's round trip. This is the reported bug: a healthy node, the right
# password, and a no-reply popup.
#
# Both go away by owning the wait. ``send_login_sync`` is still what transmits (it is
# the library's supported path, and its own listener is harmless — the dispatcher
# delivers to every matching subscription), but ours is armed first and outlives it:
# when it gives up early, the node still gets a full budget of its own, sized to the
# route the way a trace to the same node would be, and counted from the send rather
# than from the queuing — see the wait below for why that distinction is the fix.
answer: asyncio.Future = loop.create_future()
# Frames that landed during the exchange but are not our verdict. Kept only to be
# logged beside the outcome: a "no reply" with one of these next to it is a
# different fault from a node that stayed silent, and nothing else would show it.
stray: list[str] = []
def note(what: str, event) -> None: # noqa: ANN001 - meshcore Event
if len(stray) < _LOGIN_STRAY_LOG_CAP:
stray.append(f"{what} {getattr(event, 'payload', None)!r}")
def on_answer(event) -> None: # noqa: ANN001 - meshcore Event
if answer.done():
return
if self._refers_to(event, pub):
answer.set_result(event)
else:
# An answer naming some other node — two admin flows can overlap. Not ours
# to act on, but worth saying we heard it: a login that reports silence with
# one of these logged is a key prefix that did not match, not a quiet node.
note("login frame for another node:", event)
def on_local_error(event) -> None: # noqa: ANN001 - meshcore Event
# The one failure that never reaches the mesh: the companion refusing to send.
# ``send_login_sync`` erases it — it turns its own ERROR into a bare ``None``,
# indistinguishable from an acknowledgement that was merely slow — so the reason
# is only recoverable by listening for the frame. Uncorrelated (an ERROR carries
# no request id and may belong to another command in flight), so it is evidence
# for the log and never a verdict.
note("companion error:", event)
subscriptions = [
mc.subscribe(EventType.LOGIN_SUCCESS, on_answer),
mc.subscribe(EventType.LOGIN_FAILED, on_answer),
mc.subscribe(EventType.ERROR, on_local_error),
]
# A login is a round trip along the contact's route and back, which is the shape a
# trace budget already describes; the stored route is one-way, so the wire carries
# twice its hops. Knowing the route only ever buys *more* patience, never less: an
# admin exchange is heavier than a trace's single small packet, so a contact we hold
# a short route for must not be given a narrower window than the routeless one
# beside it — which is exactly what ``trace_timeout(0)``, the flood budget, is.
hops = 2 * len(node.route_hops or ())
budget = max(trace_timeout(hops), trace_timeout(0))
started = loop.time()
try:
async with self.transmitting():
sent = await mc.commands.send_login_sync(pub, password)
# The budget times the *node's* answer, so it is spent from the moment the
# request is on the air — not from the moment we began queuing it. Everything
# before that belongs to the companion, and it can be most of a minute:
# ``send_login_sync`` first waits its turn on the library's mesh-request lock,
# which every telemetry poll and courier retry holds for a whole round trip, and
# then blocks until a MSG_SENT it correlates by event type alone arrives. Timing
# the repeater from before all that is what made the last fix a longer way of
# failing at the same ten seconds: the wait was widened, then handed a window
# some other command had already spent.
sent_at = loop.time()
if not answer.done():
# Shielded: a timeout here must leave the future readable, not cancel it.
try:
await asyncio.wait_for(asyncio.shield(answer), budget)
except asyncio.TimeoutError:
pass
finally:
for subscription in subscriptions:
subscription.unsubscribe()
event = answer.result() if answer.done() else None
if event is None and getattr(sent, "type", None) is EventType.LOGIN_SUCCESS:
# Cannot normally happen — our subscription was registered first, so anything the
# library's own wait saw, ours saw too. Deferring to it anyway costs nothing and
# can only ever turn a false no-reply into the acceptance it really was, which is
# the direction this whole method is trying to fail in.
event = sent
etype = getattr(event, "type", None)
# Three numbers, because a no-reply has three different causes and they are told
# apart only by where the time went: a send that took seconds means the request sat
# behind another command, a send that returned at once with no acknowledgement means
# the companion never put it on the air, and a full budget spent after a clean send
# means the node really did stay silent.
_log.debug(
"admin login to %s: send %s in %.0fms, budget=%.1fs -> %s after %.0fms%s",
node.name,
"acknowledged" if sent is not None else "unacknowledged",
(sent_at - started) * 1000.0,
budget,
etype,
(loop.time() - sent_at) * 1000.0,
f" [also heard: {'; '.join(stray)}]" if stray else "",
)
if etype is EventType.LOGIN_SUCCESS:
return LoginResult.ACCEPTED
if etype is EventType.LOGIN_FAILED:
return LoginResult.REFUSED
# Nothing came back — including the local-ERROR case, where the companion would not
# even send the request. Either way we never heard the node, so the password stands
# unproven rather than disproven and the caller must keep it.
return LoginResult.NO_REPLY
@staticmethod
def _refers_to(event: object, pubkey: str) -> bool:
"""Is this login frame about the node we addressed?
The firmware stamps a login reply with the *answering node's* 6-byte key prefix when
the frame is long enough to carry one; older/terser frames arrive bare. So this
matches when there is something to match on and accepts the frame otherwise — the
alternative, demanding a prefix, would silently downgrade every answer from terse
firmware into a no-reply, which is the failure this whole path exists to stop.
"""
payload = getattr(event, "payload", None) or {}
prefix = str(payload.get("pubkey_prefix") or "").lower().removeprefix("0x")
return not prefix or pubkey.lower().startswith(prefix)
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)
async with self.transmitting():
sent = await mc.commands.send_cmd(pub, cmd)
if sent is not None and getattr(sent, "is_error", lambda: False)():
raise DeviceCommandError(
f"couldn't send admin command {cmd!r} to {node.name}: {reject_reason(sent)}"
)
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 send_remote_command( # noqa: D102 - inherited docstring
self, node: Contact, command: str, *, timeout: float = 8.0
) -> str | None:
transmit_gate.mark()
return await self._send_admin_cmd(node, command, timeout=timeout)
async def get_remote_tx_power(self, node: Contact) -> int | None: # 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 fetch_neighbours(self, node: Contact) -> list[NeighbourInfo]: # noqa: D102
mc = self._require()
pub = self._node_pubkey(node)
# The library pages through the table (one binary request per ~25 entries) and
# concatenates; ``min_timeout`` keeps slow multi-hop replies from being cut off
# at the companion's optimistic suggested timeout.
async with self.transmitting():
result = await mc.commands.fetch_all_neighbours(pub, min_timeout=20)
if result is None:
raise DeviceCommandError(
f"{node.name!r} did not answer the neighbour request. Firmware ignores "
"it without an admin login (log in first), and firmware older than "
"~v1.15 has no neighbour table at all."
)
now = utcnow()
neighbours: list[NeighbourInfo] = []
for entry in result.get("neighbours") or []:
pubkey = str(entry.get("pubkey") or "").lower()
if not pubkey:
continue
snr = entry.get("snr")
secs_ago = entry.get("secs_ago")
heard_at = None
if isinstance(secs_ago, (int, float)) and secs_ago >= 0:
heard_at = now - timedelta(seconds=float(secs_ago))
neighbours.append(
NeighbourInfo(
node=pubkey,
snr=float(snr) if snr is not None else None,
heard_at=heard_at,
)
)
return neighbours
async def request_regions(self, node: Contact) -> list[str]: # noqa: D102
mc = self._require()
pub = self._node_pubkey(node)
transmit_gate.mark()
# ``min_timeout`` for the reason the neighbour request carries one: the companion's
# suggested timeout is optimistic for anything but an adjacent node.
async with self.transmitting():
text = await mc.commands.req_regions_sync(pub, min_timeout=10)
if text is None:
raise DeviceCommandError(
f"{node.name!r} did not answer the regions request. A repeater answers it "
"only when it arrives direct (a neighbour, or a contact with a route), at "
"most every few seconds, and only on firmware 1.12 or newer."
)
return parse_region_list(text)
async def run_trace( # noqa: D102 - inherited docstring
self,
target: str,
*,
path: str | None = None,
timeout: float | None = None,
) -> TraceResult:
transmit_gate.mark()
from meshcore import EventType # local import keeps mock path dependency-free
mc = self._require()
tag = random.randint(0, 0xFFFFFFFF)
loop = asyncio.get_running_loop()
started = 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: bytes | None = 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
# Size the reply-wait to the route unless the caller pinned it. ``path_bytes`` is
# the whole walk — out plus the mirrored return leg — so its entry count (each
# ``1 << flags`` bytes wide) is the number of relay transmissions the packet makes
# before the reply reaches us. A path-less flood leaves the count unknown (0).
if timeout is None:
hops_walked = len(path_bytes) // (1 << flags) if path_bytes else 0
timeout = trace_timeout(hops_walked)
# Listen for our tag *before* transmitting, and keep listening for the whole
# trace. ``send_trace`` doesn't return until the companion's ``MSG_SENT``
# arrives, and the library correlates that acknowledgement by nothing but its
# event type — so any other command in flight (a scheduled advert, a telemetry
# poll, the courier) can consume ours and leave the send blocked on its own
# 15-second default. Subscribing afterwards would mean every reply that landed
# during that stall was dispatched to no listener and dropped, and the trace
# recorded as "no reply" though the mesh answered it — which is why a burst of
# background traffic used to fail *every* trace for as long as it lasted, not
# just the one it collided with. The reply's own arrival time is stamped in the
# handler so a stalled send inflates no round trip.
reply: asyncio.Future = loop.create_future()
landed = started
def on_trace_reply(event) -> None: # noqa: ANN001 - meshcore Event
nonlocal landed
if not reply.done():
landed = loop.time()
reply.set_result(event)
subscription = mc.subscribe(EventType.TRACE_DATA, on_trace_reply, {"tag": tag})
try:
sent = await mc.commands.send_trace(auth_code=0, tag=tag, flags=flags, path=path_bytes)
if getattr(sent, "is_error", None) is not None and sent.is_error():
# Uncorrelated acknowledgements make this ambiguous — the error may
# belong to another command entirely — so it is evidence, not a verdict:
# the trace is still on the air and its reply may yet arrive.
_log.debug("trace %08x: send reported %s", tag, sent.payload)
try:
event = await asyncio.wait_for(reply, timeout)
except asyncio.TimeoutError:
event = None
finally:
subscription.unsubscribe()
elapsed_ms = (landed - started) * 1000.0
_log.debug(
"trace %08x: path=%s flags=%d timeout=%.1fs -> %s",
tag,
path_bytes.hex() if path_bytes else "(none)",
flags,
timeout,
f"{elapsed_ms:.0f}ms" if event is not None else "no reply",
)
# 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) -> tuple[bytes, int] | None: # 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 outbound leg at the contact's key
prefix, prepending any learned repeater hops (``out_path``) ahead of it — and,
since the trace protocol has no separate return-path field, mirror those same
repeaters back afterwards (see :func:`~meshterm.services.topology.render_forced_spec`,
which does the same for a composed/adopted path): without an explicit return
leg the repeaters have nothing to relay the reply through, so it never comes
home.
* direct neighbor / no learned route → just ``[destination]`` (no repeaters
to mirror, so the outbound leg is the whole path);
* learned multi-hop route → ``[repeater…, destination, repeater… (reversed)]``.
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.
"""
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)
repeaters: 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]
repeaters = [route[i * size : i * size + trace_size] for i in range(out_path_len)]
dest = bytes.fromhex(pub)[:trace_size]
# The outbound leg always finishes at the destination's own hash so it
# recognizes the trace and replies; the return leg mirrors the same
# repeaters back to us, since nothing reflects the packet automatically.
path_bytes = b"".join(repeaters) + dest + b"".join(reversed(repeaters))
return path_bytes, path_hash_flags(trace_size) or 0
return None
async def subscribe_events( # noqa: D102 - inherited docstring
self, on_event: EventCallback
) -> Unsubscribe:
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."
)
def observation_handler(kind: str): # type: ignore[no-untyped-def]
def handler(event) -> None: # noqa: ANN001
obs = observation_from_event(event, kind)
if obs is not None:
on_event(MeshEvent.observation_event(obs))
return handler
def message_handler(event) -> None: # noqa: ANN001
msg = message_from_event(event)
if msg is not None:
on_event(MeshEvent.message_event(msg))
def ack_handler(event) -> None: # noqa: ANN001
on_event(MeshEvent.ack_event(ack_from_event(event)))
def packet_handler(event) -> None: # noqa: ANN001
obs = packet_observation_from_event(event)
if obs is not None:
on_event(MeshEvent.observation_event(obs))
# Subscribe to whichever event types this firmware/library build exposes. The
# event payload field names the mappers read 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, observation_handler(kind)))
# The companion's RX packet log, when its firmware has packet logging enabled:
# every overheard frame arrives with the relay path it traversed — the passive
# topology evidence the trace path composer suggests hops from. Firmware without
# RX logging simply never pushes these; subscribing is free either way.
etype = getattr(EventType, "RX_LOG_DATA", None)
if etype is not None:
subs.append(subscribe(etype, packet_handler))
for attr in ("CONTACT_MSG_RECV", "CHANNEL_MSG_RECV"):
etype = getattr(EventType, attr, None)
if etype is not None:
subs.append(subscribe(etype, message_handler))
etype = getattr(EventType, "ACK", None)
if etype is not None:
subs.append(subscribe(etype, ack_handler))
# Trace replies aren't observations — the issuing ``run_trace`` consumes them by
# tag — but logging every one that lands is what makes a "no reply" diagnosable:
# a reply logged here with no matching ``trace <tag>`` line means the walk came
# home and we weren't listening, which is a different fault from silence on air.
etype = getattr(EventType, "TRACE_DATA", None)
if etype is not None:
subs.append(
subscribe(
etype,
lambda event: _log.debug(
"trace reply heard: tag=%08x",
(getattr(event, "payload", {}) or {}).get("tag", 0),
),
)
)
# Drive the inbound-message pull ourselves (see ``_message_pump``): MeshCore never
# pushes message bodies, so without this sending works but nothing is received.
stop_pump = self._message_pump(mc, subs, subscribe)
def unsubscribe() -> None:
stop_pump()
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 unsubscribe
def _message_pump(self, mc, subs: list, subscribe) -> Unsubscribe: # type: ignore[no-untyped-def]
"""Continuously pull inbound messages from the companion (the RX pull model).
MeshCore doesn't push message bodies unsolicited: the device raises a
``MESSAGES_WAITING`` notification and the client must call ``get_msg()`` to retrieve
each queued message, which the library's reader then dispatches as
``CONTACT_MSG_RECV`` / ``CHANNEL_MSG_RECV`` to the handler registered above (a
command's own temporary listener does not consume the event — every subscriber
still sees it). We drive that pull three ways so it is robust across firmware
builds: an immediate drain (delivers anything already queued), a drain on each
``MESSAGES_WAITING`` push (low latency), and a slow timer (a safety net for builds
whose pushes are unreliable — the failure this fixes). Drains are serialized by a
lock so the overlapping triggers never issue concurrent ``get_msg`` commands.
Args:
mc: The connected ``MeshCore`` client.
subs: The subscription list to append the ``MESSAGES_WAITING`` sub to (so it is
torn down with the others).
subscribe: The client's ``subscribe`` callable.
Returns:
A zero-argument callable that stops the pump (its poll task and drains).
"""
from meshcore import EventType
stop = asyncio.Event()
draining = asyncio.Lock()
async def drain() -> None:
# Pull until the device reports the queue is empty; each retrieved message is
# delivered to our handler by the reader's dispatch, so there's nothing to do
# with the returned event but check whether to keep going.
async with draining:
while not stop.is_set():
try:
event = await mc.commands.get_msg(timeout=_MESSAGE_GET_TIMEOUT_S)
except Exception as exc: # noqa: BLE001 - transient; the poll retries
_log.debug("message pump: get_msg failed: %s", exc)
return
etype = getattr(event, "type", None)
if event is None or etype in (EventType.NO_MORE_MSGS, EventType.ERROR):
return
def schedule_drain(_event=None) -> None: # noqa: ANN001 - MESSAGES_WAITING callback
asyncio.ensure_future(drain())
async def poll_loop() -> None:
while not stop.is_set():
try:
await asyncio.wait_for(stop.wait(), _MESSAGE_POLL_INTERVAL_S)
except asyncio.TimeoutError:
await drain() # interval elapsed; sweep for anything the push missed
waiting = getattr(EventType, "MESSAGES_WAITING", None)
if waiting is not None:
subs.append(subscribe(waiting, schedule_drain))
schedule_drain() # immediate initial drain of anything already queued
poll_task = asyncio.ensure_future(poll_loop())
def stop_pump() -> None:
stop.set() # ends the poll loop and any in-flight drain at the next check
poll_task.cancel()
return stop_pump
async def send_direct_message( # noqa: D102 - inherited docstring
self, contact: Contact, text: str
) -> Ack | None:
transmit_gate.mark()
from meshcore import EventType
mc = self._require()
pub = self._node_pubkey(contact)
# Only the hand-over holds the transmit lock; the ack wait below is not a send.
async with self.transmitting():
result = await mc.commands.send_msg(pub, text)
if result is None or getattr(result, "is_error", lambda: False)():
# A recipient the firmware has no entry for is the one rejection with an obvious
# fix, so it gets its own class for the chat screen to offer that fix on; see
# :class:`ContactNotOnDeviceError` for how a listed contact can be missing here.
if error_code(result) == _ERR_NOT_FOUND:
raise ContactNotOnDeviceError(contact)
raise DeviceCommandError(f"couldn't send to {contact.name}: {reject_reason(result)}")
# The companion acknowledges the send immediately with an ``expected_ack`` code and
# a suggested wait; the recipient's delivery ACK arrives later carrying that code.
payload = getattr(result, "payload", {}) or {}
expected = payload.get("expected_ack")
expected_hex = expected.hex() if isinstance(expected, (bytes, bytearray)) else expected
if not expected_hex:
return None
suggested = payload.get("suggested_timeout")
timeout = suggested / 1000 * 1.2 if suggested else 8.0
ack = await mc.wait_for_event(
EventType.ACK, attribute_filters={"code": expected_hex}, timeout=timeout
)
return ack_from_event(ack) if ack is not None else None
async def send_channel_message( # noqa: D102 - inherited docstring
self, index: int, text: str
) -> None:
transmit_gate.mark()
async with self.transmitting():
self._ok(await self._require().commands.send_chan_msg(index, text))
@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 the command: {reject_reason(event)}")
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 {}
# The firmware stores floats and reports them ×1000 (rx_delay_base * 1000,
# airtime_factor * 1000); undo that so callers see the real values.
return {
"rx_delay": int(payload.get("rx_delay", 0)) / 1000.0,
"airtime_factor": int(payload.get("airtime_factor", 0)) / 1000.0,
}
#: The hop limit that arrived in the same frame as the last bitmask, until it is taken.
_autoadd_hops: object = _UNREAD
async def get_autoadd_config(self) -> int | None: # noqa: D102 - inherited docstring
mc = self._require()
# One reply carries the bitmask *and* the hop limit, but the library's parser keeps
# only the first. So the reader is tapped for the length of this one read — every
# transport hands frames to ``reader.handle_rx`` by attribute lookup, so an instance
# attribute sees them first — and the raw frame's second byte is kept.
reader = getattr(mc, "_reader", None)
frames: list[bytes] = []
tapped = reader is not None and "handle_rx" not in vars(reader)
if tapped:
original = reader.handle_rx
async def tap(data: bytearray) -> None:
if data and data[0] == _RESP_AUTOADD_CONFIG:
frames.append(bytes(data))
await original(data)
reader.handle_rx = tap
try:
event = self._ok(await mc.commands.get_autoadd_config())
finally:
if tapped:
del reader.handle_rx
payload = getattr(event, "payload", {}) or {}
config = payload.get("config")
frame = frames[-1] if frames else b""
self._autoadd_hops = frame[2] if len(frame) >= 3 else None
return None if config is None else int(config)
async def get_autoadd_max_hops(self) -> int | None: # noqa: D102 - inherited docstring
# build_snapshot asks for the bitmask and then for this. The frame that answered the
# first question already carried the second, so it is taken rather than asked again.
if self._autoadd_hops is _UNREAD:
await self.get_autoadd_config()
hops, self._autoadd_hops = self._autoadd_hops, _UNREAD
return hops # type: ignore[return-value]
async def get_allowed_repeat_freqs(self) -> list[tuple[float, float]]: # noqa: D102
event = self._ok(await self._require().commands.get_allowed_repeat_freq())
payload = getattr(event, "payload", {}) or {}
# Ranges travel in kHz, like every other frequency on this protocol.
return [
(int(r["min"]) / 1000.0, int(r["max"]) / 1000.0) for r in payload.get("freqs") or []
]
async def get_default_flood_scope(self) -> str | None: # noqa: D102
event = self._ok(await self._require().commands.get_default_flood_scope())
payload = getattr(event, "payload", {}) or {}
name = payload.get("scope_name")
# Bare, whichever client wrote it: MeshTerm before its own framing, and the library
# still, stored a ``#`` the firmware and the apps leave off.
return None if name is None else normalize_region(str(name))
async def get_time(self) -> int | None: # noqa: D102 - inherited docstring
event = self._ok(await self._require().commands.get_time())
payload = getattr(event, "payload", {}) or {}
value = payload.get("time")
return None if value is None else int(value)
async def get_battery(self) -> dict: # noqa: D102 - inherited docstring
event = self._ok(await self._require().commands.get_bat())
return dict(getattr(event, "payload", {}) or {})
async def get_hw_charging(self) -> bool | None: # noqa: D102 - inherited docstring
# BLE only, and only when a device exposes the standard Battery Level Status
# characteristic — no MeshCore firmware does today, so this returns None on every
# current device and the battery poller falls back to its voltage-trend inference. It
# reaches the raw bleak client the way the forced-teardown path does (through
# ``connection_manager.connection``) and is wrapped whole: any failure — attribute
# path moved, service absent, read refused, short value — is a quiet None, never a
# disrupted poll.
if self._transport != "ble" or self._mc is None:
return None
try:
client = self._mc.connection_manager.connection.client
service = client.services.get_service(_BATTERY_SERVICE_UUID)
if service is None:
return None
char = service.get_characteristic(_BATTERY_LEVEL_STATUS_UUID)
if char is None or "read" not in getattr(char, "properties", ()):
return None
value = await client.read_gatt_char(char)
except Exception as exc: # noqa: BLE001 - a best-effort probe must never disrupt polling
_log.debug("hardware charging read failed: %s", exc)
return None
if not self._logged_bas:
self._logged_bas = True
_log.info(
"device exposes a standard BLE Battery Service (0x180F); using its "
"charging flag over the voltage-trend inference — verify the reading"
)
return charging_from_battery_level_status(bytes(value))
async def get_stats(self) -> dict: # noqa: D102 - inherited docstring
mc = self._require()
stats: dict = {}
# Each frame independently best-effort: firmware predating one stats type answers
# with an error, which must not cost us the frames it does support.
for read in (
mc.commands.get_stats_core,
mc.commands.get_stats_radio,
mc.commands.get_stats_packets,
):
try:
event = self._ok(await read())
except Exception: # noqa: BLE001 - optional read; absence is acceptable
continue
stats.update(getattr(event, "payload", {}) or {})
return stats
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) -> dict | None: # noqa: D102
# The read is serialized (see self._channel_read_lock) so the uncorrelated
# CHANNEL_INFO response can't be stolen by a concurrent read. We still verify the
# response is for the slot we asked about: a stray CHANNEL_INFO (from another source,
# or a slow response arriving after a timeout) would otherwise misidentify the slot and
# misfile a channel's messages. On mismatch we raise rather than return foreign data.
async with self._channel_read_lock:
event = await self._channel_event(index)
payload = getattr(event, "payload", {}) or {}
got = payload.get("channel_idx")
if got is not None and int(got) != index:
raise DeviceCommandError(f"channel read for slot {index} returned slot {got}")
if not payload.get("channel_name"):
return None
return payload
async def _channel_event(self, index: int): # noqa: ANN202 - a meshcore Event
"""Read one slot, telling the firmware's "no such slot" apart from any other error.
The slot probe ends on a *rejection* and keeps the list it has as the device's whole
layout, so what counts as one matters. The firmware answers an index past its last
slot with ``ERR_CODE_NOT_FOUND`` and nothing else. But the library does not
serialize commands, and every one that waits for "OK or ERROR" takes the first
ERROR that arrives — so a read racing another command can be handed *that*
command's refusal. At connect the channel prewarm runs beside the clock sync, and a
clock the firmware refuses as malformed ended the probe at whatever slot it had
reached, which cached a list missing every channel after it for the session.
So only ``NOT_FOUND`` (or a code-less refusal from firmware too old to send one) is
a rejection. Any other refusal is asked again once, since a borrowed error is
someone else's and the next answer is this read's own; a second one, or no answer
at all, is a failed read (:class:`DeviceCommandError`), which the probe reports as
unfinished rather than caching.
"""
commands = self._require().commands
for attempt in (1, 2):
event = await commands.get_channel(index)
if event is None or not getattr(event, "is_error", lambda: False)():
return event
payload = getattr(event, "payload", {}) or {}
if isinstance(payload, dict) and payload.get("reason") == "no_event_received":
raise DeviceCommandError(f"channel read for slot {index} got no answer")
code = error_code(event)
if code is None or code == _ERR_NOT_FOUND:
return self._ok(event) # the firmware's own "no such slot"
if attempt == 2:
raise DeviceCommandError(
f"channel read for slot {index} was refused: {reject_reason(event)}"
)
return None # pragma: no cover - the loop always returns or raises
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( # noqa: D102 - inherited docstring
self, freq: float, bw: float, sf: int, cr: int, repeat: bool | None = None
) -> None:
self._ok(
await self._require().commands.set_radio(
freq, bw, sf, cr, None if repeat is None else int(bool(repeat))
)
)
async def set_tuning(self, rx_delay: float, airtime_factor: float) -> None: # noqa: D102
# CMD_SET_TUNING_PARAMS carries both floats ×1000; the firmware divides them
# back out (prefs.rx_delay_base = rx / 1000, prefs.airtime_factor = af / 1000).
self._ok(
await self._require().commands.set_tuning(
round(float(rx_delay) * 1000), round(float(airtime_factor) * 1000)
)
)
async def set_autoadd_config( # noqa: D102 - inherited docstring
self, flags: int, max_hops: int | None = None
) -> None:
from meshcore import EventType
mc = self._require()
if max_hops is None:
self._ok(await mc.commands.set_autoadd_config(int(flags)))
else:
# The library sends the bitmask alone; the hop limit is the byte after it.
frame = bytes([_CMD_SET_AUTOADD_CONFIG, int(flags) & 0xFF, min(int(max_hops), 64)])
self._ok(await mc.commands.send(frame, [EventType.OK, EventType.ERROR]))
self._autoadd_hops = _UNREAD
async def set_default_flood_scope(self, scope: str) -> None: # noqa: D102
from meshcore import EventType
# Framed here rather than by the library's ``set_default_flood_scope``, which stores
# the name *with* a ``#`` (the firmware and the apps store it bare, so ours read
# back differently from theirs, and a 30-byte name became a 31-byte one the
# firmware refuses) and pads by characters rather than UTF-8 bytes (so a name with
# an accent put the key at the wrong offset). ``[63][name, 31 bytes NUL-padded]
# [key16]``; a frame shorter than that clears the default.
bare = normalize_region(scope)
if not bare or bare == REGION_WILDCARD:
frame = bytes([_CMD_SET_DEFAULT_FLOOD_SCOPE])
else:
bare = validate_region(bare)
name = bare.encode("utf-8")
frame = (
bytes([_CMD_SET_DEFAULT_FLOOD_SCOPE])
+ name
+ bytes(31 - len(name))
+ region_key(bare)
)
mc = self._require()
self._ok(await mc.commands.send(frame, [EventType.OK, EventType.ERROR]))
async def set_flood_scope(self, region: str | None) -> None: # noqa: D102
from meshcore import EventType
# Framed here, like the default scope, rather than through the library's helpers:
# ``reset_flood_scope`` and ``force_unscoped`` arrived late in meshcore 2.3.x, and
# an install that satisfies ``meshcore>=2.3`` without them (the uConsole's 2.3.7)
# crashed every scoped channel send on the restore. The frame is the firmware's
# own ``CMD_SET_FLOOD_SCOPE_KEY``: ``[54][0][key16]`` sets the session scope,
# ``[54][0]`` alone clears it back to the default, ``[54][1]`` forces unscoped.
bare = normalize_region(region) if region else ""
if not bare:
frame = bytes([_CMD_SET_FLOOD_SCOPE_KEY, 0])
elif bare == REGION_WILDCARD:
frame = bytes([_CMD_SET_FLOOD_SCOPE_KEY, 1])
else:
frame = bytes([_CMD_SET_FLOOD_SCOPE_KEY, 0]) + region_key(validate_region(bare))
mc = self._require()
self._ok(await mc.commands.send(frame, [EventType.OK, EventType.ERROR]))
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: bytes | None) -> 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
event = await self._require().commands.set_time(epoch)
if getattr(event, "is_error", lambda: False)() and error_code(event) == _ERR_ILLEGAL_ARG:
# The one argument the set-time handler refuses: a time behind the radio's own
# clock, which firmware never winds back (see ClockAheadError).
raise ClockAheadError(None)
self._ok(event)
async def send_advert(self, flood: bool = False) -> None: # noqa: D102
transmit_gate.mark(flood_advert=flood)
async with self.transmitting():
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
# Routes mirror what real firmware learns from received floods: the repeaters are
# direct neighbours, the leaf nodes sit one hop behind one of them — so the trace
# path composer and its topology suggestions are fully exercisable without radio.
self._contacts = [
Contact(
name="Yagi-Repeater",
public_key=_mock_pub("a1b2c3d4"),
key_prefix="a1b2c3d4",
node_type=NODE_TYPE_REPEATER,
lat=45.5019,
lon=-73.5674,
route_hops=(),
),
Contact(
name="Local-Repeater",
public_key=_mock_pub("b2c3d4e5"),
key_prefix="b2c3d4e5",
node_type=NODE_TYPE_REPEATER,
lat=45.4768,
lon=-73.5990,
route_hops=(),
),
Contact(
name="Observer-Bot",
public_key=_mock_pub("c3d4e5f6"),
key_prefix="c3d4e5f6",
node_type=NODE_TYPE_CHAT,
lat=45.4880,
lon=-73.5810,
route_hops=("b2c3d4e5",),
),
Contact(
name="Alice",
public_key=_mock_pub("d4e5f6a7"),
key_prefix="d4e5f6a7",
node_type=NODE_TYPE_CHAT,
route_hops=("a1b2c3d4",),
),
]
# 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()
# Nodes the simulator answers *nothing* for — the one failure a password cannot
# explain. Empty by default, so the simulated mesh is fully reachable as before;
# put a contact's name in here to walk the down-repeater path, where a login comes
# back NO_REPLY and the remembered credential has to survive it.
self._unreachable: set[str] = set()
self._remote_tx: dict[str, int] = {}
self._default_remote_tx = 20
# Each simulated repeater's CLI-visible configuration, populated with the
# defaults below on first touch (keyed by full public key, like the TX map).
self._remote_cfg: dict[str, dict[str, str]] = {}
# Simulated neighbour tables, keyed by the repeater's key prefix: what each
# repeater "hears directly" as ``(neighbour_prefix, snr_db, secs_ago)``. The
# ``e5f6a7b8`` entry is deliberately absent from the contact list, so the
# fetched-evidence flow exercises discovering a node we never received from.
self._neighbour_tables: dict[str, list[tuple[str, float, int]]] = {
"a1b2c3d4": [
("d4e5f6a7", 6.5, 300),
("b2c3d4e5", -3.25, 1200),
("e5f6a7b8", 2.0, 3600),
],
"b2c3d4e5": [
("c3d4e5f6", 8.0, 240),
("a1b2c3d4", -3.25, 900),
],
}
# Simulated region tables, keyed like the neighbour tables: one per repeater, what
# its ``region`` CLI edits and the anonymous regions request reads. Yagi's fits in
# one reply; Local's is deliberately too big for the 160-byte reply buffer, so the
# region editor's cut-and-recover path is drivable without hardware.
self._region_maps: dict[str, SimulatedRegionMap] = {
"a1b2c3d4": SimulatedRegionMap(
[
("lakeside", "*", True),
("lakeside-north", "lakeside", True),
("lakeside-south", "lakeside", False),
("harbour", "*", True),
]
),
"b2c3d4e5": SimulatedRegionMap(
[
("lakeside", "*", True),
("lakeside-north", "lakeside", True),
("lakeside-south", "lakeside", False),
("harbour", "*", True),
("harbour-east", "harbour", True),
("harbour-west", "harbour", True),
("old-town", "*", False),
("old-town-market", "old-town", True),
("riverside", "*", True),
("riverside-upper", "riverside", True),
("riverside-lower", "riverside", False),
("hilltop", "*", True),
]
),
}
# 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,
# The firmware's own build defaults (MeshCore's platformio.ini: LORA_FREQ
# 869.618, BW 62.5, SF8; CR falls back to 5), so the simulator boots on the
# settings a freshly flashed companion does rather than on the 250 kHz /
# SF11 modulation the EU mesh left behind.
"radio_freq": 869.618,
"radio_bw": 62.5,
"radio_sf": 8,
"radio_cr": 5,
"simulated": True,
}
self._tuning: dict = {"rx_delay": 0.0, "airtime_factor": 0.0}
self._autoadd_config = 0
self._autoadd_max_hops = 0
# Client repeat (firmware v9+): off, as a freshly flashed companion ships.
self._client_repeat = False
self._flood_scope = ""
#: The session send scope (``""`` follows the default, ``"*"`` is forced unscoped).
self._send_scope = ""
#: Every channel message handed over: ``(slot, text, session scope at the time)``.
self.sent_channel: list[tuple[int, str, str]] = []
# Simulated clock skew (seconds behind the host), so the sync-clock flow has a
# visible drift to correct until set_time is called.
self._clock_offset: int | None = -125
self._path_hash_mode = 0
self._custom_vars: dict[str, str] = {}
self._channels: dict[int, dict] = {}
# Model the firmware's fixed slot count: reads past it are rejected, exactly as a
# real device signals its ceiling (so ``channel_capacity`` discovers 8 on the mock).
self._max_channels = 8
self._device_pin = 0
self._private_key = "11" * 32
# Background emitter tasks spawned by ``subscribe_events``; tracked so they
# can be cancelled on disconnect and are never garbage-collected while pending.
self._bg_tasks: set[asyncio.Task] = set()
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
for task in list(self._bg_tasks):
task.cancel()
self._bg_tasks.clear()
async def get_self_info(self) -> dict: # noqa: D102 - inherited docstring
return {**self._info, "tx_power": self._tx_power}
async def get_device_info(self) -> dict: # noqa: D102 - inherited docstring
# The simulator reports a stable, obviously-synthetic model so the hardware column
# renders identically to a real board without pretending to be one. ``ble_pin``
# rides here rather than in SELF_INFO because that is where real firmware puts it.
return {
"model": "MeshCore Simulator",
"ver": "mock",
"fw_build": "mock",
"ble_pin": self._device_pin,
"repeat": self._client_repeat,
}
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
return list(self._contacts)
async def add_contact(self, node: Contact) -> None: # noqa: D102 - inherited docstring
await asyncio.sleep(0)
key = self._mock_key(node)
self._contacts = [c for c in self._contacts if self._mock_key(c) != key]
# Added with no learned route, exactly as the firmware stores a contact it was
# handed rather than heard from.
self._contacts.append(replace(node, route_hops=None))
async def remove_contact(self, node: Contact) -> None: # noqa: D102 - inherited docstring
await asyncio.sleep(0)
key = self._mock_key(node)
# Firmware can only delete a contact it holds, and answers ERR_CODE_NOT_FOUND for
# one it doesn't; the simulator refuses the same way so the "wasn't on the device,
# removed here anyway" path is walkable without a radio.
if key not in {self._mock_key(c) for c in self._contacts}:
raise ContactNotOnDeviceError(node)
self._contacts = [c for c in self._contacts if self._mock_key(c) != key]
async def get_tx_power(self) -> int | None: # 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 send_direct_message( # noqa: D102 - inherited docstring
self, contact: Contact, text: str
) -> Ack | None:
transmit_gate.mark()
async with self.transmitting():
await asyncio.sleep(0)
# Firmware can only address a contact it holds, so a recipient this simulated device
# doesn't have is refused exactly as hardware refuses one — which is what makes the
# chat screen's "add it back and send" offer walkable on the simulator.
if self._mock_key(contact) not in {self._mock_key(c) for c in self._contacts}:
raise ContactNotOnDeviceError(contact)
# Otherwise the simulator "delivers" instantly and always acknowledges, so outbound
# direct messages show as acked without a radio.
return Ack(code="mock")
async def send_channel_message( # noqa: D102 - inherited docstring
self, index: int, text: str
) -> None:
transmit_gate.mark()
async with self.transmitting():
await asyncio.sleep(0)
#: What each channel message went out under — the session scope at the moment
#: it was handed over (``""`` the default, ``"*"`` forced unscoped).
self.sent_channel.append((index, text, self._send_scope))
async def admin_login(self, node: Contact, password: str) -> LoginResult: # noqa: D102
await asyncio.sleep(0)
if node.name in self._unreachable:
return LoginResult.NO_REPLY # simulates a node that is down or out of range
if password != self._admin_password:
return LoginResult.REFUSED
self._admin_sessions.add(self._mock_key(node))
return LoginResult.ACCEPTED
#: The simulated repeater CLI's configuration, keyed and answered the way MeshCore's
#: ``CommonCLI.cpp`` does (see send_remote_command). What is absent is absent on purpose:
#: a board with no front-end module and a build with no bridge, so a read of those
#: answers ``??:`` exactly as that hardware would.
_REMOTE_CFG_DEFAULTS = {
"owner.info": "",
"lat": "0",
"lon": "0",
"freq": "910.525",
"bw": "62.5",
"sf": "7",
"cr": "5",
"radio.rxgain": "on",
"cad": "off",
"int.thresh": "0",
"agc.reset.interval": "0",
"repeat": "on",
"txdelay": "0.5",
"direct.txdelay": "0.2",
"rxdelay": "0",
"af": "1",
"loop.detect": "off",
"path.hash.mode": "0",
"multi.acks": "0",
"flood.max": "64",
"flood.max.unscoped": "64",
"flood.max.advert": "8",
"advert.interval": "240",
"flood.advert.interval": "12",
"guest.password": "",
"allow.read.only": "off",
"powersaving": "off",
"adc.multiplier": "0.000",
"gps advert": "none",
"bridge.type": "none",
}
#: The ``get radio`` fields, in the order the reply joins them.
_REMOTE_RADIO = ("freq", "bw", "sf", "cr")
async def send_remote_command( # noqa: D102 - inherited docstring
self, node: Contact, command: str, *, timeout: float = 8.0
) -> str | None:
transmit_gate.mark()
await asyncio.sleep(0)
key = self._mock_key(node)
if key not in self._admin_sessions:
return None # firmware ignores strangers — reads as a timeout, like hardware
cfg = self._remote_cfg.setdefault(key, {"name": node.name, **self._REMOTE_CFG_DEFAULTS})
parts = command.strip().split()
verb = parts[0].lower() if parts else ""
if verb == "ver":
return "MeshCore v1.15.0 (simulator)"
if verb == "clock":
return "OK - clock synced" if parts[1:] == ["sync"] else "12:00 - 1/1/2026 UTC"
if verb == "advert":
return "OK - Advert sent"
regions = self._region_maps.get(node.key_prefix or "")
if verb == "region" and regions is not None:
return regions.command(command.strip())
if verb == "reboot" and regions is not None:
regions.reboot() # a power cycle drops every region edit not saved
if verb in ("reboot", "password", "time", "start"):
return "OK"
if verb == "neighbors":
table = self._neighbour_tables.get(node.key_prefix or "", [])
return "\n".join(f"{p} {snr:+.1f}dB {ago}s" for p, snr, ago in table) or "none"
if verb == "powersaving":
if len(parts) == 2 and parts[1] in ("on", "off"):
cfg["powersaving"] = parts[1]
return cfg["powersaving"] # bare, no "> ": a verb, not a get
if verb == "gps":
if parts[1:2] == ["advert"]:
if len(parts) == 2:
return f"> {cfg['gps advert']}"
if parts[2] in ("none", "share", "prefs"):
cfg["gps advert"] = parts[2]
return "OK"
return "error: expected none, share or prefs"
if len(parts) == 1:
return "off" # no receiver on this board
return "gps toggle not supported"
if verb == "get" and len(parts) == 2:
param = parts[1].lower()
if param == "tx":
return f"> {self._remote_tx.get(key, self._default_remote_tx)}"
if param == "radio":
return "> " + ",".join(cfg[name] for name in self._REMOTE_RADIO)
if param == "dutycycle":
return f"> {100.0 / (float(cfg['af']) + 1.0):.1f}%"
if param in cfg and param not in ("gps advert", "powersaving"):
return f"> {cfg[param]}"
return f"??: {param}"
if verb == "set" and len(parts) >= 3:
param = parts[1].lower()
value = " ".join(parts[2:])
if param == "tx":
self._remote_tx[key] = int(float(value))
return "OK"
if param == "radio":
fields = value.split(",")
if len(fields) != len(self._REMOTE_RADIO):
return "Error, invalid params"
cfg.update(zip(self._REMOTE_RADIO, fields, strict=True))
return "OK - reboot to apply"
if param == "dutycycle":
duty = float(value)
if not 1 <= duty <= 100:
return "ERROR: dutycycle must be 1-100"
cfg["af"] = f"{100.0 / duty - 1.0:g}"
return f"OK - {duty:.1f}%"
# freq alone is serial-only; the rest of the radio goes through "set radio".
read_only = (*self._REMOTE_RADIO, "bridge.type", "gps advert", "powersaving")
if param in cfg and param not in read_only:
cfg[param] = value
return "OK"
return f"unknown config: {param} {value}"
return f"??: {command.strip()}"
async def get_remote_tx_power(self, node: Contact) -> int | None: # 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
async def fetch_neighbours(self, node: Contact) -> list[NeighbourInfo]: # noqa: D102
await asyncio.sleep(0)
key = self._mock_key(node)
# Mirrors real firmware (verified on v1.15): without a login the request is
# silently dropped, which the caller experiences as a timeout.
if key not in self._admin_sessions:
raise DeviceCommandError(
f"{node.name!r} did not answer the neighbour request. Firmware ignores "
"it without an admin login (log in first)."
)
table = self._neighbour_tables.get(key[:8])
if table is None:
raise DeviceCommandError(
f"{node.name!r} did not answer the neighbour request (no neighbour "
"table on this node type)."
)
now = utcnow()
return [
NeighbourInfo(node=prefix, snr=snr, heard_at=now - timedelta(seconds=ago))
for prefix, snr, ago in table
]
async def request_regions(self, node: Contact) -> list[str]: # noqa: D102
await asyncio.sleep(0)
key = self._mock_key(node)
# Only a node with a region table is a repeater here, and only a repeater answers.
regions = self._region_maps.get(node.key_prefix or key[:8])
if node.name in self._unreachable:
raise DeviceCommandError(
f"{node.name!r} did not answer the regions request (out of direct reach)."
)
if regions is None:
raise DeviceCommandError(
f"{node.name!r} did not answer the regions request (not a repeater)."
)
return parse_region_list(regions.allowed_names())
@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) -> int | None:
"""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_autoadd_config(self) -> int | None: # noqa: D102
return self._autoadd_config
async def get_autoadd_max_hops(self) -> int | None: # noqa: D102 - inherited docstring
return self._autoadd_max_hops
async def get_allowed_repeat_freqs(self) -> list[tuple[float, float]]: # noqa: D102
return list(_DEFAULT_REPEAT_FREQS)
async def get_default_flood_scope(self) -> str | None: # noqa: D102
return self._flood_scope
async def get_time(self) -> int | None: # noqa: D102 - inherited docstring
import time as _time
if self._clock_offset is None:
return self._info.get("clock")
return int(_time.time()) + self._clock_offset
async def get_battery(self) -> dict: # noqa: D102 - inherited docstring
return {"level": 4100, "used_kb": 128, "total_kb": 1024}
async def get_stats(self) -> dict: # noqa: D102 - inherited docstring
return {
"battery_mv": 4100,
"uptime_secs": 93784, # 1d 2h 3m 4s
"errors": 0,
"queue_len": 0,
"noise_floor": -110,
"last_rssi": -62,
"last_snr": 9.5,
"tx_air_secs": 42,
"rx_air_secs": 360,
"recv": 1234,
"sent": 210,
"flood_tx": 40,
"direct_tx": 170,
"flood_rx": 900,
"direct_rx": 334,
"recv_errors": 3,
}
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) -> dict | None: # noqa: D102
if index >= self._max_channels:
raise DeviceCommandError(f"channel index {index} out of range")
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( # noqa: D102 - inherited docstring
self, freq: float, bw: float, sf: int, cr: int, repeat: bool | None = None
) -> None:
# Modelled on the firmware: relaying is refused off the allowed frequencies, and a
# command that leaves the repeat byte off turns relaying *off* — the trap a radio
# change has to restate its way around.
if repeat and not repeat_freq_allowed(freq, _DEFAULT_REPEAT_FREQS):
raise DeviceCommandError("device rejected the command: illegal argument")
self._info.update(radio_freq=freq, radio_bw=bw, radio_sf=sf, radio_cr=cr)
self._client_repeat = bool(repeat)
async def set_tuning(self, rx_delay: float, airtime_factor: float) -> None: # noqa: D102
# Round-trip through the wire's ×1000 integer scaling so the simulator loses
# precision exactly where real firmware would.
self._tuning = {
"rx_delay": round(float(rx_delay) * 1000) / 1000.0,
"airtime_factor": round(float(airtime_factor) * 1000) / 1000.0,
}
async def set_autoadd_config( # noqa: D102 - inherited docstring
self, flags: int, max_hops: int | None = None
) -> None:
self._autoadd_config = int(flags)
if max_hops is not None: # the firmware leaves the limit alone when it isn't sent
self._autoadd_max_hops = min(int(max_hops), 64)
async def set_default_flood_scope(self, scope: str) -> None: # noqa: D102
# Mirror the firmware: empty (or the wildcard) clears, a name is stored bare.
bare = normalize_region(scope)
self._flood_scope = "" if bare in ("", REGION_WILDCARD) else validate_region(bare)
async def set_flood_scope(self, region: str | None) -> None: # noqa: D102
bare = normalize_region(region) if region else ""
self._send_scope = validate_region(bare) if bare and bare != REGION_WILDCARD else bare
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: bytes | None) -> 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
import time as _time
# Like the firmware, the simulated clock only ever moves forward.
current = await self.get_time()
if current is not None and epoch < current:
raise ClockAheadError(None)
self._info["clock"] = epoch
# The simulated clock now runs from the set point (drift corrected).
self._clock_offset = epoch - int(_time.time())
async def send_advert(self, flood: bool = False) -> None: # noqa: D102
transmit_gate.mark(flood_advert=flood)
async with self.transmitting():
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 subscribe_events( # noqa: D102 - inherited docstring
self, on_event: EventCallback
) -> Unsubscribe:
# Simulate a live event stream. Emit a burst of synthetic adverts/telemetry from
# the known contacts *synchronously* here, then keep emitting at a steady cadence
# from a background task until unsubscribed. The immediate first burst means even
# a zero-length window always sees every contact (two of which carry a location)
# and one inbound message, keeping capture tests deterministic.
stop = asyncio.Event()
seq = 0
burst = 0
def emit_burst() -> None:
nonlocal seq, burst
for contact in self._contacts:
on_event(MeshEvent.observation_event(self._synth_observation(contact, seq)))
seq += 1
# Simulate the companion's RX packet log: overheard packets from the routed
# leaf nodes, each carrying the relay path it crossed — so topology capture
# accumulates passive path evidence on the simulator exactly as on hardware
# with packet logging enabled. The first burst always includes one.
if burst % 4 == 0:
on_event(MeshEvent.observation_event(self._synth_packet(burst // 4)))
# Periodically simulate an inbound direct message so message-driven features
# (and their tests) have traffic to react to; the first burst always includes
# one so a subscriber sees a message without waiting.
if burst % 8 == 0:
on_event(MeshEvent.message_event(self._synth_message(burst // 8)))
burst += 1
emit_burst()
async def emit_loop() -> None:
while not stop.is_set():
try:
await asyncio.wait_for(stop.wait(), _MOCK_MONITOR_INTERVAL_S)
except asyncio.TimeoutError:
pass # cadence tick elapsed; emit the next burst
if not stop.is_set():
emit_burst()
task = asyncio.create_task(emit_loop())
self._bg_tasks.add(task)
task.add_done_callback(self._bg_tasks.discard)
def unsubscribe() -> None:
stop.set() # wakes the loop's wait immediately; it exits on the next check
return unsubscribe
#: Fixed locations advertised by the two simulated repeaters, so location-aware
#: features (e.g. the coverage map) always have coordinates to work with.
_MOCK_LOCATIONS = {
"Yagi-Repeater": (45.5019, -73.5674),
"Local-Repeater": (45.4768, -73.5990),
}
def _synth_observation(self, contact: Contact, seq: int) -> Observation:
"""Build one plausible synthetic observation for ``contact`` (simulator only).
Args:
contact: The contact to synthesize a reception from.
seq: Monotonic emission counter, used to vary the packet kind.
Returns:
A noisy :class:`Observation` tagged ``telemetry`` on every fourth packet and
``advert`` otherwise, carrying a location for the simulated repeaters.
"""
lat_lon = self._MOCK_LOCATIONS.get(contact.name)
# The two located nodes are the simulated repeaters (fixed infrastructure); the rest
# advertise as ordinary chat nodes, so the map has both classes to prioritise.
node_type = NODE_TYPE_REPEATER if contact.name in self._MOCK_LOCATIONS else NODE_TYPE_CHAT
return Observation(
node=contact.key_prefix or contact.public_key[:12],
public_key=contact.public_key or None,
name=contact.name,
kind="telemetry" if seq % 4 == 3 else "advert",
node_type=node_type,
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,
)
def _synth_packet(self, seq: int) -> Observation:
"""Build one plausible RX-logged packet observation (simulator only).
Rotates over the leaf contacts that sit behind a repeater, emitting the packet
with the relay path its route implies — matching how a real companion reports an
overheard relayed frame.
Args:
seq: Monotonic emission counter, used to rotate the originating contact.
Returns:
A ``packet``-kind :class:`Observation` carrying a one-hop relay path.
"""
routed = [c for c in self._contacts if c.route_hops]
contact = routed[seq % len(routed)]
return Observation(
node=contact.key_prefix or contact.public_key[:12],
public_key=contact.public_key or None,
kind="packet",
snr=round(self._rng.gauss(6.0, 3.0), 1),
rssi=round(self._rng.gauss(-95.0, 8.0), 1),
path=",".join(contact.route_hops or ()),
)
def _synth_message(self, seq: int) -> Message:
"""Build one plausible synthetic inbound direct message (simulator only).
Args:
seq: Monotonic burst counter, used to rotate the sending contact and body.
Returns:
A :class:`Message` from one of the known contacts.
"""
contact = self._contacts[seq % len(self._contacts)]
return Message(
text=f"hello from {contact.name} #{seq}",
sender=contact.key_prefix or contact.public_key[:12],
is_channel=False,
snr=round(self._rng.gauss(6.0, 3.0), 1),
)
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: str | None = None,
timeout: float | None = None,
) -> TraceResult:
transmit_gate.mark()
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) -> Observation | None: # 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 {})
ident = (
payload.get("public_key")
or payload.get("pubkey")
or payload.get("hash")
or payload.get("key_prefix")
)
if not ident:
return None
ident = str(ident).lower().removeprefix("0x")
node = ident[:12] # the stored 12-hex canonical id (what everything groups/joins on)
# Keep the whole key when the advert carried one (public_key/pubkey), so a hash lane can
# later show more than the twelve stored digits; a short-hash-only advert leaves it None.
public_key = ident if len(ident) > len(node) else None
lat = payload.get("adv_lat", payload.get("lat"))
lon = payload.get("adv_lon", payload.get("lon"))
return Observation(
node=node,
public_key=public_key,
name=payload.get("adv_name") or payload.get("name"),
kind=kind,
node_type=_as_int(payload.get("adv_type", payload.get("type"))),
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 packet_observation_from_event(event) -> Observation | None: # noqa: ANN001
"""Map a meshcore ``RX_LOG_DATA`` event into a ``packet``-kind :class:`Observation`.
The companion's RX packet log reports every frame it overhears together with the
header's relay path — the repeaters the packet crossed before reaching us, nearest
the originator first. That path is the passive topology evidence the trace path
composer runs on, so it is preserved verbatim (as comma-separated per-hop hex).
The originating node is only knowable when the payload class reveals it: the library
decodes adverts inline (``adv_key``/``adv_name``), so those carry an origin; other
packet classes are recorded origin-less — their path (plus our reception of its last
relay) is still adjacency evidence.
A frame that crossed no relays at all is kept too, with an empty path. Far from
teaching us nothing, it is the strongest adjacency evidence there is — we heard the
transmitter directly — and it is the whole of what an adjacent device puts on the air,
so discarding it made a neighbour's traffic invisible. Only a frame the library could
not even assign a payload class to is dropped.
One class needs its header read differently from the rest: a ``TRACE``'s ``path``
field carries per-hop SNR readings rather than relay hashes (see
:func:`~meshterm.core.frames.trace_link_snrs`), so it yields no hops and its readings
are merged in as ``trace_snrs`` instead.
Origin-less does not mean featureless, though: what the frame *addresses* is decoded
out of its undecoded body (:func:`~meshterm.core.frames.frame_addressing`) and merged
into the raw payload — the recipient and sender hashes of a direct message or request,
an anonymous request's whole sender key, a channel datagram's envelope, an ack's
checksum, a trace's tag — so every class has something to say about itself downstream.
Args:
event: A meshcore ``RX_LOG_DATA`` event (anything exposing a ``payload`` mapping).
Returns:
The parsed :class:`Observation` (``kind="packet"``), or ``None`` for a frame with
no decodable payload class or an unparsable path.
"""
payload = dict(getattr(event, "payload", {}) or {})
typename = payload.get("payload_typename")
if not typename or typename == "UNK":
return None # the library's sentinel for a frame too short to have a class at all
path_len = _as_int(payload.get("path_len")) or 0
hash_size = _as_int(payload.get("path_hash_size")) or 1
path_hex = str(payload.get("path") or "").lower().removeprefix("0x")
hops: list[str] = []
# A trace's path field is SNR readings, not relay hashes (see
# :func:`~meshterm.core.frames.trace_link_snrs`), so it contributes no hops — its
# readings are recovered below and kept beside the frame instead.
if path_len > 0 and typename != "TRACE":
width = hash_size * 2
hops = [path_hex[i * width : (i + 1) * width] for i in range(path_len)]
if any(len(h) != width for h in hops):
return None # a truncated path would fabricate adjacency between wrong nodes
origin = payload.get("adv_key")
# A frame with no origin and no relays is not featureless — it is the strongest
# adjacency evidence the mesh produces: we heard the transmitter *directly*, with
# nothing in between. Dropping it made every non-advert frame from an adjacent node
# invisible, which is why a companion device sitting beside this one could trace all
# day and never appear in the feed. What it carries — its class, what it addresses,
# how well it was heard — is recorded exactly as a relayed frame's is, with an empty
# path standing for the zero hops it crossed.
#
# What the frame addresses — the recipient, the sender, the channel, the token it
# carries — read out of the body the library leaves undecoded for every class but
# advert and channel text (see :mod:`~meshterm.core.frames`). Merged in under its own
# keys so a class that names no origin node still says what it is *about*.
payload.update(frame_addressing(payload))
readings = trace_link_snrs(payload)
if readings is not None:
# Kept beside the frame, not in ``path``: a trace's hop readings say how well each
# leg was heard, and nothing at all about who relayed it.
payload["trace_snrs"] = readings
ident = str(origin).lower().removeprefix("0x") if origin else None
node = ident[:12] if ident else None
public_key = ident if ident and len(ident) > 12 else None # keep the whole adv_key
lat = payload.get("adv_lat")
lon = payload.get("adv_lon")
return Observation(
node=node,
public_key=public_key,
name=payload.get("adv_name"),
kind="packet",
node_type=_as_int(payload.get("adv_type")),
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,
path=",".join(hops),
raw=payload,
)
def message_from_event(event) -> Message | None: # noqa: ANN001
"""Map a meshcore ``CONTACT_MSG_RECV`` / ``CHANNEL_MSG_RECV`` event into a message.
Direct messages carry a ``pubkey_prefix`` sender; channel messages carry a
``channel_idx`` instead (``type`` is ``"PRIV"`` or ``"CHAN"``). Field names are
best-effort and should be validated against your firmware's event schema.
Args:
event: A meshcore message event (anything exposing a ``payload`` mapping).
Returns:
The parsed :class:`Message`, or ``None`` if the payload carried no text body.
"""
payload = dict(getattr(event, "payload", {}) or {})
text = payload.get("text")
if text is None:
return None
is_channel = payload.get("type") == "CHAN" or "channel_idx" in payload
ts = payload.get("sender_timestamp")
sender_ts = (
datetime.fromtimestamp(ts, tz=timezone.utc) if isinstance(ts, (int, float)) and ts else None
)
return Message(
text=str(text),
sender=None if is_channel else payload.get("pubkey_prefix"),
channel=payload.get("channel_idx") if is_channel else None,
is_channel=is_channel,
sender_timestamp=sender_ts,
snr=_as_float(payload.get("SNR", payload.get("snr"))),
raw=payload,
)
def ack_from_event(event) -> Ack: # noqa: ANN001
"""Map a meshcore ``ACK`` event into an :class:`Ack` (delivery acknowledgement)."""
payload = dict(getattr(event, "payload", {}) or {})
return Ack(code=payload.get("code"), raw=payload)
def _as_float(value: object) -> float | None:
"""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 _as_int(value: object) -> int | None:
"""Best-effort int conversion, returning ``None`` on missing/garbage values."""
if value is None:
return None
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _contact_route(info: dict) -> tuple[str, ...] | None:
"""Extract a contact's device-learned outbound route as per-hop hex hashes.
The firmware distills the paths of received flood packets into each contact's
``out_path``: the repeater chain to send through, one path-hash per hop, from us
outward. The wire reports the hop count and hash width packed into one byte
(``0xFF`` = no learned route, i.e. flood) and the path itself as a fixed 64-byte
field, so the real route is the leading ``out_path_len × size`` bytes.
Args:
info: One contact's raw info mapping from the companion's contacts payload.
Returns:
The route as a tuple of per-hop hex hashes (empty = a learned *direct* route),
or ``None`` when no route is learned or the report is unparsable.
"""
out_path_len = _as_int(info.get("out_path_len"))
if out_path_len is None or out_path_len < 0:
return None # 0xFF on the wire: flood routing, no learned path
if out_path_len == 0:
return ()
mode = _as_int(info.get("out_path_hash_mode"))
size = max((mode if mode is not None and mode >= 0 else 0) + 1, 1)
out_path = str(info.get("out_path") or "").lower().removeprefix("0x")
try:
route = bytes.fromhex(out_path)[: out_path_len * size]
except ValueError:
return None
hops = tuple(route[i * size : (i + 1) * size].hex() for i in range(out_path_len))
if any(len(h) != size * 2 for h in hops):
return None # the field was shorter than the declared route; don't guess
return hops
def _contact_location(info: dict) -> tuple[float | None, float | None]:
"""Extract a contact's advertised ``(lat, lon)``, or ``(None, None)`` if it has none.
A node that has never set coordinates advertises ``0.0/0.0`` (null island), which the
firmware reports verbatim; we treat that as "no location" rather than plotting the
Gulf of Guinea.
Args:
info: One contact's raw info mapping from the companion's contacts payload.
Returns:
The advertised latitude and longitude in decimal degrees, or ``(None, None)``.
"""
lat = _as_float(info.get("adv_lat", info.get("lat")))
lon = _as_float(info.get("adv_lon", info.get("lon")))
if lat is None or lon is None or (abs(lat) < 1e-6 and abs(lon) < 1e-6):
return None, None
return lat, lon
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: str | None) -> int | None:
"""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: str | None,
baudrate: int = 115200,
mock_optimal_tx: int = 14,
transport: str = "serial",
address: str | None = None,
pin: str | None = None,
ble_device: object | None = None,
host: str | None = None,
tcp_port: int | None = None,
spi: SpiWiring | None = None,
state: Path | None = None,
) -> 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 serial device. Required for the serial transport
unless ``mock`` is set.
baudrate: Serial baud rate for a real serial device.
mock_optimal_tx: Peak TX power for the simulator.
transport: ``"serial"`` (default), ``"ble"``, ``"tcp"``, or ``"spi"``.
address: Bluetooth address for the BLE transport. Required when ``transport="ble"``.
pin: Optional BLE pairing PIN (BLE only).
ble_device: The scanned ``bleak.BLEDevice`` for ``address``, when this session's
discovery produced one (BLE only) — lets the connect skip re-discovering the
peripheral by address (see :class:`MeshCoreDevice`).
host: Hostname/IP for the TCP transport. Required when ``transport="tcp"``.
tcp_port: TCP port for the TCP transport. Required when ``transport="tcp"``.
spi: How the radio is wired, for the SPI transport. Required when ``transport="spi"``.
state: The SPI radio's state directory (see :func:`meshterm.core.spiradio.state_dir`).
Required when ``transport="spi"``.
Returns:
A connected-on-enter :class:`Device` instance.
Raises:
ValueError: If a real device is requested without a usable endpoint for its transport.
"""
if mock:
return MockDevice(optimal_tx=mock_optimal_tx)
if transport == "ble":
if not address:
raise ValueError(
"No Bluetooth address configured. Pass --ble, pick a device at startup, "
"or use --mock."
)
return MeshCoreDevice(transport="ble", address=address, pin=pin, ble_device=ble_device)
if transport == "tcp":
if not host or not tcp_port:
raise ValueError(
"No network address configured. Pass --tcp host:port, set a TCP profile, "
"pick a device at startup, or use --mock."
)
return MeshCoreDevice(transport="tcp", host=host, tcp_port=tcp_port)
if transport == "spi":
if spi is None or state is None:
raise ValueError(
"No SPI radio configured. Pass --spi, set an SPI profile, or use --mock."
)
from .spiradio import SpiRadioDevice # imports this module, so not at the top
return SpiRadioDevice(spi, state)
if not port:
raise ValueError("No serial port configured. Pass --port, set a profile, or use --mock.")
return MeshCoreDevice(port=port, baudrate=baudrate)
#: Handshake window (seconds) for probing a serial companion. A genuine board answers in well
#: under a second; a non-MeshCore port is rejected within this bound.
_PROBE_TIMEOUT_SERIAL_S = 6.0
#: Handshake window (seconds) for probing a BLE companion. Longer than serial: a BLE connect
#: involves a link-layer connection and GATT service discovery before the identity reply.
_PROBE_TIMEOUT_BLE_S = 20.0
#: Handshake window (seconds) for probing a TCP companion. Between serial and BLE: a TCP
#: connect is a quick socket open, but an unreachable host can sit in the OS connect backoff,
#: so the window allows for that before the endpoint is written off as absent.
_PROBE_TIMEOUT_TCP_S = 10.0
async def probe_device(
device: DiscoveredDevice,
*,
baudrate: int = 115200,
pin: str | None = None,
spi_state: Path | None = None,
) -> tuple[MeshCoreDevice, dict] | None:
"""Open a discovered device, confirm a MeshCore companion answers, and keep it connected.
Transport-agnostic front door for the startup smoke test: it opens the right connection
for ``device`` (serial port or BLE address) and issues an identity query (the APPSTART
that backs :meth:`MeshCoreDevice.get_self_info`). A genuine companion replies with a
self-info payload; anything else — a non-MeshCore gadget, an unresponsive port, a BLE
device out of range — never answers and is rejected within the transport's timeout.
On success the connection is **left open** and returned to the caller, which reuses it as
the session device. This is deliberate: many companion boards reset on each serial open
(and a BLE reconnect re-runs service discovery), so a probe-then-reopen cycle is slow and
flaky — opening the radio exactly once is both faster and far more reliable. On any
failure the probe connection is closed.
Args:
device: The discovered device to probe (serial or BLE).
baudrate: Serial baud rate (serial transport only).
pin: Optional BLE pairing PIN (BLE transport only).
spi_state: The radio's state directory (SPI transport only) — probing an SPI radio
starts its node, and the node needs somewhere to keep its identity.
Returns:
``(device, self_info)`` with a connected :class:`MeshCoreDevice` on success (the
caller owns and must eventually close it), or ``None`` if it is not a reachable
MeshCore companion.
Raises:
DeviceCommandError: On an actionable failure the user can fix — e.g. a Bluetooth
companion that requires a pairing PIN — so the caller can show the remedy rather
than an unhelpful "didn't answer".
"""
if device.is_ble:
timeout = _PROBE_TIMEOUT_BLE_S
probe = MeshCoreDevice(
transport="ble",
address=device.address,
pin=pin,
connect_timeout=timeout,
# The scan that discovered the device already holds its BLEDevice; connecting
# through it skips the by-address re-discovery that makes BLE startups flaky.
ble_device=device.ble_device,
)
elif device.is_tcp:
timeout = _PROBE_TIMEOUT_TCP_S
probe = MeshCoreDevice(
transport="tcp",
host=device.host,
tcp_port=device.tcp_port,
connect_timeout=timeout,
)
elif device.is_spi:
from .config import SpiWiring
from .spiradio import READY_TIMEOUT_S, SpiRadioDevice
if spi_state is None:
raise ValueError("probing an SPI radio needs its state directory")
# The window covers starting the node, not just the handshake: the radio library's
# import and the chip's bring-up come first, and a board this small is slow at both.
timeout = READY_TIMEOUT_S
probe = SpiRadioDevice(device.spi or SpiWiring(), spi_state) # type: ignore[arg-type]
else:
timeout = _PROBE_TIMEOUT_SERIAL_S
probe = MeshCoreDevice(port=device.port, baudrate=baudrate, connect_timeout=timeout)
return await _probe(probe, timeout)
async def probe_meshcore(
port: str, baudrate: int = 115200, *, timeout: float = _PROBE_TIMEOUT_SERIAL_S
) -> tuple[MeshCoreDevice, dict] | None:
"""Probe a serial ``port`` for a MeshCore companion (see :func:`probe_device`).
Thin serial-only convenience wrapper retained for callers that hold a bare port string.
Args:
port: Serial port to probe (e.g. ``COM5`` or ``/dev/ttyUSB0``).
baudrate: Serial baud rate.
timeout: Seconds to bound the connection handshake and the identity reply.
Returns:
``(device, self_info)`` on success, or ``None`` if the port is not a reachable
MeshCore companion.
"""
return await _probe(
MeshCoreDevice(port=port, baudrate=baudrate, connect_timeout=timeout), timeout
)
async def _probe(device: MeshCoreDevice, timeout: float) -> tuple[MeshCoreDevice, dict] | None:
"""Connect ``device`` and read its identity, returning it live or closing it on failure.
Args:
device: An unconnected :class:`MeshCoreDevice` configured for its transport.
timeout: Handshake window bounding both the connect and the identity read.
Returns:
``(device, self_info)`` with the connection left open, or ``None`` if the endpoint
simply isn't a reachable MeshCore companion.
Raises:
DeviceCommandError: On an *actionable* failure the user can fix — e.g. a Bluetooth
companion that needs a pairing PIN. This is deliberately distinct from ``None``
(an unremarkable "not a companion" miss) so the caller can show the real remedy
instead of a generic "didn't answer".
"""
# ``timeout`` is the handshake window handed to the client, so a non-MeshCore endpoint is
# rejected in ~``timeout`` seconds and the client cleans up its own connection. The outer
# ``wait_for`` is only a safety net a few seconds beyond that, so we never cancel the
# client mid-handshake (which would leak the open connection). A real companion answers in
# well under a second (serial) or a few seconds (BLE), so this never delays a good device.
stage = "connect"
try:
await asyncio.wait_for(device.connect(), timeout + 4.0)
stage = "identity"
info = await asyncio.wait_for(device.get_self_info(), timeout)
except asyncio.TimeoutError as exc:
await _safe_disconnect(device)
if getattr(device, "transport", None) != "ble":
return None
# A Bluetooth connect has more stages than a serial one (link, pairing, service
# discovery, the identity reply), so saying which one ran out of time is what
# tells a device out of range apart from one that connected and then went quiet.
where = device.endpoint or "the selected Bluetooth device"
if stage == "connect":
message = (
f"connecting to {where} over Bluetooth took longer than {timeout + 4.0:.0f}s — "
"the link, pairing, or service discovery stalled. Move closer, restart the "
"device, and try again."
)
else:
message = (
f"connected to {where} over Bluetooth, but it didn't answer the identity query "
f"within {timeout:.0f}s — it may be busy with another app; restart it and try "
"again."
)
_log.warning("BLE probe of %s timed out at the %s stage", where, stage)
raise DeviceCommandError(message) from exc
except DeviceCommandError:
# A clean, actionable failure (the device needs a PIN, say): close the probe and let it
# through so the picker surfaces the remedy rather than hiding it behind "didn't answer".
await _safe_disconnect(device)
raise
except Exception as exc: # noqa: BLE001 - any other failure just means "not confirmed"
# Still "not confirmed" to the picker, but said in the log: this used to be the one
# failure that left no trace, so a report from another machine had nothing to show.
_log.warning("probe of %s failed: %r", getattr(device, "endpoint", None), exc)
await _safe_disconnect(device)
return None
if not info:
await _safe_disconnect(device)
return None
# The connection is already open, so learn the hardware model now (its own protocol frame)
# and fold it into the identity dict — this is the one moment the model is obtainable, and
# it lets the caller remember "Seeed Tracker T1000-E" without a second connect. It's purely
# additive: self-info fields win on the (currently non-overlapping) keys, and a firmware
# that can't answer the query simply contributes nothing.
try:
device_info = await asyncio.wait_for(device.get_device_info(), timeout)
except Exception: # noqa: BLE001 - model is a nicety; never fail a good probe over it
device_info = {}
return device, {**device_info, **info}
async def _safe_disconnect(device: Device) -> None:
"""Best-effort disconnect that never raises (used to discard a failed probe)."""
try:
await device.disconnect()
except Exception: # noqa: BLE001 - best-effort cleanup of the probe connection
pass