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
# 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."""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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