Improve availability tracking and coordinator setup in bluetooth (#75582)

This commit is contained in:
J. Nick Koston 2022-07-21 19:16:45 -05:00 committed by GitHub
parent 975378ba44
commit 90ca3fe350
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 357 additions and 195 deletions

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum from enum import Enum
import fnmatch import fnmatch
import logging import logging
@ -22,6 +23,7 @@ from homeassistant.core import (
callback as hass_callback, callback as hass_callback,
) )
from homeassistant.helpers import discovery_flow 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.service_info.bluetooth import BluetoothServiceInfo
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import ( from homeassistant.loader import (
@ -39,6 +41,8 @@ _LOGGER = logging.getLogger(__name__)
MAX_REMEMBER_ADDRESSES: Final = 2048 MAX_REMEMBER_ADDRESSES: Final = 2048
UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
SOURCE_LOCAL: Final = "local" SOURCE_LOCAL: Final = "local"
@ -160,6 +164,20 @@ def async_register_callback(
return manager.async_register_callback(callback, match_dict) 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the bluetooth integration.""" """Set up the bluetooth integration."""
integration_matchers = await async_get_bluetooth(hass) integration_matchers = await async_get_bluetooth(hass)
@ -231,6 +249,8 @@ class BluetoothManager:
self._integration_matchers = integration_matchers self._integration_matchers = integration_matchers
self.scanner: HaBleakScanner | None = None self.scanner: HaBleakScanner | None = None
self._cancel_device_detected: CALLBACK_TYPE | 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[ self._callbacks: list[
tuple[BluetoothCallback, BluetoothCallbackMatcher | None] tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
] = [] ] = []
@ -251,6 +271,7 @@ class BluetoothManager:
) )
return return
install_multiple_bleak_catcher(self.scanner) install_multiple_bleak_catcher(self.scanner)
self.async_setup_unavailable_tracking()
# We have to start it right away as some integrations might # We have to start it right away as some integrations might
# need it straight away. # need it straight away.
_LOGGER.debug("Starting bluetooth scanner") _LOGGER.debug("Starting bluetooth scanner")
@ -261,6 +282,34 @@ class BluetoothManager:
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
await self.scanner.start() 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 @hass_callback
def _device_detected( def _device_detected(
self, device: BLEDevice, advertisement_data: AdvertisementData self, device: BLEDevice, advertisement_data: AdvertisementData
@ -283,6 +332,7 @@ class BluetoothManager:
} }
if matched_domains: if matched_domains:
self._matched[match_key] = True self._matched[match_key] = True
_LOGGER.debug( _LOGGER.debug(
"Device detected: %s with advertisement_data: %s matched domains: %s", "Device detected: %s with advertisement_data: %s matched domains: %s",
device, device,
@ -321,6 +371,21 @@ class BluetoothManager:
service_info, 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 @hass_callback
def async_register_callback( def async_register_callback(
self, self,
@ -369,25 +434,17 @@ class BluetoothManager:
def async_address_present(self, address: str) -> bool: def async_address_present(self, address: str) -> bool:
"""Return if the address is present.""" """Return if the address is present."""
return bool( return bool(
models.HA_BLEAK_SCANNER models.HA_BLEAK_SCANNER and address in models.HA_BLEAK_SCANNER.history
and any(
device.address == address
for device in models.HA_BLEAK_SCANNER.discovered_devices
)
) )
@hass_callback @hass_callback
def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]: def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]:
"""Return if the address is present.""" """Return if the address is present."""
if models.HA_BLEAK_SCANNER: if models.HA_BLEAK_SCANNER:
discovered = models.HA_BLEAK_SCANNER.discovered_devices
history = models.HA_BLEAK_SCANNER.history history = models.HA_BLEAK_SCANNER.history
return [ return [
BluetoothServiceInfoBleak.from_advertisement( BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL)
*history[device.address], SOURCE_LOCAL for device_adv in history.values()
)
for device in discovered
if device.address in history
] ]
return [] return []
@ -396,6 +453,9 @@ class BluetoothManager:
if self._cancel_device_detected: if self._cancel_device_detected:
self._cancel_device_detected() self._cancel_device_detected()
self._cancel_device_detected = None self._cancel_device_detected = None
if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking()
self._cancel_unavailable_tracking = None
if self.scanner: if self.scanner:
await self.scanner.stop() await self.scanner.stop()
models.HA_BLEAK_SCANNER = None models.HA_BLEAK_SCANNER = None

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Mapping
import contextlib import contextlib
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
@ -14,7 +13,6 @@ from bleak.backends.scanner import (
AdvertisementDataCallback, AdvertisementDataCallback,
BaseBleakScanner, BaseBleakScanner,
) )
from lru import LRU # pylint: disable=no-name-in-module
from homeassistant.core import CALLBACK_TYPE, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, callback as hass_callback
@ -24,8 +22,6 @@ FILTER_UUIDS: Final = "UUIDs"
HA_BLEAK_SCANNER: HaBleakScanner | None = None HA_BLEAK_SCANNER: HaBleakScanner | None = None
MAX_HISTORY_SIZE: Final = 512
def _dispatch_callback( def _dispatch_callback(
callback: AdvertisementDataCallback, callback: AdvertisementDataCallback,
@ -57,9 +53,7 @@ class HaBleakScanner(BleakScanner): # type: ignore[misc]
self._callbacks: list[ self._callbacks: list[
tuple[AdvertisementDataCallback, dict[str, set[str]]] tuple[AdvertisementDataCallback, dict[str, set[str]]]
] = [] ] = []
self.history: Mapping[str, tuple[BLEDevice, AdvertisementData]] = LRU( self.history: dict[str, tuple[BLEDevice, AdvertisementData]] = {}
MAX_HISTORY_SIZE
)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@hass_callback @hass_callback
@ -90,7 +84,7 @@ class HaBleakScanner(BleakScanner): # type: ignore[misc]
Here we get the actual callback from bleak and dispatch Here we get the actual callback from bleak and dispatch
it to all the wrapped HaBleakScannerWrapper classes 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: for callback_filters in self._callbacks:
_dispatch_callback(*callback_filters, device, advertisement_data) _dispatch_callback(*callback_filters, device, advertisement_data)

View file

@ -1,9 +1,8 @@
"""The Bluetooth integration.""" """The Bluetooth integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
import dataclasses import dataclasses
from datetime import datetime
import logging import logging
import time import time
from typing import Any, Generic, TypeVar 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.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import ( from . import (
BluetoothCallbackMatcher, BluetoothCallbackMatcher,
BluetoothChange, BluetoothChange,
async_address_present,
async_register_callback, async_register_callback,
async_track_unavailable,
) )
from .const import DOMAIN from .const import DOMAIN
UNAVAILABLE_SECONDS = 60 * 5
NEVER_TIME = -UNAVAILABLE_SECONDS
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class PassiveBluetoothEntityKey: class PassiveBluetoothEntityKey:
@ -49,10 +44,13 @@ class PassiveBluetoothDataUpdate(Generic[_T]):
"""Generic bluetooth data.""" """Generic bluetooth data."""
devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict) devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict)
entity_descriptions: dict[ entity_descriptions: Mapping[
PassiveBluetoothEntityKey, EntityDescription PassiveBluetoothEntityKey, EntityDescription
] = dataclasses.field(default_factory=dict) ] = 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 default_factory=dict
) )
@ -106,6 +104,7 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
] = {} ] = {}
self.update_method = update_method self.update_method = update_method
self.entity_names: dict[PassiveBluetoothEntityKey, str | None] = {}
self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {} self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {}
self.entity_descriptions: dict[ self.entity_descriptions: dict[
PassiveBluetoothEntityKey, EntityDescription PassiveBluetoothEntityKey, EntityDescription
@ -113,54 +112,45 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self.devices: dict[str | None, DeviceInfo] = {} self.devices: dict[str | None, DeviceInfo] = {}
self.last_update_success = True self.last_update_success = True
self._last_callback_time: float = NEVER_TIME self._cancel_track_unavailable: CALLBACK_TYPE | None = None
self._cancel_track_available: CALLBACK_TYPE | None = None self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None
self._present = False self.present = False
self.last_seen = 0.0
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if the device is available.""" """Return if the device is available."""
return self._present and self.last_update_success return self.present and self.last_update_success
@callback @callback
def _async_cancel_available_tracker(self) -> None: def _async_handle_unavailable(self, _address: str) -> None:
"""Reset the available tracker.""" """Handle the device going unavailable."""
if self._cancel_track_available: self.present = False
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
self.async_update_listeners(None) self.async_update_listeners(None)
@callback @callback
def async_setup(self) -> CALLBACK_TYPE: def _async_start(self) -> None:
"""Start the callback.""" """Start the callbacks."""
return async_register_callback( self._cancel_bluetooth_advertisements = async_register_callback(
self.hass, self.hass,
self._async_handle_bluetooth_event, self._async_handle_bluetooth_event,
BluetoothCallbackMatcher(address=self.address), 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 @callback
def async_add_entities_listener( def async_add_entities_listener(
@ -199,10 +189,22 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
def remove_listener() -> None: def remove_listener() -> None:
"""Remove update listener.""" """Remove update listener."""
self._listeners.remove(update_callback) self._listeners.remove(update_callback)
self._async_handle_listeners_changed()
self._listeners.append(update_callback) self._listeners.append(update_callback)
self._async_handle_listeners_changed()
return remove_listener 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 @callback
def async_add_entity_key_listener( def async_add_entity_key_listener(
self, self,
@ -217,8 +219,10 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self._entity_key_listeners[entity_key].remove(update_callback) self._entity_key_listeners[entity_key].remove(update_callback)
if not self._entity_key_listeners[entity_key]: if not self._entity_key_listeners[entity_key]:
del 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._entity_key_listeners.setdefault(entity_key, []).append(update_callback)
self._async_handle_listeners_changed()
return remove_listener return remove_listener
@callback @callback
@ -242,11 +246,9 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
change: BluetoothChange, change: BluetoothChange,
) -> None: ) -> None:
"""Handle a Bluetooth event.""" """Handle a Bluetooth event."""
self.last_seen = time.monotonic()
self.name = service_info.name self.name = service_info.name
self._last_callback_time = time.monotonic() self.present = True
self._present = True
if not self._cancel_track_available:
self._async_schedule_available_tracker(UNAVAILABLE_SECONDS)
if self.hass.is_stopping: if self.hass.is_stopping:
return return
@ -272,6 +274,7 @@ class PassiveBluetoothDataUpdateCoordinator(Generic[_T]):
self.devices.update(new_data.devices) self.devices.update(new_data.devices)
self.entity_descriptions.update(new_data.entity_descriptions) self.entity_descriptions.update(new_data.entity_descriptions)
self.entity_data.update(new_data.entity_data) self.entity_data.update(new_data.entity_data)
self.entity_names.update(new_data.entity_names)
self.async_update_listeners(new_data) self.async_update_listeners(new_data)
@ -315,6 +318,7 @@ class PassiveBluetoothCoordinatorEntity(
self._attr_unique_id = f"{address}-{key}" self._attr_unique_id = f"{address}-{key}"
if ATTR_NAME not in self._attr_device_info: if ATTR_NAME not in self._attr_device_info:
self._attr_device_info[ATTR_NAME] = self.coordinator.name self._attr_device_info[ATTR_NAME] = self.coordinator.name
self._attr_name = coordinator.entity_names.get(entity_key)
@property @property
def available(self) -> bool: def available(self) -> bool:

View 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}?"
}
}
}
}

View 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"
}
}
}
}

View file

@ -1,4 +1,5 @@
"""Tests for the Bluetooth integration.""" """Tests for the Bluetooth integration."""
from datetime import timedelta
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bleak import BleakError from bleak import BleakError
@ -7,12 +8,18 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
SOURCE_LOCAL, SOURCE_LOCAL,
UNAVAILABLE_TRACK_SECONDS,
BluetoothChange, BluetoothChange,
BluetoothServiceInfo, BluetoothServiceInfo,
async_track_unavailable,
models, models,
) )
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import callback
from homeassistant.setup import async_setup_component 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): 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_device = BLEDevice("44:44:33:11:23:45", "wohand")
switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[])
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) 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() await hass.async_block_till_done()
service_infos = bluetooth.async_discovered_service_info(hass) 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 assert len(service_infos) == 1
# wrong_name should not appear because bleak no longer sees it # wrong_name should not appear because bleak no longer sees it
assert service_infos[0].name == "wohand" assert service_infos[0].name == "wohand"

View file

@ -3,15 +3,17 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
import time
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from home_assistant_bluetooth import BluetoothServiceInfo from home_assistant_bluetooth import BluetoothServiceInfo
import pytest 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 ( from homeassistant.components.bluetooth.passive_update_coordinator import (
UNAVAILABLE_SECONDS,
PassiveBluetoothCoordinatorEntity, PassiveBluetoothCoordinatorEntity,
PassiveBluetoothDataUpdate, PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator, PassiveBluetoothDataUpdateCoordinator,
@ -21,6 +23,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescr
from homeassistant.const import TEMP_CELSIUS from homeassistant.const import TEMP_CELSIUS
from homeassistant.core import CoreState, callback from homeassistant.core import CoreState, callback
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from tests.common import MockEntityPlatform, async_fire_time_changed 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("temperature", None): 14.5,
PassiveBluetoothEntityKey("pressure", None): 1234, PassiveBluetoothEntityKey("pressure", None): 1234,
}, },
entity_names={
PassiveBluetoothEntityKey("temperature", None): "Temperature",
PassiveBluetoothEntityKey("pressure", None): "Pressure",
},
entity_descriptions={ entity_descriptions={
PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription(
key="temperature", key="temperature",
name="Temperature",
native_unit_of_measurement=TEMP_CELSIUS, native_unit_of_measurement=TEMP_CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
), ),
PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription(
key="pressure", key="pressure",
name="Pressure",
native_unit_of_measurement="hPa", native_unit_of_measurement="hPa",
device_class=SensorDeviceClass.PRESSURE, 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.""" """Test basic usage of the PassiveBluetoothDataUpdateCoordinator."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@callback @callback
def _async_generate_mock_data( def _async_generate_mock_data(
@ -91,7 +97,6 @@ async def test_basic_usage(hass):
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
cancel_coordinator = coordinator.async_setup()
entity_key = PassiveBluetoothEntityKey("temperature", None) entity_key = PassiveBluetoothEntityKey("temperature", None)
entity_key_events = [] entity_key_events = []
@ -103,10 +108,12 @@ async def test_basic_usage(hass):
"""Mock entity key listener.""" """Mock entity key listener."""
entity_key_events.append(data) 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, _async_entity_key_listener,
entity_key, entity_key,
) )
)
def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None:
"""Mock an all listener.""" """Mock an all listener."""
@ -155,11 +162,15 @@ async def test_basic_usage(hass):
assert len(mock_entity.mock_calls) == 2 assert len(mock_entity.mock_calls) == 2
assert coordinator.available is True assert coordinator.available is True
cancel_coordinator()
async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
async def test_unavailable_after_no_data(hass):
"""Test that the coordinator is unavailable after no data for a while.""" """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 @callback
def _async_generate_mock_data( 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", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
cancel_coordinator = coordinator.async_setup()
mock_entity = MagicMock() mock_entity = MagicMock()
mock_add_entities = 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 len(mock_add_entities.mock_calls) == 1
assert coordinator.available is True assert coordinator.available is True
monotonic_now = time.monotonic()
now = dt_util.utcnow()
with patch( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.time.monotonic", "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices",
return_value=monotonic_now + UNAVAILABLE_SECONDS, [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() await hass.async_block_till_done()
assert coordinator.available is False assert coordinator.available is False
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(mock_add_entities.mock_calls) == 1
assert coordinator.available is True 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( with patch(
"homeassistant.components.bluetooth.passive_update_coordinator.async_address_present", "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices",
return_value=True, [MagicMock(address="44:44:33:11:23:45")],
), patch( ), patch(
"homeassistant.components.bluetooth.passive_update_coordinator.time.monotonic", "homeassistant.components.bluetooth.models.HA_BLEAK_SCANNER.history",
return_value=monotonic_now + UNAVAILABLE_SECONDS, {"aa:bb:cc:dd:ee:ff": MagicMock()},
): ):
async_fire_time_changed(hass, now + timedelta(seconds=UNAVAILABLE_SECONDS)) async_fire_time_changed(
await hass.async_block_till_done() hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
)
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))
await hass.async_block_till_done() await hass.async_block_till_done()
assert coordinator.available is False assert coordinator.available is False
cancel_coordinator()
async def test_no_updates_once_stopping(hass, mock_bleak_scanner_start):
async def test_no_updates_once_stopping(hass):
"""Test updates are ignored once hass is stopping.""" """Test updates are ignored once hass is stopping."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@callback @callback
def _async_generate_mock_data( 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", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
cancel_coordinator = coordinator.async_setup()
all_events = [] all_events = []
@ -288,11 +285,11 @@ async def test_no_updates_once_stopping(hass):
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(all_events) == 1 assert len(all_events) == 1
cancel_coordinator()
async def test_exception_from_update_method(hass, caplog, mock_bleak_scanner_start):
async def test_exception_from_update_method(hass, caplog):
"""Test we handle exceptions from the update method.""" """Test we handle exceptions from the update method."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
run_count = 0 run_count = 0
@callback @callback
@ -321,7 +318,7 @@ async def test_exception_from_update_method(hass, caplog):
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
cancel_coordinator = coordinator.async_setup() coordinator.async_add_listener(MagicMock())
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is True 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) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is True assert coordinator.available is True
cancel_coordinator()
async def test_bad_data_from_update_method(hass, mock_bleak_scanner_start):
async def test_bad_data_from_update_method(hass):
"""Test we handle bad data from the update method.""" """Test we handle bad data from the update method."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
run_count = 0 run_count = 0
@callback @callback
@ -368,7 +365,7 @@ async def test_bad_data_from_update_method(hass):
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
cancel_coordinator = coordinator.async_setup() coordinator.async_add_listener(MagicMock())
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is True 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) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert coordinator.available is True assert coordinator.available is True
cancel_coordinator()
GOVEE_B5178_REMOTE_SERVICE_INFO = BluetoothServiceInfo( GOVEE_B5178_REMOTE_SERVICE_INFO = BluetoothServiceInfo(
name="B5178D6FB", name="B5178D6FB",
@ -429,7 +424,6 @@ GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Temperature",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="°C", native_unit_of_measurement="°C",
@ -446,7 +440,6 @@ GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Humidity",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="%", native_unit_of_measurement="%",
@ -463,7 +456,6 @@ GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Battery",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="%", native_unit_of_measurement="%",
@ -480,13 +472,20 @@ GOVEE_B5178_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Signal Strength",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="dBm", native_unit_of_measurement="dBm",
state_class=None, 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={ entity_data={
PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642, PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642,
PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2, PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2,
@ -520,7 +519,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Temperature",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="°C", native_unit_of_measurement="°C",
@ -537,7 +535,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Humidity",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="%", native_unit_of_measurement="%",
@ -554,7 +551,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Battery",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="%", native_unit_of_measurement="%",
@ -571,7 +567,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Signal Strength",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="dBm", native_unit_of_measurement="dBm",
@ -588,7 +583,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Temperature",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="°C", native_unit_of_measurement="°C",
@ -605,7 +599,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Humidity",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="%", native_unit_of_measurement="%",
@ -622,7 +615,6 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Battery",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="%", native_unit_of_measurement="%",
@ -639,13 +631,30 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = (
force_update=False, force_update=False,
icon=None, icon=None,
has_entity_name=False, has_entity_name=False,
name="Signal Strength",
unit_of_measurement=None, unit_of_measurement=None,
last_reset=None, last_reset=None,
native_unit_of_measurement="dBm", native_unit_of_measurement="dBm",
state_class=None, 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={ entity_data={
PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642, PassiveBluetoothEntityKey(key="temperature", device_id="remote"): 30.8642,
PassiveBluetoothEntityKey(key="humidity", device_id="remote"): 64.2, 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.""" """Test integration of PassiveBluetoothDataUpdateCoordinator with PassiveBluetoothCoordinatorEntity."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
update_count = 0 update_count = 0
@ -691,7 +701,7 @@ async def test_integration_with_entity(hass):
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
coordinator.async_setup() coordinator.async_add_listener(MagicMock())
mock_add_entities = 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.""" """Test integration with PassiveBluetoothCoordinatorEntity with no device."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
@callback @callback
def _async_generate_mock_data( 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", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
coordinator.async_setup()
mock_add_entities = MagicMock() 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.""" """Test with a mock entity platform."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
entity_platform = MockEntityPlatform(hass) entity_platform = MockEntityPlatform(hass)
@callback @callback
@ -852,7 +866,6 @@ async def test_passive_bluetooth_entity_with_entity_platform(hass):
"homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback", "homeassistant.components.bluetooth.passive_update_coordinator.async_register_callback",
_async_register_callback, _async_register_callback,
): ):
coordinator.async_setup()
coordinator.async_add_entities_listener( coordinator.async_add_entities_listener(
PassiveBluetoothCoordinatorEntity, PassiveBluetoothCoordinatorEntity,
@ -865,5 +878,11 @@ async def test_passive_bluetooth_entity_with_entity_platform(hass):
await hass.async_block_till_done() await hass.async_block_till_done()
saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get("test_domain.temperature") is not None assert (
assert hass.states.get("test_domain.pressure") is not None 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
)