Automatically determine the advertising interval for bluetooth devices (#79669)

This commit is contained in:
J. Nick Koston 2022-10-14 08:39:18 -10:00 committed by GitHub
parent a68bd8df6f
commit 0c76e3a97e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 625 additions and 101 deletions

View File

@ -39,6 +39,7 @@ from .const import (
DATA_MANAGER, DATA_MANAGER,
DEFAULT_ADDRESS, DEFAULT_ADDRESS,
DOMAIN, DOMAIN,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SOURCE_LOCAL, SOURCE_LOCAL,
AdapterDetails, AdapterDetails,
) )
@ -81,6 +82,7 @@ __all__ = [
"BluetoothCallback", "BluetoothCallback",
"HaBluetoothConnector", "HaBluetoothConnector",
"SOURCE_LOCAL", "SOURCE_LOCAL",
"FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
] ]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@ -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)

View File

@ -31,11 +31,17 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
START_TIMEOUT = 15 START_TIMEOUT = 15
MAX_DBUS_SETUP_SECONDS = 5 # The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker cannot determine the interval.
# Anything after 30s is considered stale, we have buffer #
# for start timeouts and execution time # We have to set this quite high as we don't know
STALE_ADVERTISEMENT_SECONDS: Final = 30 + START_TIMEOUT + MAX_DBUS_SETUP_SECONDS # 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 # We must recover before we hit the 180s mark

View File

@ -6,6 +6,7 @@ from collections.abc import Callable, Iterable
from datetime import datetime, timedelta from datetime import datetime, timedelta
import itertools import itertools
import logging import logging
import time
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
from bleak.backends.scanner import AdvertisementDataCallback from bleak.backends.scanner import AdvertisementDataCallback
@ -20,11 +21,12 @@ from homeassistant.core import (
from homeassistant.helpers import discovery_flow from homeassistant.helpers import discovery_flow
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from .advertisement_tracker import AdvertisementTracker
from .const import ( from .const import (
ADAPTER_ADDRESS, ADAPTER_ADDRESS,
ADAPTER_PASSIVE_SCAN, ADAPTER_PASSIVE_SCAN,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
NO_RSSI_VALUE, NO_RSSI_VALUE,
STALE_ADVERTISEMENT_SECONDS,
UNAVAILABLE_TRACK_SECONDS, UNAVAILABLE_TRACK_SECONDS,
AdapterDetails, AdapterDetails,
) )
@ -66,49 +68,11 @@ APPLE_START_BYTES_WANTED: Final = {
RSSI_SWITCH_THRESHOLD = 6 RSSI_SWITCH_THRESHOLD = 6
MONOTONIC_TIME: Final = time.monotonic
_LOGGER = logging.getLogger(__name__) _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( def _dispatch_bleak_callback(
callback: AdvertisementDataCallback | None, callback: AdvertisementDataCallback | None,
filters: dict[str, set[str]], filters: dict[str, set[str]],
@ -142,13 +106,17 @@ class BluetoothManager:
"""Init bluetooth manager.""" """Init bluetooth manager."""
self.hass = hass self.hass = hass
self._integration_matcher = integration_matcher 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[ self._unavailable_callbacks: dict[
str, list[Callable[[BluetoothServiceInfoBleak], None]] str, list[Callable[[BluetoothServiceInfoBleak], None]]
] = {} ] = {}
self._connectable_unavailable_callbacks: dict[ self._connectable_unavailable_callbacks: dict[
str, list[Callable[[BluetoothServiceInfoBleak], None]] str, list[Callable[[BluetoothServiceInfoBleak], None]]
] = {} ] = {}
self._callback_index = BluetoothCallbackMatcherIndex() self._callback_index = BluetoothCallbackMatcherIndex()
self._bleak_callbacks: list[ self._bleak_callbacks: list[
tuple[AdvertisementDataCallback, dict[str, set[str]]] tuple[AdvertisementDataCallback, dict[str, set[str]]]
@ -190,6 +158,7 @@ class BluetoothManager:
"history": [ "history": [
service_info.as_dict() for service_info in self._history.values() 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: def _find_adapter_by_address(self, address: str) -> str | None:
@ -229,9 +198,8 @@ class BluetoothManager:
"""Stop the Bluetooth integration at shutdown.""" """Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager") _LOGGER.debug("Stopping bluetooth manager")
if self._cancel_unavailable_tracking: if self._cancel_unavailable_tracking:
for cancel in self._cancel_unavailable_tracking: self._cancel_unavailable_tracking()
cancel() self._cancel_unavailable_tracking = None
self._cancel_unavailable_tracking.clear()
uninstall_multiple_bleak_catcher() uninstall_multiple_bleak_catcher()
async def async_get_devices_by_address( async def async_get_devices_by_address(
@ -274,18 +242,24 @@ class BluetoothManager:
@hass_callback @hass_callback
def async_setup_unavailable_tracking(self) -> None: def async_setup_unavailable_tracking(self) -> None:
"""Set up the unavailable tracking.""" """Set up the unavailable tracking."""
self._async_setup_unavailable_tracking(True) self._cancel_unavailable_tracking = async_track_time_interval(
self._async_setup_unavailable_tracking(False) self.hass,
self._async_check_unavailable,
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
)
@hass_callback @hass_callback
def _async_setup_unavailable_tracking(self, connectable: bool) -> None: def _async_check_unavailable(self, now: datetime) -> None:
"""Set up the unavailable tracking.""" """Watch for unavailable devices and cleanup state history."""
unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) monotonic_now = MONOTONIC_TIME()
history = self._get_history_by_type(connectable) connectable_history = self._connectable_history
all_history = self._history
removed_addresses: set[str] = set()
@hass_callback for connectable in (True, False):
def _async_check_unavailable(now: datetime) -> None: unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable)
"""Watch for unavailable devices.""" intervals = self._advertisement_tracker.intervals
history = connectable_history if connectable else all_history
history_set = set(history) history_set = set(history)
active_addresses = { active_addresses = {
device.address device.address
@ -293,35 +267,79 @@ class BluetoothManager:
} }
disappeared = history_set.difference(active_addresses) disappeared = history_set.difference(active_addresses)
for address in disappeared: 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) service_info = history.pop(address)
removed_addresses.add(address)
if not (callbacks := unavailable_callbacks.get(address)): if not (callbacks := unavailable_callbacks.get(address)):
continue continue
for callback in callbacks: for callback in callbacks:
try: try:
callback(service_info) callback(service_info)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in unavailable callback") _LOGGER.exception("Error in unavailable callback")
self._cancel_unavailable_tracking.append( # If we removed the device from both the connectable history
async_track_time_interval( # and all history then we can remove it from the advertisement tracker
self.hass, for address in removed_addresses:
_async_check_unavailable, if address not in connectable_history and address not in all_history:
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), 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 @hass_callback
def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None:
"""Handle a new advertisement from any scanner. """Handle a new advertisement from any scanner.
Callbacks from all the scanners arrive here. 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 # Pre-filter noisy apple devices as they can account for 20-35% of the
@ -340,8 +358,14 @@ class BluetoothManager:
connectable = service_info.connectable connectable = service_info.connectable
address = device.address address = device.address
all_history = self._connectable_history if connectable else self._history all_history = self._connectable_history if connectable else self._history
old_service_info = all_history.get(address) source = service_info.source
if old_service_info and _prefer_previous_adv(old_service_info, service_info): 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 return
self._history[address] = service_info self._history[address] = service_info
@ -350,6 +374,15 @@ class BluetoothManager:
self._connectable_history[address] = service_info self._connectable_history[address] = service_info
# Bleak callbacks must get a connectable device # 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 # If the advertisement data is the same as the last time we saw it, we
# don't need to do anything else. # don't need to do anything else.
if old_service_info and not ( if old_service_info and not (
@ -360,7 +393,6 @@ class BluetoothManager:
): ):
return return
source = service_info.source
if connectable: if connectable:
# Bleak callbacks must get a connectable device # Bleak callbacks must get a connectable device
for callback_filters in self._bleak_callbacks: for callback_filters in self._bleak_callbacks:
@ -515,6 +547,7 @@ class BluetoothManager:
scanners = self._get_scanners_by_type(connectable) scanners = self._get_scanners_by_type(connectable)
def _unregister_scanner() -> None: def _unregister_scanner() -> None:
self._advertisement_tracker.async_remove_source(scanner.source)
scanners.remove(scanner) scanners.remove(scanner)
scanners.append(scanner) scanners.append(scanner)

View File

@ -20,7 +20,7 @@ from bleak.backends.scanner import (
) )
from bleak_retry_connector import freshen_ble_device 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.frame import report
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
@ -105,6 +105,11 @@ class _HaWrappedBleakBackend:
class BaseHaScanner: class BaseHaScanner:
"""Base class for Ha Scanners.""" """Base class for Ha Scanners."""
def __init__(self, hass: HomeAssistant, source: str) -> None:
"""Initialize the scanner."""
self.hass = hass
self.source = source
@property @property
@abstractmethod @abstractmethod
def discovered_devices(self) -> list[BLEDevice]: def discovered_devices(self) -> list[BLEDevice]:

View File

@ -50,8 +50,6 @@ PASSIVE_SCANNER_ARGS = BlueZScannerArgs(
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MONOTONIC_TIME = time.monotonic
# If the adapter is in a stuck state the following errors are raised: # If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [ NEED_RESET_ERRORS = [
"org.bluez.Error.Failed", "org.bluez.Error.Failed",
@ -130,7 +128,8 @@ class HaScanner(BaseHaScanner):
address: str, address: str,
) -> None: ) -> None:
"""Init bluetooth discovery.""" """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.mode = mode
self.adapter = adapter self.adapter = adapter
self._start_stop_lock = asyncio.Lock() self._start_stop_lock = asyncio.Lock()
@ -139,7 +138,6 @@ class HaScanner(BaseHaScanner):
self._start_time = 0.0 self._start_time = 0.0
self._callbacks: list[Callable[[BluetoothServiceInfoBleak], None]] = [] self._callbacks: list[Callable[[BluetoothServiceInfoBleak], None]] = []
self.name = adapter_human_name(adapter, address) self.name = adapter_human_name(adapter, address)
self.source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL
@property @property
def discovered_devices(self) -> list[BLEDevice]: def discovered_devices(self) -> list[BLEDevice]:

View File

@ -11,19 +11,15 @@ from aioesphomeapi import BluetoothLEAdvertisement
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth import BaseHaScanner, HaBluetoothConnector from homeassistant.components.bluetooth import (
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
BaseHaScanner,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_interval 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("..") TWO_CHAR = re.compile("..")
@ -39,11 +35,10 @@ class ESPHomeScanner(BaseHaScanner):
connectable: bool, connectable: bool,
) -> None: ) -> None:
"""Initialize the scanner.""" """Initialize the scanner."""
self._hass = hass super().__init__(hass, scanner_id)
self._new_info_callback = new_info_callback self._new_info_callback = new_info_callback
self._discovered_devices: dict[str, BLEDevice] = {} self._discovered_devices: dict[str, BLEDevice] = {}
self._discovered_device_timestamps: dict[str, float] = {} self._discovered_device_timestamps: dict[str, float] = {}
self._source = scanner_id
self._connector = connector self._connector = connector
self._connectable = connectable self._connectable = connectable
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
@ -54,7 +49,7 @@ class ESPHomeScanner(BaseHaScanner):
def async_setup(self) -> CALLBACK_TYPE: def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner.""" """Set up the scanner."""
return async_track_time_interval( 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: def _async_expire_devices(self, _datetime: datetime.datetime) -> None:
@ -63,7 +58,7 @@ class ESPHomeScanner(BaseHaScanner):
expired = [ expired = [
address address
for address, timestamp in self._discovered_device_timestamps.items() 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: for address in expired:
del self._discovered_devices[address] del self._discovered_devices[address]
@ -113,7 +108,7 @@ class ESPHomeScanner(BaseHaScanner):
manufacturer_data=advertisement_data.manufacturer_data, manufacturer_data=advertisement_data.manufacturer_data,
service_data=advertisement_data.service_data, service_data=advertisement_data.service_data,
service_uuids=advertisement_data.service_uuids, service_uuids=advertisement_data.service_uuids,
source=self._source, source=self.source,
device=device, device=device,
advertisement=advertisement_data, advertisement=advertisement_data,
connectable=self._connectable, connectable=self._connectable,

View File

@ -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()

View File

@ -96,6 +96,11 @@ async def test_diagnostics(
} }
}, },
"manager": { "manager": {
"advertisement_tracker": {
"intervals": {},
"sources": {},
"timings": {},
},
"adapters": { "adapters": {
"hci0": { "hci0": {
"address": "00:00:00:00:00:01", "address": "00:00:00:00:00:01",
@ -198,6 +203,11 @@ async def test_diagnostics_macos(
} }
}, },
"manager": { "manager": {
"advertisement_tracker": {
"intervals": {},
"sources": {"44:44:33:11:23:45": "local"},
"timings": {"44:44:33:11:23:45": [ANY]},
},
"adapters": { "adapters": {
"Core Bluetooth": { "Core Bluetooth": {
"address": "00:00:00:00:00:00", "address": "00:00:00:00:00:00",

View File

@ -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): async def test_scanner_count_connectable(hass, enable_bluetooth):
"""Test getting the connectable scanner count.""" """Test getting the connectable scanner count."""
scanner = models.BaseHaScanner() scanner = models.BaseHaScanner(hass, "any")
cancel = bluetooth.async_register_scanner(hass, scanner, False) cancel = bluetooth.async_register_scanner(hass, scanner, False)
assert bluetooth.async_scanner_count(hass, connectable=True) == 1 assert bluetooth.async_scanner_count(hass, connectable=True) == 1
cancel() cancel()
@ -2603,7 +2603,7 @@ async def test_scanner_count_connectable(hass, enable_bluetooth):
async def test_scanner_count(hass, enable_bluetooth): async def test_scanner_count(hass, enable_bluetooth):
"""Test getting the connectable and non-connectable scanner count.""" """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) cancel = bluetooth.async_register_scanner(hass, scanner, False)
assert bluetooth.async_scanner_count(hass, connectable=False) == 2 assert bluetooth.async_scanner_count(hass, connectable=False) == 2
cancel() cancel()

View File

@ -6,7 +6,9 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice
from bluetooth_adapters import AdvertisementHistory from bluetooth_adapters import AdvertisementHistory
from homeassistant.components import bluetooth 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 homeassistant.setup import async_setup_component
from . import ( from . import (
@ -227,7 +229,7 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth):
hass, hass,
switchbot_device_poor_signal_hci1, switchbot_device_poor_signal_hci1,
switchbot_adv_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", "hci1",
) )

View File

@ -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 switchbot_proxy_device_has_connection_slot
return None return None
scanner = FakeScanner() scanner = FakeScanner(hass, "esp32")
cancel = manager.async_register_scanner(scanner, True) cancel = manager.async_register_scanner(scanner, True)
assert manager.async_discovered_devices(True) == [ assert manager.async_discovered_devices(True) == [
switchbot_proxy_device_no_connection_slot 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 switchbot_proxy_device_has_connection_slot
return None return None
scanner = FakeScanner() scanner = FakeScanner(hass, "esp32")
cancel = manager.async_register_scanner(scanner, True) cancel = manager.async_register_scanner(scanner, True)
assert manager.async_discovered_devices(True) == [ assert manager.async_discovered_devices(True) == [
switchbot_proxy_device_no_connection_slot switchbot_proxy_device_no_connection_slot