diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index f175b01b798..1d0b8824fb5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -39,6 +39,7 @@ from .const import ( DATA_MANAGER, DEFAULT_ADDRESS, DOMAIN, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, AdapterDetails, ) @@ -81,6 +82,7 @@ __all__ = [ "BluetoothCallback", "HaBluetoothConnector", "SOURCE_LOCAL", + "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py new file mode 100644 index 00000000000..f4577496e04 --- /dev/null +++ b/homeassistant/components/bluetooth/advertisement_tracker.py @@ -0,0 +1,68 @@ +"""The bluetooth integration advertisement tracker.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import callback + +from .models import BluetoothServiceInfoBleak + +ADVERTISING_TIMES_NEEDED = 16 + + +class AdvertisementTracker: + """Tracker to determine the interval that a device is advertising.""" + + def __init__(self) -> None: + """Initialize the tracker.""" + self.intervals: dict[str, float] = {} + self.sources: dict[str, str] = {} + self._timings: dict[str, list[float]] = {} + + @callback + def async_diagnostics(self) -> dict[str, dict[str, Any]]: + """Return diagnostics.""" + return { + "intervals": self.intervals, + "sources": self.sources, + "timings": self._timings, + } + + @callback + def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: + """Collect timings for the tracker. + + For performance reasons, it is the responsibility of the + caller to check if the device already has an interval set or + the source has changed before calling this function. + """ + address = service_info.address + self.sources[address] = service_info.source + timings = self._timings.setdefault(address, []) + timings.append(service_info.time) + if len(timings) != ADVERTISING_TIMES_NEEDED: + return + + max_time_between_advertisements = timings[1] - timings[0] + for i in range(2, len(timings)): + time_between_advertisements = timings[i] - timings[i - 1] + if time_between_advertisements > max_time_between_advertisements: + max_time_between_advertisements = time_between_advertisements + + # We now know the maximum time between advertisements + self.intervals[address] = max_time_between_advertisements + del self._timings[address] + + @callback + def async_remove_address(self, address: str) -> None: + """Remove the tracker.""" + self.intervals.pop(address, None) + self.sources.pop(address, None) + self._timings.pop(address, None) + + @callback + def async_remove_source(self, source: str) -> None: + """Remove the tracker.""" + for address, tracked_source in list(self.sources.items()): + if tracked_source == source: + self.async_remove_address(address) diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 4d4a096bb66..2ad05d80c7a 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -31,11 +31,17 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 START_TIMEOUT = 15 -MAX_DBUS_SETUP_SECONDS = 5 - -# Anything after 30s is considered stale, we have buffer -# for start timeouts and execution time -STALE_ADVERTISEMENT_SECONDS: Final = 30 + START_TIMEOUT + MAX_DBUS_SETUP_SECONDS +# The maximum time between advertisements for a device to be considered +# stale when the advertisement tracker cannot determine the interval. +# +# We have to set this quite high as we don't know +# when devices fall out of the ESPHome device (and other non-local scanners)'s +# stack like we do with BlueZ so its safer to assume its available +# since if it does go out of range and it is in range +# of another device the timeout is much shorter and it will +# switch over to using that adapter anyways. +# +FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 # We must recover before we hit the 180s mark diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index f0152f5ae5e..07330396bbd 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Iterable from datetime import datetime, timedelta import itertools import logging +import time from typing import TYPE_CHECKING, Any, Final from bleak.backends.scanner import AdvertisementDataCallback @@ -20,11 +21,12 @@ from homeassistant.core import ( from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_track_time_interval +from .advertisement_tracker import AdvertisementTracker from .const import ( ADAPTER_ADDRESS, ADAPTER_PASSIVE_SCAN, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, NO_RSSI_VALUE, - STALE_ADVERTISEMENT_SECONDS, UNAVAILABLE_TRACK_SECONDS, AdapterDetails, ) @@ -66,49 +68,11 @@ APPLE_START_BYTES_WANTED: Final = { RSSI_SWITCH_THRESHOLD = 6 +MONOTONIC_TIME: Final = time.monotonic + _LOGGER = logging.getLogger(__name__) -def _prefer_previous_adv( - old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak -) -> bool: - """Prefer previous advertisement if it is better.""" - if new.time - old.time > STALE_ADVERTISEMENT_SECONDS: - # If the old advertisement is stale, any new advertisement is preferred - if new.source != old.source: - _LOGGER.debug( - "%s (%s): Switching from %s[%s] to %s[%s] (time elapsed:%s > stale seconds:%s)", - new.advertisement.local_name, - new.device.address, - old.source, - old.connectable, - new.source, - new.connectable, - new.time - old.time, - STALE_ADVERTISEMENT_SECONDS, - ) - return False - if new.device.rssi - RSSI_SWITCH_THRESHOLD > (old.device.rssi or NO_RSSI_VALUE): - # If new advertisement is RSSI_SWITCH_THRESHOLD more, the new one is preferred - if new.source != old.source: - _LOGGER.debug( - "%s (%s): Switching from %s[%s] to %s[%s] (new rssi:%s - threshold:%s > old rssi:%s)", - new.advertisement.local_name, - new.device.address, - old.source, - old.connectable, - new.source, - new.connectable, - new.device.rssi, - RSSI_SWITCH_THRESHOLD, - old.device.rssi, - ) - return False - # If the source is the different, the old one is preferred because its - # not stale and its RSSI_SWITCH_THRESHOLD less than the new one - return old.source != new.source - - def _dispatch_bleak_callback( callback: AdvertisementDataCallback | None, filters: dict[str, set[str]], @@ -142,13 +106,17 @@ class BluetoothManager: """Init bluetooth manager.""" self.hass = hass self._integration_matcher = integration_matcher - self._cancel_unavailable_tracking: list[CALLBACK_TYPE] = [] + self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None + + self._advertisement_tracker = AdvertisementTracker() + self._unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] ] = {} self._connectable_unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] ] = {} + self._callback_index = BluetoothCallbackMatcherIndex() self._bleak_callbacks: list[ tuple[AdvertisementDataCallback, dict[str, set[str]]] @@ -190,6 +158,7 @@ class BluetoothManager: "history": [ service_info.as_dict() for service_info in self._history.values() ], + "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), } def _find_adapter_by_address(self, address: str) -> str | None: @@ -229,9 +198,8 @@ class BluetoothManager: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") if self._cancel_unavailable_tracking: - for cancel in self._cancel_unavailable_tracking: - cancel() - self._cancel_unavailable_tracking.clear() + self._cancel_unavailable_tracking() + self._cancel_unavailable_tracking = None uninstall_multiple_bleak_catcher() async def async_get_devices_by_address( @@ -274,18 +242,24 @@ class BluetoothManager: @hass_callback def async_setup_unavailable_tracking(self) -> None: """Set up the unavailable tracking.""" - self._async_setup_unavailable_tracking(True) - self._async_setup_unavailable_tracking(False) + self._cancel_unavailable_tracking = async_track_time_interval( + self.hass, + self._async_check_unavailable, + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + ) @hass_callback - def _async_setup_unavailable_tracking(self, connectable: bool) -> None: - """Set up the unavailable tracking.""" - unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) - history = self._get_history_by_type(connectable) + def _async_check_unavailable(self, now: datetime) -> None: + """Watch for unavailable devices and cleanup state history.""" + monotonic_now = MONOTONIC_TIME() + connectable_history = self._connectable_history + all_history = self._history + removed_addresses: set[str] = set() - @hass_callback - def _async_check_unavailable(now: datetime) -> None: - """Watch for unavailable devices.""" + for connectable in (True, False): + unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) + intervals = self._advertisement_tracker.intervals + history = connectable_history if connectable else all_history history_set = set(history) active_addresses = { device.address @@ -293,35 +267,79 @@ class BluetoothManager: } disappeared = history_set.difference(active_addresses) for address in disappeared: + # + # For non-connectable devices we also check the device has exceeded + # the advertising interval before we mark it as unavailable + # since it may have gone to sleep and since we do not need an active connection + # to it we can only determine its availability by the lack of advertisements + # + if not connectable and (advertising_interval := intervals.get(address)): + time_since_seen = monotonic_now - history[address].time + if time_since_seen <= advertising_interval: + continue + service_info = history.pop(address) + removed_addresses.add(address) + if not (callbacks := unavailable_callbacks.get(address)): continue + for callback in callbacks: try: callback(service_info) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in unavailable callback") - self._cancel_unavailable_tracking.append( - async_track_time_interval( - self.hass, - _async_check_unavailable, - timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + # If we removed the device from both the connectable history + # and all history then we can remove it from the advertisement tracker + for address in removed_addresses: + if address not in connectable_history and address not in all_history: + self._advertisement_tracker.async_remove_address(address) + + def _prefer_previous_adv_from_different_source( + self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak + ) -> bool: + """Prefer previous advertisement from a different source if it is better.""" + if new.time - old.time > ( + stale_seconds := self._advertisement_tracker.intervals.get( + new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS ) - ) + ): + # If the old advertisement is stale, any new advertisement is preferred + _LOGGER.debug( + "%s (%s): Switching from %s[%s] to %s[%s] (time elapsed:%s > stale seconds:%s)", + new.advertisement.local_name, + new.device.address, + old.source, + old.connectable, + new.source, + new.connectable, + new.time - old.time, + stale_seconds, + ) + return False + if new.device.rssi - RSSI_SWITCH_THRESHOLD > (old.device.rssi or NO_RSSI_VALUE): + # If new advertisement is RSSI_SWITCH_THRESHOLD more, the new one is preferred + _LOGGER.debug( + "%s (%s): Switching from %s[%s] to %s[%s] (new rssi:%s - threshold:%s > old rssi:%s)", + new.advertisement.local_name, + new.device.address, + old.source, + old.connectable, + new.source, + new.connectable, + new.device.rssi, + RSSI_SWITCH_THRESHOLD, + old.device.rssi, + ) + return False + return True @hass_callback def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: """Handle a new advertisement from any scanner. Callbacks from all the scanners arrive here. - - In the future we will only process callbacks if - - - The device is not in the history - - The RSSI is above a certain threshold better than - than the source from the history or the timestamp - in the history is older than 180s """ # Pre-filter noisy apple devices as they can account for 20-35% of the @@ -340,8 +358,14 @@ class BluetoothManager: connectable = service_info.connectable address = device.address all_history = self._connectable_history if connectable else self._history - old_service_info = all_history.get(address) - if old_service_info and _prefer_previous_adv(old_service_info, service_info): + source = service_info.source + if ( + (old_service_info := all_history.get(address)) + and source != old_service_info.source + and self._prefer_previous_adv_from_different_source( + old_service_info, service_info + ) + ): return self._history[address] = service_info @@ -350,6 +374,15 @@ class BluetoothManager: self._connectable_history[address] = service_info # Bleak callbacks must get a connectable device + # Track advertisement intervals to determine when we need to + # switch adapters or mark a device as unavailable + tracker = self._advertisement_tracker + if (last_source := tracker.sources.get(address)) and last_source != source: + # Source changed, remove the old address from the tracker + tracker.async_remove_address(address) + if address not in tracker.intervals: + tracker.async_collect(service_info) + # If the advertisement data is the same as the last time we saw it, we # don't need to do anything else. if old_service_info and not ( @@ -360,7 +393,6 @@ class BluetoothManager: ): return - source = service_info.source if connectable: # Bleak callbacks must get a connectable device for callback_filters in self._bleak_callbacks: @@ -515,6 +547,7 @@ class BluetoothManager: scanners = self._get_scanners_by_type(connectable) def _unregister_scanner() -> None: + self._advertisement_tracker.async_remove_source(scanner.source) scanners.remove(scanner) scanners.append(scanner) diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 9e93ea4d142..852ce4e47d3 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -20,7 +20,7 @@ from bleak.backends.scanner import ( ) from bleak_retry_connector import freshen_ble_device -from homeassistant.core import CALLBACK_TYPE, callback as hass_callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.helpers.frame import report from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo @@ -105,6 +105,11 @@ class _HaWrappedBleakBackend: class BaseHaScanner: """Base class for Ha Scanners.""" + def __init__(self, hass: HomeAssistant, source: str) -> None: + """Initialize the scanner.""" + self.hass = hass + self.source = source + @property @abstractmethod def discovered_devices(self) -> list[BLEDevice]: diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 9bc68059a7f..87c6a48380b 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -50,8 +50,6 @@ PASSIVE_SCANNER_ARGS = BlueZScannerArgs( _LOGGER = logging.getLogger(__name__) -MONOTONIC_TIME = time.monotonic - # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ "org.bluez.Error.Failed", @@ -130,7 +128,8 @@ class HaScanner(BaseHaScanner): address: str, ) -> None: """Init bluetooth discovery.""" - self.hass = hass + source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL + super().__init__(hass, source) self.mode = mode self.adapter = adapter self._start_stop_lock = asyncio.Lock() @@ -139,7 +138,6 @@ class HaScanner(BaseHaScanner): self._start_time = 0.0 self._callbacks: list[Callable[[BluetoothServiceInfoBleak], None]] = [] self.name = adapter_human_name(adapter, address) - self.source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL @property def discovered_devices(self) -> list[BLEDevice]: diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 36138192f8f..82a6bdfbece 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -11,19 +11,15 @@ from aioesphomeapi import BluetoothLEAdvertisement from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from homeassistant.components.bluetooth import BaseHaScanner, HaBluetoothConnector -from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + BaseHaScanner, + BluetoothServiceInfoBleak, + HaBluetoothConnector, +) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval -# We have to set this quite high as we don't know -# when devices fall out of the esphome device's stack -# like we do with BlueZ so its safer to assume its available -# since if it does go out of range and it is in range -# of another device the timeout is much shorter and it will -# switch over to using that adapter anyways. -ADV_STALE_TIME = 60 * 15 # seconds - TWO_CHAR = re.compile("..") @@ -39,11 +35,10 @@ class ESPHomeScanner(BaseHaScanner): connectable: bool, ) -> None: """Initialize the scanner.""" - self._hass = hass + super().__init__(hass, scanner_id) self._new_info_callback = new_info_callback self._discovered_devices: dict[str, BLEDevice] = {} self._discovered_device_timestamps: dict[str, float] = {} - self._source = scanner_id self._connector = connector self._connectable = connectable self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} @@ -54,7 +49,7 @@ class ESPHomeScanner(BaseHaScanner): def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" return async_track_time_interval( - self._hass, self._async_expire_devices, timedelta(seconds=30) + self.hass, self._async_expire_devices, timedelta(seconds=30) ) def _async_expire_devices(self, _datetime: datetime.datetime) -> None: @@ -63,7 +58,7 @@ class ESPHomeScanner(BaseHaScanner): expired = [ address for address, timestamp in self._discovered_device_timestamps.items() - if now - timestamp > ADV_STALE_TIME + if now - timestamp > FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS ] for address in expired: del self._discovered_devices[address] @@ -113,7 +108,7 @@ class ESPHomeScanner(BaseHaScanner): manufacturer_data=advertisement_data.manufacturer_data, service_data=advertisement_data.service_data, service_uuids=advertisement_data.service_uuids, - source=self._source, + source=self.source, device=device, advertisement=advertisement_data, connectable=self._connectable, diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py new file mode 100644 index 00000000000..6e9671cfe4e --- /dev/null +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -0,0 +1,405 @@ +"""Tests for the Bluetooth integration advertisement tracking.""" + +from datetime import timedelta +import time +from unittest.mock import patch + +from bleak.backends.scanner import AdvertisementData, BLEDevice + +from homeassistant.components.bluetooth import ( + async_register_scanner, + async_track_unavailable, +) +from homeassistant.components.bluetooth.advertisement_tracker import ( + ADVERTISING_TIMES_NEEDED, +) +from homeassistant.components.bluetooth.const import ( + SOURCE_LOCAL, + UNAVAILABLE_TRACK_SECONDS, +) +from homeassistant.components.bluetooth.models import BaseHaScanner +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +from . import inject_advertisement_with_time_and_source + +from tests.common import async_fire_time_changed + +ONE_HOUR_SECONDS = 3600 + + +async def test_advertisment_interval_shorter_than_adapter_stack_timeout( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test we can determine the advertisement interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ((ADVERTISING_TIMES_NEEDED - 1) * 2) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_change_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval with an adapter change.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + "original", + ) + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval that is not connectable not reaching the advertising interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a short advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "original", + ) + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, switchbot_device, switchbot_adv, start_monotonic_time + (i * 2), "new" + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + class FakeScanner(BaseHaScanner): + """Fake scanner.""" + + @property + def discovered_devices(self) -> list[BLEDevice]: + return [] + + scanner = FakeScanner(hass, "new") + cancel_scanner = async_register_scanner(hass, scanner, False) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + "original", + ) + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + cancel_scanner() + + # Now that the scanner is gone we should go back to the stack default timeout + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a increasing advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED, 2 * ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i**2), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + UNAVAILABLE_TRACK_SECONDS + 1 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + switchbot_device_unavailable_cancel() diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 1da071a76ab..7e2f15a984f 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -96,6 +96,11 @@ async def test_diagnostics( } }, "manager": { + "advertisement_tracker": { + "intervals": {}, + "sources": {}, + "timings": {}, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -198,6 +203,11 @@ async def test_diagnostics_macos( } }, "manager": { + "advertisement_tracker": { + "intervals": {}, + "sources": {"44:44:33:11:23:45": "local"}, + "timings": {"44:44:33:11:23:45": [ANY]}, + }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 2e311d9d97e..746f3004c30 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2595,7 +2595,7 @@ async def test_getting_the_scanner_returns_the_wrapped_instance(hass, enable_blu async def test_scanner_count_connectable(hass, enable_bluetooth): """Test getting the connectable scanner count.""" - scanner = models.BaseHaScanner() + scanner = models.BaseHaScanner(hass, "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() @@ -2603,7 +2603,7 @@ async def test_scanner_count_connectable(hass, enable_bluetooth): async def test_scanner_count(hass, enable_bluetooth): """Test getting the connectable and non-connectable scanner count.""" - scanner = models.BaseHaScanner() + scanner = models.BaseHaScanner(hass, "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index f3f3d1b3664..4e5ab24b80f 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -6,7 +6,9 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory from homeassistant.components import bluetooth -from homeassistant.components.bluetooth.manager import STALE_ADVERTISEMENT_SECONDS +from homeassistant.components.bluetooth.manager import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.setup import async_setup_component from . import ( @@ -227,7 +229,7 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth): hass, switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, - start_time_monotonic + STALE_ADVERTISEMENT_SECONDS + 1, + start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1, "hci1", ) diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index d126dcac301..e0e782dff84 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -204,7 +204,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab return switchbot_proxy_device_has_connection_slot return None - scanner = FakeScanner() + scanner = FakeScanner(hass, "esp32") cancel = manager.async_register_scanner(scanner, True) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot @@ -290,7 +290,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab return switchbot_proxy_device_has_connection_slot return None - scanner = FakeScanner() + scanner = FakeScanner(hass, "esp32") cancel = manager.async_register_scanner(scanner, True) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot