A T-Deck on MeshOS or wadamesh pairs over Bluetooth like any companion

Those firmwares guard only the UART write characteristic, so the
refusal arrives on the handshake's first write, which meshcore's send
swallows. The empty handshake read as "not a MeshCore companion", and
the PIN pairing, the stale-bond repair and the macOS Passkey wait never
ran. The write is now wrapped to keep its refusal, and an empty
handshake behind one raises it, so both firmware shapes take the same
pairing path.

bleak no longer gets the PIN on Windows either: its WinRT pair() is
Just Works only, and after a mistyped PIN it left Windows holding an
unauthenticated bond that the next, correct attempt reused and was
refused over.

Verified against a T-Deck on MeshOS 1.3.0: a planted Just Works bond
heals in one run, and an unbonded connect without a PIN now says the
device needs one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-09-28 23:26:14 -04:00
1 parent 307e767570
commit 854b7fd765
2 files changed
+150 -10

No files matched your search

+65 -5
View File
@@ -799,6 +799,55 @@ def _hold_disconnects_while_connecting(connection) -> _ConnectingClients: # noq
return seen
class _WriteRefusals(list):
"""Every exception a ``BLEConnection`` write raised, oldest first."""
def refusal(self) -> BaseException | None:
"""The first write the peripheral refused for want of a bond, else ``None``."""
return next((exc for exc in self if _is_ble_auth_error(exc)), None)
def _record_write_refusals(connection) -> _WriteRefusals: # noqa: ANN001
"""Keep what a refused write said, which meshcore's ``send`` logs and then forgets.
Stock companion firmware guards *both* halves of its UART service with ENC+MITM, so an
unbonded host is refused at the notify subscribe — raised out of ``connect()``, where
:meth:`MeshCoreDevice._open_ble` turns it into a pairing. The standalone T-Deck firmwares
(MeshOS, wadamesh) guard only the **write** characteristic: the subscribe succeeds, and
the refusal ("Insufficient Encryption" unbonded, "Insufficient Authentication" over a
Just Works bond) arrives on the identity handshake's first write. ``BLEConnection.send``
catches that, logs "BLE write failed", and returns ``False``; the handshake then comes
back empty and the device read as "not a MeshCore companion" — with no PIN pairing, no
stale-bond repair, and no macOS Passkey wait, because nothing downstream ever saw an
authentication error. Verified against a T-Deck on MeshOS 1.3.0.
So the write is wrapped to record the exception on its way through (it still propagates
to ``send``, which behaves as before), and :meth:`MeshCoreDevice._connect_owned_ble`
raises the recorded refusal when the handshake came back empty. Both firmwares then take
the same pairing path.
Args:
connection: The ``meshcore.BLEConnection`` about to connect.
Returns:
The record of write failures, empty until one happens.
"""
seen = _WriteRefusals()
write = getattr(connection, "_write_locked", None)
if write is None: # a meshcore without the hook: the handshake reads as before
return seen
async def _write_locked(data) -> None: # noqa: ANN001 - bytes-like, as meshcore sends
try:
await write(data)
except Exception as exc:
seen.append(exc)
raise
connection._write_locked = _write_locked
return seen
def _held_client(connection, seen: _ConnectingClients): # noqa: ANN001, ANN202
"""The bleak client to keep for teardown: the connection's, else the last one it lost."""
client = getattr(connection, "client", None)
@@ -1939,7 +1988,7 @@ class MeshCoreDevice(Device):
"""
from meshcore import BLEConnection
# Withhold the PIN on macOS. Handed one, ``BLEConnection.connect`` calls bleak's
# bleak never gets the PIN, on any platform. 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
@@ -1949,15 +1998,21 @@ class MeshCoreDevice(Device):
# 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`).
# the OS keeps the bond, and later connections need no PIN (a firmware that guards
# only the write gets there too, via :func:`_record_write_refusals`).
# Linux is the same story from the other side: bleak's BlueZ ``pair()`` ignores the
# PIN and pairs through whatever system agent there is (a desktop dialog, or nothing
# at all), so MeshTerm pairs there itself beforehand (see :meth:`_pair_ble_bluez`).
# Handing bleak the PIN would only start a second pairing we can't answer.
pin = None if sys.platform == "darwin" or sys.platform.startswith("linux") else self._pin
connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin)
# And Windows: bleak's WinRT ``pair()`` is hardcoded to CONFIRM_ONLY
# ("Just Works") and never sends a PIN, so after a PIN pairing of our own that failed
# (a mistyped PIN), handing bleak the PIN made it bond *without* one — and Windows
# kept that unauthenticated bond. The next attempt, with the right PIN, then reused
# it and was refused, until the bond was removed by hand. Pairing is ours to run on
# every platform that has an API for it (see :meth:`_pair_ble`).
connection = BLEConnection(address=self._address, device=self._ble_device, pin=None)
seen = _hold_disconnects_while_connecting(connection)
refused = _record_write_refusals(connection)
mc = mesh_core(
connection,
default_timeout=self._connect_timeout,
@@ -1979,6 +2034,11 @@ class MeshCoreDevice(Device):
if started is None:
await MeshCoreDevice._discard_meshcore(mc)
await self._release_ble_client()
# An empty handshake whose write was refused for want of a bond is a pairing
# problem, not a stranger on the air (see :func:`_record_write_refusals`).
refusal = refused.refusal()
if refusal is not None:
raise refusal
return None
return mc
+85 -5
View File
@@ -50,8 +50,14 @@ class _FakeTransport:
self.pin = pin
self.link_open = False
self.disconnect_calls = 0
#: What the peripheral answers a write with: ``None`` accepts it, an exception refuses.
self.write_answer: BaseException | None = None
_FakeTransport.last = self
async def _write_locked(self, data) -> None:
if self.write_answer is not None:
raise self.write_answer
async def disconnect(self) -> None:
self.disconnect_calls += 1
self.link_open = False
@@ -75,8 +81,10 @@ class _FakeMeshCore:
``outcomes`` drives each successive ``connect()``: an exception instance is raised (with
the link already up, as the real GATT failure leaves it), ``"none"`` returns ``None``
(transport up, no identity reply), ``"slow"`` hangs for a caller to cancel, and anything
else is returned as a successful handshake.
(transport up, no identity reply), ``"slow"`` hangs for a caller to cancel, a
``("refused", exc)`` pair has the handshake's write refused with ``exc`` — swallowed, as
meshcore's ``send`` does, leaving the handshake empty — and anything else is returned as
a successful handshake.
"""
#: Every instance built during a test, in order (a retry builds a second one).
@@ -100,6 +108,13 @@ class _FakeMeshCore:
# The GATT subscribe fails here — after the link is up and *before* the manager
# records it. Nothing downstream will close it unless someone reaches the cx.
raise outcome
if isinstance(outcome, tuple) and outcome[0] == "refused":
self.cx.write_answer = outcome[1]
try: # meshcore's send: log the failure, return False, and carry on
await self.cx._write_locked(b"")
except Exception:
pass
return None
if outcome == "slow":
await asyncio.sleep(30) # a handshake the caller's wait_for will cancel
self.connection_manager._is_connected = True
@@ -204,8 +219,7 @@ def test_a_good_connect_hands_the_client_over_still_open() -> None:
def test_the_connection_is_built_from_this_device_s_own_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Address, discovered ``BLEDevice`` and PIN all reach the connection we construct."""
# Pinned off macOS, where the PIN is deliberately withheld (see the test below).
"""Address and discovered ``BLEDevice`` reach the connection we construct; the PIN doesn't."""
monkeypatch.setattr(sys, "platform", "win32")
_FakeMeshCore.reset("ok")
sentinel = object()
@@ -220,7 +234,7 @@ def test_the_connection_is_built_from_this_device_s_own_endpoint(
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
cx = _FakeTransport.last
assert (cx.address, cx.device, cx.pin) == (_ADDR, sentinel, "000000")
assert (cx.address, cx.device, cx.pin) == (_ADDR, sentinel, None)
client = _FakeMeshCore.built[0]
# auto_reconnect stays off: MeshTerm drives reconnection itself.
assert (client.default_timeout, client.auto_reconnect) == (7.5, False)
@@ -261,6 +275,72 @@ def test_the_pin_is_withheld_from_bleak_on_linux(monkeypatch: pytest.MonkeyPatch
assert dev._pin == "000000"
def test_the_pin_is_withheld_from_bleak_on_windows(monkeypatch: pytest.MonkeyPatch) -> None:
"""bleak's WinRT ``pair()`` is Just Works only, so a PIN handed to it bonds *without* one.
After our own PIN pairing failed on a mistyped PIN, that fallback left Windows holding an
unauthenticated bond, and the next attempt — right PIN — reused it and was refused.
"""
monkeypatch.setattr(sys, "platform", "win32")
_FakeMeshCore.reset("ok")
dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000")
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
assert _FakeTransport.last.pin is None
assert dev._pin == "000000"
def test_a_refused_handshake_write_is_a_pairing_problem() -> None:
"""A firmware that guards only the write (MeshOS, wadamesh on a T-Deck) is still paired.
The subscribe succeeds there, and the refusal lands on the handshake's first write, which
meshcore's ``send`` swallows. The empty handshake must surface as that refusal — not as
"not a companion" — so the pairing, the stale-bond repair and the macOS wait all run.
"""
refusal = _AuthError("GATT Protocol Error: Insufficient Encryption")
_FakeMeshCore.reset(("refused", refusal))
dev = _device()
with pytest.raises(_AuthError) as caught:
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
assert caught.value is refusal
assert _FakeMeshCore.built[0].cx.link_open is False # released before it was raised
def test_a_write_failing_for_another_reason_still_reads_as_no_answer() -> None:
"""Only a refusal for want of a bond is promoted; a dropped write stays "no answer"."""
_FakeMeshCore.reset(("refused", OSError("device unreachable")))
dev = _device()
assert asyncio.run(dev._connect_owned_ble(_FakeMeshCore)) is None
def test_a_refused_write_heals_through_the_pin_repair(monkeypatch: pytest.MonkeyPatch) -> None:
"""End to end on Windows: refused write → unpair and pair with the PIN → connected.
The T-Deck case exactly: a Just Works bond is trusted on the fast path, the handshake's
write is refused over it, and the repair that stock firmware reaches from the subscribe
must be reached from the write too.
"""
monkeypatch.setattr(sys, "platform", "win32")
forced: list[bool] = []
async def _pair(self, *, force: bool) -> bool:
forced.append(force)
return True
monkeypatch.setattr(MeshCoreDevice, "_pair_ble", _pair)
_FakeMeshCore.reset(("refused", _AuthError("Insufficient Authentication")), "ok")
dev = _device(pin="000000")
client = asyncio.run(dev._create_ble(_FakeMeshCore))
assert client is _FakeMeshCore.built[1]
assert forced == [False, True] # the fast path, then the repair
def test_a_cancelled_handshake_still_closes_the_link() -> None:
"""A probe's ``wait_for`` expiring mid-handshake must not leak what it cancelled.