Use fallback advertising interval for non-connectable Bluetooth devices (#85701)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Felix T 2023-01-11 23:11:25 +01:00 committed by GitHub
parent c8cd41b5d4
commit 42a4dd98f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 198 additions and 23 deletions

View file

@ -9,6 +9,11 @@ from .models import BluetoothServiceInfoBleak
ADVERTISING_TIMES_NEEDED = 16 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: class AdvertisementTracker:
"""Tracker to determine the interval that a device is advertising.""" """Tracker to determine the interval that a device is advertising."""

View file

@ -30,7 +30,6 @@ from homeassistant.util.dt import monotonic_time_coarse
from . import models from . import models
from .const import ( from .const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT, SCANNER_WATCHDOG_TIMEOUT,
) )
@ -207,13 +206,11 @@ class BaseHaRemoteScanner(BaseHaScanner):
self._discovered_device_timestamps: dict[str, float] = {} self._discovered_device_timestamps: dict[str, float] = {}
self.connectable = connectable self.connectable = connectable
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} 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 assert models.MANAGER is not None
self._storage = models.MANAGER.storage self._storage = models.MANAGER.storage
if connectable:
self._expire_seconds = (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
)
@hass_callback @hass_callback
def async_setup(self) -> CALLBACK_TYPE: def async_setup(self) -> CALLBACK_TYPE:

View file

@ -28,7 +28,10 @@ from homeassistant.helpers import discovery_flow
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.dt import monotonic_time_coarse 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 .base_scanner import BaseHaScanner, BluetoothScannerDevice
from .const import ( from .const import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
@ -291,9 +294,14 @@ class BluetoothManager:
# connection to it we can only determine its availability # connection to it we can only determine its availability
# by the lack of advertisements # by the lack of advertisements
if advertising_interval := intervals.get(address): if advertising_interval := intervals.get(address):
time_since_seen = monotonic_now - all_history[address].time advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS
if time_since_seen <= advertising_interval: else:
continue 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 second loop (connectable=False) is responsible for removing
# the device from all the interval tracking since it is no longer # the device from all the interval tracking since it is no longer

View file

@ -14,6 +14,7 @@ from homeassistant.components.bluetooth.advertisement_tracker import (
ADVERTISING_TIMES_NEEDED, ADVERTISING_TIMES_NEEDED,
) )
from homeassistant.components.bluetooth.const import ( from homeassistant.components.bluetooth.const import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SOURCE_LOCAL, SOURCE_LOCAL,
UNAVAILABLE_TRACK_SECONDS, 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() 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() switchbot_device_unavailable_cancel()

View file

@ -14,10 +14,15 @@ from homeassistant.components.bluetooth import (
HaBluetoothConnector, HaBluetoothConnector,
storage, storage,
) )
from homeassistant.components.bluetooth.advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
)
from homeassistant.components.bluetooth.const import ( from homeassistant.components.bluetooth.const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
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.helpers.json import json_loads
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util 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 > 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 = ( expire_monotonic = (
start_time_monotonic start_time_monotonic
+ CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 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) async_fire_time_changed(hass, expire_utc)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(scanner.discovered_devices) == 1 assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 1 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 = ( expire_monotonic = (
start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
) )
@ -394,3 +399,119 @@ async def test_restore_history_remote_adapter(hass, hass_storage):
cancel() cancel()
unsetup() 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()

View file

@ -3,15 +3,16 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
import time
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
DOMAIN, DOMAIN,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
BluetoothChange, BluetoothChange,
BluetoothScanningMode, BluetoothScanningMode,
) )
from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity, PassiveBluetoothCoordinatorEntity,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
@ -126,6 +127,7 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters hass, mock_bleak_scanner_start, mock_bluetooth_adapters
): ):
"""Test that the coordinator goes unavailable when the bluetooth stack no longer sees the device.""" """Test that the coordinator goes unavailable when the bluetooth stack no longer sees the device."""
start_monotonic = time.monotonic()
with patch( with patch(
"bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup "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())}, {"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) inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
assert coordinator.available is True 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( 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() await hass.async_block_till_done()
assert coordinator.available is False 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) inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
assert coordinator.available is True 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( 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() await hass.async_block_till_done()
assert coordinator.available is False assert coordinator.available is False

View file

@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import (
) )
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
DOMAIN, DOMAIN,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
BluetoothChange, BluetoothChange,
BluetoothScanningMode, BluetoothScanningMode,
BluetoothServiceInfoBleak, BluetoothServiceInfoBleak,
@ -200,6 +201,8 @@ async def test_unavailable_after_no_data(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters hass, mock_bleak_scanner_start, mock_bluetooth_adapters
): ):
"""Test that the coordinator is unavailable after no data for a while.""" """Test that the coordinator is unavailable after no data for a while."""
start_monotonic = time.monotonic()
with patch( with patch(
"bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup "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())}, {"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 len(mock_add_entities.mock_calls) == 1
assert coordinator.available is True assert coordinator.available is True
assert processor.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( async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) 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 coordinator.available is True
assert processor.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( async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
) )