Use fallback advertising interval for non-connectable Bluetooth devices (#85701)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
c8cd41b5d4
commit
42a4dd98f1
7 changed files with 198 additions and 23 deletions
|
@ -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."""
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue