Improve availability tracking and coordinator setup in bluetooth (#75582)
This commit is contained in:
parent
975378ba44
commit
90ca3fe350
7 changed files with 357 additions and 195 deletions
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from 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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
16
homeassistant/components/bluetooth/strings.json
Normal file
16
homeassistant/components/bluetooth/strings.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"flow_title": "{name}",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Choose a device to setup",
|
||||||
|
"data": {
|
||||||
|
"address": "Device"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bluetooth_confirm": {
|
||||||
|
"description": "Do you want to setup {name}?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
homeassistant/components/bluetooth/translations/en.json
Normal file
16
homeassistant/components/bluetooth/translations/en.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"flow_title": "{name}",
|
||||||
|
"step": {
|
||||||
|
"bluetooth_confirm": {
|
||||||
|
"description": "Do you want to setup {name}?"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"address": "Device"
|
||||||
|
},
|
||||||
|
"description": "Choose a device to setup"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
"""Tests for the Bluetooth integration."""
|
"""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"
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue