Skip to content

Commit

Permalink
feat: add support for recovering failed adapters after reboot (#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
bdraco committed Apr 17, 2024
1 parent 00c07dc commit 04948c3
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/habluetooth/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@


UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5


FAILED_ADAPTER_MAC = "00:00:00:00:00:00"
1 change: 1 addition & 0 deletions src/habluetooth/manager.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ cdef class BluetoothManager:
cdef public bint shutdown
cdef public object _loop
cdef public object _adapter_refresh_future
cdef public object _recovery_lock

@cython.locals(stale_seconds=float)
cdef bint _prefer_previous_adv_from_different_source(
Expand Down
29 changes: 29 additions & 0 deletions src/habluetooth/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
)
from .const import (
CALLBACK_TYPE,
FAILED_ADAPTER_MAC,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
UNAVAILABLE_TRACK_SECONDS,
)
from .models import BluetoothServiceInfoBleak
from .scanner_device import BluetoothScannerDevice
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_reset_adapter

if TYPE_CHECKING:
from bleak.backends.device import BLEDevice
Expand Down Expand Up @@ -115,6 +117,7 @@ class BluetoothManager:
"shutdown",
"_loop",
"_adapter_refresh_future",
"_recovery_lock",
)

def __init__(
Expand Down Expand Up @@ -149,6 +152,7 @@ def __init__(
self.shutdown = False
self._loop: asyncio.AbstractEventLoop | None = None
self._adapter_refresh_future: asyncio.Future[None] | None = None
self._recovery_lock: asyncio.Lock = asyncio.Lock()

@property
def supports_passive_scan(self) -> bool:
Expand Down Expand Up @@ -227,6 +231,31 @@ async def async_get_adapter_from_address(self, address: str) -> str | None:
await self._async_refresh_adapters()
return self._find_adapter_by_address(address)

async def async_get_adapter_from_address_or_recover(
self, address: str
) -> str | None:
"""Get adapter from address or recover."""
if adapter := self._find_adapter_by_address(address):
return adapter
await self._async_recover_failed_adapters()
return self._find_adapter_by_address(address)

async def _async_recover_failed_adapters(self) -> None:
"""Recover failed adapters."""
if self._recovery_lock.locked():
# Already recovering, no need to
# start another recovery
return
async with self._recovery_lock:
adapters = await self.async_get_bluetooth_adapters()
for adapter in [
adapter
for adapter, details in adapters.items()
if details[ADAPTER_ADDRESS] == FAILED_ADAPTER_MAC
]:
await async_reset_adapter(adapter, FAILED_ADAPTER_MAC)
await self._async_refresh_adapters()

async def async_setup(self) -> None:
"""Set up the bluetooth manager."""
self._loop = asyncio.get_running_loop()
Expand Down
131 changes: 131 additions & 0 deletions tests/test_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Tests for the manager."""

from typing import Any
from unittest.mock import patch

import pytest
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import BluetoothAdapters
from bluetooth_adapters.systems.linux import LinuxAdapters

from habluetooth import (
BluetoothManager,
set_manager,
)


@pytest.mark.asyncio
@pytest.mark.skipif("platform.system() == 'Windows'")
async def test_async_recover_failed_adapters() -> None:
"""Return the BluetoothManager instance."""
attempt = 0

class MockLinuxAdapters(LinuxAdapters):
@property
def adapters(self) -> dict[str, Any]:
nonlocal attempt
attempt += 1

if attempt == 1:
return {
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci1": {
"address": "00:00:00:00:00:00",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci2": {
"address": "00:00:00:00:00:00",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
}

return {
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci1": {
"address": "00:00:00:00:00:02",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci2": {
"address": "00:00:00:00:00:03",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
}

with (
patch("habluetooth.manager.async_reset_adapter") as mock_async_reset_adapter,
):
adapters = MockLinuxAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
await manager.async_setup()
set_manager(manager)
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:03"
)
assert adapter == "hci2"
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:02"
)
assert adapter == "hci1"
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:01"
)
assert adapter == "hci0"

assert mock_async_reset_adapter.call_count == 2
assert mock_async_reset_adapter.call_args_list == [
(("hci1", "00:00:00:00:00:00"),),
(("hci2", "00:00:00:00:00:00"),),
]


@pytest.mark.asyncio
async def test_create_manager() -> None:
"""Return the BluetoothManager instance."""
adapters = BluetoothAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
set_manager(manager)
assert manager

0 comments on commit 04948c3

Please sign in to comment.