Files
MeshTerm/meshterm/core/discovery.py
T
Jean-Pierre MartineauandClaude Opus 5.5 0cd40bbd6c Fix three small CLI rough edges: records --width, the simulator's label, two help pages
records --width now refuses anything but 1, 2, or 4 with a usage error, the
way --category already refused an unknown discipline. A width that doesn't
exist used to come back as exit 5, indistinguishable from an empty database,
and --width 0 was falsy and so listed every width. (#15)

A device with no port is labelled by its name alone. The simulator fell
through to the serial branch and came out as "… (nothing transmits) ()". (#14)

platform and specimen pass their own one-line help, so Typer stops printing
their docstrings — notes for whoever reads cli.py, Sphinx roles and source
paths included — and their summaries fit in meshterm --help. A new test runs
--help on every registered command and refuses those markers anywhere. (#16)

Built from the issues alone; the pull requests filed against them were
closed unreviewed and their code was never read.

Closes #14, closes #15, closes #16.

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

575 lines
25 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
"""Companion-device discovery across transports (serial + Bluetooth LE + TCP).
Discovery enumerates the companion kinds that can be *found* automatically:
* **Serial**: USB serial ports via ``pyserial``, flagging the ones whose USB vendor ID
matches hardware commonly used for LoRa companion devices (ESP32/nRF boards and their
USB-UART bridges). Nothing is hidden — unrecognized adapters still appear, just sorted
after the likely candidates.
* **Bluetooth LE**: nearby devices advertising a ``MeshCore*`` name, scanned via ``bleak``.
Unlike a bare serial adapter, a BLE advert that names itself MeshCore is a strong signal,
so scanned devices are always treated as likely companions.
A **TCP** companion — a MeshCore device reachable at a network ``host:port`` (a WiFi board,
or a ``meshcored``-style proxy) — has no discovery: it is not attached and does not
advertise, so there is nothing to scan for. It is instead named explicitly (``--tcp``, a
``transport = "tcp"`` profile, or the picker's "add a network device" prompt) and, once
confirmed, remembered like any other companion. :func:`tcp_device` builds the
:class:`DiscoveredDevice` for such an endpoint so the rest of the pipeline (selection,
remembering, the picker) treats it uniformly.
This module has no UI and no persistent state; it only reads what is currently attached or
in range. The companion connection itself still goes through
:class:`~meshterm.core.connection.MeshCoreDevice`, which the ``transport`` field selects.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
_log = logging.getLogger(__name__)
#: Transport discriminators for a :class:`DiscoveredDevice` (also stored/remembered as-is).
TRANSPORT_SERIAL = "serial"
TRANSPORT_BLE = "ble"
TRANSPORT_TCP = "tcp"
#: A LoRa chip on the host's own SPI bus, run by a node MeshTerm starts itself
#: (:mod:`meshterm.core.spiradio`). Found by its ``/dev/spidev*`` node, like a serial port.
TRANSPORT_SPI = "spi"
#: The built-in simulator, as a device inventory reports it under ``--mock``. Never
#: discovered — there is nothing to find — but a session that promised no real hardware
#: still has exactly one device, and this is what it is called.
TRANSPORT_MOCK = "mock"
#: Default TCP port assumed when a network companion is named as a bare host with no port.
#: 5000 is the conventional MeshCore companion-over-TCP / ``meshcored`` listen port.
DEFAULT_TCP_PORT = 5000
#: BLE advertisements from a MeshCore companion begin with this name prefix; the scan filters
#: to it so unrelated Bluetooth gadgets (headphones, watches, beacons) never clutter the list.
BLE_NAME_PREFIX = "MeshCore"
#: Default seconds to scan for BLE companions. Long enough to hear a nearby device's periodic
#: advertisement, short enough not to stall the startup splash noticeably.
BLE_SCAN_TIMEOUT_S = 5.0
try: # bleak >= 3 only; the floor in pyproject is 0.22, so this must not be assumed
from bleak.exc import BleakBluetoothNotAvailableError as _BleakNotAvailable
_BLE_NOT_AVAILABLE: tuple[type[BaseException], ...] = (_BleakNotAvailable,)
except Exception: # noqa: BLE001 - older bleak, or bleak absent entirely
# An empty tuple in ``except`` matches nothing, so older installs fall straight through
# to the broad handler and behave exactly as they did before.
_BLE_NOT_AVAILABLE = ()
#: Why the last BLE scan could not run, when the cause is something the reader can act on
#: (Bluetooth off; on macOS, permission not granted to the terminal running us). ``None``
#: when the scan worked — including when it simply heard nothing, which is not a fault.
_ble_unavailable: str | None = None
def ble_unavailable_reason() -> str | None:
"""Why the last BLE scan was refused, if it was, as a sentence fit to show the reader.
Returns:
The reason the most recent :func:`discover_ble_devices` could not scan, or ``None``
if it ran — whether or not it found anything. Reset at the start of every scan, so
it always describes the latest attempt rather than a stale one.
"""
return _ble_unavailable
#: Default seconds to watch for one *known* companion's advertisement while waiting for it to
#: come back (see :func:`find_ble_device`). Deliberately longer than the startup scan: nothing
#: is waiting on it, the watch ends the instant the device is heard, and a companion that just
#: powered on can take several advertising intervals to be picked up by the OS.
BLE_PRESENCE_TIMEOUT_S = 8.0
# USB vendor IDs frequently seen on MeshCore/Meshtastic companion hardware and the
# USB-UART bridges they ship with. Used only to name, flag, and sort likely devices.
KNOWN_LORA_VIDS: dict[int, str] = {
0x303A: "Espressif", # ESP32-S3 / native USB (e.g. XIAO ESP32-S3)
0x10C4: "Silicon Labs", # CP210x UART bridge
0x1A86: "QinHeng", # CH340 / CH9102 UART bridge
0x0403: "FTDI", # FT232 UART bridge
0x239A: "Adafruit", # nRF52840 boards
0x2886: "Seeed Studio", # XIAO / Wio boards
0x1915: "Nordic", # nRF52 native USB
}
# Native-USB VIDs of LoRa companion dev boards — the chip *is* the board, so seeing one is
# a strong signal the port is companion hardware.
NATIVE_LORA_VIDS: frozenset[int] = frozenset({0x303A, 0x239A, 0x2886, 0x1915})
# Generic USB-UART bridge chips. LoRa boards often use them, but so do countless unrelated
# gadgets (Arduinos, GPS pucks, 3D printers…), so their presence is only a weak hint.
UART_BRIDGE_VIDS: frozenset[int] = frozenset({0x10C4, 0x1A86, 0x0403})
# Sort rank per confidence tier: boards first, then bare serial bridges, then everything else.
_CONFIDENCE_RANK: dict[str, int] = {"board": 0, "bridge": 1, "unknown": 2}
@dataclass(slots=True)
class DiscoveredDevice:
"""A companion device found on the system, with transport metadata.
A serial device carries its USB metadata; a Bluetooth LE device carries its BLE
``address`` and advertised ``name`` instead (its ``port`` is left blank); a TCP device
carries a network ``host`` and ``tcp_port``. The ``transport`` field says which, and
:attr:`target` returns the identifier used to open a connection regardless of kind.
Attributes:
port: Serial port path (e.g. ``COM5`` or ``/dev/ttyUSB0``); ``""`` for BLE/TCP.
description: Human-readable description reported by the OS/driver/advert.
hwid: Raw hardware id string from ``pyserial`` (VID/PID/serial blob); serial only.
vid: USB vendor ID, if the port is a USB device.
pid: USB product ID, if the port is a USB device.
serial_number: USB serial number, if exposed by the device.
manufacturer: USB manufacturer string, if available.
product: USB product string, if available.
transport: ``"serial"``, ``"ble"``, ``"tcp"``, or ``"spi"`` — the connection layer.
address: Bluetooth address for a BLE device (e.g. ``AA:BB:CC:DD:EE:FF``).
name: Advertised BLE local name (e.g. ``"MeshCore-Basestation"``); BLE only.
ble_device: The live ``bleak.BLEDevice`` the scan produced; BLE only, and only for
devices discovered *this session* (a remembered device reloads as a bare
address). Handed to the connection layer so it can open the peripheral
directly — connecting by address alone makes bleak re-discover the device with
a fresh internal scan, which on Windows intermittently misses a
slow-advertising companion. Typed ``object`` so ``bleak`` stays optional; never
persisted.
host: Hostname or IP of a TCP companion (e.g. ``"192.168.1.50"``); TCP only.
tcp_port: TCP port the companion listens on (e.g. ``5000``); TCP only.
spi: The :class:`~meshterm.core.config.SpiWiring` of a radio on the host's SPI bus;
SPI only. Its ``port`` is the SPI device node (``/dev/spidev1.0``), which is what
such a radio is named and remembered by. Typed ``object`` to keep this module
free of the config import.
"""
port: str = ""
description: str = ""
hwid: str = ""
vid: int | None = None
pid: int | None = None
serial_number: str | None = None
manufacturer: str | None = None
product: str | None = None
transport: str = TRANSPORT_SERIAL
address: str | None = None
name: str | None = None
ble_device: object | None = None
host: str | None = None
tcp_port: int | None = None
spi: object | None = None
@property
def is_spi(self) -> bool:
"""Whether this device is a radio on the host's own SPI bus, run by MeshTerm's node."""
return self.transport == TRANSPORT_SPI
@property
def is_ble(self) -> bool:
"""Whether this device is reached over Bluetooth LE rather than a serial port."""
return self.transport == TRANSPORT_BLE
@property
def is_tcp(self) -> bool:
"""Whether this device is reached over a TCP network connection."""
return self.transport == TRANSPORT_TCP
@property
def target(self) -> str:
"""The identifier a connection opens on.
The network ``host:port`` for TCP, the BLE address for Bluetooth, else the
serial port.
"""
if self.is_tcp:
return f"{self.host}:{self.tcp_port}"
if self.transport == TRANSPORT_MOCK:
return "--mock" # the flag is the target: it is how this device is selected
return self.address or self.port if self.is_ble else self.port
@property
def stable_id(self) -> str:
"""Return an identifier stable across replug/reboot.
For TCP, the network ``host:port``. For BLE, the Bluetooth address (stable per
adapter pairing). For serial, the USB serial number (survives a changed COM
number), then the ``vid:pid`` pair, and finally the port name as a last resort.
Returns:
A non-empty identifier string used to recognize this device later.
"""
if self.is_tcp:
return f"tcp:{self.host}:{self.tcp_port}"
if self.transport == TRANSPORT_MOCK:
return "mock:simulator"
if self.is_spi:
return f"spi:{self.port}"
if self.is_ble:
return f"ble:{(self.address or self.name or '').lower()}"
if self.serial_number:
return f"sn:{self.serial_number}"
if self.vid is not None and self.pid is not None:
return f"vidpid:{self.vid:04x}:{self.pid:04x}"
return f"port:{self.port}"
@property
def confidence(self) -> str:
"""How likely this device is a LoRa companion.
Returns:
``"board"`` for a BLE device that named itself MeshCore, a TCP endpoint the user
named explicitly, or a LoRa dev board's native USB (all strong signals),
``"bridge"`` for a generic USB-UART chip (a weak hint — could be anything), or
``"unknown"`` for an unrecognized adapter.
"""
if self.is_ble or self.is_tcp or self.is_spi:
return "board" # a MeshCore advert / a named endpoint / a wired radio is confident
if self.vid in NATIVE_LORA_VIDS:
return "board"
if self.vid in UART_BRIDGE_VIDS:
return "bridge"
return "unknown"
@property
def is_likely_lora(self) -> bool:
"""Whether this looks like a companion: a MeshCore BLE advert or a known-VID board."""
return self.confidence != "unknown"
@property
def vendor_label(self) -> str:
"""The hardware maker's name for the VENDOR column, or ``""`` when unknown.
This is deliberately *just the vendor* — the transport (USB vs Bluetooth vs TCP) is a
separate concern shown in its own TYPE column, so a BLE advert or TCP endpoint (which
rarely expose a maker) reports whatever manufacturer string it carries and otherwise
stays blank rather than mislabelling the transport as a vendor. A serial port maps
its USB vendor ID to a friendly name, falling back to the driver's manufacturer
string.
"""
if self.is_ble or self.is_tcp or self.is_spi:
return self.manufacturer or ""
if self.vid in KNOWN_LORA_VIDS:
return KNOWN_LORA_VIDS[self.vid]
return self.manufacturer or ""
@property
def label(self) -> str:
"""A concise human-friendly label, e.g. ``"Wio SX1262 (COM5)"`` or ``"…(BLE)"``.
The OS description often already ends with the port (Windows reports
``"USB Serial Device (COM11)"``), so the identifier is not appended a second time.
"""
if self.is_tcp:
name = self.name or self.product or self.description or "Network device"
return f"{name} ({self.target})"
if self.is_ble:
name = self.name or self.product or self.description or "Bluetooth device"
return f"{name} (BLE)"
if self.is_spi:
return f"{self.name or 'SPI radio'} ({self.port})"
name = self.product or self.description or self.vendor_label or "Serial device"
if not self.port: # the simulator has nothing to point at, so no "()"
return name
suffix = f"({self.port})"
if name.endswith(suffix): # avoid "… (COM11) (COM11)"
name = name[: -len(suffix)].rstrip()
return f"{name} ({self.port})"
def parse_tcp_endpoint(text: str, *, default_port: int = DEFAULT_TCP_PORT) -> tuple[str, int]:
"""Parse a ``host[:port]`` string into a ``(host, port)`` pair.
Accepts a bare host (``"192.168.1.50"``, ``"meshcore.local"``) — defaulting the port to
``default_port`` — or an explicit ``host:port``. IPv6 literals must be bracketed
(``"[::1]:5000"``) so the address colons aren't mistaken for the port separator.
Args:
text: The user-supplied network address.
default_port: Port assumed when ``text`` names only a host.
Returns:
The ``(host, port)`` to connect to.
Raises:
ValueError: If the host is empty or the port isn't a valid 1–65535 integer — with a
message already phrased for the user.
"""
raw = text.strip()
if not raw:
raise ValueError("Enter a network address, e.g. 192.168.1.50 or 192.168.1.50:5000.")
host, sep, port_text = _split_host_port(raw)
host = host.strip()
if not host:
raise ValueError("Enter a host, e.g. 192.168.1.50 or 192.168.1.50:5000.")
if not sep or not port_text.strip():
return host, default_port
try:
port = int(port_text.strip())
except ValueError:
raise ValueError(f"'{port_text.strip()}' isn't a valid port number.") from None
if not 1 <= port <= 65535:
raise ValueError("The port must be between 1 and 65535.")
return host, port
def _split_host_port(raw: str) -> tuple[str, str, str]:
"""Split ``host[:port]`` into ``(host, separator, port)``, honouring ``[ipv6]`` brackets.
A bracketed IPv6 literal keeps its colons; only a ``:port`` *after* the closing bracket
(or the single colon of a plain ``host:port``) is treated as the port separator.
"""
if raw.startswith("["):
close = raw.find("]")
if close != -1:
host = raw[1:close]
rest = raw[close + 1 :]
if rest.startswith(":"):
return host, ":", rest[1:]
return host, "", ""
host, sep, port_text = raw.rpartition(":")
if not sep: # no colon at all → the whole string is the host
return port_text, "", ""
return host, sep, port_text
def tcp_device(host: str, port: int, *, name: str = "") -> DiscoveredDevice:
"""Build the :class:`DiscoveredDevice` for a TCP companion at ``host:port``.
TCP companions aren't scanned for, so this is how a network endpoint enters the same
selection/remember/picker pipeline the discovered transports use. An optional ``name``
(a friendly label the user typed, or a mesh node name learned on a prior connect) is
carried through to the picker's DEVICE column.
Args:
host: Hostname or IP of the companion.
port: TCP port it listens on.
name: Optional friendly name for display.
Returns:
A TCP :class:`DiscoveredDevice` ready to select or connect.
"""
return DiscoveredDevice(
transport=TRANSPORT_TCP,
host=host,
tcp_port=port,
name=name or None,
description=name,
product=name or None,
)
def serial_device(port: str, *, name: str = "") -> DiscoveredDevice:
"""Build the :class:`DiscoveredDevice` for a serial companion at ``port``.
A soldered platform-bus UART (e.g. ``/dev/ttyS1`` on the Luckfox Lyra) is not enumerated by
pyserial, so a scan never finds it; this is how a configured serial ``port`` enters the same
selection/remember/picker pipeline the discovered transports use — mirroring
:func:`tcp_device` for network companions. An optional ``name`` (the profile alias, or a
mesh node name learned on a prior connect) is carried to the picker's DEVICE column.
Args:
port: Serial port path (e.g. ``/dev/ttyS1`` or ``COM5``).
name: Optional friendly name for display.
Returns:
A serial :class:`DiscoveredDevice` ready to select or connect.
"""
return DiscoveredDevice(
port=port,
description=name,
product=name or None,
)
def _sort_key(device: DiscoveredDevice) -> tuple[int, str]:
"""Sort likely companions first, then bridges, then unknown; ties by target id."""
return (_CONFIDENCE_RANK[device.confidence], device.target)
def discover_devices() -> list[DiscoveredDevice]:
"""Enumerate serial ports currently attached to the system.
Likely LoRa companion devices (by known USB vendor ID) are returned first. If
``pyserial`` is unavailable the function returns an empty list rather than raising,
so callers can fall back to ``--port``/``--mock`` with a friendly message.
Returns:
Discovered serial devices, likely candidates first, then sorted by port name.
"""
try:
from serial.tools import list_ports
except ImportError: # pragma: no cover - pyserial is a declared dependency
return []
devices = [
DiscoveredDevice(
port=info.device,
description=info.description or "",
hwid=info.hwid or "",
vid=info.vid,
pid=info.pid,
serial_number=info.serial_number,
manufacturer=info.manufacturer,
product=info.product,
)
for info in list_ports.comports()
]
devices.sort(key=_sort_key)
return devices
async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[DiscoveredDevice]:
"""Scan for nearby Bluetooth LE companions advertising a ``MeshCore*`` name.
Uses ``bleak`` to listen for advertisements for ``timeout`` seconds and keeps only the
ones whose advertised name marks them as a MeshCore companion. If ``bleak`` is
unavailable, or the platform has no usable Bluetooth adapter, this returns an empty list
rather than raising — BLE is an optional transport, and its absence must never block
serial discovery or the simulator.
Args:
timeout: Seconds to scan for advertising devices.
Returns:
Discovered BLE companions (deduplicated by address), sorted by name.
"""
global _ble_unavailable
_ble_unavailable = None
try:
from bleak import BleakScanner
except ImportError: # bleak not installed → BLE simply unavailable
return []
try:
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
except _BLE_NOT_AVAILABLE as exc:
# A *state the reader can fix* — Bluetooth switched off, or (on macOS, routinely)
# the terminal running us has not been granted Bluetooth. That is not the "no
# adapter / driver hiccup" the broad catch below is for, and returning [] silently
# made it indistinguishable from "nothing is nearby": the one message that would
# have told the user what to do was thrown away at debug level, under a log that
# defaults to WARNING. Keep the sentence bleak wrote — it already names the remedy.
_ble_unavailable = str(getattr(exc, "args", [None])[0] or exc)
_log.warning("BLE unavailable: %s", _ble_unavailable)
return []
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
_log.debug("BLE scan unavailable: %s", exc)
return []
devices: list[DiscoveredDevice] = []
for dev, adv in found.values():
name = (getattr(adv, "local_name", None) or getattr(dev, "name", None) or "").strip()
if not name.startswith(BLE_NAME_PREFIX):
continue
devices.append(
DiscoveredDevice(
transport=TRANSPORT_BLE,
address=dev.address,
name=name,
description=name,
product=name,
ble_device=dev,
)
)
devices.sort(key=lambda d: (d.name or "").casefold())
return devices
async def find_ble_device(address: str, timeout: float = BLE_PRESENCE_TIMEOUT_S) -> object | None:
"""Watch for one known companion's advertisement, returning its live ``BLEDevice``.
The Bluetooth counterpart to :func:`~meshterm.core.connection.serial_port_present`, and it
answers the same question the reconnect flow asks of a serial port: *is the device back
yet?* Unlike a serial port there is no OS registry to consult — a peripheral exists only
while it is advertising — so the probe is a scan. ``find_device_by_address`` returns the
**moment** the address is heard rather than at the end of the window, so a generous
``timeout`` costs nothing when the device is present and simply keeps listening when it
isn't.
The returned handle matters as much as the answer. A companion that has been power-cycled
is a *new* peripheral to the OS: the ``BLEDevice`` captured by an earlier scan names a
connection endpoint that no longer resolves, and on Windows handing bleak that stale object
fails the connect outright. Answering the presence question with a freshly-scanned handle
means the reconnect that follows opens the device that is actually there (see
:meth:`~meshterm.context.AppContext.reconnect`).
Never raises: a missing ``bleak``, a disabled adapter, or a driver hiccup reads as "not
advertising", which is the truthful answer for a device we cannot currently reach.
Args:
address: The Bluetooth address to listen for (as reported by discovery).
timeout: Seconds to keep listening before giving up on this round.
Returns:
The live ``bleak.BLEDevice`` if the address advertised within ``timeout``, else
``None``. Typed ``object`` so ``bleak`` stays an optional dependency.
"""
try:
from bleak import BleakScanner
except ImportError: # bleak not installed → BLE simply unavailable
return None
try:
return await BleakScanner.find_device_by_address(address, timeout=timeout)
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
_log.debug("BLE presence scan for %s unavailable: %s", address, exc)
return None
async def discover_all(
*, ble: bool = True, ble_timeout: float = BLE_SCAN_TIMEOUT_S
) -> list[DiscoveredDevice]:
"""Enumerate every attached or in-range companion across all transports.
Serial enumeration is instant; the BLE scan is what takes ``ble_timeout`` seconds. Serial
devices are listed first (they're already attached and connect fastest), then BLE
companions.
Args:
ble: Whether to include a Bluetooth LE scan (skip it to avoid the scan delay).
ble_timeout: Seconds to spend scanning for BLE companions.
Returns:
All discovered devices: serial (likely-LoRa first), then BLE companions.
"""
# ``comports()`` is a blocking SetupAPI/sysfs walk — hundreds of milliseconds on
# Windows, and long enough on a slow console to freeze whatever spinner is covering
# this call. Off the event loop, so the animation reporting the wait keeps running.
serial = await asyncio.to_thread(discover_devices)
if not ble:
return serial
# A device paired over both USB and BLE is vanishingly unlikely to collide by stable_id
# (USB serial number vs BLE address), so no cross-transport dedup is needed here.
ble_devices = await discover_ble_devices(ble_timeout)
return serial + ble_devices
def spi_device(wiring: object, *, name: str = "") -> DiscoveredDevice:
"""Build the :class:`DiscoveredDevice` for a radio on the host's own SPI bus.
Such a radio is "found" the way a serial port is — its ``/dev/spidev*`` node exists —
but nothing answers on it until MeshTerm starts a node, so it is listed from its wiring
(a profile's, or the AIO's defaults) rather than probed.
Args:
wiring: The radio's :class:`~meshterm.core.config.SpiWiring`.
name: Optional friendly name for display (a profile's description, a node name).
Returns:
An SPI :class:`DiscoveredDevice` ready to select or connect.
"""
return DiscoveredDevice(
transport=TRANSPORT_SPI,
port=wiring.spidev, # type: ignore[attr-defined]
spi=wiring,
name=name or None,
description=name or "SPI radio",
product=name or None,
)