"""The advertisement tracker.""" from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .models import BluetoothServiceInfoBleak ADVERTISING_TIMES_NEEDED = 16 _ADVERTISING_TIMES_NEEDED = ADVERTISING_TIMES_NEEDED # Each scanner may buffer incoming packets so # we need to give a bit of leeway before we # mark a device unavailable TRACKER_BUFFERING_WOBBLE_SECONDS = 5 _str = str class AdvertisementTracker: """Tracker to determine the interval that a device is advertising.""" __slots__ = ("_timings", "fallback_intervals", "intervals", "sources") def __init__(self) -> None: """Initialize the tracker.""" self.intervals: dict[str, float] = {} self.fallback_intervals: dict[str, float] = {} self.sources: dict[str, str] = {} self._timings: dict[str, list[float]] = {} def async_diagnostics(self) -> dict[str, dict[str, Any]]: """Return diagnostics.""" return { "intervals": self.intervals, "fallback_intervals": self.fallback_intervals, "sources": self.sources, "timings": self._timings, } 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. """ self.sources[service_info.address] = service_info.source if not (timings := self._timings.get(service_info.address)): self._timings[service_info.address] = [service_info.time] return 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[service_info.address] = max_time_between_advertisements del self._timings[service_info.address] 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) def async_remove_fallback_interval(self, address: str) -> None: """Remove fallback interval.""" self.fallback_intervals.pop(address, None) 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) def async_scanner_paused(self, source: str) -> None: """ Clear timing collection data when scanner is paused. When a scanner pauses to establish a connection, it stops listening for advertisements. If we don't clear the timing data, the next advertisement after the connection attempt will create an incorrectly large interval measurement (time_after_connection - time_before_connection) which doesn't represent the actual advertising interval of the device. """ # Only iterate through timing data (typically much smaller than sources) for address in list(self._timings): if self.sources.get(address) == source: del self._timings[address]