Ensure bluetooth recovers if Dbus gets restarted (#76249)

This commit is contained in:
J. Nick Koston 2022-08-07 05:03:56 -10:00 committed by GitHub
parent 4aeaeeda0d
commit 1fe44d0997
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 116 additions and 8 deletions

View File

@ -8,6 +8,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
import logging import logging
import time
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
import async_timeout import async_timeout
@ -56,6 +57,10 @@ START_TIMEOUT = 9
SOURCE_LOCAL: Final = "local" SOURCE_LOCAL: Final = "local"
SCANNER_WATCHDOG_TIMEOUT: Final = 60 * 5
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=SCANNER_WATCHDOG_TIMEOUT)
MONOTONIC_TIME = time.monotonic
@dataclass @dataclass
class BluetoothServiceInfoBleak(BluetoothServiceInfo): class BluetoothServiceInfoBleak(BluetoothServiceInfo):
@ -259,9 +264,10 @@ async def async_setup_entry(
) -> bool: ) -> bool:
"""Set up the bluetooth integration from a config entry.""" """Set up the bluetooth integration from a config entry."""
manager: BluetoothManager = hass.data[DOMAIN] manager: BluetoothManager = hass.data[DOMAIN]
await manager.async_start( async with manager.start_stop_lock:
BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER) await manager.async_start(
) BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER)
)
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True return True
@ -270,8 +276,6 @@ async def _async_update_listener(
hass: HomeAssistant, entry: config_entries.ConfigEntry hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> None: ) -> None:
"""Handle options update.""" """Handle options update."""
manager: BluetoothManager = hass.data[DOMAIN]
manager.async_start_reload()
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
@ -280,7 +284,9 @@ async def async_unload_entry(
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
manager: BluetoothManager = hass.data[DOMAIN] 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 return True
@ -296,13 +302,19 @@ class BluetoothManager:
self.hass = hass self.hass = hass
self._integration_matcher = integration_matcher self._integration_matcher = integration_matcher
self.scanner: HaBleakScanner | None = None self.scanner: HaBleakScanner | None = None
self.start_stop_lock = asyncio.Lock()
self._cancel_device_detected: CALLBACK_TYPE | None = None self._cancel_device_detected: CALLBACK_TYPE | None = None
self._cancel_unavailable_tracking: 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._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {}
self._callbacks: list[ self._callbacks: list[
tuple[BluetoothCallback, BluetoothCallbackMatcher | None] tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
] = [] ] = []
self._last_detection = 0.0
self._reloading = False self._reloading = False
self._adapter: str | None = None
self._scanning_mode = BluetoothScanningMode.ACTIVE
@hass_callback @hass_callback
def async_setup(self) -> None: def async_setup(self) -> None:
@ -324,6 +336,8 @@ class BluetoothManager:
) -> None: ) -> None:
"""Set up BT Discovery.""" """Set up BT Discovery."""
assert self.scanner is not None assert self.scanner is not None
self._adapter = adapter
self._scanning_mode = scanning_mode
if self._reloading: if self._reloading:
# On reload, we need to reset the scanner instance # On reload, we need to reset the scanner instance
# since the devices in its history may not be reachable # 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) _LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True)
raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex
self.async_setup_unavailable_tracking() 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 @hass_callback
def async_setup_unavailable_tracking(self) -> None: def async_setup_unavailable_tracking(self) -> None:
@ -423,6 +462,7 @@ class BluetoothManager:
self, device: BLEDevice, advertisement_data: AdvertisementData self, device: BLEDevice, advertisement_data: AdvertisementData
) -> None: ) -> None:
"""Handle a detected device.""" """Handle a detected device."""
self._last_detection = MONOTONIC_TIME()
matched_domains = self._integration_matcher.match_domains( matched_domains = self._integration_matcher.match_domains(
device, advertisement_data device, advertisement_data
) )
@ -535,14 +575,26 @@ class BluetoothManager:
for device_adv in self.scanner.history.values() 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.""" """Stop bluetooth discovery."""
_LOGGER.debug("Stopping bluetooth discovery")
if self._cancel_watchdog:
self._cancel_watchdog()
self._cancel_watchdog = None
if self._cancel_device_detected: if self._cancel_device_detected:
self._cancel_device_detected() self._cancel_device_detected()
self._cancel_device_detected = None self._cancel_device_detected = None
if self._cancel_unavailable_tracking: if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking() self._cancel_unavailable_tracking()
self._cancel_unavailable_tracking = None self._cancel_unavailable_tracking = None
if self._cancel_stop:
self._cancel_stop()
self._cancel_stop = None
if self.scanner: if self.scanner:
try: try:
await self.scanner.stop() # type: ignore[no-untyped-call] await self.scanner.stop() # type: ignore[no-untyped-call]

View File

@ -10,6 +10,8 @@ import pytest
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
SOURCE_LOCAL, SOURCE_LOCAL,
UNAVAILABLE_TRACK_SECONDS, UNAVAILABLE_TRACK_SECONDS,
BluetoothChange, BluetoothChange,
@ -1562,3 +1564,57 @@ async def test_invalid_dbus_message(hass, caplog):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done() await hass.async_block_till_done()
assert "dbus" in caplog.text 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