From 2d3a3bf4fcf991772cabddfa48b41c29e9a06bf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 May 2026 09:43:52 -0500 Subject: [PATCH] Allow bluetooth coordinators to request an active scan cadence (#172015) --- .../bluetooth/active_update_processor.py | 13 +++++- .../bluetooth/passive_update_processor.py | 6 ++- .../bluetooth/update_coordinator.py | 6 +++ .../bluetooth/test_active_update_processor.py | 44 ++++++++++++++++++- .../test_passive_update_processor.py | 8 +++- tests/components/bthome/test_config_flow.py | 8 +++- tests/components/bthome/test_repairs.py | 4 +- .../components/xiaomi_ble/test_config_flow.py | 20 ++++++--- 8 files changed, 96 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index 2ecf4345958a..1e24c4ebdb2f 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -68,9 +68,20 @@ class ActiveBluetoothProcessorCoordinator[_DataT]( | None = None, poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, connectable: bool = True, + scan_interval: float | None = None, + scan_duration: float | None = None, ) -> None: """Initialize the processor.""" - super().__init__(hass, logger, address, mode, update_method, connectable) + super().__init__( + hass, + logger, + address, + mode, + update_method, + connectable, + scan_interval, + scan_duration, + ) self._needs_poll_method = needs_poll_method self._poll_method = poll_method diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index e5a294980a3b..2164d93c4bd6 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -298,9 +298,13 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat mode: BluetoothScanningMode, update_method: Callable[[BluetoothServiceInfoBleak], _DataT], connectable: bool = False, + scan_interval: float | None = None, + scan_duration: float | None = None, ) -> None: """Initialize the coordinator.""" - super().__init__(hass, logger, address, mode, connectable) + super().__init__( + hass, logger, address, mode, connectable, scan_interval, scan_duration + ) self._processors: list[PassiveBluetoothDataProcessor[Any, _DataT]] = [] self._update_method = update_method self.last_update_success = True diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index aa4f967a150a..10b331be57b1 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -30,6 +30,8 @@ class BasePassiveBluetoothCoordinator(ABC): address: str, mode: BluetoothScanningMode, connectable: bool, + scan_interval: float | None = None, + scan_duration: float | None = None, ) -> None: """Initialize the coordinator.""" self.hass = hass @@ -38,6 +40,8 @@ class BasePassiveBluetoothCoordinator(ABC): self.connectable = connectable self._on_stop: list[CALLBACK_TYPE] = [] self.mode = mode + self._scan_interval = scan_interval + self._scan_duration = scan_duration self._last_unavailable_time = 0.0 self._last_name = address # Subclasses are responsible for setting _available to True @@ -92,6 +96,8 @@ class BasePassiveBluetoothCoordinator(ABC): address=self.address, connectable=self.connectable ), self.mode, + scan_interval=self._scan_interval, + scan_duration=self._scan_duration, ) ) self._on_stop.append( diff --git a/tests/components/bluetooth/test_active_update_processor.py b/tests/components/bluetooth/test_active_update_processor.py index 73842fd9c0c8..5c57bd301d9c 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -2,7 +2,7 @@ import asyncio import logging -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock, call, patch from bleak import BleakError import pytest @@ -15,6 +15,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth.active_update_processor import ( ActiveBluetoothProcessorCoordinator, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo @@ -422,3 +423,44 @@ async def test_no_polling_after_stop_event(hass: HomeAssistant) -> None: assert needs_poll_calls == 1 cancel() + + +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_scan_interval_and_duration_forwarded(hass: HomeAssistant) -> None: + """Test scan_interval and scan_duration reach the bluetooth manager.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + def _update_method(service_info: BluetoothServiceInfoBleak): + return {"testdata": 0} + + def _poll_needed(*args, **kwargs): + return False + + async def _poll(*args, **kwargs): + return {"testdata": 1} + + coordinator = ActiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address="aa:bb:cc:dd:ee:ff", + mode=BluetoothScanningMode.ACTIVE, + update_method=_update_method, + needs_poll_method=_poll_needed, + poll_method=_poll, + scan_interval=165.0, + scan_duration=10.0, + ) + + cancel_scan = MagicMock() + with patch.object( + HomeAssistantBluetoothManager, + "async_register_active_scan", + return_value=cancel_scan, + ) as mock_register: + cancel = coordinator.async_start() + + mock_register.assert_called_once_with("aa:bb:cc:dd:ee:ff", 165.0, 10.0) + cancel_scan.assert_not_called() + + cancel() + cancel_scan.assert_called_once() diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 26c6af3181e3..1ca85367a364 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -703,7 +703,9 @@ async def test_exception_from_update_method( assert coordinator.available is False # no data yet saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None @@ -768,7 +770,9 @@ async def test_bad_data_from_update_method(hass: HomeAssistant) -> None: assert coordinator.available is False # no data yet saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None diff --git a/tests/components/bthome/test_config_flow.py b/tests/components/bthome/test_config_flow.py index 26021b4a38d3..8a436c8f7ae2 100644 --- a/tests/components/bthome/test_config_flow.py +++ b/tests/components/bthome/test_config_flow.py @@ -501,7 +501,9 @@ async def test_async_step_reauth(hass: HomeAssistant) -> None: entry.add_to_hass(hass) saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None @@ -541,7 +543,9 @@ async def test_async_step_reauth_wrong_key(hass: HomeAssistant) -> None: entry.add_to_hass(hass) saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None diff --git a/tests/components/bthome/test_repairs.py b/tests/components/bthome/test_repairs.py index 65bd33935273..257ea47f281b 100644 --- a/tests/components/bthome/test_repairs.py +++ b/tests/components/bthome/test_repairs.py @@ -40,7 +40,9 @@ async def _setup_entry( saved_callback: Callable[[object, BluetoothChange], None] | None = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None diff --git a/tests/components/xiaomi_ble/test_config_flow.py b/tests/components/xiaomi_ble/test_config_flow.py index ebc1eae5c3e1..98bfd8ddb6ce 100644 --- a/tests/components/xiaomi_ble/test_config_flow.py +++ b/tests/components/xiaomi_ble/test_config_flow.py @@ -1162,7 +1162,9 @@ async def test_async_step_reauth_legacy(hass: HomeAssistant) -> None: entry.add_to_hass(hass) saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None @@ -1211,7 +1213,9 @@ async def test_async_step_reauth_legacy_wrong_key(hass: HomeAssistant) -> None: entry.add_to_hass(hass) saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None @@ -1268,7 +1272,9 @@ async def test_async_step_reauth_v4(hass: HomeAssistant) -> None: entry.add_to_hass(hass) saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None @@ -1322,7 +1328,9 @@ async def test_async_step_reauth_v4_wrong_key(hass: HomeAssistant) -> None: entry.add_to_hass(hass) saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None @@ -1384,7 +1392,9 @@ async def test_async_step_reauth_v4_from_cloud(hass: HomeAssistant) -> None: entry.add_to_hass(hass) saved_callback = None - def _async_register_callback(_hass, _callback, _matcher, _mode): + def _async_register_callback( + _hass, _callback, _matcher, _mode, *, scan_interval=None, scan_duration=None + ): nonlocal saved_callback saved_callback = _callback return lambda: None