diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py index f4577496e04..3936435f84e 100644 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ b/homeassistant/components/bluetooth/advertisement_tracker.py @@ -9,6 +9,11 @@ from .models import BluetoothServiceInfoBleak ADVERTISING_TIMES_NEEDED = 16 +# 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 + class AdvertisementTracker: """Tracker to determine the interval that a device is advertising.""" diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index d9fcc750ed4..00cc9fff0fe 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -30,7 +30,6 @@ from homeassistant.util.dt import monotonic_time_coarse from . import models from .const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) @@ -207,13 +206,11 @@ class BaseHaRemoteScanner(BaseHaScanner): self._discovered_device_timestamps: dict[str, float] = {} self.connectable = connectable self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} - self._expire_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + # Scanners only care about connectable devices. The manager + # will handle taking care of availability for non-connectable devices + self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS assert models.MANAGER is not None self._storage = models.MANAGER.storage - if connectable: - self._expire_seconds = ( - CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - ) @hass_callback def async_setup(self) -> CALLBACK_TYPE: diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 91d658cdf58..a8b890116d5 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -28,7 +28,10 @@ from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.dt import monotonic_time_coarse -from .advertisement_tracker import AdvertisementTracker +from .advertisement_tracker import ( + TRACKER_BUFFERING_WOBBLE_SECONDS, + AdvertisementTracker, +) from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -291,9 +294,14 @@ class BluetoothManager: # connection to it we can only determine its availability # by the lack of advertisements if advertising_interval := intervals.get(address): - time_since_seen = monotonic_now - all_history[address].time - if time_since_seen <= advertising_interval: - continue + advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS + else: + advertising_interval = ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ) + time_since_seen = monotonic_now - all_history[address].time + if time_since_seen <= advertising_interval: + continue # The second loop (connectable=False) is responsible for removing # the device from all the interval tracking since it is no longer diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index d4255a8cc91..6e94e58cf1c 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -14,6 +14,7 @@ from homeassistant.components.bluetooth.advertisement_tracker import ( ADVERTISING_TIMES_NEEDED, ) from homeassistant.components.bluetooth.const import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) @@ -370,7 +371,21 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) await hass.async_block_till_done() - assert switchbot_device_went_unavailable is True + assert switchbot_device_went_unavailable is False + + # 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=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_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_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 53c2716b0bd..935b35b5863 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -14,10 +14,15 @@ from homeassistant.components.bluetooth import ( HaBluetoothConnector, storage, ) +from homeassistant.components.bluetooth.advertisement_tracker import ( + TRACKER_BUFFERING_WOBBLE_SECONDS, +) from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.core import callback from homeassistant.helpers.json import json_loads from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -239,7 +244,9 @@ async def test_remote_scanner_expires_non_connectable(hass, enable_bluetooth): > CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS ) - # The connectable timeout is not used for non connectable devices + # The connectable timeout is used for all devices + # as the manager takes care of availability and the scanner + # if only concerned about making a connection expire_monotonic = ( start_time_monotonic + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS @@ -255,11 +262,9 @@ async def test_remote_scanner_expires_non_connectable(hass, enable_bluetooth): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() - assert len(scanner.discovered_devices) == 1 - assert len(scanner.discovered_devices_and_advertisement_data) == 1 + assert len(scanner.discovered_devices) == 0 + assert len(scanner.discovered_devices_and_advertisement_data) == 0 - # The non connectable timeout is used for non connectable devices - # which is always longer than the connectable timeout expire_monotonic = ( start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) @@ -394,3 +399,119 @@ async def test_restore_history_remote_adapter(hass, hass_storage): cancel() unsetup() + + +async def test_device_with_ten_minute_advertising_interval( + hass, caplog, enable_bluetooth +): + """Test a device with a 10 minute advertising interval.""" + manager = _get_manager() + + bparasite_device = BLEDevice( + "44:44:33:11:23:45", + "bparasite", + {}, + rssi=-100, + ) + bparasite_device_adv = generate_advertisement_data( + local_name="bparasite", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + class FakeScanner(BaseHaRemoteScanner): + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + ) + + new_info_callback = manager.scanner_adv_received + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + unsetup = scanner.async_setup() + cancel = manager.async_register_scanner(scanner, True) + + monotonic_now = time.monotonic() + new_time = monotonic_now + bparasite_device_went_unavailable = False + + @callback + def _bparasite_device_unavailable_callback(_address: str) -> None: + """Barasite device unavailable callback.""" + nonlocal bparasite_device_went_unavailable + bparasite_device_went_unavailable = True + + advertising_interval = 60 * 10 + + bparasite_device_unavailable_cancel = bluetooth.async_track_unavailable( + hass, + _bparasite_device_unavailable_callback, + bparasite_device.address, + connectable=False, + ) + + for _ in range(0, 20): + new_time += advertising_interval + with patch( + "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + return_value=new_time, + ): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + + future_time = new_time + assert ( + bluetooth.async_address_present(hass, bparasite_device.address, False) is True + ) + assert bparasite_device_went_unavailable is False + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=new_time, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=future_time)) + await hass.async_block_till_done() + + assert bparasite_device_went_unavailable is False + + missed_advertisement_future_time = ( + future_time + advertising_interval + TRACKER_BUFFERING_WOBBLE_SECONDS + 1 + ) + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=missed_advertisement_future_time, + ), patch( + "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + return_value=missed_advertisement_future_time, + ): + # Fire once for the scanner to expire the device + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + # Fire again for the manager to expire the device + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=missed_advertisement_future_time) + ) + await hass.async_block_till_done() + + assert ( + bluetooth.async_address_present(hass, bparasite_device.address, False) is False + ) + assert bparasite_device_went_unavailable is True + bparasite_device_unavailable_cancel() + + cancel() + unsetup() diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index fb80bb7cec4..e88d60a669f 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -3,15 +3,16 @@ from __future__ import annotations from datetime import timedelta import logging +import time from typing import Any from unittest.mock import MagicMock, patch from homeassistant.components.bluetooth import ( DOMAIN, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, BluetoothChange, BluetoothScanningMode, ) -from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, PassiveBluetoothDataUpdateCoordinator, @@ -126,6 +127,7 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( hass, mock_bleak_scanner_start, mock_bluetooth_adapters ): """Test that the coordinator goes unavailable when the bluetooth stack no longer sees the device.""" + start_monotonic = time.monotonic() with patch( "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, @@ -146,9 +148,16 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) assert coordinator.available is True - with patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), ) await hass.async_block_till_done() assert coordinator.available is False @@ -156,9 +165,16 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) assert coordinator.available is True - with patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2), ) await hass.async_block_till_done() assert coordinator.available is False diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index e72efd565de..a594267da96 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.bluetooth import ( DOMAIN, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak, @@ -200,6 +201,8 @@ async def test_unavailable_after_no_data( hass, mock_bleak_scanner_start, mock_bluetooth_adapters ): """Test that the coordinator is unavailable after no data for a while.""" + start_monotonic = time.monotonic() + with patch( "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, @@ -265,7 +268,12 @@ async def test_unavailable_after_no_data( assert len(mock_add_entities.mock_calls) == 1 assert coordinator.available is True assert processor.available is True - with patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) ) @@ -279,7 +287,12 @@ async def test_unavailable_after_no_data( assert coordinator.available is True assert processor.available is True - with patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) )