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 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

View file

@ -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)

View file

@ -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:

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."""
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"

View file

@ -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
)