Skip to content

Commit 782b717

Browse files
authored
feat: callback on scanner start success (#298)
1 parent 15f1a12 commit 782b717

File tree

7 files changed

+293
-3
lines changed

7 files changed

+293
-3
lines changed

src/habluetooth/base_scanner.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ def __init__(
123123
self._connect_failures: dict[str, int] = {}
124124
self._connect_in_progress: dict[str, int] = {}
125125

126+
def _on_start_success(self) -> None:
127+
"""
128+
Called when the scanner successfully starts.
129+
130+
Notifies the manager that this scanner has started.
131+
"""
132+
if self._manager:
133+
self._manager.on_scanner_start(self)
134+
126135
def _clear_connection_history(self) -> None:
127136
"""Clear the connection history for a scanner."""
128137
self._connect_failures.clear()

src/habluetooth/manager.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,22 @@ def supports_passive_scan(self) -> bool:
215215
"""Return if passive scan is supported."""
216216
return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values())
217217

218+
def is_operating_degraded(self) -> bool:
219+
"""
220+
Return if the manager is operating in degraded mode.
221+
222+
On Linux, we're in degraded mode if mgmt control is not available.
223+
This typically means we don't have NET_ADMIN/NET_RAW capabilities.
224+
"""
225+
return IS_LINUX and self._mgmt_ctl is None
226+
227+
def on_scanner_start(self, scanner: BaseHaScanner) -> None:
228+
"""
229+
Called when a scanner starts.
230+
231+
Subclasses can override this to perform custom actions when a scanner starts.
232+
"""
233+
218234
def async_scanner_count(self, connectable: bool = True) -> int:
219235
"""Return the number of scanners."""
220236
if connectable:

src/habluetooth/scanner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ async def _async_start_attempt(self, attempt: int) -> bool:
456456
self._start_future = None
457457

458458
self._log_start_success(attempt)
459+
self._on_start_success()
459460
return True
460461

461462
def _log_adapter_init_wait(self, attempt: int) -> None:

tests/conftest.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,68 @@ def register_non_connectable_scanner() -> Generator[None, None, None]:
224224
cancel = manager.async_register_scanner(remote_scanner)
225225
yield
226226
cancel()
227+
228+
229+
class MockBluetoothManagerWithCallbacks(BluetoothManager):
230+
"""Mock bluetooth manager that tracks scanner start callbacks."""
231+
232+
def __init__(self, *args, **kwargs):
233+
"""Initialize the mock manager."""
234+
super().__init__(*args, **kwargs)
235+
self.scanner_start_calls = []
236+
237+
def on_scanner_start(self, scanner):
238+
"""Track scanner start calls."""
239+
self.scanner_start_calls.append(scanner)
240+
super().on_scanner_start(scanner)
241+
242+
243+
@pytest.fixture
244+
def mock_manager_with_scanner_callbacks() -> (
245+
Generator[MockBluetoothManagerWithCallbacks, None, None]
246+
):
247+
"""Provide a mock BluetoothManager that tracks scanner start callbacks."""
248+
mock_bluetooth_adapters = FakeBluetoothAdapters()
249+
manager = MockBluetoothManagerWithCallbacks(
250+
mock_bluetooth_adapters,
251+
slot_manager=MagicMock(),
252+
)
253+
254+
# Save the original manager
255+
original_manager = get_manager()
256+
257+
# Set our mock manager as the global manager
258+
set_manager(manager)
259+
260+
try:
261+
yield manager
262+
finally:
263+
# Restore the original manager
264+
set_manager(original_manager)
265+
266+
267+
@pytest_asyncio.fixture
268+
async def async_mock_manager_with_scanner_callbacks() -> (
269+
AsyncGenerator[MockBluetoothManagerWithCallbacks, None]
270+
):
271+
"""Provide an async mock BluetoothManager that tracks scanner start callbacks."""
272+
mock_bluetooth_adapters = FakeBluetoothAdapters()
273+
manager = MockBluetoothManagerWithCallbacks(
274+
mock_bluetooth_adapters,
275+
slot_manager=MagicMock(),
276+
)
277+
278+
# Setup the manager
279+
await manager.async_setup()
280+
281+
# Save the original manager
282+
original_manager = get_manager()
283+
284+
# Set our mock manager as the global manager
285+
set_manager(manager)
286+
287+
try:
288+
yield manager
289+
finally:
290+
# Restore the original manager
291+
set_manager(original_manager)

tests/test_base_scanner.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import time
77
from datetime import timedelta
88
from typing import Any
9-
from unittest.mock import ANY, MagicMock, patch
9+
from unittest.mock import ANY, MagicMock, Mock, patch
1010

1111
import pytest
1212
from bleak.backends.device import BLEDevice
@@ -17,13 +17,15 @@
1717
from habluetooth import (
1818
BaseHaRemoteScanner,
1919
BaseHaScanner,
20+
BluetoothManager,
2021
BluetoothScannerDevice,
2122
BluetoothScanningMode,
2223
HaBluetoothConnector,
2324
HaScannerDetails,
2425
HaScannerModeChange,
2526
HaScannerType,
2627
get_manager,
28+
set_manager,
2729
)
2830
from habluetooth.const import (
2931
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
@@ -44,6 +46,7 @@
4446
patch_bluetooth_time,
4547
utcnow,
4648
)
49+
from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks
4750

4851

4952
class FakeScanner(BaseHaRemoteScanner):
@@ -1190,3 +1193,91 @@ def test_score_with_connections_in_progress_and_slots():
11901193
# -50 (RSSI) - 10.1 (connection in progress) - 7.6 (last slot)
11911194
assert score == -50 - 10.1 - 7.6
11921195
assert score == -67.7
1196+
1197+
1198+
@pytest.mark.asyncio
1199+
async def test_on_scanner_start_callback_remote_scanner(
1200+
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
1201+
) -> None:
1202+
"""Test that on_scanner_start is called when a remote scanner starts."""
1203+
manager = async_mock_manager_with_scanner_callbacks
1204+
1205+
# Create a fake remote scanner
1206+
scanner = FakeScanner(
1207+
source="esp32_proxy",
1208+
adapter="esp32_proxy",
1209+
connector=None,
1210+
connectable=True,
1211+
)
1212+
1213+
# Simulate scanner start success
1214+
scanner._on_start_success()
1215+
1216+
# Verify the callback was called
1217+
assert len(manager.scanner_start_calls) == 1
1218+
assert manager.scanner_start_calls[0] is scanner
1219+
1220+
1221+
@pytest.mark.asyncio
1222+
async def test_on_scanner_start_multiple_scanners(
1223+
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
1224+
) -> None:
1225+
"""Test that on_scanner_start is called for multiple scanners."""
1226+
manager = async_mock_manager_with_scanner_callbacks
1227+
1228+
# Create multiple scanners
1229+
scanner1 = FakeScanner(
1230+
source="scanner1",
1231+
adapter="scanner1",
1232+
connector=None,
1233+
connectable=True,
1234+
)
1235+
1236+
scanner2 = FakeScanner(
1237+
source="scanner2",
1238+
adapter="scanner2",
1239+
connector=None,
1240+
connectable=True,
1241+
)
1242+
1243+
# Simulate both scanners starting
1244+
scanner1._on_start_success()
1245+
scanner2._on_start_success()
1246+
1247+
# Verify both callbacks were called
1248+
assert len(manager.scanner_start_calls) == 2
1249+
assert scanner1 in manager.scanner_start_calls
1250+
assert scanner2 in manager.scanner_start_calls
1251+
1252+
1253+
@pytest.mark.asyncio
1254+
async def test_scanner_without_manager() -> None:
1255+
"""Test that _on_start_success handles scanner without manager gracefully."""
1256+
# Set a temporary manager for scanner creation
1257+
mock_bluetooth_adapters = FakeBluetoothAdapters()
1258+
temp_manager = BluetoothManager(
1259+
mock_bluetooth_adapters,
1260+
slot_manager=Mock(),
1261+
)
1262+
1263+
original_manager = get_manager()
1264+
set_manager(temp_manager)
1265+
1266+
try:
1267+
# Create a scanner
1268+
scanner = FakeScanner(
1269+
source="test",
1270+
adapter="test",
1271+
connector=None,
1272+
connectable=True,
1273+
)
1274+
1275+
# Clear the manager to simulate no manager scenario
1276+
scanner._manager = None # type: ignore[assignment]
1277+
1278+
# Should not raise an exception
1279+
scanner._on_start_success()
1280+
1281+
finally:
1282+
# Restore original manager
1283+
set_manager(original_manager)

tests/test_manager.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import time
55
from datetime import timedelta
66
from typing import Any
7-
from unittest.mock import ANY, patch
7+
from unittest.mock import ANY, AsyncMock, Mock, patch
88

99
import pytest
1010
from bleak_retry_connector import AllocationChange, Allocations, BleakSlotManager
@@ -1328,3 +1328,81 @@ class TestBluetoothManager2(BluetoothManager):
13281328

13291329
TestBluetoothManager2(bluetooth_adapters, slot_manager)
13301330
assert "does not implement _discover_service_info" in caplog.text
1331+
1332+
1333+
@pytest.mark.asyncio
1334+
async def test_is_operating_degraded_on_linux_with_mgmt() -> None:
1335+
"""Test is_operating_degraded returns False on Linux with mgmt control."""
1336+
mock_bluetooth_adapters = FakeBluetoothAdapters()
1337+
manager = BluetoothManager(
1338+
mock_bluetooth_adapters,
1339+
slot_manager=Mock(),
1340+
)
1341+
1342+
with (
1343+
patch("habluetooth.manager.IS_LINUX", True),
1344+
patch.object(manager, "_mgmt_ctl", Mock()),
1345+
):
1346+
# Mock mgmt_ctl being available
1347+
assert manager.is_operating_degraded() is False
1348+
1349+
1350+
@pytest.mark.asyncio
1351+
async def test_is_operating_degraded_on_linux_without_mgmt() -> None:
1352+
"""Test is_operating_degraded returns True on Linux without mgmt control."""
1353+
mock_bluetooth_adapters = FakeBluetoothAdapters()
1354+
manager = BluetoothManager(
1355+
mock_bluetooth_adapters,
1356+
slot_manager=Mock(),
1357+
)
1358+
1359+
with patch("habluetooth.manager.IS_LINUX", True):
1360+
# mgmt_ctl is None by default
1361+
assert manager._mgmt_ctl is None
1362+
assert manager.is_operating_degraded() is True
1363+
1364+
1365+
@pytest.mark.asyncio
1366+
async def test_is_operating_degraded_on_non_linux() -> None:
1367+
"""Test is_operating_degraded returns False on non-Linux systems."""
1368+
mock_bluetooth_adapters = FakeBluetoothAdapters()
1369+
manager = BluetoothManager(
1370+
mock_bluetooth_adapters,
1371+
slot_manager=Mock(),
1372+
)
1373+
1374+
with patch("habluetooth.manager.IS_LINUX", False):
1375+
# Should return False regardless of mgmt_ctl state
1376+
assert manager.is_operating_degraded() is False
1377+
1378+
# Even with mgmt_ctl set
1379+
manager._mgmt_ctl = Mock()
1380+
assert manager.is_operating_degraded() is False
1381+
1382+
1383+
@pytest.mark.asyncio
1384+
async def test_is_operating_degraded_after_permission_error() -> None:
1385+
"""Test is_operating_degraded after mgmt setup fails with permission error."""
1386+
mock_bluetooth_adapters = FakeBluetoothAdapters()
1387+
manager = BluetoothManager(
1388+
mock_bluetooth_adapters,
1389+
slot_manager=Mock(),
1390+
)
1391+
1392+
with (
1393+
patch("habluetooth.manager.IS_LINUX", True),
1394+
patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class,
1395+
):
1396+
# Make setup fail with permission error
1397+
mock_mgmt_instance = Mock()
1398+
mock_mgmt_instance.setup = AsyncMock(
1399+
side_effect=PermissionError("No permission")
1400+
)
1401+
mock_mgmt_class.return_value = mock_mgmt_instance
1402+
1403+
# Setup should handle the error and set mgmt_ctl to None
1404+
await manager.async_setup()
1405+
1406+
# Should be in degraded mode
1407+
assert manager._mgmt_ctl is None
1408+
assert manager.is_operating_degraded() is True

tests/test_scanner.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
patch_bluetooth_time,
4343
utcnow,
4444
)
45-
from .conftest import FakeBluetoothAdapters
45+
from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks
4646

4747
DEVICE_FOUND = 0x0012
4848
ADV_MONITOR_DEVICE_FOUND = 0x002F
@@ -1611,3 +1611,33 @@ def test_ha_scanner_get_allocations_updates_dynamically() -> None:
16111611
assert allocations is not None
16121612
assert allocations.free == 1
16131613
assert len(allocations.allocated) == 2
1614+
1615+
1616+
@pytest.mark.asyncio
1617+
async def test_on_scanner_start_callback(
1618+
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
1619+
) -> None:
1620+
"""Test that on_scanner_start is called when a local scanner starts."""
1621+
manager = async_mock_manager_with_scanner_callbacks
1622+
1623+
# Create a local scanner (it will get the manager from get_manager())
1624+
scanner = HaScanner(
1625+
mode=BluetoothScanningMode.ACTIVE,
1626+
adapter="hci0",
1627+
address="00:00:00:00:00:00",
1628+
)
1629+
1630+
# Register scanner with manager
1631+
manager.async_register_scanner(scanner)
1632+
1633+
# Setup the scanner
1634+
scanner.async_setup()
1635+
1636+
# Directly call _on_start_success to test the callback
1637+
# (In real usage, this is called by HaScanner._async_start_attempt
1638+
# after successful start)
1639+
scanner._on_start_success()
1640+
1641+
# Verify the callback was called
1642+
assert len(manager.scanner_start_calls) == 1
1643+
assert manager.scanner_start_calls[0] is scanner

0 commit comments

Comments
 (0)