Add new Bluetooth coordinator helper for polling mostly passive devices (#76549)
This commit is contained in:
parent
982d197ff3
commit
0639681991
2 changed files with 489 additions and 0 deletions
141
homeassistant/components/bluetooth/active_update_coordinator.py
Normal file
141
homeassistant/components/bluetooth/active_update_coordinator.py
Normal file
|
@ -0,0 +1,141 @@
|
|||
"""A Bluetooth passive coordinator that collects data from advertisements but can also poll."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
from .passive_update_processor import PassiveBluetoothProcessorCoordinator
|
||||
|
||||
POLL_DEFAULT_COOLDOWN = 10
|
||||
POLL_DEFAULT_IMMEDIATE = True
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class ActiveBluetoothProcessorCoordinator(
|
||||
Generic[_T], PassiveBluetoothProcessorCoordinator[_T]
|
||||
):
|
||||
"""
|
||||
A coordinator that parses passive data from advertisements but can also poll.
|
||||
|
||||
Every time an advertisement is received, needs_poll_method is called to work
|
||||
out if a poll is needed. This should return True if it is and False if it is
|
||||
not needed.
|
||||
|
||||
def needs_poll_method(svc_info: BluetoothServiceInfoBleak, last_poll: float | None) -> bool:
|
||||
return True
|
||||
|
||||
If there has been no poll since HA started, `last_poll` will be None. Otherwise it is
|
||||
the number of seconds since one was last attempted.
|
||||
|
||||
If a poll is needed, the coordinator will call poll_method. This is a coroutine.
|
||||
It should return the same type of data as your update_method. The expectation is that
|
||||
data from advertisements and from polling are being parsed and fed into a shared
|
||||
object that represents the current state of the device.
|
||||
|
||||
async def poll_method(svc_info: BluetoothServiceInfoBleak) -> YourDataType:
|
||||
return YourDataType(....)
|
||||
|
||||
BluetoothServiceInfoBleak.device contains a BLEDevice. You should use this in
|
||||
your poll function, as it is the most efficient way to get a BleakClient.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: logging.Logger,
|
||||
*,
|
||||
address: str,
|
||||
mode: BluetoothScanningMode,
|
||||
update_method: Callable[[BluetoothServiceInfoBleak], _T],
|
||||
needs_poll_method: Callable[[BluetoothServiceInfoBleak, float | None], bool],
|
||||
poll_method: Callable[
|
||||
[BluetoothServiceInfoBleak],
|
||||
Coroutine[Any, Any, _T],
|
||||
]
|
||||
| None = None,
|
||||
poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the processor."""
|
||||
super().__init__(hass, logger, address, mode, update_method)
|
||||
|
||||
self._needs_poll_method = needs_poll_method
|
||||
self._poll_method = poll_method
|
||||
self._last_poll: float | None = None
|
||||
self.last_poll_successful = True
|
||||
|
||||
# We keep the last service info in case the poller needs to refer to
|
||||
# e.g. its BLEDevice
|
||||
self._last_service_info: BluetoothServiceInfoBleak | None = None
|
||||
|
||||
if poll_debouncer is None:
|
||||
poll_debouncer = Debouncer(
|
||||
hass,
|
||||
logger,
|
||||
cooldown=POLL_DEFAULT_COOLDOWN,
|
||||
immediate=POLL_DEFAULT_IMMEDIATE,
|
||||
function=self._async_poll,
|
||||
)
|
||||
else:
|
||||
poll_debouncer.function = self._async_poll
|
||||
|
||||
self._debounced_poll = poll_debouncer
|
||||
|
||||
def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool:
|
||||
"""Return true if time to try and poll."""
|
||||
poll_age: float | None = None
|
||||
if self._last_poll:
|
||||
poll_age = time.monotonic() - self._last_poll
|
||||
return self._needs_poll_method(service_info, poll_age)
|
||||
|
||||
async def _async_poll_data(
|
||||
self, last_service_info: BluetoothServiceInfoBleak
|
||||
) -> _T:
|
||||
"""Fetch the latest data from the source."""
|
||||
if self._poll_method is None:
|
||||
raise NotImplementedError("Poll method not implemented")
|
||||
return await self._poll_method(last_service_info)
|
||||
|
||||
async def _async_poll(self) -> None:
|
||||
"""Poll the device to retrieve any extra data."""
|
||||
assert self._last_service_info
|
||||
|
||||
try:
|
||||
update = await self._async_poll_data(self._last_service_info)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
if self.last_poll_successful:
|
||||
self.logger.exception("%s: Failure while polling", self.address)
|
||||
self.last_poll_successful = False
|
||||
return
|
||||
finally:
|
||||
self._last_poll = time.monotonic()
|
||||
|
||||
if not self.last_poll_successful:
|
||||
self.logger.debug("%s: Polling recovered")
|
||||
self.last_poll_successful = True
|
||||
|
||||
for processor in self._processors:
|
||||
processor.async_handle_update(update)
|
||||
|
||||
@callback
|
||||
def _async_handle_bluetooth_event(
|
||||
self,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
change: BluetoothChange,
|
||||
) -> None:
|
||||
"""Handle a Bluetooth event."""
|
||||
super()._async_handle_bluetooth_event(service_info, change)
|
||||
|
||||
self._last_service_info = service_info
|
||||
|
||||
# See if its time to poll
|
||||
# We use bluetooth events to trigger the poll so that we scan as soon as
|
||||
# possible after a device comes online or back in range, if a poll is due
|
||||
if self.needs_poll(service_info):
|
||||
self.hass.async_create_task(self._debounced_poll.async_call())
|
348
tests/components/bluetooth/test_active_update_coordinator.py
Normal file
348
tests/components/bluetooth/test_active_update_coordinator.py
Normal file
|
@ -0,0 +1,348 @@
|
|||
"""Tests for the Bluetooth integration PassiveBluetoothDataUpdateCoordinator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
DOMAIN,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from homeassistant.components.bluetooth.active_update_coordinator import (
|
||||
ActiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
GENERIC_BLUETOOTH_SERVICE_INFO = BluetoothServiceInfo(
|
||||
name="Generic",
|
||||
address="aa:bb:cc:dd:ee:ff",
|
||||
rssi=-95,
|
||||
manufacturer_data={
|
||||
1: b"\x01\x01\x01\x01\x01\x01\x01\x01",
|
||||
},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
source="local",
|
||||
)
|
||||
|
||||
|
||||
async def test_basic_usage(hass: HomeAssistant, mock_bleak_scanner_start):
|
||||
"""Test basic usage of the ActiveBluetoothProcessorCoordinator."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
def _update_method(service_info: BluetoothServiceInfoBleak):
|
||||
return {"testdata": 0}
|
||||
|
||||
def _poll_needed(*args, **kwargs):
|
||||
return True
|
||||
|
||||
async def _poll(*args, **kwargs):
|
||||
return {"testdata": 1}
|
||||
|
||||
coordinator = ActiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address="aa:bb:cc:dd:ee:ff",
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=_update_method,
|
||||
needs_poll_method=_poll_needed,
|
||||
poll_method=_poll,
|
||||
)
|
||||
assert coordinator.available is False # no data yet
|
||||
saved_callback = None
|
||||
|
||||
processor = MagicMock()
|
||||
coordinator.async_register_processor(processor)
|
||||
async_handle_update = processor.async_handle_update
|
||||
|
||||
def _async_register_callback(_hass, _callback, _matcher, _mode):
|
||||
nonlocal saved_callback
|
||||
saved_callback = _callback
|
||||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel = coordinator.async_start()
|
||||
|
||||
assert saved_callback is not None
|
||||
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert coordinator.available is True
|
||||
|
||||
# async_handle_update should have been called twice
|
||||
# The first time, it was passed the data from parsing the advertisement
|
||||
# The second time, it was passed the data from polling
|
||||
assert len(async_handle_update.mock_calls) == 2
|
||||
assert async_handle_update.mock_calls[0] == call({"testdata": 0})
|
||||
assert async_handle_update.mock_calls[1] == call({"testdata": 1})
|
||||
|
||||
cancel()
|
||||
|
||||
|
||||
async def test_poll_can_be_skipped(hass: HomeAssistant, mock_bleak_scanner_start):
|
||||
"""Test need_poll callback works and can skip a poll if its not needed."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
flag = True
|
||||
|
||||
def _update_method(service_info: BluetoothServiceInfoBleak):
|
||||
return {"testdata": None}
|
||||
|
||||
def _poll_needed(*args, **kwargs):
|
||||
nonlocal flag
|
||||
return flag
|
||||
|
||||
async def _poll(*args, **kwargs):
|
||||
return {"testdata": flag}
|
||||
|
||||
coordinator = ActiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address="aa:bb:cc:dd:ee:ff",
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=_update_method,
|
||||
needs_poll_method=_poll_needed,
|
||||
poll_method=_poll,
|
||||
poll_debouncer=Debouncer(
|
||||
hass,
|
||||
_LOGGER,
|
||||
cooldown=0,
|
||||
immediate=True,
|
||||
),
|
||||
)
|
||||
assert coordinator.available is False # no data yet
|
||||
saved_callback = None
|
||||
|
||||
processor = MagicMock()
|
||||
coordinator.async_register_processor(processor)
|
||||
async_handle_update = processor.async_handle_update
|
||||
|
||||
def _async_register_callback(_hass, _callback, _matcher, _mode):
|
||||
nonlocal saved_callback
|
||||
saved_callback = _callback
|
||||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel = coordinator.async_start()
|
||||
|
||||
assert saved_callback is not None
|
||||
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
await hass.async_block_till_done()
|
||||
assert async_handle_update.mock_calls[-1] == call({"testdata": True})
|
||||
|
||||
flag = False
|
||||
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
await hass.async_block_till_done()
|
||||
assert async_handle_update.mock_calls[-1] == call({"testdata": None})
|
||||
|
||||
flag = True
|
||||
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
await hass.async_block_till_done()
|
||||
assert async_handle_update.mock_calls[-1] == call({"testdata": True})
|
||||
|
||||
cancel()
|
||||
|
||||
|
||||
async def test_poll_failure_and_recover(hass: HomeAssistant, mock_bleak_scanner_start):
|
||||
"""Test error handling and recovery."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
flag = True
|
||||
|
||||
def _update_method(service_info: BluetoothServiceInfoBleak):
|
||||
return {"testdata": None}
|
||||
|
||||
def _poll_needed(*args, **kwargs):
|
||||
return True
|
||||
|
||||
async def _poll(*args, **kwargs):
|
||||
nonlocal flag
|
||||
if flag:
|
||||
raise RuntimeError("Poll failure")
|
||||
return {"testdata": flag}
|
||||
|
||||
coordinator = ActiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address="aa:bb:cc:dd:ee:ff",
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=_update_method,
|
||||
needs_poll_method=_poll_needed,
|
||||
poll_method=_poll,
|
||||
poll_debouncer=Debouncer(
|
||||
hass,
|
||||
_LOGGER,
|
||||
cooldown=0,
|
||||
immediate=True,
|
||||
),
|
||||
)
|
||||
assert coordinator.available is False # no data yet
|
||||
saved_callback = None
|
||||
|
||||
processor = MagicMock()
|
||||
coordinator.async_register_processor(processor)
|
||||
async_handle_update = processor.async_handle_update
|
||||
|
||||
def _async_register_callback(_hass, _callback, _matcher, _mode):
|
||||
nonlocal saved_callback
|
||||
saved_callback = _callback
|
||||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel = coordinator.async_start()
|
||||
|
||||
assert saved_callback is not None
|
||||
|
||||
# First poll fails
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
await hass.async_block_till_done()
|
||||
assert async_handle_update.mock_calls[-1] == call({"testdata": None})
|
||||
|
||||
# Second poll works
|
||||
flag = False
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
await hass.async_block_till_done()
|
||||
assert async_handle_update.mock_calls[-1] == call({"testdata": False})
|
||||
|
||||
cancel()
|
||||
|
||||
|
||||
async def test_second_poll_needed(hass: HomeAssistant, mock_bleak_scanner_start):
|
||||
"""If a poll is queued, by the time it starts it may no longer be needed."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
count = 0
|
||||
|
||||
def _update_method(service_info: BluetoothServiceInfoBleak):
|
||||
return {"testdata": None}
|
||||
|
||||
# Only poll once
|
||||
def _poll_needed(*args, **kwargs):
|
||||
nonlocal count
|
||||
return count == 0
|
||||
|
||||
async def _poll(*args, **kwargs):
|
||||
nonlocal count
|
||||
count += 1
|
||||
return {"testdata": count}
|
||||
|
||||
coordinator = ActiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address="aa:bb:cc:dd:ee:ff",
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=_update_method,
|
||||
needs_poll_method=_poll_needed,
|
||||
poll_method=_poll,
|
||||
)
|
||||
assert coordinator.available is False # no data yet
|
||||
saved_callback = None
|
||||
|
||||
processor = MagicMock()
|
||||
coordinator.async_register_processor(processor)
|
||||
async_handle_update = processor.async_handle_update
|
||||
|
||||
def _async_register_callback(_hass, _callback, _matcher, _mode):
|
||||
nonlocal saved_callback
|
||||
saved_callback = _callback
|
||||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel = coordinator.async_start()
|
||||
|
||||
assert saved_callback is not None
|
||||
|
||||
# First poll gets queued
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
# Second poll gets stuck behind first poll
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert async_handle_update.mock_calls[-1] == call({"testdata": 1})
|
||||
|
||||
cancel()
|
||||
|
||||
|
||||
async def test_rate_limit(hass: HomeAssistant, mock_bleak_scanner_start):
|
||||
"""Test error handling and recovery."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
count = 0
|
||||
|
||||
def _update_method(service_info: BluetoothServiceInfoBleak):
|
||||
return {"testdata": None}
|
||||
|
||||
def _poll_needed(*args, **kwargs):
|
||||
return True
|
||||
|
||||
async def _poll(*args, **kwargs):
|
||||
nonlocal count
|
||||
count += 1
|
||||
await asyncio.sleep(0)
|
||||
return {"testdata": count}
|
||||
|
||||
coordinator = ActiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address="aa:bb:cc:dd:ee:ff",
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=_update_method,
|
||||
needs_poll_method=_poll_needed,
|
||||
poll_method=_poll,
|
||||
)
|
||||
assert coordinator.available is False # no data yet
|
||||
saved_callback = None
|
||||
|
||||
processor = MagicMock()
|
||||
coordinator.async_register_processor(processor)
|
||||
async_handle_update = processor.async_handle_update
|
||||
|
||||
def _async_register_callback(_hass, _callback, _matcher, _mode):
|
||||
nonlocal saved_callback
|
||||
saved_callback = _callback
|
||||
return lambda: None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel = coordinator.async_start()
|
||||
|
||||
assert saved_callback is not None
|
||||
|
||||
# First poll gets queued
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
# Second poll gets stuck behind first poll
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
# Third poll gets stuck behind first poll doesn't get queued
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert async_handle_update.mock_calls[-1] == call({"testdata": 1})
|
||||
|
||||
cancel()
|
Loading…
Add table
Reference in a new issue