Bluetooth on Linux stops stranding its own link, and pairs with the PIN first time
Measured against a T1000-E from a uConsole (BlueZ 5.66, btmon on the side). A connect that BlueZ had to retry stranded its link. bleak retries a link attempt that failed to synchronise (le-connection-abort-by-local, 3-7 per connect on a Pi radio) inside connect(), and each one fired meshcore's disconnect handler, which resets BLEConnection.client to None. bleak's next retry then connected a client nobody held, meshcore gave up as "not established", and the live link kept the companion from advertising, so the retry scanned for 30 s and reported it out of range. Three runs out of three. While connect() runs, a disconnect is now only recorded, and the teardown keeps the last live client if meshcore let go of it anyway. A companion given a PIN after it was paired unauthenticated answers the subscribe with ATT 0x0E, Unlikely Error (bond Authenticated=0). That is now read as a pairing refusal, so the stale-bond message and the PIN repair reach it; with the PIN it re-paired and connected. BlueZ's Pair left its link up (the companion then stopped advertising to the connect), and failed at once on the same link flake bleak retries. It now puts the link down after pairing and retries a flake a bounded 4 times. And BlueZ starts a pairing of its own when the subscribe is refused, asking the default agent; with none (any headless machine) it waited on a yes/no until the companion's 30 s SMP timeout. For the length of a connect MeshTerm is now the default agent for that one device, KeyboardOnly: with a PIN it answers, without one it refuses at once, and it never approves a pairing without the passkey, which is how a bond ends up weak. Unpaired, no PIN: 33 s -> 4 s to "requires a PIN". Unpaired with the PIN: paired and connected in 11 s. Paired: connects in 7 s, where it failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
bd222105ae
commit
bd0c311dbc
4 files changed
+393
-23
No files matched your search
+127
-15
@@ -34,6 +34,8 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -63,6 +65,24 @@ DISCOVER_S = 6.0
|
||||
#: seconds; this only has to outlast a slow radio, and stays well inside the probe's window.
|
||||
PAIR_TIMEOUT_S = 15.0
|
||||
|
||||
#: Attempts at ``Pair`` when the link under it fails to come up, and the pause between them.
|
||||
#: A Raspberry Pi class radio drops three to seven link attempts per connect
|
||||
#: (``le-connection-abort-by-local``, HCI 0x3e). bleak retries those inside its own
|
||||
#: ``connect()``; BlueZ's ``Pair`` does not, and answers ConnectionAttemptFailed at once.
|
||||
#: Measured on a uConsole: the first Pair failed that way, the connect then went ahead
|
||||
#: unpaired, and the protected subscribe cost 32 s before the repair paired it after all.
|
||||
PAIR_ATTEMPTS = 4
|
||||
PAIR_RETRY_DELAY_S = 0.5
|
||||
|
||||
_LINK_FLAKES = ("org.bluez.Error.ConnectionAttemptFailed",)
|
||||
|
||||
|
||||
def _link_flake(reply: Any) -> bool:
|
||||
"""Whether a ``Pair`` error is the link failing to come up — worth another try."""
|
||||
detail = reply.body[0] if reply.body and isinstance(reply.body[0], str) else ""
|
||||
return reply.error_name in _LINK_FLAKES or "le-connection-abort" in detail
|
||||
|
||||
|
||||
_MAC = re.compile(r"^[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}$")
|
||||
|
||||
|
||||
@@ -100,12 +120,18 @@ def status_name(error_name: str, body: list[Any] | None = None) -> str:
|
||||
|
||||
|
||||
class PinAgent:
|
||||
"""A BlueZ ``Agent1`` that answers one device's pairing with one PIN.
|
||||
"""A BlueZ ``Agent1`` that answers one device's pairing with one PIN — or refuses it.
|
||||
|
||||
Installed as a message handler on the bus that calls ``Pair`` (see the module docstring
|
||||
for why that bus). It answers only calls addressed to its own object path, and only for
|
||||
for why that bus), or made BlueZ's default agent for the length of a connect (see
|
||||
:func:`answering`). It answers only calls addressed to its own object path, and only for
|
||||
the device it was built for; anything else is ``Rejected``, so a pairing we did not start
|
||||
can never be approved by us.
|
||||
can never be approved by us. Without a PIN it refuses every request at once, which is
|
||||
the point of it there: the alternative is BlueZ waiting on an agent that doesn't exist.
|
||||
|
||||
It never approves a pairing *without* the passkey (``RequestAuthorization``, the "Just
|
||||
Works" yes/no). That is how a companion ends up with an unauthenticated bond — encryption
|
||||
works, the UART characteristic still refuses — which is the stale-bond failure itself.
|
||||
|
||||
Attributes:
|
||||
path: The object path the agent is registered at.
|
||||
@@ -113,13 +139,15 @@ class PinAgent:
|
||||
asking was refused before the passkey stage, which is a different story.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, pin: str, device_path: str) -> None:
|
||||
def __init__(self, path: str, pin: str | None, device_path: str) -> None:
|
||||
"""Build the agent.
|
||||
|
||||
Args:
|
||||
path: The object path to answer at.
|
||||
pin: The pairing PIN (digits).
|
||||
device_path: The BlueZ object path of the one device it may answer for.
|
||||
pin: The pairing PIN (digits), or ``None`` to refuse every request.
|
||||
device_path: The one device it may answer for: its BlueZ object path, or the
|
||||
path's ``/dev_…`` tail when the adapter isn't known yet (see
|
||||
:func:`device_tail`).
|
||||
"""
|
||||
self.path = path
|
||||
self._pin = pin
|
||||
@@ -147,8 +175,11 @@ class PinAgent:
|
||||
if member in ("Release", "Cancel"):
|
||||
return Message.new_method_return(msg)
|
||||
device = msg.body[0] if msg.body else ""
|
||||
if device != self._device:
|
||||
if not str(device).endswith(self._device):
|
||||
return Message.new_error(msg, _REJECTED, "not the device MeshTerm is pairing")
|
||||
if member in ("RequestPasskey", "RequestPinCode", "RequestConfirmation") and not self._pin:
|
||||
self.asked = True
|
||||
return Message.new_error(msg, _REJECTED, "no PIN was given for this device")
|
||||
if member == "RequestPasskey":
|
||||
self.asked = True
|
||||
return Message.new_method_return(msg, "u", [int(self._pin)])
|
||||
@@ -163,11 +194,76 @@ class PinAgent:
|
||||
if int(msg.body[1]) == int(self._pin):
|
||||
return Message.new_method_return(msg)
|
||||
return Message.new_error(msg, _REJECTED, "passkey does not match the PIN")
|
||||
if member in ("DisplayPasskey", "DisplayPinCode", "RequestAuthorization"):
|
||||
if member in ("DisplayPasskey", "DisplayPinCode"):
|
||||
return Message.new_method_return(msg)
|
||||
return Message.new_error(msg, _REJECTED, f"{member} is not something MeshTerm answers")
|
||||
|
||||
|
||||
def device_tail(address: str) -> str:
|
||||
"""The ``/dev_AA_BB_…`` tail of the object path BlueZ gives the device at ``address``."""
|
||||
return "/dev_" + _normal(address).replace(":", "_")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def answering(address: str, pin: str | None) -> AsyncIterator[None]:
|
||||
"""Be BlueZ's default pairing agent for one device while a connect runs.
|
||||
|
||||
BlueZ does not wait to be asked to pair. When the companion refuses the UART subscribe
|
||||
with *Insufficient Authentication*, BlueZ raises the link's security by itself and starts
|
||||
SMP pairing, which needs an agent — and the one it asks is the **default** agent, not
|
||||
ours (that routing is only for a ``Pair`` we call). With none registered, as on any
|
||||
headless machine, it offers the companion ``DisplayYesNo``, lands on "Just Works", asks a
|
||||
yes/no nobody is there to answer, and the companion hangs up on its 30 s SMP timeout.
|
||||
Measured on a uConsole with btmon: 3.3 s to the question, 33.5 s to the hang-up, and only
|
||||
then "requires a PIN". On a desktop the question goes to the desktop's dialog instead,
|
||||
and answering it makes the unauthenticated bond that later refuses the subscribe.
|
||||
|
||||
So for the length of the connect MeshTerm is the default agent, for this device only,
|
||||
declared ``KeyboardOnly`` so the pairing BlueZ starts is Passkey Entry: with a PIN it types
|
||||
it in and the connect pairs on the spot; without one it refuses at once and the refusal
|
||||
arrives in seconds. Another device's pairing in that window is refused, not left hanging.
|
||||
BlueZ keeps default agents as a stack, so unregistering hands the role back to whoever
|
||||
held it. Best-effort: with no system bus it simply does nothing.
|
||||
|
||||
Args:
|
||||
address: The companion's Bluetooth address.
|
||||
pin: Its PIN, or ``None`` to refuse.
|
||||
|
||||
Yields:
|
||||
Nothing; the agent is live for the body of the ``async with``.
|
||||
"""
|
||||
bus = None
|
||||
agent = PinAgent(f"/net/meshterm/connect{os.getpid()}", pin, device_tail(address))
|
||||
registered = False
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
bus.add_message_handler(agent.handle)
|
||||
reply = await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os",
|
||||
[agent.path, AGENT_CAPABILITY],
|
||||
) # fmt: skip
|
||||
registered = not _is_error(reply)
|
||||
if registered:
|
||||
await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RequestDefaultAgent", "o", [agent.path]
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - no bus, no BlueZ: connect without an agent
|
||||
_log.debug("couldn't stand in as the BlueZ agent: %s", exc)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if bus is not None:
|
||||
try:
|
||||
if registered:
|
||||
await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path]
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - the bus closing drops it anyway
|
||||
_log.debug("unregistering the BlueZ agent failed: %s", exc)
|
||||
bus.remove_message_handler(agent.handle)
|
||||
bus.disconnect()
|
||||
|
||||
|
||||
async def _call(bus: Any, path: str, interface: str, member: str, signature: str = "", body=None):
|
||||
"""Call a BlueZ method and return the raw reply (errors are left for the caller)."""
|
||||
from dbus_fast import Message
|
||||
@@ -315,6 +411,7 @@ async def pair(
|
||||
if path is None:
|
||||
return "absent", ""
|
||||
agent = PinAgent(f"/net/meshterm/agent{os.getpid()}", pin, path)
|
||||
paired_link: str | None = None # set once Pair has been asked to open a link
|
||||
bus.add_message_handler(agent.handle)
|
||||
try:
|
||||
reply = await _call(
|
||||
@@ -323,13 +420,20 @@ async def pair(
|
||||
) # fmt: skip
|
||||
if _is_error(reply):
|
||||
return "error", status_name(reply.error_name, reply.body)
|
||||
try:
|
||||
reply = await asyncio.wait_for(
|
||||
_call(bus, path, _DEVICE_IFACE, "Pair"), pair_timeout_s
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await _call(bus, path, _DEVICE_IFACE, "CancelPairing")
|
||||
return "failed", "authentication timeout"
|
||||
paired_link = path
|
||||
for attempt in range(PAIR_ATTEMPTS):
|
||||
if attempt:
|
||||
await asyncio.sleep(PAIR_RETRY_DELAY_S)
|
||||
try:
|
||||
reply = await asyncio.wait_for(
|
||||
_call(bus, path, _DEVICE_IFACE, "Pair"), pair_timeout_s
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await _call(bus, path, _DEVICE_IFACE, "CancelPairing")
|
||||
return "failed", "authentication timeout"
|
||||
if not (_is_error(reply) and _link_flake(reply)):
|
||||
break
|
||||
_log.debug("BlueZ Pair lost its link (attempt %d/%d)", attempt + 1, PAIR_ATTEMPTS)
|
||||
if _is_error(reply) and reply.error_name != _ALREADY_EXISTS:
|
||||
status = status_name(reply.error_name, reply.body)
|
||||
if not agent.asked and status == "authentication failed":
|
||||
@@ -345,6 +449,14 @@ async def pair(
|
||||
) # fmt: skip
|
||||
return "paired", ""
|
||||
finally:
|
||||
if paired_link is not None:
|
||||
# ``Pair`` opens a link to pair over and BlueZ leaves it up. A companion that
|
||||
# is connected stops advertising, and the connect that follows looks the
|
||||
# device up by scanning for it, so it never finds it — measured on a
|
||||
# uConsole: paired, then "couldn't open a link" after two 30 s tries. Put
|
||||
# the link down so the connect meets an advertising device, as it would
|
||||
# have without us.
|
||||
await _call(bus, paired_link, _DEVICE_IFACE, "Disconnect")
|
||||
await _call(bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path])
|
||||
bus.remove_message_handler(agent.handle)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort: the connect reports the refusal
|
||||
|
||||
@@ -21,7 +21,7 @@ import re
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -545,6 +545,13 @@ _BLE_AUTH_HINTS = (
|
||||
"could not pair",
|
||||
"authentication failed",
|
||||
"authenticationfailed",
|
||||
# ATT 0x0E, "Unlikely Error": what a companion that has since been given a PIN answers
|
||||
# the UART subscribe with over an *unauthenticated* bond left from when it was open —
|
||||
# encryption succeeds on the old key, and the characteristic's MITM requirement is what
|
||||
# refuses. Seen on a uConsole (BlueZ bond Authenticated=0). Only consulted while
|
||||
# connecting, where it is the security layer talking; it routes the refusal to the
|
||||
# stale-bond message and the PIN repair, which is exactly what heals it.
|
||||
"unlikely error",
|
||||
)
|
||||
|
||||
|
||||
@@ -692,6 +699,69 @@ def _serial_open_message(port: str, exc: BaseException) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
class _ConnectingClients(list):
|
||||
"""Every bleak client a connecting ``BLEConnection`` reported a disconnect for.
|
||||
|
||||
Attributes:
|
||||
connecting: Whether ``connect()`` is still running; while it is, a disconnect is
|
||||
recorded here and *not* passed on to meshcore (see
|
||||
:func:`_hold_disconnects_while_connecting`).
|
||||
"""
|
||||
|
||||
connecting = True
|
||||
|
||||
|
||||
def _hold_disconnects_while_connecting(connection) -> _ConnectingClients: # noqa: ANN001
|
||||
"""Keep meshcore from dropping its bleak client while that client is still connecting.
|
||||
|
||||
On Linux, BlueZ answers a link attempt that failed to synchronise
|
||||
(``le-connection-abort-by-local``, HCI 0x3e) and bleak retries it inside its own
|
||||
``connect()`` — three to seven times per connect on a Raspberry Pi radio. Each failed
|
||||
attempt fires the client's disconnect callback, and meshcore's
|
||||
``BLEConnection.handle_disconnect`` answers by resetting ``self.client`` to what the
|
||||
caller passed in, which is ``None``. bleak's next retry then succeeds on a client
|
||||
nobody holds: meshcore's ``start_notify`` hits ``None``, its connect gives up as "not
|
||||
established", and the live link is stranded. The companion, connected to us, stops
|
||||
advertising, so the retry cannot find it and the reader is told it is out of range.
|
||||
Measured against a companion on a uConsole with btmon: three runs out of three.
|
||||
|
||||
So while ``connect()`` runs, a disconnect is only recorded. bleak raises on its own if
|
||||
the link really cannot be made, and once ``connect()`` returns, disconnects reach
|
||||
meshcore as before, because the client keeps calling this same wrapper.
|
||||
|
||||
Args:
|
||||
connection: The ``meshcore.BLEConnection`` about to connect.
|
||||
|
||||
Returns:
|
||||
The record of clients seen, whose ``connecting`` flag the caller clears once
|
||||
``connect()`` has returned or raised.
|
||||
"""
|
||||
seen = _ConnectingClients()
|
||||
forward = getattr(connection, "handle_disconnect", None)
|
||||
if forward is None: # a transport with no drop handler has nothing to hold back
|
||||
return seen
|
||||
|
||||
def _handle_disconnect(client) -> None: # noqa: ANN001 - a bleak client
|
||||
seen.append(client)
|
||||
if seen.connecting:
|
||||
_log.debug("BLE link attempt dropped while connecting; bleak retries it")
|
||||
return
|
||||
forward(client)
|
||||
|
||||
# BLEConnection hands ``self.handle_disconnect`` to each BleakClient it builds inside
|
||||
# ``connect()``, so an instance attribute set now is the callback every client gets.
|
||||
connection.handle_disconnect = _handle_disconnect
|
||||
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)
|
||||
if client is not None:
|
||||
return client
|
||||
return next((c for c in reversed(seen) if getattr(c, "is_connected", False)), None)
|
||||
|
||||
|
||||
def serial_port_present(port: str) -> bool:
|
||||
"""Return whether a serial port named ``port`` is currently enumerated by the OS.
|
||||
|
||||
@@ -1625,8 +1695,22 @@ class MeshCoreDevice(Device):
|
||||
Returns:
|
||||
The connected ``MeshCore`` client, or ``None`` if the peripheral never answered.
|
||||
"""
|
||||
await self._pair_ble(force=False)
|
||||
return await self._open_ble(mesh_core, allow_repair=True)
|
||||
async with self._ble_pairing_agent():
|
||||
await self._pair_ble(force=False)
|
||||
return await self._open_ble(mesh_core, allow_repair=True)
|
||||
|
||||
def _ble_pairing_agent(self) -> AbstractAsyncContextManager[object]:
|
||||
"""Answer BlueZ's own pairing requests for this device while connecting (Linux only).
|
||||
|
||||
See :func:`meshterm.core.bluez.answering` for why: BlueZ starts a pairing of its own
|
||||
when the subscribe is refused, and with nobody to answer it the connect stalls 30 s.
|
||||
An instance hook, so tests can keep the system bus out of it.
|
||||
"""
|
||||
from . import bluez
|
||||
|
||||
if sys.platform.startswith("linux") and bluez.is_mac(self._address or ""):
|
||||
return bluez.answering(self._address or "", self._pin)
|
||||
return nullcontext()
|
||||
|
||||
async def _open_ble(self, mesh_core, *, allow_repair: bool): # type: ignore[no-untyped-def]
|
||||
"""Open the meshcore BLE client, translating auth failures and healing stale bonds.
|
||||
@@ -1823,6 +1907,7 @@ class MeshCoreDevice(Device):
|
||||
# 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)
|
||||
seen = _hold_disconnects_while_connecting(connection)
|
||||
mc = mesh_core(
|
||||
connection,
|
||||
default_timeout=self._connect_timeout,
|
||||
@@ -1831,14 +1916,16 @@ class MeshCoreDevice(Device):
|
||||
try:
|
||||
started = await mc.connect()
|
||||
except BaseException:
|
||||
self._ble_client = getattr(connection, "client", None)
|
||||
self._ble_client = _held_client(connection, seen)
|
||||
await MeshCoreDevice._discard_meshcore(mc)
|
||||
await self._release_ble_client()
|
||||
raise
|
||||
finally:
|
||||
seen.connecting = False
|
||||
# Take our own reference to the bleak client now, while ``BLEConnection`` still holds
|
||||
# one. It drops it the instant the peripheral goes away, and by then nothing else can
|
||||
# reach the object that needs closing (see :meth:`_release_ble_client`).
|
||||
self._ble_client = getattr(connection, "client", None)
|
||||
self._ble_client = _held_client(connection, seen)
|
||||
if started is None:
|
||||
await MeshCoreDevice._discard_meshcore(mc)
|
||||
await self._release_ble_client()
|
||||
|
||||
+87
-3
@@ -110,8 +110,9 @@ class _Variant:
|
||||
class _FakeBus:
|
||||
"""A scripted system bus: BlueZ's object tree plus the answer to ``Pair``.
|
||||
|
||||
``pair_reply`` is what ``Device1.Pair`` returns; ``asks`` makes BlueZ call the agent's
|
||||
``RequestPasskey`` first, as a real Passkey Entry pairing does.
|
||||
``pair_reply`` is what ``Device1.Pair`` returns — a list is answered one entry per
|
||||
call, to script a retry; ``asks`` makes BlueZ call the agent's ``RequestPasskey``
|
||||
first, as a real Passkey Entry pairing does.
|
||||
"""
|
||||
|
||||
def __init__(self, *, known=True, paired=False, pair_reply=None, asks=True) -> None:
|
||||
@@ -149,6 +150,8 @@ class _FakeBus:
|
||||
if self.asks:
|
||||
for handler in self.handlers:
|
||||
self.agent_answer = handler(_agent_call("RequestPasskey", _DEV, path=_AGENT))
|
||||
if isinstance(self.pair_reply, list):
|
||||
return self.pair_reply.pop(0)
|
||||
return self.pair_reply
|
||||
return _Reply()
|
||||
|
||||
@@ -186,7 +189,8 @@ def test_pair_registers_an_agent_answers_the_pin_and_trusts(bus) -> None:
|
||||
"""The happy path: agent in, Pair, PIN answered, Trusted set, agent out, bus closed."""
|
||||
fake = bus()
|
||||
assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0)) == ("paired", "")
|
||||
assert fake.calls[1:] == ["RegisterAgent", "Pair", "Set", "UnregisterAgent"]
|
||||
# Disconnect: Pair leaves its link up, and a connected companion stops advertising.
|
||||
assert fake.calls[1:] == ["RegisterAgent", "Pair", "Set", "Disconnect", "UnregisterAgent"]
|
||||
assert fake.agent_answer.body == [123456] # the PIN reached BlueZ as the passkey
|
||||
assert fake.handlers == [] and fake.disconnected
|
||||
|
||||
@@ -239,3 +243,83 @@ def test_a_pin_that_is_not_digits_never_reaches_bluez(bus) -> None:
|
||||
fake = bus()
|
||||
assert asyncio.run(bluez.pair(_ADDR, "12ab56", force=False))[0] == "failed"
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
def test_pair_retries_a_link_that_failed_to_come_up(bus, monkeypatch) -> None:
|
||||
"""ConnectionAttemptFailed is the radio, not the pairing: Pair again, a bounded few times.
|
||||
|
||||
bleak retries these inside its own connect; BlueZ's Pair does not, and on a uConsole the
|
||||
first Pair failed this way, sending the connect on unpaired into a 32 s stall.
|
||||
"""
|
||||
monkeypatch.setattr(bluez, "PAIR_RETRY_DELAY_S", 0)
|
||||
flake = _Reply(error="org.bluez.Error.ConnectionAttemptFailed")
|
||||
fake = bus(pair_reply=[flake, flake, _Reply()])
|
||||
assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0)) == ("paired", "")
|
||||
assert fake.calls.count("Pair") == 3
|
||||
|
||||
fake = bus(pair_reply=[flake] * bluez.PAIR_ATTEMPTS)
|
||||
outcome, status = asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0))
|
||||
assert (outcome, status) == ("failed", "connection attempt failed")
|
||||
assert fake.calls.count("Pair") == bluez.PAIR_ATTEMPTS # bounded, never endless
|
||||
|
||||
|
||||
def test_pair_never_retries_a_wrong_pin(bus, monkeypatch) -> None:
|
||||
"""Only a link failure is retried; a refused passkey is an answer."""
|
||||
monkeypatch.setattr(bluez, "PAIR_RETRY_DELAY_S", 0)
|
||||
fake = bus(pair_reply=[_Reply(error="org.bluez.Error.AuthenticationFailed"), _Reply()])
|
||||
assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0))[0] == "failed"
|
||||
assert fake.calls.count("Pair") == 1
|
||||
|
||||
|
||||
def test_agent_without_a_pin_refuses_at_once() -> None:
|
||||
"""No PIN: every passkey question is refused immediately rather than left unanswered."""
|
||||
agent = bluez.PinAgent("/agent", None, bluez.device_tail(_ADDR))
|
||||
for member, body in (("RequestPasskey", [_DEV]), ("RequestConfirmation", [_DEV, 0])):
|
||||
reply = agent.handle(_agent_call(member, *body))
|
||||
assert reply.message_type == MessageType.ERROR, member
|
||||
assert reply.error_name == "org.bluez.Error.Rejected"
|
||||
|
||||
|
||||
def test_agent_never_approves_a_pairing_without_the_passkey() -> None:
|
||||
"""A "Just Works" yes/no is refused even with a PIN: that is how a bond ends up weak."""
|
||||
agent = bluez.PinAgent("/agent", _PIN, _DEV)
|
||||
reply = agent.handle(_agent_call("RequestAuthorization", _DEV))
|
||||
assert reply.message_type == MessageType.ERROR
|
||||
|
||||
|
||||
def test_agent_matches_a_device_by_its_path_tail() -> None:
|
||||
"""Before a connect the adapter is unknown, so the agent matches the ``/dev_…`` tail."""
|
||||
agent = bluez.PinAgent("/agent", _PIN, bluez.device_tail("00-11-22-33-44-55"))
|
||||
assert agent.handle(_agent_call("RequestPasskey", _DEV)).body == [123456]
|
||||
reply = agent.handle(_agent_call("RequestPasskey", "/org/bluez/hci1/dev_AA_BB_CC_DD_EE_FF"))
|
||||
assert reply.message_type == MessageType.ERROR
|
||||
|
||||
|
||||
def test_answering_is_the_default_agent_for_the_connect_and_then_steps_down(bus) -> None:
|
||||
"""Registered, made default, then unregistered with the bus closed — in that order."""
|
||||
fake = bus()
|
||||
|
||||
async def scenario() -> list[str]:
|
||||
async with bluez.answering(_ADDR, None):
|
||||
during = list(fake.calls)
|
||||
return during
|
||||
|
||||
during = asyncio.run(scenario())
|
||||
assert during == ["RegisterAgent", "RequestDefaultAgent"]
|
||||
assert fake.calls[-1] == "UnregisterAgent"
|
||||
assert fake.handlers == [] and fake.disconnected
|
||||
|
||||
|
||||
def test_answering_without_a_bus_still_runs_the_connect(monkeypatch) -> None:
|
||||
"""No system bus: the connect goes ahead with no agent, and nothing raises."""
|
||||
|
||||
async def _no_bus():
|
||||
raise OSError("no system bus")
|
||||
|
||||
monkeypatch.setattr(bluez, "_system_bus", _no_bus)
|
||||
|
||||
async def scenario() -> str:
|
||||
async with bluez.answering(_ADDR, _PIN):
|
||||
return "connected"
|
||||
|
||||
assert asyncio.run(scenario()) == "connected"
|
||||
@@ -128,6 +128,93 @@ _OPEN_ADDR = "AA:BB:CC:DD:EE:FF"
|
||||
_A_PIN = "123456"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_bluez_agent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep the connect-time BlueZ agent off the system bus when these run on Linux."""
|
||||
from contextlib import nullcontext
|
||||
|
||||
monkeypatch.setattr(connection.MeshCoreDevice, "_ble_pairing_agent", lambda self: nullcontext())
|
||||
|
||||
|
||||
def test_an_unlikely_error_on_the_subscribe_is_a_pairing_refusal() -> None:
|
||||
"""ATT 0x0E is how a companion given a PIN refuses a subscribe over an unauthenticated bond.
|
||||
|
||||
Seen on a uConsole whose bond with a T1000-E predated its PIN (BlueZ Authenticated=0):
|
||||
encryption succeeded, the subscribe did not, and the raw GATT error was all anyone saw.
|
||||
"""
|
||||
exc = BleakGATTProtocolError(
|
||||
"(<BleakGATTProtocolErrorCode.UNLIKELY_ERROR: 14>, 'GATT Protocol Error: Unlikely Error')"
|
||||
)
|
||||
assert connection._is_ble_auth_error(exc)
|
||||
|
||||
|
||||
class _RetryingBleakClient:
|
||||
"""A bleak client whose connect drops its first link attempts and then succeeds."""
|
||||
|
||||
def __init__(self, drops: int, callback) -> None:
|
||||
self.drops, self.callback = drops, callback
|
||||
self.is_connected = False
|
||||
self.disconnects = 0
|
||||
|
||||
async def connect(self) -> None:
|
||||
for _ in range(self.drops):
|
||||
self.callback(self) # bleak fires the disconnect callback for each lost attempt
|
||||
self.is_connected = True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self.disconnects += 1
|
||||
self.is_connected = False
|
||||
|
||||
|
||||
class _MeshcoreLikeConnection:
|
||||
"""Mirrors meshcore's BLEConnection: a disconnect resets ``client`` to what was passed."""
|
||||
|
||||
def __init__(self, drops: int) -> None:
|
||||
self.client = None
|
||||
self.drops = drops
|
||||
self.forwarded = 0
|
||||
|
||||
def handle_disconnect(self, client) -> None:
|
||||
self.forwarded += 1
|
||||
self.client = None # the reset that stranded the live link
|
||||
|
||||
async def connect(self):
|
||||
self.client = _RetryingBleakClient(self.drops, self.handle_disconnect)
|
||||
await self.client.connect()
|
||||
if self.client is None: # meshcore's start_notify on None, as "not established"
|
||||
return None
|
||||
return "address"
|
||||
|
||||
|
||||
async def test_a_retried_link_attempt_no_longer_strands_the_client() -> None:
|
||||
"""The retries inside bleak must not make meshcore drop the client that then connects.
|
||||
|
||||
The uConsole failure, three runs out of three: each lost link attempt fired meshcore's
|
||||
disconnect handler, which set ``client`` to ``None``; bleak's next retry connected a
|
||||
client nobody held, meshcore gave up, and the live link kept the companion from
|
||||
advertising to the retry.
|
||||
"""
|
||||
unguarded = _MeshcoreLikeConnection(drops=3)
|
||||
assert await unguarded.connect() is None # the bug, reproduced
|
||||
|
||||
guarded = _MeshcoreLikeConnection(drops=3)
|
||||
seen = connection._hold_disconnects_while_connecting(guarded)
|
||||
assert await guarded.connect() == "address"
|
||||
seen.connecting = False
|
||||
assert guarded.forwarded == 0 # held while connecting...
|
||||
assert connection._held_client(guarded, seen) is guarded.client
|
||||
guarded.client.callback(guarded.client) # ...and a real drop afterwards still arrives
|
||||
assert guarded.forwarded == 1
|
||||
|
||||
|
||||
def test_the_held_client_falls_back_to_the_one_meshcore_lost() -> None:
|
||||
"""If meshcore let go anyway, teardown still gets the live client to close."""
|
||||
lost = _RetryingBleakClient(0, None)
|
||||
lost.is_connected = True
|
||||
seen = connection._ConnectingClients([lost])
|
||||
assert connection._held_client(SimpleNamespace(client=None), seen) is lost
|
||||
|
||||
|
||||
async def _disable_windows_pairing(dev: connection.MeshCoreDevice) -> None:
|
||||
"""Stub out the WinRT ProvidePin step so ``_create_ble`` stays hermetic in tests.
|
||||
|
||||
|
||||
Reference in new issue
Block a user