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>
575 lines
25 KiB
Python
575 lines
25 KiB
Python
# 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,
|
||
)
|