On the Cardputer Zero, connecting to an ESP32 companion that wants a PIN
stalled for a minute and never asked for one. Its link came up and its
services resolved; the UART subscribe made BlueZ ask MeshTerm's agent
for the PIN; with none given the agent refused, and the companion
disconnected -- it hangs up rather than refusing the subscribe. The
guard that holds back drops while bleak retries HCI 0x3e swallowed that
hang-up too, so meshcore waited out its timeout on a dead link.
The agent already records whether BlueZ asked it for the PIN. A drop
after that is now flagged, and the connect ends at once as a pairing
failure -- the words the PIN handling answers -- so the reader is asked
for the PIN in seconds. Drops with no pairing asked are held as before.
Also: a link that opens and then drops during service discovery
("failed to discover services, device disconnected", an HCI Connection
Timeout 1.7 s in, at -22 dBm as readily as at -86) is retried like a
link that never opened. It was surfacing on the first try, and the next
try usually connects.
Checked on the Cardputer against the companion: three connects with no
PIN each answer "requires a Bluetooth pairing PIN" in 2-7 s, where each
took the 60 s timeout before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
507 lines
22 KiB
Python
507 lines
22 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Pair a Bluetooth companion with its PIN through BlueZ, the Linux Bluetooth stack.
|
|
|
|
A MeshCore companion puts its UART characteristic behind an *authenticated* bond (ENC+MITM),
|
|
which on a device with a fixed PIN means Passkey Entry: the computer has to type the six
|
|
digits in. On Linux, bleak's ``pair()`` cannot do that. It calls BlueZ's ``Device1.Pair`` and
|
|
nothing else, and BlueZ gets a passkey only by asking a **pairing agent** — an object some
|
|
program registers on the system bus to answer ``RequestPasskey``. On a desktop that agent is
|
|
the Settings panel, in a terminal it is ``bluetoothctl``, over SSH it is usually nobody. With
|
|
no agent to ask, BlueZ falls back to "Just Works", the firmware refuses a bond without MITM,
|
|
and the PIN MeshTerm was handed never reached the radio at all. That is the Linux twin of the
|
|
Windows gap :meth:`~meshterm.core.connection.MeshCoreDevice._pair_ble_windows` closes.
|
|
|
|
So MeshTerm registers its own agent for the length of one pairing, answering with the PIN it
|
|
was given, and calls ``Pair`` from the *same* bus connection. BlueZ routes a pairing's agent
|
|
requests to the agent of whoever called ``Pair`` (the system default agent is only the
|
|
fallback), so this never takes over the desktop's agent and never answers for any other
|
|
device's pairing.
|
|
|
|
The agent answers raw D-Bus messages (:class:`PinAgent`) rather than going through
|
|
``dbus_fast``'s annotated ``ServiceInterface``: that one reads D-Bus signatures out of
|
|
annotations like ``device: "o"``, which a linter takes for an undefined name and postponed
|
|
annotations turn into a quoted string. A message handler has neither problem and can be
|
|
exercised without a bus.
|
|
|
|
``dbus_fast`` is bleak's own dependency on Linux, so nothing new is installed; it is imported
|
|
lazily, and every entry point here is best-effort — a missing system bus or BlueZ reports an
|
|
``"error"`` outcome instead of raising.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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__)
|
|
|
|
_BLUEZ = "org.bluez"
|
|
_AGENT_IFACE = "org.bluez.Agent1"
|
|
_AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
|
|
_ADAPTER_IFACE = "org.bluez.Adapter1"
|
|
_DEVICE_IFACE = "org.bluez.Device1"
|
|
_PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"
|
|
_OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
|
|
_REJECTED = "org.bluez.Error.Rejected"
|
|
_ALREADY_EXISTS = "org.bluez.Error.AlreadyExists"
|
|
_AGENTS = "/org/bluez" # where AgentManager1 lives
|
|
|
|
#: The agent's declared input/output capability. The companion is the side with the PIN
|
|
#: (``DisplayOnly``), so we are the keyboard: ``KeyboardOnly`` makes the pairing Passkey
|
|
#: Entry with us typing, which is the one method a fixed PIN can satisfy.
|
|
AGENT_CAPABILITY = "KeyboardOnly"
|
|
|
|
#: How long to run discovery for a device BlueZ has forgotten (seconds). BlueZ drops an
|
|
#: unpaired device it has stopped hearing after about half a minute, so one the picker
|
|
#: listed a while ago may need hearing again before it can be paired.
|
|
DISCOVER_S = 6.0
|
|
|
|
#: Bound on the ``Pair`` call itself (seconds). A healthy passkey pairing takes one to three
|
|
#: 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}$")
|
|
|
|
|
|
def is_mac(address: str) -> bool:
|
|
"""Whether ``address`` is a colon- or dash-separated 48-bit Bluetooth address."""
|
|
return bool(_MAC.match(address or ""))
|
|
|
|
|
|
def _normal(address: str) -> str:
|
|
"""An address as BlueZ spells it: upper case, colon-separated."""
|
|
return address.replace("-", ":").upper()
|
|
|
|
|
|
def status_name(error_name: str, body: list[Any] | None = None) -> str:
|
|
"""Turn a BlueZ error into the lowercase words a message can quote.
|
|
|
|
``org.bluez.Error.AuthenticationFailed`` → ``"authentication failed"``. The bare
|
|
``org.bluez.Error.Failed`` says nothing on its own, so its text rides along
|
|
(``"failed: le-connection-abort-by-local"``).
|
|
|
|
Args:
|
|
error_name: The D-Bus error name from the reply.
|
|
body: The error reply's body, whose first item is BlueZ's detail string.
|
|
|
|
Returns:
|
|
The status, as :data:`~meshterm.core.connection._PAIRING_REFUSED` and its sibling
|
|
sets spell them.
|
|
"""
|
|
leaf = error_name.rsplit(".", 1)[-1]
|
|
words = re.sub(r"(?<!^)(?=[A-Z])", " ", leaf).lower()
|
|
detail = body[0] if body and isinstance(body[0], str) else ""
|
|
if words == "failed" and detail:
|
|
return f"failed: {detail}"
|
|
return words
|
|
|
|
|
|
class PinAgent:
|
|
"""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), 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. 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.
|
|
asked: Whether BlueZ actually asked for the PIN — a pairing that failed without
|
|
asking was refused before the passkey stage, which is a different story.
|
|
"""
|
|
|
|
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), 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
|
|
self._device = device_path
|
|
self.asked = False
|
|
|
|
def handle(self, msg: Any) -> Any:
|
|
"""Answer one incoming message, or return ``None`` to leave it to other handlers.
|
|
|
|
Args:
|
|
msg: A ``dbus_fast.Message``.
|
|
|
|
Returns:
|
|
A reply ``Message``, or ``None`` when the message is not ours.
|
|
"""
|
|
from dbus_fast import Message, MessageType
|
|
|
|
if (
|
|
msg.message_type != MessageType.METHOD_CALL
|
|
or msg.path != self.path
|
|
or msg.interface != _AGENT_IFACE
|
|
):
|
|
return None
|
|
member = msg.member
|
|
if member in ("Release", "Cancel"):
|
|
return Message.new_method_return(msg)
|
|
device = msg.body[0] if msg.body else ""
|
|
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)])
|
|
if member == "RequestPinCode":
|
|
self.asked = True
|
|
return Message.new_method_return(msg, "s", [self._pin])
|
|
if member == "RequestConfirmation":
|
|
# Numeric Comparison: approve only the number our PIN names. A companion with a
|
|
# fixed PIN never asks for this; one that does and shows another number is not
|
|
# the pairing we were told to make.
|
|
self.asked = True
|
|
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"):
|
|
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[PinAgent]:
|
|
"""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:
|
|
The agent, live for the body of the ``async with``. Its ``asked`` says whether
|
|
BlueZ asked it for the PIN, which is how the connect tells a companion that hung
|
|
up on a pairing from a link that merely dropped.
|
|
"""
|
|
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 agent
|
|
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
|
|
|
|
return await bus.call(
|
|
Message(
|
|
destination=_BLUEZ,
|
|
path=path,
|
|
interface=interface,
|
|
member=member,
|
|
signature=signature,
|
|
body=body or [],
|
|
)
|
|
)
|
|
|
|
|
|
def _is_error(reply: Any) -> bool:
|
|
from dbus_fast import MessageType
|
|
|
|
return reply.message_type == MessageType.ERROR
|
|
|
|
|
|
async def _objects(bus: Any) -> dict[str, dict[str, dict[str, Any]]]:
|
|
"""Every BlueZ object, from ``GetManagedObjects`` (empty when BlueZ didn't answer)."""
|
|
reply = await _call(bus, "/", _OBJECT_MANAGER_IFACE, "GetManagedObjects")
|
|
if _is_error(reply) or not reply.body:
|
|
return {}
|
|
return reply.body[0]
|
|
|
|
|
|
def _value(props: dict[str, Any], name: str) -> Any:
|
|
"""A property's plain value, unwrapping the ``Variant`` BlueZ sends."""
|
|
raw = props.get(name)
|
|
return getattr(raw, "value", raw)
|
|
|
|
|
|
def _device_in(objects: dict[str, dict[str, dict[str, Any]]], address: str) -> str | None:
|
|
"""The object path of the device at ``address``, if BlueZ knows it."""
|
|
want = _normal(address)
|
|
for path, interfaces in objects.items():
|
|
props = interfaces.get(_DEVICE_IFACE)
|
|
if props is not None and str(_value(props, "Address") or "").upper() == want:
|
|
return path
|
|
return None
|
|
|
|
|
|
def _adapter_in(objects: dict[str, dict[str, dict[str, Any]]]) -> str | None:
|
|
"""The first Bluetooth adapter's object path."""
|
|
return next((path for path, ifaces in objects.items() if _ADAPTER_IFACE in ifaces), None)
|
|
|
|
|
|
async def _find_device(bus: Any, address: str, discover_s: float) -> tuple[str | None, bool]:
|
|
"""Find the device's object path, listening for it briefly if BlueZ has forgotten it.
|
|
|
|
Returns:
|
|
``(path, paired)`` — ``path`` is ``None`` when it could not be heard.
|
|
"""
|
|
objects = await _objects(bus)
|
|
path = _device_in(objects, address)
|
|
if path is None:
|
|
adapter = _adapter_in(objects)
|
|
if adapter is None:
|
|
return None, False
|
|
# A refusal here is fine: bleak or the desktop may already be discovering, which
|
|
# is all we need.
|
|
await _call(bus, adapter, _ADAPTER_IFACE, "StartDiscovery")
|
|
try:
|
|
deadline = asyncio.get_running_loop().time() + discover_s
|
|
while path is None and asyncio.get_running_loop().time() < deadline:
|
|
await asyncio.sleep(0.5)
|
|
objects = await _objects(bus)
|
|
path = _device_in(objects, address)
|
|
finally:
|
|
await _call(bus, adapter, _ADAPTER_IFACE, "StopDiscovery")
|
|
if path is None:
|
|
return None, False
|
|
return path, bool(_value(objects[path][_DEVICE_IFACE], "Paired"))
|
|
|
|
|
|
def _adapter_of(objects: dict[str, dict[str, dict[str, Any]]], path: str) -> str | None:
|
|
"""The adapter a device hangs off — its own ``Adapter`` property, else the first one."""
|
|
own = _value(objects.get(path, {}).get(_DEVICE_IFACE, {}), "Adapter")
|
|
return str(own) if own else _adapter_in(objects)
|
|
|
|
|
|
async def _remove(bus: Any, path: str) -> Any:
|
|
"""Forget a device's bond (``Adapter1.RemoveDevice``), which also forgets the device.
|
|
|
|
Returns:
|
|
The raw reply, or ``None`` when there was no adapter to ask.
|
|
"""
|
|
adapter = _adapter_of(await _objects(bus), path)
|
|
if adapter is None:
|
|
return None
|
|
return await _call(bus, adapter, _ADAPTER_IFACE, "RemoveDevice", "o", [path])
|
|
|
|
|
|
async def _system_bus() -> Any:
|
|
from dbus_fast import BusType
|
|
from dbus_fast.aio import MessageBus
|
|
|
|
return await MessageBus(bus_type=BusType.SYSTEM).connect()
|
|
|
|
|
|
async def pair(
|
|
address: str,
|
|
pin: str,
|
|
*,
|
|
force: bool,
|
|
discover_s: float = DISCOVER_S,
|
|
pair_timeout_s: float = PAIR_TIMEOUT_S,
|
|
) -> tuple[str, str]:
|
|
"""Pair the companion at ``address`` using ``pin``, through our own BlueZ agent.
|
|
|
|
Mirrors the Windows ceremony's contract: an existing bond is trusted unless ``force``,
|
|
which tears it down and pairs afresh — the heal for a bond the device has since lost.
|
|
|
|
Args:
|
|
address: The companion's Bluetooth address.
|
|
pin: Its pairing PIN (six digits).
|
|
force: Whether to remove an existing bond first.
|
|
discover_s: How long to listen for a device BlueZ has forgotten.
|
|
pair_timeout_s: Bound on the ``Pair`` call.
|
|
|
|
Returns:
|
|
``(outcome, status)`` in :class:`~meshterm.core.connection._BlePairing`'s vocabulary:
|
|
``"reused"``, ``"paired"``, ``"absent"``, ``"failed"`` with the BlueZ status, or
|
|
``"error"`` with what went wrong.
|
|
"""
|
|
if not pin.isdigit():
|
|
return "failed", "authentication failed" # a passkey is a number; this can't be it
|
|
try:
|
|
bus = await _system_bus()
|
|
except Exception as exc: # noqa: BLE001 - no system bus / no dbus_fast: nothing to pair with
|
|
return "error", f"couldn't reach BlueZ: {exc}"
|
|
try:
|
|
path, paired = await _find_device(bus, address, discover_s)
|
|
if path is None:
|
|
return "absent", ""
|
|
if paired:
|
|
if not force:
|
|
return "reused", ""
|
|
await _remove(bus, path)
|
|
path, _ = await _find_device(bus, address, discover_s)
|
|
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(
|
|
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os",
|
|
[agent.path, AGENT_CAPABILITY],
|
|
) # fmt: skip
|
|
if _is_error(reply):
|
|
return "error", status_name(reply.error_name, reply.body)
|
|
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":
|
|
# Refused before the passkey stage: the PIN was never in question.
|
|
status = "authentication rejected"
|
|
return "failed", status
|
|
# Trusted lets later connections through without an agent to authorize them.
|
|
from dbus_fast import Variant
|
|
|
|
await _call(
|
|
bus, path, _PROPERTIES_IFACE, "Set", "ssv",
|
|
[_DEVICE_IFACE, "Trusted", Variant("b", True)],
|
|
) # 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
|
|
return "error", str(exc)
|
|
finally:
|
|
bus.disconnect()
|
|
|
|
|
|
async def is_paired(address: str) -> bool:
|
|
"""Whether BlueZ holds a bond for ``address``. ``False`` on any failure to ask."""
|
|
try:
|
|
bus = await _system_bus()
|
|
except Exception as exc: # noqa: BLE001 - no bus: nothing is known to be paired
|
|
_log.debug("BlueZ pairing query unavailable: %s", exc)
|
|
return False
|
|
try:
|
|
objects = await _objects(bus)
|
|
path = _device_in(objects, address)
|
|
return path is not None and bool(_value(objects[path][_DEVICE_IFACE], "Paired"))
|
|
except Exception as exc: # noqa: BLE001 - a status hiccup is not a bond
|
|
_log.debug("BlueZ pairing query failed for %s: %s", address, exc)
|
|
return False
|
|
finally:
|
|
bus.disconnect()
|
|
|
|
|
|
async def unpair(address: str) -> bool:
|
|
"""Forget BlueZ's bond for ``address``. ``True`` only if a bond was removed."""
|
|
try:
|
|
bus = await _system_bus()
|
|
except Exception as exc: # noqa: BLE001 - no bus: nothing to remove
|
|
_log.debug("BlueZ unpair unavailable: %s", exc)
|
|
return False
|
|
try:
|
|
objects = await _objects(bus)
|
|
path = _device_in(objects, address)
|
|
if path is None or not _value(objects[path][_DEVICE_IFACE], "Paired"):
|
|
return False
|
|
reply = await _remove(bus, path)
|
|
return reply is not None and not _is_error(reply)
|
|
except Exception as exc: # noqa: BLE001 - best-effort, like the Windows twin
|
|
_log.debug("BlueZ unpair failed for %s: %s", address, exc)
|
|
return False
|
|
finally:
|
|
bus.disconnect()
|