diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index bfda2ac..59c989d 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -1305,7 +1305,20 @@ class MeshCoreDevice(Device): """ from meshcore import BLEConnection - connection = BLEConnection(address=self._address, device=self._ble_device, pin=self._pin) + # 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`). + pin = None if sys.platform == "darwin" else self._pin + connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin) mc = mesh_core( connection, default_timeout=self._connect_timeout, diff --git a/meshterm/core/discovery.py b/meshterm/core/discovery.py index a4f3e33..60b6a35 100644 --- a/meshterm/core/discovery.py +++ b/meshterm/core/discovery.py @@ -53,6 +53,31 @@ BLE_NAME_PREFIX = "MeshCore" #: 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 @@ -393,6 +418,9 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc 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 @@ -400,6 +428,16 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc 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 [] diff --git a/meshterm/core/selection.py b/meshterm/core/selection.py index b3af113..b54923e 100644 --- a/meshterm/core/selection.py +++ b/meshterm/core/selection.py @@ -112,9 +112,17 @@ def resolve_device( 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 attached device, if exactly one is present. + 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. + 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). @@ -155,7 +163,23 @@ def resolve_device( if match is not None: return Resolution(match.target, match, "remembered", match.transport) - if len(devices) == 1: + # 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: @@ -164,9 +188,20 @@ def resolve_device( "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(devices)}\n" + f"{_format_device_list(likely)}\n" "Choose one with --port or --ble
(or run 'meshterm devices' to " "inspect them). The chosen device is remembered as the default after it connects." ) diff --git a/meshterm/tools/devices.py b/meshterm/tools/devices.py index 9f9a3f3..1a2f137 100644 --- a/meshterm/tools/devices.py +++ b/meshterm/tools/devices.py @@ -18,7 +18,13 @@ import typer from ..context import AppContext from ..core import exitcodes -from ..core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_all, discover_devices +from ..core.discovery import ( + TRANSPORT_MOCK, + DiscoveredDevice, + ble_unavailable_reason, + discover_all, + discover_devices, +) from .base import Tool, ToolResult, register if TYPE_CHECKING: # pragma: no cover - typing only @@ -73,6 +79,18 @@ class DevicesTool(Tool): active = ctx.selected_device active_target = ctx.ble_override or ctx.port_override or (active.target if active else None) + # A refused Bluetooth scan is not the same as a quiet one, and the difference is + # invisible in the listing — both simply lack BLE rows. Say so whether or not + # anything else was found: a serial board being present does not make a companion + # the reader expected over Bluetooth any less missing. + blocked = ble_unavailable_reason() + if blocked: + from ..ui import script + + script.stderr_console().print( + f"meshterm: Bluetooth not scanned — {blocked}", style="warn", highlight=False + ) + if not devices: # Not an error: the scan ran and found nothing. Said on stderr so a caller # redirecting stdout still hears it, and reported as NO_RESULT so a script can diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2994e14..44a5e12 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -263,6 +263,50 @@ async def test_discover_ble_survives_no_adapter(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr(bleak, "BleakScanner", _BoomScanner, raising=False) assert await discovery.discover_ble_devices(timeout=0.0) == [] + # A hiccup is not something the reader can act on, so it leaves no reason behind. + assert discovery.ble_unavailable_reason() is None + + +async def test_discover_ble_records_a_refusal_the_user_can_fix( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A denied scan keeps bleak's sentence instead of reading as "nothing nearby". + + On macOS this is the routine case, not an exotic one: Bluetooth is granted to the + terminal running MeshTerm, so an ungranted terminal — and every SSH session, which + macOS refuses to even prompt in — scans successfully and hears nothing at all. + """ + import bleak + + import meshterm.core.discovery as discovery + + not_available = pytest.importorskip("bleak.exc").BleakBluetoothNotAvailableError + + class _DeniedScanner: + @staticmethod + async def discover(timeout: float, return_adv: bool): + raise not_available("Bluetooth access is denied by the user", 2) + + monkeypatch.setattr(bleak, "BleakScanner", _DeniedScanner, raising=False) + assert await discovery.discover_ble_devices(timeout=0.0) == [] + reason = discovery.ble_unavailable_reason() + assert reason is not None and "denied" in reason + + # And it describes the latest attempt, never a stale one. + monkeypatch.setattr(bleak, "BleakScanner", _empty_scanner(), raising=False) + assert await discovery.discover_ble_devices(timeout=0.0) == [] + assert discovery.ble_unavailable_reason() is None + + +def _empty_scanner(): + """A scanner that works and simply hears nothing — the "not a fault" case.""" + + class _Quiet: + @staticmethod + async def discover(timeout: float, return_adv: bool): + return {} + + return _Quiet # -- device store -------------------------------------------------------------- @@ -496,15 +540,57 @@ def test_resolve_bad_tcp_endpoint_raises() -> None: def test_resolve_ambiguous_raises(tmp_path: Path) -> None: - """Multiple devices with no usable default raise a guidance error.""" + """Two plausible companions with no usable default raise a guidance error.""" devices = [ - DiscoveredDevice("COM3", serial_number="SN1"), - DiscoveredDevice("COM4", serial_number="SN2"), + DiscoveredDevice("COM3", vid=0x303A, pid=0x1001, serial_number="SN1"), + DiscoveredDevice("COM4", vid=0x10C4, pid=0xEA60, serial_number="SN2"), ] with pytest.raises(DeviceSelectionError, match="Multiple companion devices"): resolve_device(devices, None) +def test_resolve_ignores_implausible_ports_when_one_board_is_present() -> None: + """One real board among ports that look like nothing still resolves to the board. + + This is the macOS case: every Mac permanently presents two virtual ``/dev/cu.*`` + ports with no USB VID/PID, so counting them meant a Mac with a single companion + attached saw three devices and refused to choose — auto-detection could never fire + on that platform. + """ + board = DiscoveredDevice("/dev/cu.usbmodem1101", vid=0x303A, pid=0x1001) + devices = [ + board, + DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"), + DiscoveredDevice("/dev/cu.debug-console"), + ] + res = resolve_device(devices, None) + assert res.target == board.target and res.source == "only" + + +def test_resolve_lone_unrecognized_device_still_resolves() -> None: + """A single adapter we can't place still connects — an unlisted VID is usually real.""" + lone = DiscoveredDevice("COM9", vid=0x1234, pid=0x0001) + assert not lone.is_likely_lora + res = resolve_device([lone], None) + assert res.target == "COM9" and res.source == "only" + + +def test_resolve_several_implausible_ports_does_not_call_them_companions() -> None: + """Ports that look like nothing are not reported as "multiple companion devices". + + A bare Mac with no companion attached reaches here with its two virtual ports, and + being told it has several companions — and asked to pick one of them — is the least + true answer available. + """ + devices = [ + DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"), + DiscoveredDevice("/dev/cu.debug-console"), + ] + with pytest.raises(DeviceSelectionError, match="No companion devices detected among") as err: + resolve_device(devices, None) + assert "Multiple companion devices" not in str(err.value) + + def test_resolve_no_devices_raises() -> None: """No devices at all raises a clear error mentioning --mock.""" with pytest.raises(DeviceSelectionError, match="No companion devices"):