diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 5edc0053203..46d1e5e8332 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -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,12 +332,13 @@ class BluetoothManager: } if matched_domains: self._matched[match_key] = True - _LOGGER.debug( - "Device detected: %s with advertisement_data: %s matched domains: %s", - device, - advertisement_data, - matched_domains, - ) + + _LOGGER.debug( + "Device detected: %s with advertisement_data: %s matched domains: %s", + device, + advertisement_data, + matched_domains, + ) if not matched_domains and not self._callbacks: return @@ -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 diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index fd7559aeefa..ffb0ad107ec 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -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) diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index 77b3fc54aba..b2fdd5d7d58 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -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: diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json new file mode 100644 index 00000000000..925e9c512cc --- /dev/null +++ b/homeassistant/components/bluetooth/strings.json @@ -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}?" + } + } + } +} diff --git a/homeassistant/components/bluetooth/translations/en.json b/homeassistant/components/bluetooth/translations/en.json new file mode 100644 index 00000000000..f75dc2603db --- /dev/null +++ b/homeassistant/components/bluetooth/translations/en.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 1a002e5e354..e76ad559305 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -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" diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index a377a8f4a46..755b2ec07f8 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -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,35 +97,36 @@ 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 = [] - all_events = [] - mock_entity = MagicMock() - mock_add_entities = MagicMock() + entity_key = PassiveBluetoothEntityKey("temperature", None) + entity_key_events = [] + all_events = [] + mock_entity = MagicMock() + mock_add_entities = MagicMock() - def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: - """Mock entity key listener.""" - entity_key_events.append(data) + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) - cancel_async_add_entity_key_listener = coordinator.async_add_entity_key_listener( - _async_entity_key_listener, - entity_key, - ) + 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.""" - all_events.append(data) + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) - cancel_listener = coordinator.async_add_listener( - _all_listener, - ) + cancel_listener = coordinator.async_add_listener( + _all_listener, + ) - cancel_async_add_entities_listener = coordinator.async_add_entities_listener( - mock_entity, - mock_add_entities, - ) + cancel_async_add_entities_listener = coordinator.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) @@ -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,14 +194,13 @@ 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() - coordinator.async_add_entities_listener( - mock_entity, - mock_add_entities, - ) + mock_entity = MagicMock() + mock_add_entities = MagicMock() + coordinator.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) assert coordinator.available is False @@ -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,17 +265,16 @@ 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 = [] + all_events = [] - def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: - """Mock an all listener.""" - all_events.append(data) + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) - coordinator.async_add_listener( - _all_listener, - ) + coordinator.async_add_listener( + _all_listener, + ) saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert len(all_events) == 1 @@ -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,14 +806,13 @@ 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() + mock_add_entities = MagicMock() - coordinator.async_add_entities_listener( - PassiveBluetoothCoordinatorEntity, - mock_add_entities, - ) + coordinator.async_add_entities_listener( + PassiveBluetoothCoordinatorEntity, + mock_add_entities, + ) saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) # First call with just the remote sensor entities results in them being added @@ -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,18 +866,23 @@ 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, - lambda entities: hass.async_create_task( - entity_platform.async_add_entities(entities) - ), + coordinator.async_add_entities_listener( + PassiveBluetoothCoordinatorEntity, + lambda entities: hass.async_create_task( + entity_platform.async_add_entities(entities) + ), + ) + + saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) + 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.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 ) - - saved_callback(NO_DEVICES_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) - 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