Skip to content

Commit c6fd2ba

Browse files
authored
feat: add support for getting callbacks when adapter allocations change (#115)
1 parent c7b4cd9 commit c6fd2ba

File tree

4 files changed

+127
-10
lines changed

4 files changed

+127
-10
lines changed

src/habluetooth/manager.pxd

+2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ cdef class BluetoothManager:
5151
cdef public object _adapter_refresh_future
5252
cdef public object _recovery_lock
5353
cdef public set _disappeared_callbacks
54+
cdef public set _allocations_callbacks
55+
cdef public object _cancel_allocation_callbacks
5456

5557
@cython.locals(stale_seconds=float)
5658
cdef bint _prefer_previous_adv_from_different_source(

src/habluetooth/manager.py

+37-8
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
from typing import TYPE_CHECKING, Any, Final
1111

1212
from bleak.backends.scanner import AdvertisementDataCallback
13-
from bleak_retry_connector import NO_RSSI_VALUE, BleakSlotManager
13+
from bleak_retry_connector import (
14+
NO_RSSI_VALUE,
15+
AllocationChangeEvent,
16+
Allocations,
17+
BleakSlotManager,
18+
)
1419
from bluetooth_adapters import (
1520
ADAPTER_ADDRESS,
1621
ADAPTER_PASSIVE_SCAN,
@@ -99,8 +104,10 @@ class BluetoothManager:
99104
"_adapters",
100105
"_advertisement_tracker",
101106
"_all_history",
107+
"_allocations_callbacks",
102108
"_bleak_callbacks",
103109
"_bluetooth_adapters",
110+
"_cancel_allocation_callbacks",
104111
"_cancel_unavailable_tracking",
105112
"_connectable_history",
106113
"_connectable_scanners",
@@ -146,12 +153,18 @@ def __init__(
146153
self._sources: dict[str, BaseHaScanner] = {}
147154
self._bluetooth_adapters = bluetooth_adapters
148155
self.slot_manager = slot_manager
156+
self._cancel_allocation_callbacks = (
157+
self.slot_manager.register_allocation_callback(
158+
self._async_slot_manager_changed
159+
)
160+
)
149161
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
150162
self.shutdown = False
151163
self._loop: asyncio.AbstractEventLoop | None = None
152164
self._adapter_refresh_future: asyncio.Future[None] | None = None
153165
self._recovery_lock: asyncio.Lock = asyncio.Lock()
154166
self._disappeared_callbacks: set[Callable[[str], None]] = set()
167+
self._allocations_callbacks: set[Callable[[Allocations], None]] = set()
155168

156169
@property
157170
def supports_passive_scan(self) -> bool:
@@ -203,13 +216,7 @@ def async_register_disappeared_callback(
203216
) -> CALLBACK_TYPE:
204217
"""Register a callback to be called when an address disappears."""
205218
self._disappeared_callbacks.add(callback)
206-
return partial(self._async_remove_disappeared_callback, callback)
207-
208-
def _async_remove_disappeared_callback(
209-
self, callback: Callable[[str], None]
210-
) -> None:
211-
"""Remove a disappeared callback."""
212-
self._disappeared_callbacks.discard(callback)
219+
return partial(self._disappeared_callbacks.discard, callback)
213220

214221
async def _async_refresh_adapters(self) -> None:
215222
"""Refresh the adapters."""
@@ -283,6 +290,7 @@ def async_stop(self) -> None:
283290
self._cancel_unavailable_tracking.cancel()
284291
self._cancel_unavailable_tracking = None
285292
uninstall_multiple_bleak_catcher()
293+
self._cancel_allocation_callbacks()
286294

287295
def async_scanner_devices_by_address(
288296
self, address: str, connectable: bool
@@ -749,3 +757,24 @@ def async_set_fallback_availability_interval(
749757
) -> None:
750758
"""Override the fallback availability timeout for a MAC address."""
751759
self._fallback_intervals[address] = interval
760+
761+
def _async_slot_manager_changed(self, event: AllocationChangeEvent) -> None:
762+
"""Handle slot manager changes."""
763+
self.async_on_allocation_changed(
764+
self.slot_manager.get_allocations(event.adapter)
765+
)
766+
767+
def async_on_allocation_changed(self, allocations: Allocations) -> None:
768+
"""Call allocation callbacks."""
769+
for callback_ in self._allocations_callbacks:
770+
try:
771+
callback_(allocations)
772+
except Exception:
773+
_LOGGER.exception("Error in allocation callback")
774+
775+
def async_register_allocation_callback(
776+
self, callback: Callable[[str], None]
777+
) -> CALLBACK_TYPE:
778+
"""Register a callback to be called when an allocations change."""
779+
self._allocations_callbacks.add(callback)
780+
return partial(self._allocations_callbacks.discard, callback)

tests/conftest.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def discovered_devices_and_advertisement_data(
5757
def manager():
5858
slot_manager = BleakSlotManager()
5959
bluetooth_adapters = FakeBluetoothAdapters()
60-
set_manager(BluetoothManager(bluetooth_adapters, slot_manager))
60+
manager = BluetoothManager(bluetooth_adapters, slot_manager)
61+
set_manager(manager)
62+
manager.async_stop()
6163

6264

6365
@pytest_asyncio.fixture(name="enable_bluetooth")

tests/test_manager.py

+85-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from unittest.mock import patch
77

88
import pytest
9-
from bleak_retry_connector import BleakSlotManager
9+
from bleak_retry_connector import AllocationChange, Allocations, BleakSlotManager
1010
from bluetooth_adapters.systems.linux import LinuxAdapters
1111
from freezegun import freeze_time
1212

@@ -208,3 +208,87 @@ def _ok_callback(_address: str) -> None:
208208

209209
cancel1()
210210
cancel2()
211+
212+
213+
@pytest.mark.asyncio
214+
@pytest.mark.usefixtures("enable_bluetooth")
215+
async def test_async_register_allocation_callback(
216+
register_hci0_scanner: None,
217+
register_hci1_scanner: None,
218+
) -> None:
219+
"""Test bluetooth async_register_allocation_callback handles failures."""
220+
manager = get_manager()
221+
assert manager._loop is not None
222+
223+
address = "44:44:33:11:23:12"
224+
225+
switchbot_device_signal_100 = generate_ble_device(
226+
address, "wohand_signal_100", rssi=-100
227+
)
228+
switchbot_adv_signal_100 = generate_advertisement_data(
229+
local_name="wohand_signal_100", service_uuids=[]
230+
)
231+
inject_advertisement_with_source(
232+
switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
233+
)
234+
235+
failed_allocations: list[Allocations] = []
236+
237+
def _failing_callback(allocations: Allocations) -> None:
238+
"""Failing callback."""
239+
failed_allocations.append(allocations)
240+
raise ValueError("This is a test")
241+
242+
ok_allocations: list[Allocations] = []
243+
244+
def _ok_callback(allocations: Allocations) -> None:
245+
"""Ok callback."""
246+
ok_allocations.append(allocations)
247+
248+
cancel1 = manager.async_register_allocation_callback(_failing_callback)
249+
# Make sure the second callback still works if the first one fails and
250+
# raises an exception
251+
cancel2 = manager.async_register_allocation_callback(_ok_callback)
252+
253+
switchbot_adv_signal_100 = generate_advertisement_data(
254+
local_name="wohand_signal_100",
255+
manufacturer_data={123: b"abc"},
256+
service_uuids=[],
257+
rssi=-80,
258+
)
259+
inject_advertisement_with_source(
260+
switchbot_device_signal_100, switchbot_adv_signal_100, "hci1"
261+
)
262+
263+
manager.async_on_allocation_changed(
264+
Allocations(
265+
"hci0",
266+
5,
267+
4,
268+
["44:44:33:11:23:12"],
269+
)
270+
)
271+
272+
assert len(ok_allocations) == 1
273+
assert ok_allocations[0] == Allocations(
274+
"hci0",
275+
5,
276+
4,
277+
["44:44:33:11:23:12"],
278+
)
279+
assert len(failed_allocations) == 1
280+
assert failed_allocations[0] == Allocations(
281+
"hci0",
282+
5,
283+
4,
284+
["44:44:33:11:23:12"],
285+
)
286+
287+
manager.slot_manager._allocations_by_adapter["hci0"] = {}
288+
manager.slot_manager._call_callbacks(
289+
AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_44_44_33_11_23_12"
290+
)
291+
assert len(ok_allocations) == 2
292+
293+
cancel1()
294+
cancel2()

0 commit comments

Comments
 (0)