diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 2e0e62440ab..c59249e8bd5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -45,6 +45,8 @@ from .api import ( async_ble_device_from_address, async_discovered_service_info, async_get_advertisement_callback, + async_get_fallback_availability_interval, + async_get_learned_advertising_interval, async_get_scanner, async_last_service_info, async_process_advertisements, @@ -54,6 +56,7 @@ from .api import ( async_scanner_by_source, async_scanner_count, async_scanner_devices_by_address, + async_set_fallback_availability_interval, async_track_unavailable, ) from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice @@ -86,12 +89,15 @@ __all__ = [ "async_address_present", "async_ble_device_from_address", "async_discovered_service_info", + "async_get_fallback_availability_interval", + "async_get_learned_advertising_interval", "async_get_scanner", "async_last_service_info", "async_process_advertisements", "async_rediscover_address", "async_register_callback", "async_register_scanner", + "async_set_fallback_availability_interval", "async_track_unavailable", "async_scanner_by_source", "async_scanner_count", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index e364fd08e88..9d24428e3d2 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -197,3 +197,27 @@ def async_get_advertisement_callback( ) -> Callable[[BluetoothServiceInfoBleak], None]: """Get the advertisement callback.""" return _get_manager(hass).scanner_adv_received + + +@hass_callback +def async_get_learned_advertising_interval( + hass: HomeAssistant, address: str +) -> float | None: + """Get the learned advertising interval for a MAC address.""" + return _get_manager(hass).async_get_learned_advertising_interval(address) + + +@hass_callback +def async_get_fallback_availability_interval( + hass: HomeAssistant, address: str +) -> float | None: + """Get the fallback availability timeout for a MAC address.""" + return _get_manager(hass).async_get_fallback_availability_interval(address) + + +@hass_callback +def async_set_fallback_availability_interval( + hass: HomeAssistant, address: str, interval: float +) -> None: + """Override the fallback availability timeout for a MAC address.""" + _get_manager(hass).async_set_fallback_availability_interval(address, interval) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index bd91c622316..80fbe2d49a5 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -108,6 +108,7 @@ class BluetoothManager: "_cancel_unavailable_tracking", "_cancel_logging_listener", "_advertisement_tracker", + "_fallback_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", "_callback_index", @@ -139,6 +140,7 @@ class BluetoothManager: self._cancel_logging_listener: CALLBACK_TYPE | None = None self._advertisement_tracker = AdvertisementTracker() + self._fallback_intervals: dict[str, float] = {} self._unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] @@ -342,7 +344,9 @@ class BluetoothManager: # 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 advertising_interval := intervals.get(address): + if advertising_interval := ( + intervals.get(address) or self._fallback_intervals.get(address) + ): advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS else: advertising_interval = ( @@ -355,6 +359,7 @@ class BluetoothManager: # The second loop (connectable=False) is responsible for removing # the device from all the interval tracking since it is no longer # available for both connectable and non-connectable + self._fallback_intervals.pop(address, None) tracker.async_remove_address(address) self._integration_matcher.async_clear_address(address) self._async_dismiss_discoveries(address) @@ -386,7 +391,10 @@ class BluetoothManager: """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 + new.address, + self._fallback_intervals.get( + new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ), ) ): # If the old advertisement is stale, any new advertisement is preferred @@ -779,3 +787,20 @@ class BluetoothManager: def async_allocate_connection_slot(self, device: BLEDevice) -> bool: """Allocate a connection slot.""" return self.slot_manager.allocate_slot(device) + + @hass_callback + def async_get_learned_advertising_interval(self, address: str) -> float | None: + """Get the learned advertising interval for a MAC address.""" + return self._advertisement_tracker.intervals.get(address) + + @hass_callback + def async_get_fallback_availability_interval(self, address: str) -> float | None: + """Get the fallback availability timeout for a MAC address.""" + return self._fallback_intervals.get(address) + + @hass_callback + def async_set_fallback_availability_interval( + self, address: str, interval: float + ) -> None: + """Override the fallback availability timeout for a MAC address.""" + self._fallback_intervals[address] = interval diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 5a2c55259bb..f04ea2873f0 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components.bluetooth import ( + async_get_learned_advertising_interval, async_register_scanner, async_track_unavailable, ) @@ -62,6 +63,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout( SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:12" + ) == pytest.approx(2.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -109,6 +114,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectab SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:18" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -158,6 +167,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c "original", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(2.0) + for i in range(ADVERTISING_TIMES_NEEDED): inject_advertisement_with_time_and_source( hass, @@ -167,6 +180,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -216,6 +233,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_conne SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -270,6 +291,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ "original", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:5C" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_adv_better_rssi = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -284,6 +309,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:5C" + ) == pytest.approx(2.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -342,6 +371,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c connectable=False, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(2.0) + switchbot_better_rssi_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -357,6 +390,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c connectable=False, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -437,6 +474,10 @@ async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeou "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(61.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index f637ee3a27a..63091b18843 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -20,11 +20,17 @@ from homeassistant.components.bluetooth import ( HaBluetoothConnector, async_ble_device_from_address, async_get_advertisement_callback, + async_get_fallback_availability_interval, + async_get_learned_advertising_interval, async_scanner_count, + async_set_fallback_availability_interval, async_track_unavailable, storage, ) -from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS +from homeassistant.components.bluetooth.const import ( + SOURCE_LOCAL, + UNAVAILABLE_TRACK_SECONDS, +) from homeassistant.components.bluetooth.manager import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) @@ -1053,3 +1059,142 @@ async def test_debug_logging( "hci0", ) assert "wohand_good_signal_hci0" not in caplog.text + + +async def test_set_fallback_interval_small( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_bluetooth: None, + macos_adapter: None, +) -> None: + """Test we can set the fallback advertisement interval.""" + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + async_set_fallback_availability_interval(hass, "44:44:33:11:23:12", 2.0) + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") == 2.0 + + start_monotonic_time = time.monotonic() + switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time, + SOURCE_LOCAL, + ) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + assert async_get_learned_advertising_interval(hass, "44:44:33:11:23:12") is None + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + 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() + + # We should forget fallback interval after it expires + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + +async def test_set_fallback_interval_big( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_bluetooth: None, + macos_adapter: None, +) -> None: + """Test we can set the fallback advertisement interval.""" + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + # Force the interval to be really big and check it doesn't expire using the default timeout (900) + + async_set_fallback_availability_interval(hass, "44:44:33:11:23:12", 604800.0) + assert ( + async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") == 604800.0 + ) + + start_monotonic_time = time.monotonic() + switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time, + SOURCE_LOCAL, + ) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + assert async_get_learned_advertising_interval(hass, "44:44:33:11:23:12") is None + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + # Check that device hasn't expired after a day + + monotonic_now = start_monotonic_time + 86400 + 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 + + # Try again after it has expired + + monotonic_now = start_monotonic_time + 604800 + 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() + + # We should forget fallback interval after it expires + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None