mirror of https://github.com/home-assistant/core
170 lines
6.1 KiB
Python
170 lines
6.1 KiB
Python
"""A Bluetooth passive coordinator.
|
|
|
|
Receives data from advertisements but can also poll.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Coroutine
|
|
import logging
|
|
from typing import Any, Generic, TypeVar
|
|
|
|
from bleak import BleakError
|
|
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.debounce import Debouncer
|
|
from homeassistant.util.dt import monotonic_time_coarse
|
|
|
|
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
|
from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator
|
|
|
|
POLL_DEFAULT_COOLDOWN = 10
|
|
POLL_DEFAULT_IMMEDIATE = True
|
|
|
|
_T = TypeVar("_T")
|
|
|
|
|
|
class ActiveBluetoothDataUpdateCoordinator(
|
|
PassiveBluetoothDataUpdateCoordinator, Generic[_T]
|
|
):
|
|
"""A coordinator that receives passive data from advertisements but can also poll.
|
|
|
|
Unlike the passive processor coordinator, this coordinator does call a parser
|
|
method to parse the data from the advertisement.
|
|
|
|
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.
|
|
|
|
Once the poll is complete, the coordinator will call _async_handle_bluetooth_poll
|
|
which needs to be implemented in the subclass.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
logger: logging.Logger,
|
|
*,
|
|
address: str,
|
|
mode: BluetoothScanningMode,
|
|
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,
|
|
connectable: bool = True,
|
|
) -> None:
|
|
"""Initialize the coordinator."""
|
|
super().__init__(hass, logger, address, mode, connectable)
|
|
# It's None before the first successful update.
|
|
# Set type to just T to remove annoying checks that data is not None
|
|
# when it was already checked during setup.
|
|
self.data: _T = None # type: ignore[assignment]
|
|
|
|
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 = monotonic_time_coarse() - 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:
|
|
self.data = await self._async_poll_data(self._last_service_info)
|
|
except BleakError as exc:
|
|
if self.last_poll_successful:
|
|
self.logger.error(
|
|
"%s: Bluetooth error whilst polling: %s", self.address, str(exc)
|
|
)
|
|
self.last_poll_successful = False
|
|
return
|
|
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 = monotonic_time_coarse()
|
|
|
|
if not self.last_poll_successful:
|
|
self.logger.debug("%s: Polling recovered")
|
|
self.last_poll_successful = True
|
|
|
|
self._async_handle_bluetooth_poll()
|
|
|
|
@callback
|
|
def _async_handle_bluetooth_poll(self) -> None:
|
|
"""Handle a poll event."""
|
|
self.async_update_listeners()
|
|
|
|
@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())
|