Files
MeshTerm/tests/test_bluez.py
T
Jean-Pierre MartineauandClaude Opus 5.5 bd0c311dbc 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>
2026-09-25 04:37:51 -04:00

326 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for MeshTerm's own BlueZ pairing (Linux PIN pairing through a D-Bus agent).
bleak's BlueZ ``pair()`` never delivers a PIN — BlueZ asks a pairing *agent* for it and bleak
registers none — so a PIN-protected companion could not bond on Linux at all.
:mod:`meshterm.core.bluez` registers an agent for the length of one pairing. These tests run
against a scripted stand-in for the system bus: no BlueZ, no radio, and no real address or PIN.
``dbus_fast`` is bleak's Linux dependency; its ``Message`` type is all that is needed here.
"""
from __future__ import annotations
import asyncio
import os
import pytest
dbus_fast = pytest.importorskip("dbus_fast")
from dbus_fast import Message, MessageType # noqa: E402
from meshterm.core import bluez # noqa: E402
_ADDR = "00:11:22:33:44:55"
_DEV = "/org/bluez/hci0/dev_00_11_22_33_44_55"
_PIN = "123456"
def _agent_call(member: str, *body, path: str = "/agent") -> Message:
"""A method call BlueZ would send the agent."""
signature = {
"RequestPasskey": "o",
"RequestPinCode": "o",
"RequestConfirmation": "ou",
"RequestAuthorization": "o",
"AuthorizeService": "os",
"Cancel": "",
}[member]
return Message(
path=path,
interface="org.bluez.Agent1",
member=member,
signature=signature,
body=list(body),
serial=1,
)
def test_status_names_read_as_words() -> None:
"""A BlueZ error name becomes the words the connection's status sets use."""
assert bluez.status_name("org.bluez.Error.AuthenticationFailed") == "authentication failed"
assert bluez.status_name("org.bluez.Error.ConnectionAttemptFailed") == (
"connection attempt failed"
)
# The bare Failed says nothing, so BlueZ's own detail rides along.
assert bluez.status_name("org.bluez.Error.Failed", ["le-connection-abort-by-local"]) == (
"failed: le-connection-abort-by-local"
)
def test_is_mac() -> None:
"""Only a 48-bit address can be paired through BlueZ (a macOS UUID cannot)."""
assert bluez.is_mac(_ADDR) and bluez.is_mac("aa-bb-cc-dd-ee-ff")
assert not bluez.is_mac("550e8400-e29b-41d4-a716-446655440000")
assert not bluez.is_mac("")
def test_agent_answers_the_passkey_with_the_pin() -> None:
"""RequestPasskey is answered with the PIN as a number, and the agent notes it was asked."""
agent = bluez.PinAgent("/agent", _PIN, _DEV)
reply = agent.handle(_agent_call("RequestPasskey", _DEV))
assert reply.message_type == MessageType.METHOD_RETURN
assert reply.signature == "u" and reply.body == [123456]
assert agent.asked
def test_agent_refuses_another_device_and_other_paths() -> None:
"""A pairing MeshTerm didn't start is never approved, and foreign calls are left alone."""
agent = bluez.PinAgent("/agent", _PIN, _DEV)
reply = agent.handle(_agent_call("RequestPasskey", "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"))
assert reply.message_type == MessageType.ERROR
assert reply.error_name == "org.bluez.Error.Rejected"
assert not agent.asked
# Not addressed to the agent's path: none of its business.
assert agent.handle(_agent_call("RequestPasskey", _DEV, path="/elsewhere")) is None
def test_agent_confirms_only_the_number_its_pin_names() -> None:
"""Numeric Comparison is approved only for our own PIN's number."""
agent = bluez.PinAgent("/agent", _PIN, _DEV)
ok = agent.handle(_agent_call("RequestConfirmation", _DEV, 123456))
assert ok.message_type == MessageType.METHOD_RETURN
wrong = agent.handle(_agent_call("RequestConfirmation", _DEV, 654321))
assert wrong.message_type == MessageType.ERROR
class _Reply:
"""A reply as ``bus.call`` returns it."""
def __init__(self, body=None, error: str | None = None, detail: str = "") -> None:
self.message_type = MessageType.ERROR if error else MessageType.METHOD_RETURN
self.error_name = error
self.body = [detail] if error else (body or [])
class _Variant:
def __init__(self, value) -> None:
self.value = value
class _FakeBus:
"""A scripted system bus: BlueZ's object tree plus the answer to ``Pair``.
``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:
self.known, self.paired = known, paired
self.pair_reply = pair_reply or _Reply()
self.asks = asks
self.calls: list[str] = []
self.handlers: list = []
self.agent_answer = None
self.disconnected = False
def _objects(self) -> dict:
objects = {"/org/bluez/hci0": {"org.bluez.Adapter1": {}}}
if self.known:
objects[_DEV] = {
"org.bluez.Device1": {
"Address": _Variant(_ADDR),
"Paired": _Variant(self.paired),
"Adapter": _Variant("/org/bluez/hci0"),
}
}
return objects
async def call(self, msg):
self.calls.append(msg.member)
if msg.member == "GetManagedObjects":
return _Reply([self._objects()])
if msg.member == "RemoveDevice":
self.known = self.paired = False
return _Reply()
if msg.member == "StartDiscovery":
self.known = True # the device is heard again
return _Reply()
if msg.member == "Pair":
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()
def add_message_handler(self, handler) -> None:
self.handlers.append(handler)
def remove_message_handler(self, handler) -> None:
self.handlers.remove(handler)
def disconnect(self) -> None:
self.disconnected = True
_AGENT = f"/net/meshterm/agent{os.getpid()}"
@pytest.fixture
def bus(monkeypatch):
"""Point :mod:`bluez` at a fresh fake bus."""
holder: dict = {}
async def _system_bus():
return holder["bus"]
monkeypatch.setattr(bluez, "_system_bus", _system_bus)
def _make(**kwargs) -> _FakeBus:
holder["bus"] = _FakeBus(**kwargs)
return holder["bus"]
return _make
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", "")
# 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
def test_pair_trusts_an_existing_bond_unless_forced(bus) -> None:
"""An existing bond is reused; ``force`` removes it and pairs afresh."""
fake = bus(paired=True)
assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0)) == ("reused", "")
assert "Pair" not in fake.calls
fake = bus(paired=True)
assert asyncio.run(bluez.pair(_ADDR, _PIN, force=True, discover_s=1)) == ("paired", "")
assert fake.calls.index("RemoveDevice") < fake.calls.index("Pair")
def test_pair_reports_a_wrong_pin_and_a_refusal_apart(bus) -> None:
"""AuthenticationFailed after the PIN was asked is a wrong PIN; before it, a refusal."""
bus(pair_reply=_Reply(error="org.bluez.Error.AuthenticationFailed"))
assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0)) == (
"failed",
"authentication failed",
)
bus(pair_reply=_Reply(error="org.bluez.Error.AuthenticationFailed"), asks=False)
assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0)) == (
"failed",
"authentication rejected",
)
def test_pair_listens_for_a_forgotten_device_then_gives_up(bus) -> None:
"""A device BlueZ has forgotten is listened for; one never heard is ``absent``."""
fake = bus(known=False)
assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=1))[0] == "paired"
assert fake.calls.count("StartDiscovery") == fake.calls.count("StopDiscovery") == 1
def test_pair_without_a_bus_is_an_error_not_a_raise(monkeypatch) -> None:
"""No system bus (a container, no BlueZ) reports ``error`` rather than raising."""
async def _no_bus():
raise OSError("no system bus")
monkeypatch.setattr(bluez, "_system_bus", _no_bus)
outcome, status = asyncio.run(bluez.pair(_ADDR, _PIN, force=False))
assert outcome == "error" and "no system bus" in status
def test_a_pin_that_is_not_digits_never_reaches_bluez(bus) -> None:
"""A passkey is a number; anything else is a wrong PIN without asking the radio."""
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"