Skip to content

Commit

Permalink
bluezdbus/client: add retry on le-connection-abort-by-local
Browse files Browse the repository at this point in the history
Since BlueZ 6.62, BlueZ has a new connection error
"le-connection-abort-by-local" that is seen quite frequently,
particularly when there are many BLE devices in the area.

Reconnecting immediately after receiving this error is generally
successful, so we add an automatic retry in the BleakClient.

This is a bit tricky because the device does actually connect in this
case and we receive D-Bus property changes indicating this, so we have
to be careful about waiting for the disconnect event before trying
again.

Fixes: #1220
  • Loading branch information
dlech committed Mar 17, 2023
1 parent 138697f commit 6abf521
Showing 1 changed file with 152 additions and 117 deletions.
269 changes: 152 additions & 117 deletions bleak/backends/bluezdbus/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,134 +135,169 @@ async def connect(self, dangerous_use_bleak_cache: bool = False, **kwargs) -> bo

manager = await get_global_bluez_manager()

# Each BLE connection session needs a new D-Bus connection to avoid a
# BlueZ quirk where notifications are automatically enabled on reconnect.
self._bus = await MessageBus(
bus_type=BusType.SYSTEM,
negotiate_unix_fd=True,
auth=get_dbus_authenticator(),
).connect()

def on_connected_changed(connected: bool) -> None:
if not connected:
logger.debug(f"Device disconnected ({self._device_path})")

self._is_connected = False

if self._disconnect_monitor_event:
self._disconnect_monitor_event.set()
self._disconnect_monitor_event = None

self._cleanup_all()
if self._disconnected_callback is not None:
self._disconnected_callback(self)
disconnecting_event = self._disconnecting_event
if disconnecting_event:
disconnecting_event.set()

def on_value_changed(char_path: str, value: bytes) -> None:
callback = self._notification_callbacks.get(char_path)

if callback:
callback(bytearray(value))

watcher = manager.add_device_watcher(
self._device_path, on_connected_changed, on_value_changed
)
self._remove_device_watcher = lambda: manager.remove_device_watcher(watcher)
async with async_timeout(timeout):
while True:
# Each BLE connection session needs a new D-Bus connection to avoid a
# BlueZ quirk where notifications are automatically enabled on reconnect.
self._bus = await MessageBus(
bus_type=BusType.SYSTEM,
negotiate_unix_fd=True,
auth=get_dbus_authenticator(),
).connect()

def on_connected_changed(connected: bool) -> None:
if not connected:
logger.debug(f"Device disconnected ({self._device_path})")

self._is_connected = False

if self._disconnect_monitor_event:
self._disconnect_monitor_event.set()
self._disconnect_monitor_event = None

self._cleanup_all()
if self._disconnected_callback is not None:
self._disconnected_callback(self)
disconnecting_event = self._disconnecting_event
if disconnecting_event:
disconnecting_event.set()

def on_value_changed(char_path: str, value: bytes) -> None:
callback = self._notification_callbacks.get(char_path)

if callback:
callback(bytearray(value))

watcher = manager.add_device_watcher(
self._device_path, on_connected_changed, on_value_changed
)
self._remove_device_watcher = lambda: manager.remove_device_watcher(
watcher
)

local_disconnect_monitor_event = asyncio.Event()
self._disconnect_monitor_event = (
local_disconnect_monitor_event
) = asyncio.Event()

try:
try:
#
# The BlueZ backend does not disconnect devices when the
# application closes or crashes. This can cause problems
# when trying to reconnect to the same device. To work
# around this, we check if the device is already connected.
#
# For additional details see https://github.com/bluez/bluez/issues/89
#
if not manager.is_connected(self._device_path):
logger.debug("Connecting to BlueZ path %s", self._device_path)
async with async_timeout(timeout):
reply = await self._bus.call(
Message(
destination=defs.BLUEZ_SERVICE,
interface=defs.DEVICE_INTERFACE,
path=self._device_path,
member="Connect",
try:
try:
#
# The BlueZ backend does not disconnect devices when the
# application closes or crashes. This can cause problems
# when trying to reconnect to the same device. To work
# around this, we check if the device is already connected.
#
# For additional details see https://github.com/bluez/bluez/issues/89
#
if manager.is_connected(self._device_path):
logger.debug(
'skipping calling "Connect" since %s is already connected',
self._device_path,
)
else:
logger.debug(
"Connecting to BlueZ path %s", self._device_path
)
reply = await self._bus.call(
Message(
destination=defs.BLUEZ_SERVICE,
interface=defs.DEVICE_INTERFACE,
path=self._device_path,
member="Connect",
)
)
)

if (
reply.message_type == MessageType.ERROR
and reply.error_name == ErrorType.UNKNOWN_OBJECT.value
):
raise BleakDeviceNotFoundError(
self.address,
f"Device with address {self.address} was not found. It may have been removed from BlueZ when scanning stopped.",
)

assert_reply(reply)

self._is_connected = True
assert reply is not None

if reply.message_type == MessageType.ERROR:
# This error is often caused by RF interference
# from other Bluetooth or Wi-Fi devices. In many
# cases, retrying will connect successfully.
# Note: this error was added in BlueZ 6.62.
if (
reply.error_name == "org.bluez.Error.Failed"
and reply.body
and reply.body[0] == "le-connection-abort-by-local"
):
logger.debug(
"retry due to le-connection-abort-by-local"
)

# When this error occurs, BlueZ actually
# connected so we get "Connected" property changes
# that we need to wait for before attempting
# to connect again.
await local_disconnect_monitor_event.wait()

# Jump way back to the `while True:`` to retry.
continue

if reply.error_name == ErrorType.UNKNOWN_OBJECT.value:
raise BleakDeviceNotFoundError(
self.address,
f"Device with address {self.address} was not found. It may have been removed from BlueZ when scanning stopped.",
)

# Create a task that runs until the device is disconnected.
self._disconnect_monitor_event = local_disconnect_monitor_event
asyncio.ensure_future(
self._disconnect_monitor(
self._bus, self._device_path, local_disconnect_monitor_event
)
)
assert_reply(reply)

#
# We will try to use the cache if it exists and `dangerous_use_bleak_cache`
# is True.
#
await self.get_services(
dangerous_use_bleak_cache=dangerous_use_bleak_cache
)
self._is_connected = True

return True
except BaseException:
# Calling Disconnect cancels any pending connect request. Also,
# if connection was successful but get_services() raises (e.g.
# because task was cancelled), the we still need to disconnect
# before passing on the exception.
if self._bus:
# If disconnected callback already fired, this will be a no-op
# since self._bus will be None and the _cleanup_all call will
# have already disconnected.
try:
reply = await self._bus.call(
Message(
destination=defs.BLUEZ_SERVICE,
interface=defs.DEVICE_INTERFACE,
path=self._device_path,
member="Disconnect",
# Create a task that runs until the device is disconnected.
asyncio.ensure_future(
self._disconnect_monitor(
self._bus,
self._device_path,
local_disconnect_monitor_event,
)
)
try:
assert_reply(reply)
except BleakDBusError as e:
# if the object no longer exists, then we know we
# are disconnected for sure, so don't need to log a
# warning about it
if e.dbus_error != ErrorType.UNKNOWN_OBJECT.value:
raise
except Exception as e:
logger.warning(
f"Failed to cancel connection ({self._device_path}): {e}"

#
# We will try to use the cache if it exists and `dangerous_use_bleak_cache`
# is True.
#
await self.get_services(
dangerous_use_bleak_cache=dangerous_use_bleak_cache
)

raise
except BaseException:
# this effectively cancels the disconnect monitor in case the event
# was not triggered by a D-Bus callback
local_disconnect_monitor_event.set()
self._cleanup_all()
raise
return True
except BaseException:
# Calling Disconnect cancels any pending connect request. Also,
# if connection was successful but get_services() raises (e.g.
# because task was cancelled), the we still need to disconnect
# before passing on the exception.
if self._bus:
# If disconnected callback already fired, this will be a no-op
# since self._bus will be None and the _cleanup_all call will
# have already disconnected.
try:
reply = await self._bus.call(
Message(
destination=defs.BLUEZ_SERVICE,
interface=defs.DEVICE_INTERFACE,
path=self._device_path,
member="Disconnect",
)
)
try:
assert_reply(reply)
except BleakDBusError as e:
# if the object no longer exists, then we know we
# are disconnected for sure, so don't need to log a
# warning about it
if e.dbus_error != ErrorType.UNKNOWN_OBJECT.value:
raise
except Exception as e:
logger.warning(
f"Failed to cancel connection ({self._device_path}): {e}"
)

raise
except BaseException:
# this effectively cancels the disconnect monitor in case the event
# was not triggered by a D-Bus callback
local_disconnect_monitor_event.set()
self._cleanup_all()
raise

@staticmethod
async def _disconnect_monitor(
Expand Down

0 comments on commit 6abf521

Please sign in to comment.