Files
MeshTerm/meshterm/core/selection.py
T
Jean-Pierre MartineauandClaude Opus 5 add17b49d6 A Mac can find its own companion
Three faults, one cause: macOS is the only platform that permanently presents
ports nobody attached. Every Mac carries /dev/cu.Bluetooth-Incoming-Port and
/dev/cu.debug-console, with no USB VID/PID between them.

Selection counted them. `len(devices) == 1` was therefore unreachable on a Mac --
one real board attached made three devices -- so auto-detection could never fire
there and every Mac user met "Multiple companion devices detected" on a first
run, naming two virtual ports as the companions to choose between. Which device
is plausible was already known and already printed in that very listing as
"[likely LoRa]"; it now decides with it instead of only saying it. A lone
unrecognized adapter still resolves, because an unlisted VID is a real board more
often than not, and several implausible ports no longer claim to be companions.

A refused Bluetooth scan read as silence. bleak states the case precisely and
hands over a typed reason, and that was caught as a driver hiccup and logged at
debug -- under a log that defaults to WARNING, so a denial left no trace at all
and looked exactly like an empty room. On macOS this is the routine case rather
than an exotic one: Bluetooth is granted to the terminal MeshTerm runs in, never
to MeshTerm, so an ungranted terminal scans and hears nothing. The sentence bleak
wrote is kept and shown, since it already names the remedy.

The PIN broke the connection it was for. Handed one, the meshcore transport calls
bleak's pair(), and CoreBluetooth has no pairing API -- the macOS backend raises
outright, and the library then disconnects and re-raises, so a correct PIN was
what failed. Pairing there is the OS's to run: the firmware puts its UART
characteristic at ENC+MITM, the unbonded subscribe is answered with Insufficient
Authentication, and macOS responds by running Passkey Entry and asking for the
code itself. Withholding the PIN is what lets a protected companion bond. Windows
and Linux do expose explicit pairing and keep it.

Found by running the 0.3.6 arm64 build on a Mac. Detection over SSH is a separate
thing and stays impossible: an ssh session is a Background session, and macOS
refuses to prompt for Bluetooth in one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:22:59 -04:00

208 lines
9.0 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Non-interactive device selection: turn discovery + memory into a chosen port.
This is the logic used on the scripted CLI path (and as the fallback when the interactive
picker is unavailable). It is pure and UI-free: it takes the currently discovered devices,
the remembered default, and any explicit overrides, and returns a :class:`Resolution` — or
raises :class:`DeviceSelectionError` with a ready-to-print, user-facing message.
"""
from __future__ import annotations
from dataclasses import dataclass
from .config import DeviceProfile
from .device_store import RememberedDevice
from .discovery import (
TRANSPORT_BLE,
TRANSPORT_SERIAL,
TRANSPORT_TCP,
DiscoveredDevice,
parse_tcp_endpoint,
tcp_device,
)
class DeviceSelectionError(ValueError):
"""Raised when no device can be chosen unambiguously.
The message is already formatted for display to the user (it lists the discovered
devices and how to disambiguate), so callers can print ``str(exc)`` directly.
"""
@dataclass(slots=True)
class Resolution:
"""The outcome of resolving which device to use.
Attributes:
port: The chosen connection target — the serial port for a serial device, or the
Bluetooth address for a BLE device. (Named ``port`` for historical reasons; use
:attr:`target`.)
device: The matching discovered device, when enumeration knows it (so the caller
can remember it on a successful connection). ``None`` for an explicit
target that is not currently enumerable.
source: Where the choice came from (``"port"``, ``"ble"``, ``"tcp"``, ``"profile"``,
``"remembered"``, or ``"only"``), for logging and messaging.
transport: ``"serial"``, ``"ble"``, or ``"tcp"`` — the connection layer for the device.
"""
port: str
device: DiscoveredDevice | None
source: str
transport: str = TRANSPORT_SERIAL
@property
def target(self) -> str:
"""The connection target (serial port or BLE address); alias for :attr:`port`."""
return self.port
def _find_by_target(devices: list[DiscoveredDevice], target: str) -> DiscoveredDevice | None:
"""Return the discovered device whose port or BLE address equals ``target``."""
return next((d for d in devices if d.target == target or d.port == target), None)
def _resolve_tcp(endpoint: str, devices: list[DiscoveredDevice], source: str) -> Resolution:
"""Build a TCP :class:`Resolution` from a ``host[:port]`` string.
The endpoint is normalized (a bare host gains the default port) so the resulting target
matches how a remembered TCP device stores itself. A TCP companion isn't discoverable, so
any matching ``devices`` entry would only be a remembered one injected by the caller.
Raises:
DeviceSelectionError: If ``endpoint`` isn't a valid ``host[:port]``.
"""
try:
host, port = parse_tcp_endpoint(endpoint)
except ValueError as exc:
raise DeviceSelectionError(str(exc)) from exc
target = f"{host}:{port}"
match = _find_by_target(devices, target) or tcp_device(host, port)
return Resolution(target, match, source, TRANSPORT_TCP)
def _format_device_list(devices: list[DiscoveredDevice]) -> str:
"""Render discovered devices as an indented, human-readable bullet list."""
if not devices:
return " no serial devices detected"
lines = []
for d in devices:
flag = " [likely LoRa]" if d.is_likely_lora else ""
kind = "TCP" if d.is_tcp else "BLE" if d.is_ble else "serial"
lines.append(f" • {d.target} ({kind}) — {d.product or d.description or 'device'}{flag}")
return "\n".join(lines)
def resolve_device(
devices: list[DiscoveredDevice],
remembered: RememberedDevice | None,
*,
explicit_port: str | None = None,
explicit_ble: str | None = None,
explicit_tcp: str | None = None,
profile: DeviceProfile | None = None,
) -> Resolution:
"""Decide which companion to connect to without prompting.
Resolution priority:
1. ``explicit_tcp`` (an explicit ``--tcp`` network address).
2. ``explicit_ble`` (an explicit ``--ble`` Bluetooth address).
3. ``explicit_port`` (an explicit ``--port``).
4. A TCP profile's ``host:port``, or a serial profile's ``port``.
5. The remembered "last known good" device, if it is currently attached/in range.
6. The single *likely-LoRa* device, if exactly one is present — a port whose USB
vendor marks it a board or bridge, or a BLE/TCP endpoint. Ports that look like
nothing in particular are ignored at this step, because a platform may present
some unconditionally (every Mac carries two) and they would otherwise make the
count ambiguous forever.
7. The single attached device, if exactly one is present and *none* looked likely —
so an unrecognized-but-real adapter still resolves.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates: the
likely ones where there are any, else everything attached under a message that does
not claim they are companions.
Args:
devices: Currently discovered devices (serial and/or BLE).
remembered: The remembered default, if any.
explicit_port: A serial port supplied on the command line.
explicit_ble: A Bluetooth address supplied on the command line.
explicit_tcp: A network ``host[:port]`` supplied on the command line.
profile: A device profile supplied on the command line.
Returns:
A :class:`Resolution` naming the chosen target and transport.
Raises:
DeviceSelectionError: If no device can be chosen unambiguously, or an explicit TCP
endpoint could not be parsed.
"""
if explicit_tcp:
return _resolve_tcp(explicit_tcp, devices, "tcp")
if explicit_ble:
return Resolution(
explicit_ble, _find_by_target(devices, explicit_ble), "ble", TRANSPORT_BLE
)
if explicit_port:
match = _find_by_target(devices, explicit_port)
return Resolution(explicit_port, match, "port", TRANSPORT_SERIAL)
if profile is not None and profile.is_tcp and profile.tcp_endpoint:
return _resolve_tcp(profile.tcp_endpoint, devices, "profile")
if profile is not None and profile.port:
match = _find_by_target(devices, profile.port)
return Resolution(profile.port, match, "profile", TRANSPORT_SERIAL)
if remembered is not None:
match = next((d for d in devices if remembered.matches(d)), None)
if match is not None:
return Resolution(match.target, match, "remembered", match.transport)
# Prefer the devices that actually look like companions. macOS is why this matters:
# every Mac permanently presents /dev/cu.Bluetooth-Incoming-Port and
# /dev/cu.debug-console, which carry no USB VID/PID and so score "unknown". Counting
# them put `len(devices) == 1` out of reach there — a Mac with exactly one real board
# attached saw three devices and refused to choose, so auto-detection could never fire
# on that platform and every Mac user met "Multiple companion devices detected" on a
# first run. Which device is plausible is already known, and already printed in the
# listing below as "[likely LoRa]"; this decides with it instead of only saying it.
likely = [d for d in devices if d.is_likely_lora]
if len(likely) == 1:
return Resolution(likely[0].target, likely[0], "only", likely[0].transport)
# Nothing recognizable: fall back to the whole list rather than narrowing to nothing,
# so a lone *unrecognized* adapter — a UART bridge carrying a VID we don't list, which
# is a real board more often than not — still connects exactly as it always has.
if not likely and len(devices) == 1:
return Resolution(devices[0].target, devices[0], "only", devices[0].transport)
if not devices:
raise DeviceSelectionError(
"No companion devices detected. Connect a companion device (USB or Bluetooth), "
"or pass --port / --ble explicitly, or use --mock for the simulator."
)
if not likely:
# Several ports attached, none of them plausible. "Multiple companion devices" is
# simply false here: on a bare Mac it names two virtual ports that are not
# companions at all, and then tells the reader to choose one of them.
raise DeviceSelectionError(
"No companion devices detected among the attached ports.\n"
f"{_format_device_list(devices)}\n"
"Connect a companion device (USB or Bluetooth), or pass --port / --ble "
"explicitly if one of these is your companion, or use --mock for the simulator."
)
raise DeviceSelectionError(
"Multiple companion devices detected and no default to fall back on.\n"
f"{_format_device_list(likely)}\n"
"Choose one with --port <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to "
"inspect them). The chosen device is remembered as the default after it connects."
)