Files
MeshTerm/meshterm/core/selection.py
T
Jean-Pierre MartineauandClaude Opus 4.8 7850587e2a Add Bluetooth LE companion support alongside serial
Companions can now be reached over Bluetooth as well as USB. Rather than
a parallel BLE path, this generalizes the existing serial abstractions so
a device is identified by a transport (serial | ble) plus a target (COM
port or Bluetooth address); every feature rides the same Device interface
and works over either link unchanged.

- discovery: DiscoveredDevice gains transport/address/name and
  transport-aware stable_id/label/confidence/target; discover_ble_devices
  scans for MeshCore* adverts via bleak, discover_all merges both.
- connection: MeshCoreDevice opens create_ble vs create_serial by
  transport; new link_present() liveness (OS port enumeration for serial,
  the BLE client's is_connected flag for Bluetooth); is_connection_lost
  recognizes Bleak errors and BLE transport callbacks; probe_device is the
  transport-aware smoke test.
- disconnect: the session watcher polls transport-neutral link_alive(), so
  a dropped BLE link raises the same reconnect dialog and auto-resume as an
  unplugged cable; reconnect rebuilds the same endpoint by address.
- selection/context/store/profiles: --ble address, remembered BLE default,
  and BLE device profiles all resolve to the right transport.
- UI/CLI: startup picker scans both transports behind a spinner and tags
  Bluetooth companions; `devices` lists them (--no-ble to skip); global
  --ble/--ble-pin flags; README and example config updated.

Validated on hardware: discovery, connect, reconnect, and channel
messaging (send + receive) over BLE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:44:39 -04:00

141 lines
5.4 KiB
Python

"""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 typing import Optional
from .config import DeviceProfile
from .device_store import RememberedDevice
from .discovery import TRANSPORT_BLE, TRANSPORT_SERIAL, DiscoveredDevice
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"``, ``"profile"``,
``"remembered"``, or ``"only"``), for logging and messaging.
transport: ``"serial"`` or ``"ble"`` — which connection layer opens the device.
"""
port: str
device: Optional[DiscoveredDevice]
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
) -> Optional[DiscoveredDevice]:
"""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 _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 = "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: Optional[RememberedDevice],
*,
explicit_port: Optional[str] = None,
explicit_ble: Optional[str] = None,
profile: Optional[DeviceProfile] = None,
) -> Resolution:
"""Decide which companion to connect to without prompting.
Resolution priority:
1. ``explicit_ble`` (an explicit ``--ble`` Bluetooth address).
2. ``explicit_port`` (an explicit ``--port``).
3. ``profile.port`` (an explicitly chosen profile with a port).
4. The remembered "last known good" device, if it is currently attached/in range.
5. The single attached device, if exactly one is present.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates.
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.
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.
"""
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.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)
if 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."
)
raise DeviceSelectionError(
"Multiple companion devices detected and no default to fall back on.\n"
f"{_format_device_list(devices)}\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."
)