mirror of
https://github.com/home-assistant/core.git
synced 2025-07-17 10:17:09 +00:00
Automatically determine the advertising interval for bluetooth devices (#79669)
This commit is contained in:
parent
a68bd8df6f
commit
0c76e3a97e
@ -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__)
|
||||||
|
68
homeassistant/components/bluetooth/advertisement_tracker.py
Normal file
68
homeassistant/components/bluetooth/advertisement_tracker.py
Normal 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)
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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]:
|
||||||
|
@ -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]:
|
||||||
|
@ -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,
|
||||||
|
405
tests/components/bluetooth/test_advertisement_tracker.py
Normal file
405
tests/components/bluetooth/test_advertisement_tracker.py
Normal 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()
|
@ -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",
|
||||||
|
@ -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()
|
||||||
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user