diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index ed04ca401ed..9492642e0e0 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum import logging +import time from typing import TYPE_CHECKING, Final import async_timeout @@ -56,6 +57,10 @@ START_TIMEOUT = 9 SOURCE_LOCAL: Final = "local" +SCANNER_WATCHDOG_TIMEOUT: Final = 60 * 5 +SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=SCANNER_WATCHDOG_TIMEOUT) +MONOTONIC_TIME = time.monotonic + @dataclass class BluetoothServiceInfoBleak(BluetoothServiceInfo): @@ -259,9 +264,10 @@ async def async_setup_entry( ) -> bool: """Set up the bluetooth integration from a config entry.""" manager: BluetoothManager = hass.data[DOMAIN] - await manager.async_start( - BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER) - ) + async with manager.start_stop_lock: + await manager.async_start( + BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER) + ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True @@ -270,8 +276,6 @@ async def _async_update_listener( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> None: """Handle options update.""" - manager: BluetoothManager = hass.data[DOMAIN] - manager.async_start_reload() await hass.config_entries.async_reload(entry.entry_id) @@ -280,7 +284,9 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" manager: BluetoothManager = hass.data[DOMAIN] - await manager.async_stop() + async with manager.start_stop_lock: + manager.async_start_reload() + await manager.async_stop() return True @@ -296,13 +302,19 @@ class BluetoothManager: self.hass = hass self._integration_matcher = integration_matcher self.scanner: HaBleakScanner | None = None + self.start_stop_lock = asyncio.Lock() self._cancel_device_detected: CALLBACK_TYPE | None = None self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None + self._cancel_stop: CALLBACK_TYPE | None = None + self._cancel_watchdog: CALLBACK_TYPE | None = None self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {} self._callbacks: list[ tuple[BluetoothCallback, BluetoothCallbackMatcher | None] ] = [] + self._last_detection = 0.0 self._reloading = False + self._adapter: str | None = None + self._scanning_mode = BluetoothScanningMode.ACTIVE @hass_callback def async_setup(self) -> None: @@ -324,6 +336,8 @@ class BluetoothManager: ) -> None: """Set up BT Discovery.""" assert self.scanner is not None + self._adapter = adapter + self._scanning_mode = scanning_mode if self._reloading: # On reload, we need to reset the scanner instance # since the devices in its history may not be reachable @@ -388,7 +402,32 @@ class BluetoothManager: _LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True) raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex self.async_setup_unavailable_tracking() - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) + self._async_setup_scanner_watchdog() + self._cancel_stop = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._async_hass_stopping + ) + + @hass_callback + def _async_setup_scanner_watchdog(self) -> None: + """If Dbus gets restarted or updated, we need to restart the scanner.""" + self._last_detection = MONOTONIC_TIME() + self._cancel_watchdog = async_track_time_interval( + self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL + ) + + async def _async_scanner_watchdog(self, now: datetime) -> None: + """Check if the scanner is running.""" + time_since_last_detection = MONOTONIC_TIME() - self._last_detection + if time_since_last_detection < SCANNER_WATCHDOG_TIMEOUT: + return + _LOGGER.info( + "Bluetooth scanner has gone quiet for %s, restarting", + SCANNER_WATCHDOG_INTERVAL, + ) + async with self.start_stop_lock: + self.async_start_reload() + await self.async_stop() + await self.async_start(self._scanning_mode, self._adapter) @hass_callback def async_setup_unavailable_tracking(self) -> None: @@ -423,6 +462,7 @@ class BluetoothManager: self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Handle a detected device.""" + self._last_detection = MONOTONIC_TIME() matched_domains = self._integration_matcher.match_domains( device, advertisement_data ) @@ -535,14 +575,26 @@ class BluetoothManager: for device_adv in self.scanner.history.values() ] - async def async_stop(self, event: Event | None = None) -> None: + async def _async_hass_stopping(self, event: Event) -> None: + """Stop the Bluetooth integration at shutdown.""" + self._cancel_stop = None + await self.async_stop() + + async def async_stop(self) -> None: """Stop bluetooth discovery.""" + _LOGGER.debug("Stopping bluetooth discovery") + if self._cancel_watchdog: + self._cancel_watchdog() + self._cancel_watchdog = None if self._cancel_device_detected: self._cancel_device_detected() self._cancel_device_detected = None if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking() self._cancel_unavailable_tracking = None + if self._cancel_stop: + self._cancel_stop() + self._cancel_stop = None if self.scanner: try: await self.scanner.stop() # type: ignore[no-untyped-call] diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 6cd22505dc4..45babd05748 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -10,6 +10,8 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, BluetoothChange, @@ -1562,3 +1564,57 @@ async def test_invalid_dbus_message(hass, caplog): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() assert "dbus" in caplog.text + + +async def test_recovery_from_dbus_restart( + hass, mock_bleak_scanner_start, enable_bluetooth +): + """Test we can recover when DBus gets restarted out from under us.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}) + await hass.async_block_till_done() + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + start_time_monotonic = 1000 + scanner = _get_underlying_scanner() + mock_discovered = [MagicMock()] + type(scanner).discovered_devices = mock_discovered + + # Ensure we don't restart the scanner if we don't need to + with patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", + return_value=start_time_monotonic + 10, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + # Fire a callback to reset the timer + with patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", + return_value=start_time_monotonic, + ): + scanner._callback( + BLEDevice("44:44:33:11:23:42", "any_name"), + AdvertisementData(local_name="any_name"), + ) + + # Ensure we don't restart the scanner if we don't need to + with patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", + return_value=start_time_monotonic + 20, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + # We hit the timer, so we restart the scanner + with patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", + return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert len(mock_bleak_scanner_start.mock_calls) == 2