Expose bluetooth availability tracking interval controls to integrations (#100774)

pull/100794/head
Jc2k 2023-09-24 09:45:25 +01:00 committed by GitHub
parent eb020dd66c
commit f0375eb97e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 244 additions and 3 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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