Skip to content

Commit

Permalink
feat: add async_register_scanner_registration_callback and async_curr…
Browse files Browse the repository at this point in the history
…ent_scanners to the manager (#125)
  • Loading branch information
bdraco authored Jan 28, 2025
1 parent ea9f985 commit 99fcb46
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 7 deletions.
6 changes: 6 additions & 0 deletions src/habluetooth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
BluetoothServiceInfoBleak,
HaBluetoothConnector,
HaBluetoothSlotAllocations,
HaScannerDetails,
HaScannerRegistration,
HaScannerRegistrationEvent,
)
from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError
from .scanner_device import BluetoothScannerDevice
Expand All @@ -44,6 +47,9 @@
"HaBluetoothConnector",
"HaBluetoothSlotAllocations",
"HaScanner",
"HaScannerDetails",
"HaScannerRegistration",
"HaScannerRegistrationEvent",
"ScannerStartError",
"get_manager",
"set_manager",
Expand Down
1 change: 1 addition & 0 deletions src/habluetooth/base_scanner.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ cdef class BaseHaScanner:
cdef public object _cancel_watchdog
cdef public object _loop
cdef BluetoothManager _manager
cdef public object details

cpdef tuple get_discovered_device_advertisement_data(self, str address)

Expand Down
15 changes: 11 additions & 4 deletions src/habluetooth/base_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
from .models import BluetoothServiceInfoBleak, HaBluetoothConnector
from .models import BluetoothServiceInfoBleak, HaBluetoothConnector, HaScannerDetails

SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds()
_LOGGER = logging.getLogger(__name__)
Expand All @@ -44,6 +44,7 @@ class BaseHaScanner:
"adapter",
"connectable",
"connector",
"details",
"name",
"scanning",
"source",
Expand All @@ -54,9 +55,10 @@ def __init__(
source: str,
adapter: str,
connector: HaBluetoothConnector | None = None,
connectable: bool = False,
) -> None:
"""Initialize the scanner."""
self.connectable = False
self.connectable = connectable
self.source = source
self.connector = connector
self._connecting = 0
Expand All @@ -68,6 +70,12 @@ def __init__(
self._cancel_watchdog: asyncio.TimerHandle | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._manager = get_manager()
self.details = HaScannerDetails(
source=self.source,
connectable=self.connectable,
name=self.name,
adapter=self.adapter,
)

def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
Expand Down Expand Up @@ -211,8 +219,7 @@ def __init__(
connectable: bool,
) -> None:
"""Initialize the scanner."""
super().__init__(scanner_id, name, connector)
self.connectable = connectable
super().__init__(scanner_id, name, connector, connectable)
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
# Scanners only care about connectable devices. The manager
# will handle taking care of availability for non-connectable devices
Expand Down
1 change: 1 addition & 0 deletions src/habluetooth/manager.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ cdef class BluetoothManager:
cdef public object _cancel_allocation_callbacks
cdef public dict _adapter_sources
cdef public dict _allocations
cdef public dict _scanner_registration_callbacks

@cython.locals(stale_seconds=float)
cdef bint _prefer_previous_adv_from_different_source(
Expand Down
51 changes: 50 additions & 1 deletion src/habluetooth/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
UNAVAILABLE_TRACK_SECONDS,
)
from .models import BluetoothServiceInfoBleak, HaBluetoothSlotAllocations
from .models import (
BluetoothServiceInfoBleak,
HaBluetoothSlotAllocations,
HaScannerRegistration,
HaScannerRegistrationEvent,
)
from .scanner_device import BluetoothScannerDevice
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_reset_adapter
Expand Down Expand Up @@ -121,6 +126,7 @@ class BluetoothManager:
"_loop",
"_non_connectable_scanners",
"_recovery_lock",
"_scanner_registration_callbacks",
"_sources",
"_unavailable_callbacks",
"shutdown",
Expand Down Expand Up @@ -171,6 +177,9 @@ def __init__(
self._allocations_callbacks: dict[
str | None, set[Callable[[HaBluetoothSlotAllocations], None]]
] = {}
self._scanner_registration_callbacks: dict[
str | None, set[Callable[[HaScannerRegistration], None]]
] = {}

@property
def supports_passive_scan(self) -> bool:
Expand Down Expand Up @@ -708,6 +717,7 @@ def _async_unregister_scanner_internal(
self._allocations.pop(scanner.source, None)
if connection_slots:
self.slot_manager.remove_adapter(scanner.adapter)
self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.REMOVED)

def async_register_scanner(
self,
Expand All @@ -728,6 +738,7 @@ def async_register_scanner(
self._adapter_sources[scanner.adapter] = scanner.source
if connection_slots:
self.slot_manager.register_adapter(scanner.adapter, connection_slots)
self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.ADDED)
return partial(
self._async_unregister_scanner_internal, scanners, scanner, connection_slots
)
Expand Down Expand Up @@ -797,6 +808,23 @@ def async_on_allocation_changed(self, allocations: Allocations) -> None:
except Exception:
_LOGGER.exception("Error in allocation callback")

def _async_on_scanner_registration(
self, scanner: BaseHaScanner, event: HaScannerRegistrationEvent
) -> None:
"""Call scanner callbacks."""
for source_key in (scanner.source, None):
if not (
scanner_callbacks := self._scanner_registration_callbacks.get(
source_key
)
):
continue
for callback_ in scanner_callbacks:
try:
callback_(HaScannerRegistration(event, scanner))
except Exception:
_LOGGER.exception("Error in scanner callback")

def async_current_allocations(
self, source: str | None = None
) -> list[HaBluetoothSlotAllocations] | None:
Expand All @@ -823,3 +851,24 @@ def _async_unregister_allocation_callback(
callbacks.discard(callback)
if not callbacks:
del self._allocations_callbacks[source]

def async_register_scanner_registration_callback(
self, callback: Callable[[HaScannerRegistration], None], source: str | None
) -> CALLBACK_TYPE:
"""Register a callback to be called when a scanner is added or removed."""
self._scanner_registration_callbacks.setdefault(source, set()).add(callback)
return partial(
self._async_unregister_scanner_registration_callback, callback, source
)

def _async_unregister_scanner_registration_callback(
self, callback: Callable[[HaScannerRegistration], None], source: str | None
) -> None:
if (callbacks := self._scanner_registration_callbacks.get(source)) is not None:
callbacks.discard(callback)
if not callbacks:
del self._scanner_registration_callbacks[source]

def async_current_scanners(self) -> list[BaseHaScanner]:
"""Return the current scanners."""
return list(self._sources.values())
27 changes: 27 additions & 0 deletions src/habluetooth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from bleak_retry_connector import NO_RSSI_VALUE

if TYPE_CHECKING:
from .base_scanner import BaseHaScanner
from .manager import BluetoothManager

_BluetoothServiceInfoSelfT = TypeVar(
Expand Down Expand Up @@ -57,6 +58,22 @@ class HaBluetoothSlotAllocations:
allocated: list[str] # Addresses of connected devices


class HaScannerRegistrationEvent(Enum):
"""Events for scanner registration."""

ADDED = "added"
REMOVED = "removed"
UPDATED = "updated"


@dataclass(slots=True, frozen=True)
class HaScannerRegistration:
"""Data for a scanner event."""

event: HaScannerRegistrationEvent
scanner: BaseHaScanner


@dataclass(slots=True)
class HaBluetoothConnector:
"""Data for how to connect a BLEDevice from a given scanner."""
Expand All @@ -66,6 +83,16 @@ class HaBluetoothConnector:
can_connect: Callable[[], bool]


@dataclass(slots=True, frozen=True)
class HaScannerDetails:
"""Details for a scanner."""

source: str
connectable: bool
name: str
adapter: str


class BluetoothScanningMode(Enum):
"""The mode of scanning for bluetooth devices."""

Expand Down
14 changes: 13 additions & 1 deletion tests/test_base_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
from bleak.backends.scanner import AdvertisementData
from bluetooth_data_tools import monotonic_time_coarse

from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector, get_manager
from habluetooth import (
BaseHaRemoteScanner,
HaBluetoothConnector,
HaScannerDetails,
get_manager,
)
from habluetooth.const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
Expand Down Expand Up @@ -103,6 +108,13 @@ async def test_remote_scanner(name_2: str | None) -> None:
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)

Expand Down
60 changes: 59 additions & 1 deletion tests/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from habluetooth import (
BluetoothManager,
HaBluetoothSlotAllocations,
HaScannerRegistration,
HaScannerRegistrationEvent,
get_manager,
set_manager,
)
Expand All @@ -24,7 +26,7 @@
inject_advertisement_with_source,
utcnow,
)
from .conftest import FakeBluetoothAdapters
from .conftest import FakeBluetoothAdapters, FakeScanner


@pytest.mark.asyncio
Expand Down Expand Up @@ -332,3 +334,59 @@ async def test_async_register_allocation_callback_non_connectable(
allocated=[],
),
]


@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_scanner_registration_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_scanner_registration_callback handles failures."""
manager = get_manager()
assert manager._loop is not None

scanners = manager.async_current_scanners()
assert len(scanners) == 2
sources = {scanner.source for scanner in scanners}
assert sources == {"AA:BB:CC:DD:EE:00", "AA:BB:CC:DD:EE:11"}

failed_scanner_callbacks: list[HaScannerRegistration] = []

def _failing_callback(scanner_registration: HaScannerRegistration) -> None:
"""Failing callback."""
failed_scanner_callbacks.append(scanner_registration)
raise ValueError("This is a test")

ok_scanner_callbacks: list[HaScannerRegistration] = []

def _ok_callback(scanner_registration: HaScannerRegistration) -> None:
"""Ok callback."""
ok_scanner_callbacks.append(scanner_registration)

cancel1 = manager.async_register_scanner_registration_callback(
_failing_callback, None
)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_scanner_registration_callback(_ok_callback, None)

hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
hci3_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5)

assert len(ok_scanner_callbacks) == 1
assert ok_scanner_callbacks[0] == HaScannerRegistration(
HaScannerRegistrationEvent.ADDED, hci3_scanner
)
assert len(failed_scanner_callbacks) == 1

cancel()

assert len(ok_scanner_callbacks) == 2
assert ok_scanner_callbacks[1] == HaScannerRegistration(
HaScannerRegistrationEvent.REMOVED, hci3_scanner
)
cancel1()
cancel2()

0 comments on commit 99fcb46

Please sign in to comment.