Improve availability tracking and coordinator setup in bluetooth (#75582)
This commit is contained in:
parent
975378ba44
commit
90ca3fe350
7 changed files with 357 additions and 195 deletions
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
import fnmatch
|
||||
import logging
|
||||
|
@ -22,6 +23,7 @@ from homeassistant.core import (
|
|||
callback as hass_callback,
|
||||
)
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import (
|
||||
|
@ -39,6 +41,8 @@ _LOGGER = logging.getLogger(__name__)
|
|||
|
||||
MAX_REMEMBER_ADDRESSES: Final = 2048
|
||||
|
||||
UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
|
||||
|
||||
SOURCE_LOCAL: Final = "local"
|
||||
|
||||
|
||||
|
@ -160,6 +164,20 @@ def async_register_callback(
|
|||
return manager.async_register_callback(callback, match_dict)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_track_unavailable(
|
||||
hass: HomeAssistant,
|
||||
callback: Callable[[str], None],
|
||||
address: str,
|
||||
) -> Callable[[], None]:
|
||||
"""Register to receive a callback when an address is unavailable.
|
||||
|
||||
Returns a callback that can be used to cancel the registration.
|
||||
"""
|
||||
manager: BluetoothManager = hass.data[DOMAIN]
|
||||
return manager.async_track_unavailable(callback, address)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the bluetooth integration."""
|
||||
integration_matchers = await async_get_bluetooth(hass)
|
||||
|
@ -231,6 +249,8 @@ class BluetoothManager:
|
|||
self._integration_matchers = integration_matchers
|
||||
self.scanner: HaBleakScanner | None = None
|
||||
self._cancel_device_detected: CALLBACK_TYPE | None = None
|
||||
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
|
||||
self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {}
|
||||
self._callbacks: list[
|
||||
tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
|
||||
] = []
|
||||
|
@ -251,6 +271,7 @@ class BluetoothManager:
|
|||
)
|
||||
return
|
||||
install_multiple_bleak_catcher(self.scanner)
|
||||
self.async_setup_unavailable_tracking()
|
||||
# We have to start it right away as some integrations might
|
||||
# need it straight away.
|
||||
_LOGGER.debug("Starting bluetooth scanner")
|
||||
|
@ -261,6 +282,34 @@ class BluetoothManager:
|
|||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
|
||||
await self.scanner.start()
|
||||
|
||||
@hass_callback
|
||||
def async_setup_unavailable_tracking(self) -> None:
|
||||
"""Set up the unavailable tracking."""
|
||||
|
||||
@hass_callback
|
||||
def _async_check_unavailable(now: datetime) -> None:
|
||||
"""Watch for unavailable devices."""
|
||||
assert models.HA_BLEAK_SCANNER is not None
|
||||
scanner = models.HA_BLEAK_SCANNER
|
||||
history = set(scanner.history)
|
||||
active = {device.address for device in scanner.discovered_devices}
|
||||
disappeared = history.difference(active)
|
||||
for address in disappeared:
|
||||
del scanner.history[address]
|
||||
if not (callbacks := self._unavailable_callbacks.get(address)):
|
||||
continue
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(address)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error in unavailable callback")
|
||||
|
||||
self._cancel_unavailable_tracking = async_track_time_interval(
|
||||
self.hass,
|
||||
_async_check_unavailable,
|
||||
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def _device_detected(
|
||||
self, device: BLEDevice, advertisement_data: AdvertisementData
|
||||
|
@ -283,6 +332,7 @@ class BluetoothManager:
|
|||
}
|
||||
if matched_domains:
|
||||
self._matched[match_key] = True
|
||||
|
||||
_LOGGER.debug(
|
||||
"Device detected: %s with advertisement_data: %s matched domains: %s",
|
||||
device,
|
||||
|
@ -321,6 +371,21 @@ class BluetoothManager:
|
|||
service_info,
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def async_track_unavailable(
|
||||
self, callback: Callable[[str], None], address: str
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
self._unavailable_callbacks.setdefault(address, []).append(callback)
|
||||
|
||||
@hass_callback
|
||||
def _async_remove_callback() -> None:
|
||||
self._unavailable_callbacks[address].remove(callback)
|
||||
if not self._unavailable_callbacks[address]:
|
||||
del self._unavailable_callbacks[address]
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def async_register_callback(
|
||||
self,
|
||||
|
@ -369,25 +434,17 @@ class BluetoothManager:
|
|||
def async_address_present(self, address: str) -> bool:
|
||||
"""Return if the address is present."""
|
||||
return bool(
|
||||
models.HA_BLEAK_SCANNER
|
||||
and any(
|
||||
device.address == address
|
||||
for device in models.HA_BLEAK_SCANNER.discovered_devices
|
||||
)
|
||||
models.HA_BLEAK_SCANNER and address in models.HA_BLEAK_SCANNER.history
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]:
|
||||
"""Return if the address is present."""
|
||||
if models.HA_BLEAK_SCANNER:
|
||||
discovered = models.HA_BLEAK_SCANNER.discovered_devices
|
||||
history = models.HA_BLEAK_SCANNER.history
|
||||
return [
|
||||
BluetoothServiceInfoBleak.from_advertisement(
|
||||
*history[device.address], SOURCE_LOCAL
|
||||
)
|
||||
for device in discovered
|
||||
if device.address in history
|
||||
BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL)
|
||||
for device_adv in history.values()
|
||||
]
|
||||
return []
|
||||
|
||||
|
@ -396,6 +453,9 @@ class BluetoothManager:
|
|||
if self._cancel_device_detected:
|
||||
self._cancel_device_detected()
|
||||
self._cancel_device_detected = None
|
||||
if self._cancel_unavailable_tracking:
|
||||
self._cancel_unavailable_tracking()
|
||||
self._cancel_unavailable_tracking = None
|
||||
if self.scanner:
|
||||
await self.scanner.stop()
|
||||
models.HA_BLEAK_SCANNER = None
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any, Final, cast
|
||||
|
@ -14,7 +13,6 @@ from bleak.backends.scanner import (
|
|||
AdvertisementDataCallback,
|
||||
BaseBleakScanner,
|
||||
)
|
||||
from lru import LRU # pylint: disable=no-name-in-module
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, callback as hass_callback
|
||||
|
||||
|
@ -24,8 +22,6 @@ FILTER_UUIDS: Final = "UUIDs"
|
|||
|
||||
HA_BLEAK_SCANNER: HaBleakScanner | None = None
|
||||
|
||||
MAX_HISTORY_SIZE: Final = 512
|
||||
|
||||
|
||||
def _dispatch_callback(
|
||||
callback: AdvertisementDataCallback,
|
||||
|
@ -57,9 +53,7 @@ class HaBleakScanner(BleakScanner): # type: ignore[misc]
|
|||
self._callbacks: list[
|
||||
tuple[AdvertisementDataCallback, dict[str, set[str]]]
|
||||
] = []
|
||||
self.history: Mapping[str, tuple[BLEDevice, AdvertisementData]] = LRU(
|
||||
MAX_HISTORY_SIZE
|
||||
)
|
||||
self.history: dict[str, tuple[BLEDevice, AdvertisementData]] = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@hass_callback
|
||||
|
@ -90,7 +84,7 @@ class HaBleakScanner(BleakScanner): # type: ignore[misc]
|
|||
Here we get the actual callback from bleak and dispatch
|
||||
it to all the wrapped HaBleakScannerWrapper classes
|
||||
"""
|
||||
self.history[device.address] = (device, advertisement_data) # type: ignore[index]
|
||||
self.history[device.address] = (device, advertisement_data)
|
||||
for callback_filters in self._callbacks:
|
||||
_dispatch_callback(*callback_filters, device, advertisement_data)
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
"""The Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
import dataclasses
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
@ -14,19 +13,15 @@ from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME
|
|||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import (
|
||||
BluetoothCallbackMatcher,
|
||||
BluetoothChange,
|
||||
async_address_present,
|
||||
async_register_callback,
|
||||
async_track_unavailable,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
|
||||
UNAVAILABLE_SECONDS = 60 * 5
|
||||
NEVER_TIME = -UNAVAILABLE_SECONDS
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PassiveBluetoothEntityKey:
|
||||
|
@ -49,10 +44,13 @@ class PassiveBluetoothDataUpdate(Generic[_T]):
|
|||
"""Generic bluetooth data."""
|
||||
|
||||
devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict)
|
||||
entity_descriptions: dict[
|
||||
entity_descriptions: Mapping[
|
||||
PassiveBluetoothEntityKey, EntityDescription
|
||||
] = dataclasses.field(default_factory=dict)
|
||||
entity_data: dict[PassiveBluetoothEntityKey, _T] = dataclasses.field(
|
||||
entity_names: Mapping[PassiveBluetoothEntityKey, str | None] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
entity_data: Mapping[PassiveBluetoothEntityKey, _T] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
|
@ -106,6 +104,7 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
|
|||
] = {}
|
||||
self.update_method = update_method
|
||||
|
||||
self.entity_names: dict[PassiveBluetoothEntityKey, str | None] = {}
|
||||
self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {}
|
||||
self.entity_descriptions: dict[
|
||||
PassiveBluetoothEntityKey, EntityDescription
|
||||
|
@ -113,54 +112,45 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
|
|||
self.devices: dict[str | None, DeviceInfo] = {}
|
||||
|
||||
self.last_update_success = True
|
||||
self._last_callback_time: float = NEVER_TIME
|
||||
self._cancel_track_available: CALLBACK_TYPE | None = None
|
||||
self._present = False
|
||||
self._cancel_track_unavailable: CALLBACK_TYPE | None = None
|
||||
self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None
|
||||
self.present = False
|
||||
self.last_seen = 0.0
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return self._present and self.last_update_success
|
||||
return self.present and self.last_update_success
|
||||
|
||||
@callback
|
||||
def _async_cancel_available_tracker(self) -> None:
|
||||
"""Reset the available tracker."""
|
||||
if self._cancel_track_available:
|
||||
self._cancel_track_available()
|
||||
self._cancel_track_available = None
|
||||
|
||||
@callback
|
||||
def _async_schedule_available_tracker(self, time_remaining: float) -> None:
|
||||
"""Schedule the available tracker."""
|
||||
self._cancel_track_available = async_call_later(
|
||||
self.hass, time_remaining, self._async_check_device_present
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_check_device_present(self, _: datetime) -> None:
|
||||
"""Check if the device is present."""
|
||||
time_passed_since_seen = time.monotonic() - self._last_callback_time
|
||||
self._async_cancel_available_tracker()
|
||||
if (
|
||||
not self._present
|
||||
or time_passed_since_seen < UNAVAILABLE_SECONDS
|
||||
or async_address_present(self.hass, self.address)
|
||||
):
|
||||
self._async_schedule_available_tracker(
|
||||
UNAVAILABLE_SECONDS - time_passed_since_seen
|
||||
)
|
||||
return
|
||||
self._present = False
|
||||
def _async_handle_unavailable(self, _address: str) -> None:
|
||||
"""Handle the device going unavailable."""
|
||||
self.present = False
|
||||
self.async_update_listeners(None)
|
||||
|
||||
@callback
|
||||
def async_setup(self) -> CALLBACK_TYPE:
|
||||
"""Start the callback."""
|
||||
return async_register_callback(
|
||||
def _async_start(self) -> None:
|
||||
"""Start the callbacks."""
|
||||
self._cancel_bluetooth_advertisements = async_register_callback(
|
||||
self.hass,
|
||||
self._async_handle_bluetooth_event,
|
||||
BluetoothCallbackMatcher(address=self.address),
|
||||
)
|
||||
self._cancel_track_unavailable = async_track_unavailable(
|
||||
self.hass,
|
||||
self._async_handle_unavailable,
|
||||
self.address,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_stop(self) -> None:
|
||||
"""Stop the callbacks."""
|
||||
if self._cancel_bluetooth_advertisements is not None:
|
||||
self._cancel_bluetooth_advertisements()
|
||||
self._cancel_bluetooth_advertisements = None
|
||||
if self._cancel_track_unavailable is not None:
|
||||
self._cancel_track_unavailable()
|
||||
self._cancel_track_unavailable = None
|
||||
|
||||
@callback
|
||||
def async_add_entities_listener(
|
||||
|
@ -199,10 +189,22 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
|
|||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._listeners.remove(update_callback)
|
||||
self._async_handle_listeners_changed()
|
||||
|
||||
self._listeners.append(update_callback)
|
||||
self._async_handle_listeners_changed()
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
def _async_handle_listeners_changed(self) -> None:
|
||||
"""Handle listeners changed."""
|
||||
has_listeners = self._listeners or self._entity_key_listeners
|
||||
running = bool(self._cancel_bluetooth_advertisements)
|
||||
if running and not has_listeners:
|
||||
self._async_stop()
|
||||
elif not running and has_listeners:
|
||||
self._async_start()
|
||||
|
||||
@callback
|
||||
def async_add_entity_key_listener(
|
||||
self,
|
||||
|
@ -217,8 +219,10 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
|
|||
self._entity_key_listeners[entity_key].remove(update_callback)
|
||||
if not self._entity_key_listeners[entity_key]:
|
||||
del self._entity_key_listeners[entity_key]
|
||||
self._async_handle_listeners_changed()
|
||||
|
||||
self._entity_key_listeners.setdefault(entity_key, []).append(update_callback)
|
||||
self._async_handle_listeners_changed()
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
|
@ -242,11 +246,9 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
|
|||
change: BluetoothChange,
|
||||
) -> None:
|
||||
"""Handle a Bluetooth event."""
|
||||
self.last_seen = time.monotonic()
|
||||
self.name = service_info.name
|
||||
self._last_callback_time = time.monotonic()
|
||||
self._present = True
|
||||
if not self._cancel_track_available:
|
||||
self._async_schedule_available_tracker(UNAVAILABLE_SECONDS)
|
||||
self.present = True
|
||||
if self.hass.is_stopping:
|
||||
return
|
||||
|
||||
|
@ -272,6 +274,7 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
|
|||
self.devices.update(new_data.devices)
|
||||
self.entity_descriptions.update(new_data.entity_descriptions)
|
||||
self.entity_data.update(new_data.entity_data)
|
||||
self.entity_names.update(new_data.entity_names)
|
||||
self.async_update_listeners(new_data)
|
||||
|
||||
|
||||
|
@ -315,6 +318,7 @@ class PassiveBluetoothCoordinatorEntity(
|
|||
self._attr_unique_id = f"{address}-{key}"
|
||||
if ATTR_NAME not in self._attr_device_info:
|
||||
self._attr_device_info[ATTR_NAME] = self.coordinator.name
|
||||
self._attr_name = coordinator.entity_names.get(entity_key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
|
16
homeassistant/components/bluetooth/strings.json
Normal file
16
homeassistant/components/bluetooth/strings.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Choose a device to setup",
|
||||
"data": {
|
||||
"address": "Device"
|
||||
}
|
||||
},
|
||||
"bluetooth_confirm": {
|
||||
"description": "Do you want to setup {name}?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
16
homeassistant/components/bluetooth/translations/en.json
Normal file
16
homeassistant/components/bluetooth/translations/en.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "Do you want to setup {name}?"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "Device"
|
||||
},
|
||||
"description": "Choose a device to setup"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
"""Tests for the Bluetooth integration."""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bleak import BleakError
|
||||
|
@ -7,12 +8,18 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice
|
|||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import (
|
||||
SOURCE_LOCAL,
|
||||
UNAVAILABLE_TRACK_SECONDS,
|
||||
BluetoothChange,
|
||||
BluetoothServiceInfo,
|
||||
async_track_unavailable,
|
||||
models,
|
||||
)
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
async def test_setup_and_stop(hass, mock_bleak_scanner_start):
|
||||
|
@ -241,9 +248,55 @@ async def test_async_discovered_device_api(hass, mock_bleak_scanner_start):
|
|||
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand")
|
||||
switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[])
|
||||
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv)
|
||||
wrong_device_went_unavailable = False
|
||||
switchbot_device_went_unavailable = False
|
||||
|
||||
@callback
|
||||
def _wrong_device_unavailable_callback(_address: str) -> None:
|
||||
"""Wrong device unavailable callback."""
|
||||
nonlocal wrong_device_went_unavailable
|
||||
wrong_device_went_unavailable = True
|
||||
raise ValueError("blow up")
|
||||
|
||||
@callback
|
||||
def _switchbot_device_unavailable_callback(_address: str) -> None:
|
||||
"""Switchbot device unavailable callback."""
|
||||
nonlocal switchbot_device_went_unavailable
|
||||
switchbot_device_went_unavailable = True
|
||||
|
||||
wrong_device_unavailable_cancel = async_track_unavailable(
|
||||
hass, _wrong_device_unavailable_callback, wrong_device.address
|
||||
)
|
||||
switchbot_device_unavailable_cancel = async_track_unavailable(
|
||||
hass, _switchbot_device_unavailable_callback, switchbot_device.address
|
||||
)
|
||||
|
||||
async_fire_time_changed(
|
||||
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
service_infos = bluetooth.async_discovered_service_info(hass)
|
||||
assert switchbot_device_went_unavailable is False
|
||||
assert wrong_device_went_unavailable is True
|
||||
|
||||
# See the devices again
|
||||
models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv)
|
||||
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv)
|
||||
# Cancel the callbacks
|
||||
wrong_device_unavailable_cancel()
|
||||
switchbot_device_unavailable_cancel()
|
||||
wrong_device_went_unavailable = False
|
||||
switchbot_device_went_unavailable = False
|
||||
|
||||
# Verify the cancel is effective
|
||||
async_fire_time_changed(
|
||||
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert switchbot_device_went_unavailable is False
|
||||
assert wrong_device_went_unavailable is False
|
||||
|
||||
assert len(service_infos) == 1
|
||||
# wrong_name should not appear because bleak no longer sees it
|
||||
assert service_infos[0].name == "wohand"
|
||||
|
|
|
@ -3,15 +3,17 @@ from __future__ import annotations
|
|||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from home_assistant_bluetooth import BluetoothServiceInfo
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothChange
|
||||
from homeassistant.components.bluetooth import (
|
||||
DOMAIN,
|
||||
UNAVAILABLE_TRACK_SECONDS,
|
||||
BluetoothChange,
|
||||
)
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
UNAVAILABLE_SECONDS,
|
||||
PassiveBluetoothCoordinatorEntity,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothDataUpdateCoordinator,
|
||||
|
@ -21,6 +23,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescr
|
|||
from homeassistant.const import TEMP_CELSIUS
|
||||
from homeassistant.core import CoreState, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockEntityPlatform, async_fire_time_changed
|
||||
|
@ -49,16 +52,18 @@ GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
|||
PassiveBluetoothEntityKey("temperature", None): 14.5,
|
||||
PassiveBluetoothEntityKey("pressure", None): 1234,
|
||||
},
|
||||
entity_names={
|
||||
PassiveBluetoothEntityKey("temperature", None): "Temperature",
|
||||
PassiveBluetoothEntityKey("pressure", None): "Pressure",
|
||||
},
|
||||
entity_descriptions={
|
||||
PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
native_unit_of_measurement=TEMP_CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription(
|
||||
key="pressure",
|
||||
name="Pressure",
|
||||
native_unit_of_measurement="hPa",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
),
|
||||
|
@ -66,8 +71,9 @@ GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
|||
)
|
||||
|
||||
|
||||
async def test_basic_usage(hass):
|
||||
async def test_basic_usage(hass, mock_bleak_scanner_start):
|
||||
"""Test basic usage of the PassiveBluetoothDataUpdateCoordinator."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
@callback
|
||||
def _async_generate_mock_data(
|
||||
|
@ -91,7 +97,6 @@ async def test_basic_usage(hass):
|
|||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel_coordinator = coordinator.async_setup()
|
||||
|
||||
entity_key = PassiveBluetoothEntityKey("temperature", None)
|
||||
entity_key_events = []
|
||||
|
@ -103,10 +108,12 @@ async def test_basic_usage(hass):
|
|||
"""Mock entity key listener."""
|
||||
entity_key_events.append(data)
|
||||
|
||||
cancel_async_add_entity_key_listener = coordinator.async_add_entity_key_listener(
|
||||
cancel_async_add_entity_key_listener = (
|
||||
coordinator.async_add_entity_key_listener(
|
||||
_async_entity_key_listener,
|
||||
entity_key,
|
||||
)
|
||||
)
|
||||
|
||||
def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None:
|
||||
"""Mock an all listener."""
|
||||
|
@ -155,11 +162,15 @@ async def test_basic_usage(hass):
|
|||
assert len(mock_entity.mock_calls) == 2
|
||||
assert coordinator.available is True
|
||||
|
||||
cancel_coordinator()
|
||||
|
||||
|
||||
async def test_unavailable_after_no_data(hass):
|
||||
async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
|
||||
"""Test that the coordinator is unavailable after no data for a while."""
|
||||
with patch(
|
||||
"bleak.BleakScanner.discovered_devices", # Must patch before we setup
|
||||
[MagicMock(address="44:44:33:11:23:45")],
|
||||
):
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@callback
|
||||
def _async_generate_mock_data(
|
||||
|
@ -183,7 +194,6 @@ async def test_unavailable_after_no_data(hass):
|
|||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel_coordinator = coordinator.async_setup()
|
||||
|
||||
mock_entity = MagicMock()
|
||||
mock_add_entities = MagicMock()
|
||||
|
@ -198,52 +208,40 @@ async def test_unavailable_after_no_data(hass):
|
|||
assert len(mock_add_entities.mock_calls) == 1
|
||||
assert coordinator.available is True
|
||||
|
||||
monotonic_now = time.monotonic()
|
||||
now = dt_util.utcnow()
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.passive_update_coordinator.time.monotonic",
|
||||
return_value=monotonic_now + UNAVAILABLE_SECONDS,
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices",
|
||||
[MagicMock(address="44:44:33:11:23:45")],
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.models.HA_BLEAK_SCANNER.history",
|
||||
{"aa:bb:cc:dd:ee:ff": MagicMock()},
|
||||
):
|
||||
async_fire_time_changed(hass, now + timedelta(seconds=UNAVAILABLE_SECONDS))
|
||||
async_fire_time_changed(
|
||||
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert coordinator.available is False
|
||||
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
assert len(mock_add_entities.mock_calls) == 1
|
||||
assert coordinator.available is True
|
||||
|
||||
# Now simulate the device is still present even though we got
|
||||
# no data for a while
|
||||
|
||||
monotonic_now = time.monotonic()
|
||||
now = dt_util.utcnow()
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.passive_update_coordinator.async_address_present",
|
||||
return_value=True,
|
||||
"homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices",
|
||||
[MagicMock(address="44:44:33:11:23:45")],
|
||||
), patch(
|
||||
"homeassistant.components.bluetooth.passive_update_coordinator.time.monotonic",
|
||||
return_value=monotonic_now + UNAVAILABLE_SECONDS,
|
||||
"homeassistant.components.bluetooth.models.HA_BLEAK_SCANNER.history",
|
||||
{"aa:bb:cc:dd:ee:ff": MagicMock()},
|
||||
):
|
||||
async_fire_time_changed(hass, now + timedelta(seconds=UNAVAILABLE_SECONDS))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert coordinator.available is True
|
||||
|
||||
# And finally that it can go unavailable again when its gone
|
||||
monotonic_now = time.monotonic()
|
||||
now = dt_util.utcnow()
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.passive_update_coordinator.time.monotonic",
|
||||
return_value=monotonic_now + UNAVAILABLE_SECONDS,
|
||||
):
|
||||
async_fire_time_changed(hass, now + timedelta(seconds=UNAVAILABLE_SECONDS))
|
||||
async_fire_time_changed(
|
||||
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert coordinator.available is False
|
||||
|
||||
cancel_coordinator()
|
||||
|
||||
|
||||
async def test_no_updates_once_stopping(hass):
|
||||
async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
|
||||
"""Test updates are ignored once hass is stopping."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
@callback
|
||||
def _async_generate_mock_data(
|
||||
|
@ -267,7 +265,6 @@ async def test_no_updates_once_stopping(hass):
|
|||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel_coordinator = coordinator.async_setup()
|
||||
|
||||
all_events = []
|
||||
|
||||
|
@ -288,11 +285,11 @@ async def test_no_updates_once_stopping(hass):
|
|||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
assert len(all_events) == 1
|
||||
|
||||
cancel_coordinator()
|
||||
|
||||
|
||||
async def test_exception_from_update_method(hass, caplog):
|
||||
async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_start):
|
||||
"""Test we handle exceptions from the update method."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
run_count = 0
|
||||
|
||||
@callback
|
||||
|
@ -321,7 +318,7 @@ async def test_exception_from_update_method(hass, caplog):
|
|||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel_coordinator = coordinator.async_setup()
|
||||
coordinator.async_add_listener(MagicMock())
|
||||
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
assert coordinator.available is True
|
||||
|
@ -335,11 +332,11 @@ async def test_exception_from_update_method(hass, caplog):
|
|||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
assert coordinator.available is True
|
||||
|
||||
cancel_coordinator()
|
||||
|
||||
|
||||
async def test_bad_data_from_update_method(hass):
|
||||
async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start):
|
||||
"""Test we handle bad data from the update method."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
run_count = 0
|
||||
|
||||
@callback
|
||||
|
@ -368,7 +365,7 @@ async def test_bad_data_from_update_method(hass):
|
|||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
cancel_coordinator = coordinator.async_setup()
|
||||
coordinator.async_add_listener(MagicMock())
|
||||
|
||||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
assert coordinator.available is True
|
||||
|
@ -383,8 +380,6 @@ async def test_bad_data_from_update_method(hass):
|
|||
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
assert coordinator.available is True
|
||||
|
||||
cancel_coordinator()
|
||||
|
||||
|
||||
GOVEE_B5178_REMOTE_SERVICE_INFO = BluetoothServiceInfo(
|
||||
name="B5178D6FB",
|
||||
|
@ -429,7 +424,6 @@ GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Temperature",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="°C",
|
||||
|
@ -446,7 +440,6 @@ GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Humidity",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="%",
|
||||
|
@ -463,7 +456,6 @@ GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Battery",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="%",
|
||||
|
@ -480,13 +472,20 @@ GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Signal Strength",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="dBm",
|
||||
state_class=None,
|
||||
),
|
||||
},
|
||||
entity_names={
|
||||
PassiveBluetoothEntityKey(key="temperature", device_id="remote"): "Temperature",
|
||||
PassiveBluetoothEntityKey(key="humidity", device_id="remote"): "Humidity",
|
||||
PassiveBluetoothEntityKey(key="battery", device_id="remote"): "Battery",
|
||||
PassiveBluetoothEntityKey(
|
||||
key="signal_strength", device_id="remote"
|
||||
): "Signal Strength",
|
||||
},
|
||||
entity_data={
|
||||
PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642,
|
||||
PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2,
|
||||
|
@ -520,7 +519,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Temperature",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="°C",
|
||||
|
@ -537,7 +535,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Humidity",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="%",
|
||||
|
@ -554,7 +551,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Battery",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="%",
|
||||
|
@ -571,7 +567,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Signal Strength",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="dBm",
|
||||
|
@ -588,7 +583,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Temperature",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="°C",
|
||||
|
@ -605,7 +599,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Humidity",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="%",
|
||||
|
@ -622,7 +615,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Battery",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="%",
|
||||
|
@ -639,13 +631,30 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
force_update=False,
|
||||
icon=None,
|
||||
has_entity_name=False,
|
||||
name="Signal Strength",
|
||||
unit_of_measurement=None,
|
||||
last_reset=None,
|
||||
native_unit_of_measurement="dBm",
|
||||
state_class=None,
|
||||
),
|
||||
},
|
||||
entity_names={
|
||||
PassiveBluetoothEntityKey(
|
||||
key="temperature", device_id="remote"
|
||||
): "Temperature",
|
||||
PassiveBluetoothEntityKey(key="humidity", device_id="remote"): "Humidity",
|
||||
PassiveBluetoothEntityKey(key="battery", device_id="remote"): "Battery",
|
||||
PassiveBluetoothEntityKey(
|
||||
key="signal_strength", device_id="remote"
|
||||
): "Signal Strength",
|
||||
PassiveBluetoothEntityKey(
|
||||
key="temperature", device_id="primary"
|
||||
): "Temperature",
|
||||
PassiveBluetoothEntityKey(key="humidity", device_id="primary"): "Humidity",
|
||||
PassiveBluetoothEntityKey(key="battery", device_id="primary"): "Battery",
|
||||
PassiveBluetoothEntityKey(
|
||||
key="signal_strength", device_id="primary"
|
||||
): "Signal Strength",
|
||||
},
|
||||
entity_data={
|
||||
PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642,
|
||||
PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2,
|
||||
|
@ -660,8 +669,9 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
|
|||
)
|
||||
|
||||
|
||||
async def test_integration_with_entity(hass):
|
||||
async def test_integration_with_entity(hass, mock_bleak_scanner_start):
|
||||
"""Test integration of PassiveBluetoothDataUpdateCoordinator with PassiveBluetoothCoordinatorEntity."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
update_count = 0
|
||||
|
||||
|
@ -691,7 +701,7 @@ async def test_integration_with_entity(hass):
|
|||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
coordinator.async_setup()
|
||||
coordinator.async_add_listener(MagicMock())
|
||||
|
||||
mock_add_entities = MagicMock()
|
||||
|
||||
|
@ -770,8 +780,9 @@ NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
|||
)
|
||||
|
||||
|
||||
async def test_integration_with_entity_without_a_device(hass):
|
||||
async def test_integration_with_entity_without_a_device(hass, mock_bleak_scanner_start):
|
||||
"""Test integration with PassiveBluetoothCoordinatorEntity with no device."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
@callback
|
||||
def _async_generate_mock_data(
|
||||
|
@ -795,7 +806,6 @@ async def test_integration_with_entity_without_a_device(hass):
|
|||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
coordinator.async_setup()
|
||||
|
||||
mock_add_entities = MagicMock()
|
||||
|
||||
|
@ -826,8 +836,12 @@ async def test_integration_with_entity_without_a_device(hass):
|
|||
)
|
||||
|
||||
|
||||
async def test_passive_bluetooth_entity_with_entity_platform(hass):
|
||||
async def test_passive_bluetooth_entity_with_entity_platform(
|
||||
hass, mock_bleak_scanner_start
|
||||
):
|
||||
"""Test with a mock entity platform."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
entity_platform = MockEntityPlatform(hass)
|
||||
|
||||
@callback
|
||||
|
@ -852,7 +866,6 @@ async def test_passive_bluetooth_entity_with_entity_platform(hass):
|
|||
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
|
||||
_async_register_callback,
|
||||
):
|
||||
coordinator.async_setup()
|
||||
|
||||
coordinator.async_add_entities_listener(
|
||||
PassiveBluetoothCoordinatorEntity,
|
||||
|
@ -865,5 +878,11 @@ async def test_passive_bluetooth_entity_with_entity_platform(hass):
|
|||
await hass.async_block_till_done()
|
||||
saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("test_domain.temperature") is not None
|
||||
assert hass.states.get("test_domain.pressure") is not None
|
||||
assert (
|
||||
hass.states.get("test_domain.test_platform_aa_bb_cc_dd_ee_ff_temperature")
|
||||
is not None
|
||||
)
|
||||
assert (
|
||||
hass.states.get("test_domain.test_platform_aa_bb_cc_dd_ee_ff_pressure")
|
||||
is not None
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue