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>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-18 22:22:59 -04:00
1 parent fc4406ba86
commit add17b49d6
5 files changed
+199 -9

No files matched your search

+14 -1
View File
@@ -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,
+38
View File
@@ -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 []
+39 -4
View File
@@ -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 <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to "
"inspect them). The chosen device is remembered as the default after it connects."
)
+19 -1
View File
@@ -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
+89 -3
View File
@@ -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"):