Expose bluetooth availability tracking interval controls to integrations (#100774)
parent
eb020dd66c
commit
f0375eb97e
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue