Add support for scanners that do not provide connectable devices (#77132)

This commit is contained in:
J. Nick Koston 2022-08-22 08:02:26 -10:00 committed by GitHub
parent 61ff1b786b
commit 3938015c93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1088 additions and 385 deletions

View File

@ -2,15 +2,15 @@
from __future__ import annotations from __future__ import annotations
from asyncio import Future from asyncio import Future
from collections.abc import Callable from collections.abc import Callable, Iterable
import platform import platform
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, cast
import async_timeout import async_timeout
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, discovery_flow from homeassistant.helpers import device_registry as dr, discovery_flow
from homeassistant.loader import async_get_bluetooth from homeassistant.loader import async_get_bluetooth
@ -31,6 +31,7 @@ from .const import (
from .manager import BluetoothManager from .manager import BluetoothManager
from .match import BluetoothCallbackMatcher, IntegrationMatcher from .match import BluetoothCallbackMatcher, IntegrationMatcher
from .models import ( from .models import (
BaseHaScanner,
BluetoothCallback, BluetoothCallback,
BluetoothChange, BluetoothChange,
BluetoothScanningMode, BluetoothScanningMode,
@ -56,6 +57,7 @@ __all__ = [
"async_rediscover_address", "async_rediscover_address",
"async_register_callback", "async_register_callback",
"async_track_unavailable", "async_track_unavailable",
"BaseHaScanner",
"BluetoothServiceInfo", "BluetoothServiceInfo",
"BluetoothServiceInfoBleak", "BluetoothServiceInfoBleak",
"BluetoothScanningMode", "BluetoothScanningMode",
@ -64,6 +66,11 @@ __all__ = [
] ]
def _get_manager(hass: HomeAssistant) -> BluetoothManager:
"""Get the bluetooth manager."""
return cast(BluetoothManager, hass.data[DATA_MANAGER])
@hass_callback @hass_callback
def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper: def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
"""Return a HaBleakScannerWrapper. """Return a HaBleakScannerWrapper.
@ -76,37 +83,32 @@ def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
@hass_callback @hass_callback
def async_discovered_service_info( def async_discovered_service_info(
hass: HomeAssistant, hass: HomeAssistant, connectable: bool = True
) -> list[BluetoothServiceInfoBleak]: ) -> Iterable[BluetoothServiceInfoBleak]:
"""Return the discovered devices list.""" """Return the discovered devices list."""
if DATA_MANAGER not in hass.data: if DATA_MANAGER not in hass.data:
return [] return []
manager: BluetoothManager = hass.data[DATA_MANAGER] return _get_manager(hass).async_discovered_service_info(connectable)
return manager.async_discovered_service_info()
@hass_callback @hass_callback
def async_ble_device_from_address( def async_ble_device_from_address(
hass: HomeAssistant, hass: HomeAssistant, address: str, connectable: bool = True
address: str,
) -> BLEDevice | None: ) -> BLEDevice | None:
"""Return BLEDevice for an address if its present.""" """Return BLEDevice for an address if its present."""
if DATA_MANAGER not in hass.data: if DATA_MANAGER not in hass.data:
return None return None
manager: BluetoothManager = hass.data[DATA_MANAGER] return _get_manager(hass).async_ble_device_from_address(address, connectable)
return manager.async_ble_device_from_address(address)
@hass_callback @hass_callback
def async_address_present( def async_address_present(
hass: HomeAssistant, hass: HomeAssistant, address: str, connectable: bool = True
address: str,
) -> bool: ) -> bool:
"""Check if an address is present in the bluetooth device list.""" """Check if an address is present in the bluetooth device list."""
if DATA_MANAGER not in hass.data: if DATA_MANAGER not in hass.data:
return False return False
manager: BluetoothManager = hass.data[DATA_MANAGER] return _get_manager(hass).async_address_present(address, connectable)
return manager.async_address_present(address)
@hass_callback @hass_callback
@ -125,8 +127,7 @@ def async_register_callback(
Returns a callback that can be used to cancel the registration. Returns a callback that can be used to cancel the registration.
""" """
manager: BluetoothManager = hass.data[DATA_MANAGER] return _get_manager(hass).async_register_callback(callback, match_dict)
return manager.async_register_callback(callback, match_dict)
async def async_process_advertisements( async def async_process_advertisements(
@ -146,7 +147,9 @@ async def async_process_advertisements(
if not done.done() and callback(service_info): if not done.done() and callback(service_info):
done.set_result(service_info) done.set_result(service_info)
unload = async_register_callback(hass, _async_discovered_device, match_dict, mode) unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict
)
try: try:
async with async_timeout.timeout(timeout): async with async_timeout.timeout(timeout):
@ -160,26 +163,47 @@ def async_track_unavailable(
hass: HomeAssistant, hass: HomeAssistant,
callback: Callable[[str], None], callback: Callable[[str], None],
address: str, address: str,
connectable: bool = True,
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Register to receive a callback when an address is unavailable. """Register to receive a callback when an address is unavailable.
Returns a callback that can be used to cancel the registration. Returns a callback that can be used to cancel the registration.
""" """
manager: BluetoothManager = hass.data[DATA_MANAGER] return _get_manager(hass).async_track_unavailable(callback, address, connectable)
return manager.async_track_unavailable(callback, address)
@hass_callback @hass_callback
def async_rediscover_address(hass: HomeAssistant, address: str) -> None: def async_rediscover_address(hass: HomeAssistant, address: str) -> None:
"""Trigger discovery of devices which have already been seen.""" """Trigger discovery of devices which have already been seen."""
manager: BluetoothManager = hass.data[DATA_MANAGER] _get_manager(hass).async_rediscover_address(address)
manager.async_rediscover_address(address)
@hass_callback
def async_register_scanner(
hass: HomeAssistant, scanner: BaseHaScanner, connectable: bool
) -> CALLBACK_TYPE:
"""Register a BleakScanner."""
return _get_manager(hass).async_register_scanner(scanner, connectable)
@hass_callback
def async_get_advertisement_callback(
hass: HomeAssistant,
) -> Callable[[BluetoothServiceInfoBleak], None]:
"""Get the advertisement callback."""
return _get_manager(hass).scanner_adv_received
async def async_get_adapter_from_address(
hass: HomeAssistant, address: str
) -> str | None:
"""Get an adapter by the address."""
return await _get_manager(hass).async_get_adapter_from_address(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_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass))
manager = BluetoothManager(hass, integration_matcher) manager = BluetoothManager(hass, integration_matcher)
manager.async_setup() manager.async_setup()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop)
@ -236,10 +260,9 @@ async def async_discover_adapters(
async def async_update_device( async def async_update_device(
hass: HomeAssistant,
entry: config_entries.ConfigEntry, entry: config_entries.ConfigEntry,
manager: BluetoothManager,
adapter: str, adapter: str,
address: str,
) -> None: ) -> None:
"""Update device registry entry. """Update device registry entry.
@ -248,6 +271,7 @@ async def async_update_device(
update the device with the new location so they can update the device with the new location so they can
figure out where the adapter is. figure out where the adapter is.
""" """
manager: BluetoothManager = hass.data[DATA_MANAGER]
adapters = await manager.async_get_bluetooth_adapters() adapters = await manager.async_get_bluetooth_adapters()
details = adapters[adapter] details = adapters[adapter]
registry = dr.async_get(manager.hass) registry = dr.async_get(manager.hass)
@ -264,10 +288,9 @@ async def async_setup_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool: ) -> bool:
"""Set up a config entry for a bluetooth scanner.""" """Set up a config entry for a bluetooth scanner."""
manager: BluetoothManager = hass.data[DATA_MANAGER]
address = entry.unique_id address = entry.unique_id
assert address is not None assert address is not None
adapter = await manager.async_get_adapter_from_address(address) adapter = await async_get_adapter_from_address(hass, address)
if adapter is None: if adapter is None:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Bluetooth adapter {adapter} with address {address} not found" f"Bluetooth adapter {adapter} with address {address} not found"
@ -280,13 +303,14 @@ async def async_setup_entry(
f"{adapter_human_name(adapter, address)}: {err}" f"{adapter_human_name(adapter, address)}: {err}"
) from err ) from err
scanner = HaScanner(hass, bleak_scanner, adapter, address) scanner = HaScanner(hass, bleak_scanner, adapter, address)
entry.async_on_unload(scanner.async_register_callback(manager.scanner_adv_received)) info_callback = async_get_advertisement_callback(hass)
entry.async_on_unload(scanner.async_register_callback(info_callback))
try: try:
await scanner.async_start() await scanner.async_start()
except ScannerStartError as err: except ScannerStartError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
entry.async_on_unload(manager.async_register_scanner(scanner)) entry.async_on_unload(async_register_scanner(hass, scanner, True))
await async_update_device(entry, manager, adapter, address) await async_update_device(hass, entry, adapter)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner
return True return True

View File

@ -61,9 +61,10 @@ class ActiveBluetoothProcessorCoordinator(
] ]
| None = None, | None = None,
poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None,
connectable: bool = True,
) -> None: ) -> None:
"""Initialize the processor.""" """Initialize the processor."""
super().__init__(hass, logger, address, mode, update_method) super().__init__(hass, logger, address, mode, update_method, connectable)
self._needs_poll_method = needs_poll_method self._needs_poll_method = needs_poll_method
self._poll_method = poll_method self._poll_method = poll_method

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
import itertools import itertools
import logging import logging
@ -22,18 +21,23 @@ from homeassistant.helpers.event import async_track_time_interval
from .const import ( from .const import (
ADAPTER_ADDRESS, ADAPTER_ADDRESS,
SOURCE_LOCAL,
STALE_ADVERTISEMENT_SECONDS, STALE_ADVERTISEMENT_SECONDS,
UNAVAILABLE_TRACK_SECONDS, UNAVAILABLE_TRACK_SECONDS,
AdapterDetails, AdapterDetails,
) )
from .match import ( from .match import (
ADDRESS, ADDRESS,
CONNECTABLE,
BluetoothCallbackMatcher, BluetoothCallbackMatcher,
IntegrationMatcher, IntegrationMatcher,
ble_device_matches, ble_device_matches,
) )
from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak from .models import (
BaseHaScanner,
BluetoothCallback,
BluetoothChange,
BluetoothServiceInfoBleak,
)
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_get_bluetooth_adapters from .util import async_get_bluetooth_adapters
@ -41,7 +45,6 @@ if TYPE_CHECKING:
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from .scanner import HaScanner
FILTER_UUIDS: Final = "UUIDs" FILTER_UUIDS: Final = "UUIDs"
@ -51,43 +54,39 @@ RSSI_SWITCH_THRESHOLD = 6
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@dataclass def _prefer_previous_adv(
class AdvertisementHistory: old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak
"""Bluetooth advertisement history.""" ) -> bool:
ble_device: BLEDevice
advertisement_data: AdvertisementData
time: float
source: str
def _prefer_previous_adv(old: AdvertisementHistory, new: AdvertisementHistory) -> bool:
"""Prefer previous advertisement if it is better.""" """Prefer previous advertisement if it is better."""
if new.time - old.time > STALE_ADVERTISEMENT_SECONDS: if new.time - old.time > STALE_ADVERTISEMENT_SECONDS:
# If the old advertisement is stale, any new advertisement is preferred # If the old advertisement is stale, any new advertisement is preferred
if new.source != old.source: if new.source != old.source:
_LOGGER.debug( _LOGGER.debug(
"%s (%s): Switching from %s to %s (time_elapsed:%s > stale_seconds:%s)", "%s (%s): Switching from %s[%s] to %s[%s] (time elapsed:%s > stale seconds:%s)",
new.advertisement_data.local_name, new.advertisement.local_name,
new.ble_device.address, new.device.address,
old.source, old.source,
old.connectable,
new.source, new.source,
new.connectable,
new.time - old.time, new.time - old.time,
STALE_ADVERTISEMENT_SECONDS, STALE_ADVERTISEMENT_SECONDS,
) )
return False return False
if new.ble_device.rssi - RSSI_SWITCH_THRESHOLD > old.ble_device.rssi: if new.device.rssi - RSSI_SWITCH_THRESHOLD > old.device.rssi:
# If new advertisement is RSSI_SWITCH_THRESHOLD more, the new one is preferred # If new advertisement is RSSI_SWITCH_THRESHOLD more, the new one is preferred
if new.source != old.source: if new.source != old.source:
_LOGGER.debug( _LOGGER.debug(
"%s (%s): Switching from %s to %s (new_rssi:%s - threadshold:%s > old_rssi:%s)", "%s (%s): Switching from %s[%s] to %s[%s] (new rssi:%s - threshold:%s > old rssi:%s)",
new.advertisement_data.local_name, new.advertisement.local_name,
new.ble_device.address, new.device.address,
old.source, old.source,
old.connectable,
new.source, new.source,
new.ble_device.rssi, new.connectable,
new.device.rssi,
RSSI_SWITCH_THRESHOLD, RSSI_SWITCH_THRESHOLD,
old.ble_device.rssi, old.device.rssi,
) )
return False return False
# If the source is the different, the old one is preferred because its # If the source is the different, the old one is preferred because its
@ -128,16 +127,24 @@ class BluetoothManager:
"""Init bluetooth manager.""" """Init bluetooth manager."""
self.hass = hass self.hass = hass
self._integration_matcher = integration_matcher self._integration_matcher = integration_matcher
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None self._cancel_unavailable_tracking: list[CALLBACK_TYPE] = []
self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {} self._unavailable_callbacks: dict[str, list[Callable[[str], None]]] = {}
self._connectable_unavailable_callbacks: dict[
str, list[Callable[[str], None]]
] = {}
self._callbacks: list[ self._callbacks: list[
tuple[BluetoothCallback, BluetoothCallbackMatcher | None] tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
] = [] ] = []
self._connectable_callbacks: list[
tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
] = []
self._bleak_callbacks: list[ self._bleak_callbacks: list[
tuple[AdvertisementDataCallback, dict[str, set[str]]] tuple[AdvertisementDataCallback, dict[str, set[str]]]
] = [] ] = []
self.history: dict[str, AdvertisementHistory] = {} self._history: dict[str, BluetoothServiceInfoBleak] = {}
self._scanners: list[HaScanner] = [] self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {}
self._scanners: list[BaseHaScanner] = []
self._connectable_scanners: list[BaseHaScanner] = []
self._adapters: dict[str, AdapterDetails] = {} self._adapters: dict[str, AdapterDetails] = {}
def _find_adapter_by_address(self, address: str) -> str | None: def _find_adapter_by_address(self, address: str) -> str | None:
@ -146,9 +153,11 @@ class BluetoothManager:
return adapter return adapter
return None return None
async def async_get_bluetooth_adapters(self) -> dict[str, AdapterDetails]: async def async_get_bluetooth_adapters(
self, cached: bool = True
) -> dict[str, AdapterDetails]:
"""Get bluetooth adapters.""" """Get bluetooth adapters."""
if not self._adapters: if not cached or not self._adapters:
self._adapters = await async_get_bluetooth_adapters() self._adapters = await async_get_bluetooth_adapters()
return self._adapters return self._adapters
@ -170,37 +179,51 @@ class BluetoothManager:
"""Stop the Bluetooth integration at shutdown.""" """Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager") _LOGGER.debug("Stopping bluetooth manager")
if self._cancel_unavailable_tracking: if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking() for cancel in self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking = None cancel()
self._cancel_unavailable_tracking.clear()
uninstall_multiple_bleak_catcher() uninstall_multiple_bleak_catcher()
@hass_callback @hass_callback
def async_all_discovered_devices(self) -> Iterable[BLEDevice]: def async_all_discovered_devices(self, connectable: bool) -> Iterable[BLEDevice]:
"""Return all of discovered devices from all the scanners including duplicates.""" """Return all of discovered devices from all the scanners including duplicates."""
return itertools.chain.from_iterable( return itertools.chain.from_iterable(
scanner.discovered_devices for scanner in self._scanners scanner.discovered_devices
for scanner in self._get_scanners_by_type(connectable)
) )
@hass_callback @hass_callback
def async_discovered_devices(self) -> list[BLEDevice]: def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]:
"""Return all of combined best path to discovered from all the scanners.""" """Return all of combined best path to discovered from all the scanners."""
return [history.ble_device for history in self.history.values()] return [
history.device
for history in self._get_history_by_type(connectable).values()
]
@hass_callback @hass_callback
def async_setup_unavailable_tracking(self) -> None: def async_setup_unavailable_tracking(self) -> None:
"""Set up the unavailable tracking.""" """Set up the unavailable tracking."""
self._async_setup_unavailable_tracking(True)
self._async_setup_unavailable_tracking(False)
@hass_callback
def _async_setup_unavailable_tracking(self, connectable: bool) -> None:
"""Set up the unavailable tracking."""
unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable)
history = self._get_history_by_type(connectable)
@hass_callback @hass_callback
def _async_check_unavailable(now: datetime) -> None: def _async_check_unavailable(now: datetime) -> None:
"""Watch for unavailable devices.""" """Watch for unavailable devices."""
history_set = set(self.history) history_set = set(history)
active_addresses = { active_addresses = {
device.address for device in self.async_all_discovered_devices() device.address
for device in self.async_all_discovered_devices(connectable)
} }
disappeared = history_set.difference(active_addresses) disappeared = history_set.difference(active_addresses)
for address in disappeared: for address in disappeared:
del self.history[address] del history[address]
if not (callbacks := self._unavailable_callbacks.get(address)): if not (callbacks := unavailable_callbacks.get(address)):
continue continue
for callback in callbacks: for callback in callbacks:
try: try:
@ -208,20 +231,16 @@ class BluetoothManager:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in unavailable callback") _LOGGER.exception("Error in unavailable callback")
self._cancel_unavailable_tracking = async_track_time_interval( self._cancel_unavailable_tracking.append(
self.hass, async_track_time_interval(
_async_check_unavailable, self.hass,
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), _async_check_unavailable,
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
)
) )
@hass_callback @hass_callback
def scanner_adv_received( def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None:
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
monotonic_time: float,
source: str,
) -> None:
"""Handle a new advertisement from any scanner. """Handle a new advertisement from any scanner.
Callbacks from all the scanners arrive here. Callbacks from all the scanners arrive here.
@ -233,42 +252,46 @@ class BluetoothManager:
than the source from the history or the timestamp than the source from the history or the timestamp
in the history is older than 180s in the history is older than 180s
""" """
new_history = AdvertisementHistory( device = service_info.device
device, advertisement_data, monotonic_time, source connectable = service_info.connectable
) address = device.address
if (old_history := self.history.get(device.address)) and _prefer_previous_adv( all_history = self._get_history_by_type(connectable)
old_history, new_history old_service_info = all_history.get(address)
): if old_service_info and _prefer_previous_adv(old_service_info, service_info):
return return
self.history[device.address] = new_history self._history[address] = service_info
advertisement_data = service_info.advertisement
source = service_info.source
for callback_filters in self._bleak_callbacks: if connectable:
_dispatch_bleak_callback(*callback_filters, device, advertisement_data) self._connectable_history[address] = service_info
# Bleak callbacks must get a connectable device
matched_domains = self._integration_matcher.match_domains( for callback_filters in self._bleak_callbacks:
device, advertisement_data _dispatch_bleak_callback(*callback_filters, device, advertisement_data)
)
matched_domains = self._integration_matcher.match_domains(service_info)
_LOGGER.debug( _LOGGER.debug(
"%s: %s %s match: %s", "%s: %s %s connectable: %s match: %s",
source, source,
device.address, address,
advertisement_data, advertisement_data,
connectable,
matched_domains, matched_domains,
) )
if not matched_domains and not self._callbacks: if (
not matched_domains
and not self._callbacks
and not self._connectable_callbacks
):
return return
service_info: BluetoothServiceInfoBleak | None = None for connectable_callback in (True, False):
for callback, matcher in self._callbacks: for callback, matcher in self._get_callbacks_by_type(connectable_callback):
if matcher is None or ble_device_matches( if matcher and not ble_device_matches(matcher, service_info):
matcher, device, advertisement_data continue
):
if service_info is None:
service_info = BluetoothServiceInfoBleak.from_advertisement(
device, advertisement_data, source
)
try: try:
callback(service_info, BluetoothChange.ADVERTISEMENT) callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
@ -276,10 +299,6 @@ class BluetoothManager:
if not matched_domains: if not matched_domains:
return return
if service_info is None:
service_info = BluetoothServiceInfoBleak.from_advertisement(
device, advertisement_data, source
)
for domain in matched_domains: for domain in matched_domains:
discovery_flow.async_create_flow( discovery_flow.async_create_flow(
self.hass, self.hass,
@ -290,16 +309,17 @@ class BluetoothManager:
@hass_callback @hass_callback
def async_track_unavailable( def async_track_unavailable(
self, callback: Callable[[str], None], address: str self, callback: Callable[[str], None], address: str, connectable: bool
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Register a callback.""" """Register a callback."""
self._unavailable_callbacks.setdefault(address, []).append(callback) unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable)
unavailable_callbacks.setdefault(address, []).append(callback)
@hass_callback @hass_callback
def _async_remove_callback() -> None: def _async_remove_callback() -> None:
self._unavailable_callbacks[address].remove(callback) unavailable_callbacks[address].remove(callback)
if not self._unavailable_callbacks[address]: if not unavailable_callbacks[address]:
del self._unavailable_callbacks[address] del unavailable_callbacks[address]
return _async_remove_callback return _async_remove_callback
@ -307,70 +327,102 @@ class BluetoothManager:
def async_register_callback( def async_register_callback(
self, self,
callback: BluetoothCallback, callback: BluetoothCallback,
matcher: BluetoothCallbackMatcher | None = None, matcher: BluetoothCallbackMatcher | None,
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Register a callback.""" """Register a callback."""
if not matcher:
matcher = BluetoothCallbackMatcher(connectable=True)
if CONNECTABLE not in matcher:
matcher[CONNECTABLE] = True
connectable = matcher[CONNECTABLE]
callback_entry = (callback, matcher) callback_entry = (callback, matcher)
self._callbacks.append(callback_entry) callbacks = self._get_callbacks_by_type(connectable)
callbacks.append(callback_entry)
@hass_callback @hass_callback
def _async_remove_callback() -> None: def _async_remove_callback() -> None:
self._callbacks.remove(callback_entry) callbacks.remove(callback_entry)
# If we have history for the subscriber, we can trigger the callback # If we have history for the subscriber, we can trigger the callback
# immediately with the last packet so the subscriber can see the # immediately with the last packet so the subscriber can see the
# device. # device.
all_history = self._get_history_by_type(connectable)
if ( if (
matcher (address := matcher.get(ADDRESS))
and (address := matcher.get(ADDRESS)) and (service_info := all_history.get(address))
and (history := self.history.get(address)) and ble_device_matches(matcher, service_info)
): ):
try: try:
callback( callback(service_info, BluetoothChange.ADVERTISEMENT)
BluetoothServiceInfoBleak.from_advertisement(
history.ble_device, history.advertisement_data, SOURCE_LOCAL
),
BluetoothChange.ADVERTISEMENT,
)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback") _LOGGER.exception("Error in bluetooth callback")
return _async_remove_callback return _async_remove_callback
@hass_callback @hass_callback
def async_ble_device_from_address(self, address: str) -> BLEDevice | None: def async_ble_device_from_address(
self, address: str, connectable: bool
) -> BLEDevice | None:
"""Return the BLEDevice if present.""" """Return the BLEDevice if present."""
if history := self.history.get(address): all_history = self._get_history_by_type(connectable)
return history.ble_device if history := all_history.get(address):
return history.device
return None return None
@hass_callback @hass_callback
def async_address_present(self, address: str) -> bool: def async_address_present(self, address: str, connectable: bool) -> bool:
"""Return if the address is present.""" """Return if the address is present."""
return address in self.history return address in self._get_history_by_type(connectable)
@hass_callback @hass_callback
def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]: def async_discovered_service_info(
self, connectable: bool
) -> Iterable[BluetoothServiceInfoBleak]:
"""Return if the address is present.""" """Return if the address is present."""
return [ return self._get_history_by_type(connectable).values()
BluetoothServiceInfoBleak.from_advertisement(
history.ble_device, history.advertisement_data, SOURCE_LOCAL
)
for history in self.history.values()
]
@hass_callback @hass_callback
def async_rediscover_address(self, address: str) -> None: def async_rediscover_address(self, address: str) -> None:
"""Trigger discovery of devices which have already been seen.""" """Trigger discovery of devices which have already been seen."""
self._integration_matcher.async_clear_address(address) self._integration_matcher.async_clear_address(address)
def async_register_scanner(self, scanner: HaScanner) -> CALLBACK_TYPE: def _get_scanners_by_type(self, connectable: bool) -> list[BaseHaScanner]:
"""Return the scanners by type."""
return self._connectable_scanners if connectable else self._scanners
def _get_unavailable_callbacks_by_type(
self, connectable: bool
) -> dict[str, list[Callable[[str], None]]]:
"""Return the unavailable callbacks by type."""
return (
self._connectable_unavailable_callbacks
if connectable
else self._unavailable_callbacks
)
def _get_history_by_type(
self, connectable: bool
) -> dict[str, BluetoothServiceInfoBleak]:
"""Return the history by type."""
return self._connectable_history if connectable else self._history
def _get_callbacks_by_type(
self, connectable: bool
) -> list[tuple[BluetoothCallback, BluetoothCallbackMatcher | None]]:
"""Return the callbacks by type."""
return self._connectable_callbacks if connectable else self._callbacks
def async_register_scanner(
self, scanner: BaseHaScanner, connectable: bool
) -> CALLBACK_TYPE:
"""Register a new scanner.""" """Register a new scanner."""
scanners = self._get_scanners_by_type(connectable)
def _unregister_scanner() -> None: def _unregister_scanner() -> None:
self._scanners.remove(scanner) scanners.remove(scanner)
self._scanners.append(scanner) scanners.append(scanner)
return _unregister_scanner return _unregister_scanner
@hass_callback @hass_callback
@ -388,9 +440,9 @@ class BluetoothManager:
# Replay the history since otherwise we miss devices # Replay the history since otherwise we miss devices
# that were already discovered before the callback was registered # that were already discovered before the callback was registered
# or we are in passive mode # or we are in passive mode
for history in self.history.values(): for history in self._connectable_history.values():
_dispatch_bleak_callback( _dispatch_bleak_callback(
callback, filters, history.ble_device, history.advertisement_data callback, filters, history.device, history.advertisement
) )
return _remove_callback return _remove_callback

View File

@ -9,10 +9,11 @@ from lru import LRU # pylint: disable=no-name-in-module
from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional
from .models import BluetoothServiceInfoBleak
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import MutableMapping from collections.abc import MutableMapping
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
@ -20,6 +21,7 @@ MAX_REMEMBER_ADDRESSES: Final = 2048
ADDRESS: Final = "address" ADDRESS: Final = "address"
CONNECTABLE: Final = "connectable"
LOCAL_NAME: Final = "local_name" LOCAL_NAME: Final = "local_name"
SERVICE_UUID: Final = "service_uuid" SERVICE_UUID: Final = "service_uuid"
SERVICE_DATA_UUID: Final = "service_data_uuid" SERVICE_DATA_UUID: Final = "service_data_uuid"
@ -50,14 +52,14 @@ class IntegrationMatchHistory:
def seen_all_fields( def seen_all_fields(
previous_match: IntegrationMatchHistory, adv_data: AdvertisementData previous_match: IntegrationMatchHistory, advertisement_data: AdvertisementData
) -> bool: ) -> bool:
"""Return if we have seen all fields.""" """Return if we have seen all fields."""
if not previous_match.manufacturer_data and adv_data.manufacturer_data: if not previous_match.manufacturer_data and advertisement_data.manufacturer_data:
return False return False
if not previous_match.service_data and adv_data.service_data: if not previous_match.service_data and advertisement_data.service_data:
return False return False
if not previous_match.service_uuids and adv_data.service_uuids: if not previous_match.service_uuids and advertisement_data.service_uuids:
return False return False
return True return True
@ -73,74 +75,93 @@ class IntegrationMatcher:
self._matched: MutableMapping[str, IntegrationMatchHistory] = LRU( self._matched: MutableMapping[str, IntegrationMatchHistory] = LRU(
MAX_REMEMBER_ADDRESSES MAX_REMEMBER_ADDRESSES
) )
self._matched_connectable: MutableMapping[str, IntegrationMatchHistory] = LRU(
MAX_REMEMBER_ADDRESSES
)
def async_clear_address(self, address: str) -> None: def async_clear_address(self, address: str) -> None:
"""Clear the history matches for a set of domains.""" """Clear the history matches for a set of domains."""
self._matched.pop(address, None) self._matched.pop(address, None)
self._matched_connectable.pop(address, None)
def match_domains(self, device: BLEDevice, adv_data: AdvertisementData) -> set[str]: def _get_matched_by_type(
self, connectable: bool
) -> MutableMapping[str, IntegrationMatchHistory]:
"""Return the matches by type."""
return self._matched_connectable if connectable else self._matched
def match_domains(self, service_info: BluetoothServiceInfoBleak) -> set[str]:
"""Return the domains that are matched.""" """Return the domains that are matched."""
device = service_info.device
advertisement_data = service_info.advertisement
matched = self._get_matched_by_type(service_info.connectable)
matched_domains: set[str] = set() matched_domains: set[str] = set()
if (previous_match := self._matched.get(device.address)) and seen_all_fields( if (previous_match := matched.get(device.address)) and seen_all_fields(
previous_match, adv_data previous_match, advertisement_data
): ):
# We have seen all fields so we can skip the rest of the matchers # We have seen all fields so we can skip the rest of the matchers
return matched_domains return matched_domains
matched_domains = { matched_domains = {
matcher["domain"] matcher["domain"]
for matcher in self._integration_matchers for matcher in self._integration_matchers
if ble_device_matches(matcher, device, adv_data) if ble_device_matches(matcher, service_info)
} }
if not matched_domains: if not matched_domains:
return matched_domains return matched_domains
if previous_match: if previous_match:
previous_match.manufacturer_data |= bool(adv_data.manufacturer_data) previous_match.manufacturer_data |= bool(
previous_match.service_data |= bool(adv_data.service_data) advertisement_data.manufacturer_data
previous_match.service_uuids |= bool(adv_data.service_uuids) )
previous_match.service_data |= bool(advertisement_data.service_data)
previous_match.service_uuids |= bool(advertisement_data.service_uuids)
else: else:
self._matched[device.address] = IntegrationMatchHistory( matched[device.address] = IntegrationMatchHistory(
manufacturer_data=bool(adv_data.manufacturer_data), manufacturer_data=bool(advertisement_data.manufacturer_data),
service_data=bool(adv_data.service_data), service_data=bool(advertisement_data.service_data),
service_uuids=bool(adv_data.service_uuids), service_uuids=bool(advertisement_data.service_uuids),
) )
return matched_domains return matched_domains
def ble_device_matches( def ble_device_matches(
matcher: BluetoothCallbackMatcher | BluetoothMatcher, matcher: BluetoothCallbackMatcher | BluetoothMatcher,
device: BLEDevice, service_info: BluetoothServiceInfoBleak,
adv_data: AdvertisementData,
) -> bool: ) -> bool:
"""Check if a ble device and advertisement_data matches the matcher.""" """Check if a ble device and advertisement_data matches the matcher."""
device = service_info.device
if (address := matcher.get(ADDRESS)) is not None and device.address != address: if (address := matcher.get(ADDRESS)) is not None and device.address != address:
return False return False
if matcher.get(CONNECTABLE, True) and not service_info.connectable:
return False
advertisement_data = service_info.advertisement
if (local_name := matcher.get(LOCAL_NAME)) is not None and not fnmatch.fnmatch( if (local_name := matcher.get(LOCAL_NAME)) is not None and not fnmatch.fnmatch(
adv_data.local_name or device.name or device.address, advertisement_data.local_name or device.name or device.address,
local_name, local_name,
): ):
return False return False
if ( if (
service_uuid := matcher.get(SERVICE_UUID) service_uuid := matcher.get(SERVICE_UUID)
) is not None and service_uuid not in adv_data.service_uuids: ) is not None and service_uuid not in advertisement_data.service_uuids:
return False return False
if ( if (
service_data_uuid := matcher.get(SERVICE_DATA_UUID) service_data_uuid := matcher.get(SERVICE_DATA_UUID)
) is not None and service_data_uuid not in adv_data.service_data: ) is not None and service_data_uuid not in advertisement_data.service_data:
return False return False
if ( if (
manfacturer_id := matcher.get(MANUFACTURER_ID) manfacturer_id := matcher.get(MANUFACTURER_ID)
) is not None and manfacturer_id not in adv_data.manufacturer_data: ) is not None and manfacturer_id not in advertisement_data.manufacturer_data:
return False return False
if (manufacturer_data_start := matcher.get(MANUFACTURER_DATA_START)) is not None: if (manufacturer_data_start := matcher.get(MANUFACTURER_DATA_START)) is not None:
manufacturer_data_start_bytes = bytearray(manufacturer_data_start) manufacturer_data_start_bytes = bytearray(manufacturer_data_start)
if not any( if not any(
manufacturer_data.startswith(manufacturer_data_start_bytes) manufacturer_data.startswith(manufacturer_data_start_bytes)
for manufacturer_data in adv_data.manufacturer_data.values() for manufacturer_data in advertisement_data.manufacturer_data.values()
): ):
return False return False

View File

@ -1,6 +1,7 @@
"""Models for bluetooth.""" """Models for bluetooth."""
from __future__ import annotations from __future__ import annotations
from abc import abstractmethod
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
import contextlib import contextlib
@ -45,23 +46,8 @@ class BluetoothServiceInfoBleak(BluetoothServiceInfo):
device: BLEDevice device: BLEDevice
advertisement: AdvertisementData advertisement: AdvertisementData
connectable: bool
@classmethod time: float
def from_advertisement(
cls, device: BLEDevice, advertisement_data: AdvertisementData, source: str
) -> BluetoothServiceInfoBleak:
"""Create a BluetoothServiceInfoBleak from an advertisement."""
return cls(
name=advertisement_data.local_name or device.name or device.address,
address=device.address,
rssi=device.rssi,
manufacturer_data=advertisement_data.manufacturer_data,
service_data=advertisement_data.service_data,
service_uuids=advertisement_data.service_uuids,
source=source,
device=device,
advertisement=advertisement_data,
)
class BluetoothScanningMode(Enum): class BluetoothScanningMode(Enum):
@ -76,6 +62,15 @@ BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]
class BaseHaScanner:
"""Base class for Ha Scanners."""
@property
@abstractmethod
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
class HaBleakScannerWrapper(BaseBleakScanner): class HaBleakScannerWrapper(BaseBleakScanner):
"""A wrapper that uses the single instance.""" """A wrapper that uses the single instance."""
@ -89,7 +84,7 @@ class HaBleakScannerWrapper(BaseBleakScanner):
"""Initialize the BleakScanner.""" """Initialize the BleakScanner."""
self._detection_cancel: CALLBACK_TYPE | None = None self._detection_cancel: CALLBACK_TYPE | None = None
self._mapped_filters: dict[str, set[str]] = {} self._mapped_filters: dict[str, set[str]] = {}
self._adv_data_callback: AdvertisementDataCallback | None = None self._advertisement_data_callback: AdvertisementDataCallback | None = None
remapped_kwargs = { remapped_kwargs = {
"detection_callback": detection_callback, "detection_callback": detection_callback,
"service_uuids": service_uuids or [], "service_uuids": service_uuids or [],
@ -136,7 +131,7 @@ class HaBleakScannerWrapper(BaseBleakScanner):
def discovered_devices(self) -> list[BLEDevice]: def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices.""" """Return a list of discovered devices."""
assert MANAGER is not None assert MANAGER is not None
return list(MANAGER.async_discovered_devices()) return list(MANAGER.async_discovered_devices(True))
def register_detection_callback( def register_detection_callback(
self, callback: AdvertisementDataCallback | None self, callback: AdvertisementDataCallback | None
@ -146,15 +141,15 @@ class HaBleakScannerWrapper(BaseBleakScanner):
This method takes the callback and registers it with the long running This method takes the callback and registers it with the long running
scanner. scanner.
""" """
self._adv_data_callback = callback self._advertisement_data_callback = callback
self._setup_detection_callback() self._setup_detection_callback()
def _setup_detection_callback(self) -> None: def _setup_detection_callback(self) -> None:
"""Set up the detection callback.""" """Set up the detection callback."""
if self._adv_data_callback is None: if self._advertisement_data_callback is None:
return return
self._cancel_callback() self._cancel_callback()
super().register_detection_callback(self._adv_data_callback) super().register_detection_callback(self._advertisement_data_callback)
assert MANAGER is not None assert MANAGER is not None
assert self._callback is not None assert self._callback is not None
self._detection_cancel = MANAGER.async_register_bleak_callback( self._detection_cancel = MANAGER.async_register_bleak_callback(
@ -193,7 +188,7 @@ class HaBleakClientWrapper(BleakClient):
error_if_core=False, error_if_core=False,
) )
assert MANAGER is not None assert MANAGER is not None
ble_device = MANAGER.async_ble_device_from_address(address_or_ble_device) ble_device = MANAGER.async_ble_device_from_address(address_or_ble_device, True)
if ble_device is None: if ble_device is None:
raise BleakError(f"No device found for address {address_or_ble_device}") raise BleakError(f"No device found for address {address_or_ble_device}")
super().__init__(ble_device, *args, **kwargs) super().__init__(ble_device, *args, **kwargs)

View File

@ -28,9 +28,10 @@ class PassiveBluetoothDataUpdateCoordinator(BasePassiveBluetoothCoordinator):
logger: logging.Logger, logger: logging.Logger,
address: str, address: str,
mode: BluetoothScanningMode, mode: BluetoothScanningMode,
connectable: bool = False,
) -> None: ) -> None:
"""Initialize PassiveBluetoothDataUpdateCoordinator.""" """Initialize PassiveBluetoothDataUpdateCoordinator."""
super().__init__(hass, logger, address, mode) super().__init__(hass, logger, address, mode, connectable)
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
@callback @callback

View File

@ -72,9 +72,10 @@ class PassiveBluetoothProcessorCoordinator(
address: str, address: str,
mode: BluetoothScanningMode, mode: BluetoothScanningMode,
update_method: Callable[[BluetoothServiceInfoBleak], _T], update_method: Callable[[BluetoothServiceInfoBleak], _T],
connectable: bool = False,
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
super().__init__(hass, logger, address, mode) super().__init__(hass, logger, address, mode, connectable)
self._processors: list[PassiveBluetoothDataProcessor] = [] self._processors: list[PassiveBluetoothDataProcessor] = []
self._update_method = update_method self._update_method = update_method
self.last_update_success = True self.last_update_success = True

View File

@ -32,7 +32,7 @@ from .const import (
SOURCE_LOCAL, SOURCE_LOCAL,
START_TIMEOUT, START_TIMEOUT,
) )
from .models import BluetoothScanningMode from .models import BaseHaScanner, BluetoothScanningMode, BluetoothServiceInfoBleak
from .util import adapter_human_name, async_reset_adapter from .util import adapter_human_name, async_reset_adapter
OriginalBleakScanner = bleak.BleakScanner OriginalBleakScanner = bleak.BleakScanner
@ -92,7 +92,7 @@ def create_bleak_scanner(
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
class HaScanner: class HaScanner(BaseHaScanner):
"""Operate and automatically recover a BleakScanner. """Operate and automatically recover a BleakScanner.
Multiple BleakScanner can be used at the same time Multiple BleakScanner can be used at the same time
@ -119,9 +119,7 @@ class HaScanner:
self._cancel_watchdog: CALLBACK_TYPE | None = None self._cancel_watchdog: CALLBACK_TYPE | None = None
self._last_detection = 0.0 self._last_detection = 0.0
self._start_time = 0.0 self._start_time = 0.0
self._callbacks: list[ self._callbacks: list[Callable[[BluetoothServiceInfoBleak], None]] = []
Callable[[BLEDevice, AdvertisementData, float, str], None]
] = []
self.name = adapter_human_name(adapter, address) self.name = adapter_human_name(adapter, address)
self.source = self.adapter or SOURCE_LOCAL self.source = self.adapter or SOURCE_LOCAL
@ -132,7 +130,7 @@ class HaScanner:
@hass_callback @hass_callback
def async_register_callback( def async_register_callback(
self, callback: Callable[[BLEDevice, AdvertisementData, float, str], None] self, callback: Callable[[BluetoothServiceInfoBleak], None]
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a callback. """Register a callback.
@ -149,7 +147,7 @@ class HaScanner:
@hass_callback @hass_callback
def _async_detection_callback( def _async_detection_callback(
self, self,
ble_device: BLEDevice, device: BLEDevice,
advertisement_data: AdvertisementData, advertisement_data: AdvertisementData,
) -> None: ) -> None:
"""Call the callback when an advertisement is received. """Call the callback when an advertisement is received.
@ -168,8 +166,21 @@ class HaScanner:
# as the adapter is in a failure # as the adapter is in a failure
# state if all the data is empty. # state if all the data is empty.
self._last_detection = callback_time self._last_detection = callback_time
service_info = BluetoothServiceInfoBleak(
name=advertisement_data.local_name or device.name or device.address,
address=device.address,
rssi=device.rssi,
manufacturer_data=advertisement_data.manufacturer_data,
service_data=advertisement_data.service_data,
service_uuids=advertisement_data.service_uuids,
source=self.source,
device=device,
advertisement=advertisement_data,
connectable=True,
time=callback_time,
)
for callback in self._callbacks: for callback in self._callbacks:
callback(ble_device, advertisement_data, callback_time, self.source) callback(service_info)
async def async_start(self) -> None: async def async_start(self) -> None:
"""Start bluetooth scanner.""" """Start bluetooth scanner."""

View File

@ -28,12 +28,14 @@ class BasePassiveBluetoothCoordinator:
logger: logging.Logger, logger: logging.Logger,
address: str, address: str,
mode: BluetoothScanningMode, mode: BluetoothScanningMode,
connectable: bool,
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
self.hass = hass self.hass = hass
self.logger = logger self.logger = logger
self.name: str | None = None self.name: str | None = None
self.address = address self.address = address
self.connectable = connectable
self._cancel_track_unavailable: CALLBACK_TYPE | None = None self._cancel_track_unavailable: CALLBACK_TYPE | None = None
self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None
self._present = False self._present = False
@ -62,13 +64,13 @@ class BasePassiveBluetoothCoordinator:
self._cancel_bluetooth_advertisements = 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, connectable=self.connectable
),
self.mode, self.mode,
) )
self._cancel_track_unavailable = async_track_unavailable( self._cancel_track_unavailable = async_track_unavailable(
self.hass, self.hass, self._async_handle_unavailable, self.address, self.connectable
self._async_handle_unavailable,
self.address,
) )
@callback @callback

View File

@ -10,6 +10,7 @@ from bleak import BleakClient, BleakError
import voluptuous as vol import voluptuous as vol
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker import (
PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA,
) )
@ -139,8 +140,20 @@ async def async_setup_scanner( # noqa: C901
) -> None: ) -> None:
"""Lookup Bluetooth LE devices and update status.""" """Lookup Bluetooth LE devices and update status."""
battery = None battery = None
# We need one we can connect to since the tracker will
# accept devices from non-connectable sources
if service_info.connectable:
device = service_info.device
elif connectable_device := bluetooth.async_ble_device_from_address(
hass, service_info.device.address, True
):
device = connectable_device
else:
# The device can be seen by a passive tracker but we
# don't have a route to make a connection
return
try: try:
async with BleakClient(service_info.device) as client: async with BleakClient(device) as client:
bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID) bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID)
battery = ord(bat_char) battery = ord(bat_char)
except asyncio.TimeoutError: except asyncio.TimeoutError:
@ -192,12 +205,17 @@ async def async_setup_scanner( # noqa: C901
# interval so they do not get set to not_home when # interval so they do not get set to not_home when
# there have been no callbacks because the RSSI or # there have been no callbacks because the RSSI or
# other properties have not changed. # other properties have not changed.
for service_info in bluetooth.async_discovered_service_info(hass): for service_info in bluetooth.async_discovered_service_info(hass, False):
_async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT) _async_update_ble(service_info, bluetooth.BluetoothChange.ADVERTISEMENT)
cancels = [ cancels = [
bluetooth.async_register_callback( bluetooth.async_register_callback(
hass, _async_update_ble, None, bluetooth.BluetoothScanningMode.ACTIVE hass,
_async_update_ble,
BluetoothCallbackMatcher(
connectable=False
), # We will take data from any source
bluetooth.BluetoothScanningMode.ACTIVE,
), ),
async_track_time_interval(hass, _async_refresh_ble, interval), async_track_time_interval(hass, _async_refresh_ble, interval),
] ]

View File

@ -73,7 +73,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
) )
current_addresses = self._async_current_ids() current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue

View File

@ -4,36 +4,43 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/govee_ble", "documentation": "https://www.home-assistant.io/integrations/govee_ble",
"bluetooth": [ "bluetooth": [
{ "local_name": "Govee*" }, { "local_name": "Govee*", "connectable": false },
{ "local_name": "GVH5*" }, { "local_name": "GVH5*", "connectable": false },
{ "local_name": "B5178*" }, { "local_name": "B5178*", "connectable": false },
{ {
"manufacturer_id": 6966, "manufacturer_id": 6966,
"service_uuid": "00008451-0000-1000-8000-00805f9b34fb" "service_uuid": "00008451-0000-1000-8000-00805f9b34fb",
"connectable": false
}, },
{ {
"manufacturer_id": 26589, "manufacturer_id": 26589,
"service_uuid": "00008351-0000-1000-8000-00805f9b34fb" "service_uuid": "00008351-0000-1000-8000-00805f9b34fb",
"connectable": false
}, },
{ {
"manufacturer_id": 18994, "manufacturer_id": 18994,
"service_uuid": "00008551-0000-1000-8000-00805f9b34fb" "service_uuid": "00008551-0000-1000-8000-00805f9b34fb",
"connectable": false
}, },
{ {
"manufacturer_id": 818, "manufacturer_id": 818,
"service_uuid": "00008551-0000-1000-8000-00805f9b34fb" "service_uuid": "00008551-0000-1000-8000-00805f9b34fb",
"connectable": false
}, },
{ {
"manufacturer_id": 59970, "manufacturer_id": 59970,
"service_uuid": "00008151-0000-1000-8000-00805f9b34fb" "service_uuid": "00008151-0000-1000-8000-00805f9b34fb",
"connectable": false
}, },
{ {
"manufacturer_id": 14474, "manufacturer_id": 14474,
"service_uuid": "00008151-0000-1000-8000-00805f9b34fb" "service_uuid": "00008151-0000-1000-8000-00805f9b34fb",
"connectable": false
}, },
{ {
"manufacturer_id": 10032, "manufacturer_id": 10032,
"service_uuid": "00008251-0000-1000-8000-00805f9b34fb" "service_uuid": "00008251-0000-1000-8000-00805f9b34fb",
"connectable": false
} }
], ],
"requirements": ["govee-ble==0.16.0"], "requirements": ["govee-ble==0.16.0"],

View File

@ -73,7 +73,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
) )
current_addresses = self._async_current_ids() current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue

View File

@ -4,11 +4,11 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/inkbird", "documentation": "https://www.home-assistant.io/integrations/inkbird",
"bluetooth": [ "bluetooth": [
{ "local_name": "sps" }, { "local_name": "sps", "connectable": false },
{ "local_name": "Inkbird*" }, { "local_name": "Inkbird*", "connectable": false },
{ "local_name": "iBBQ*" }, { "local_name": "iBBQ*", "connectable": false },
{ "local_name": "xBBQ*" }, { "local_name": "xBBQ*", "connectable": false },
{ "local_name": "tps" } { "local_name": "tps", "connectable": false }
], ],
"requirements": ["inkbird-ble==0.5.5"], "requirements": ["inkbird-ble==0.5.5"],
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],

View File

@ -73,7 +73,7 @@ class MoatConfigFlow(ConfigFlow, domain=DOMAIN):
) )
current_addresses = self._async_current_ids() current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue

View File

@ -3,7 +3,7 @@
"name": "Moat", "name": "Moat",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/moat", "documentation": "https://www.home-assistant.io/integrations/moat",
"bluetooth": [{ "local_name": "Moat_S*" }], "bluetooth": [{ "local_name": "Moat_S*", "connectable": false }],
"requirements": ["moat-ble==0.1.1"], "requirements": ["moat-ble==0.1.1"],
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],

View File

@ -100,7 +100,7 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN):
) )
current_addresses = self._async_current_ids() current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue

View File

@ -3,7 +3,7 @@
"name": "Qingping", "name": "Qingping",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/qingping", "documentation": "https://www.home-assistant.io/integrations/qingping",
"bluetooth": [{ "local_name": "Qingping*" }], "bluetooth": [{ "local_name": "Qingping*", "connectable": false }],
"requirements": ["qingping-ble==0.3.0"], "requirements": ["qingping-ble==0.3.0"],
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],

View File

@ -73,7 +73,7 @@ class SensorPushConfigFlow(ConfigFlow, domain=DOMAIN):
) )
current_addresses = self._async_current_ids() current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue

View File

@ -5,7 +5,8 @@
"documentation": "https://www.home-assistant.io/integrations/sensorpush", "documentation": "https://www.home-assistant.io/integrations/sensorpush",
"bluetooth": [ "bluetooth": [
{ {
"local_name": "SensorPush*" "local_name": "SensorPush*",
"connectable": false
} }
], ],
"requirements": ["sensorpush-ble==1.5.2"], "requirements": ["sensorpush-ble==1.5.2"],

View File

@ -18,7 +18,13 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN, SupportedModels from .const import (
CONF_RETRY_COUNT,
CONNECTABLE_SUPPORTED_MODEL_TYPES,
DEFAULT_RETRY_COUNT,
DOMAIN,
SupportedModels,
)
from .coordinator import SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotDataUpdateCoordinator
PLATFORMS_BY_TYPE = { PLATFORMS_BY_TYPE = {
@ -40,6 +46,7 @@ CLASS_BY_DEVICE = {
SupportedModels.PLUG.value: switchbot.SwitchbotPlugMini, SupportedModels.PLUG.value: switchbot.SwitchbotPlugMini,
} }
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -65,8 +72,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
sensor_type: str = entry.data[CONF_SENSOR_TYPE] sensor_type: str = entry.data[CONF_SENSOR_TYPE]
# connectable means we can make connections to the device
connectable = sensor_type in CONNECTABLE_SUPPORTED_MODEL_TYPES.values()
address: str = entry.data[CONF_ADDRESS] address: str = entry.data[CONF_ADDRESS]
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper()) ble_device = bluetooth.async_ble_device_from_address(
hass, address.upper(), connectable
)
if not ble_device: if not ble_device:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Could not find Switchbot {sensor_type} with address {address}" f"Could not find Switchbot {sensor_type} with address {address}"
@ -77,6 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
password=entry.data.get(CONF_PASSWORD), password=entry.data.get(CONF_PASSWORD),
retry_count=entry.options[CONF_RETRY_COUNT], retry_count=entry.options[CONF_RETRY_COUNT],
) )
coordinator = hass.data[DOMAIN][entry.entry_id] = SwitchbotDataUpdateCoordinator( coordinator = hass.data[DOMAIN][entry.entry_id] = SwitchbotDataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,
@ -84,6 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device, device,
entry.unique_id, entry.unique_id,
entry.data.get(CONF_NAME, entry.title), entry.data.get(CONF_NAME, entry.title),
connectable,
) )
entry.async_on_unload(coordinator.async_start()) entry.async_on_unload(coordinator.async_start())
if not await coordinator.async_wait_ready(): if not await coordinator.async_wait_ready():

View File

@ -16,7 +16,14 @@ from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD, CONF_SENSOR_TYPE
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.data_entry_flow import AbortFlow, FlowResult
from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN, SUPPORTED_MODEL_TYPES from .const import (
CONF_RETRY_COUNT,
CONNECTABLE_SUPPORTED_MODEL_TYPES,
DEFAULT_RETRY_COUNT,
DOMAIN,
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES,
SUPPORTED_MODEL_TYPES,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -67,6 +74,13 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
) )
if not parsed or parsed.data.get("modelName") not in SUPPORTED_MODEL_TYPES: if not parsed or parsed.data.get("modelName") not in SUPPORTED_MODEL_TYPES:
return self.async_abort(reason="not_supported") return self.async_abort(reason="not_supported")
model_name = parsed.data.get("modelName")
if (
not discovery_info.connectable
and model_name in CONNECTABLE_SUPPORTED_MODEL_TYPES
):
# Source is not connectable but the model is connectable
return self.async_abort(reason="not_supported")
self._discovered_adv = parsed self._discovered_adv = parsed
data = parsed.data data = parsed.data
self.context["title_placeholders"] = { self.context["title_placeholders"] = {
@ -133,18 +147,25 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
@callback @callback
def _async_discover_devices(self) -> None: def _async_discover_devices(self) -> None:
current_addresses = self._async_current_ids() current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass): for connectable in (True, False):
address = discovery_info.address for discovery_info in async_discovered_service_info(self.hass, connectable):
if ( address = discovery_info.address
format_unique_id(address) in current_addresses if (
or address in self._discovered_advs format_unique_id(address) in current_addresses
): or address in self._discovered_advs
continue ):
parsed = parse_advertisement_data( continue
discovery_info.device, discovery_info.advertisement parsed = parse_advertisement_data(
) discovery_info.device, discovery_info.advertisement
if parsed and parsed.data.get("modelName") in SUPPORTED_MODEL_TYPES: )
self._discovered_advs[address] = parsed if not parsed:
continue
model_name = parsed.data.get("modelName")
if (
discovery_info.connectable
and model_name in CONNECTABLE_SUPPORTED_MODEL_TYPES
) or model_name in NON_CONNECTABLE_SUPPORTED_MODEL_TYPES:
self._discovered_advs[address] = parsed
if not self._discovered_advs: if not self._discovered_advs:
raise AbortFlow("no_unconfigured_devices") raise AbortFlow("no_unconfigured_devices")

View File

@ -23,16 +23,23 @@ class SupportedModels(StrEnum):
MOTION = "motion" MOTION = "motion"
SUPPORTED_MODEL_TYPES = { CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.BOT: SupportedModels.BOT, SwitchbotModel.BOT: SupportedModels.BOT,
SwitchbotModel.CURTAIN: SupportedModels.CURTAIN, SwitchbotModel.CURTAIN: SupportedModels.CURTAIN,
SwitchbotModel.METER: SupportedModels.HYGROMETER,
SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT,
SwitchbotModel.PLUG_MINI: SupportedModels.PLUG, SwitchbotModel.PLUG_MINI: SupportedModels.PLUG,
SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION,
SwitchbotModel.COLOR_BULB: SupportedModels.BULB, SwitchbotModel.COLOR_BULB: SupportedModels.BULB,
} }
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.METER: SupportedModels.HYGROMETER,
SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT,
SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION,
}
SUPPORTED_MODEL_TYPES = {
**CONNECTABLE_SUPPORTED_MODEL_TYPES,
**NON_CONNECTABLE_SUPPORTED_MODEL_TYPES,
}
# Config Defaults # Config Defaults
DEFAULT_RETRY_COUNT = 3 DEFAULT_RETRY_COUNT = 3

View File

@ -41,10 +41,15 @@ class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator):
device: switchbot.SwitchbotDevice, device: switchbot.SwitchbotDevice,
base_unique_id: str, base_unique_id: str,
device_name: str, device_name: str,
connectable: bool,
) -> None: ) -> None:
"""Initialize global switchbot data updater.""" """Initialize global switchbot data updater."""
super().__init__( super().__init__(
hass, logger, ble_device.address, bluetooth.BluetoothScanningMode.ACTIVE hass,
logger,
ble_device.address,
bluetooth.BluetoothScanningMode.ACTIVE,
connectable,
) )
self.ble_device = ble_device self.ble_device = ble_device
self.device = device self.device = device

View File

@ -14,10 +14,12 @@
], ],
"bluetooth": [ "bluetooth": [
{ {
"service_data_uuid": "0000fd3d-0000-1000-8000-00805f9b34fb" "service_data_uuid": "0000fd3d-0000-1000-8000-00805f9b34fb",
"connectable": false
}, },
{ {
"service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b" "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b",
"connectable": false
} }
], ],
"iot_class": "local_push", "iot_class": "local_push",

View File

@ -10,6 +10,7 @@ from homeassistant import config_entries
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
BluetoothScanningMode, BluetoothScanningMode,
BluetoothServiceInfoBleak, BluetoothServiceInfoBleak,
async_ble_device_from_address,
) )
from homeassistant.components.bluetooth.active_update_coordinator import ( from homeassistant.components.bluetooth.active_update_coordinator import (
ActiveBluetoothProcessorCoordinator, ActiveBluetoothProcessorCoordinator,
@ -64,7 +65,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _async_poll(service_info: BluetoothServiceInfoBleak): async def _async_poll(service_info: BluetoothServiceInfoBleak):
# BluetoothServiceInfoBleak is defined in HA, otherwise would just pass it # BluetoothServiceInfoBleak is defined in HA, otherwise would just pass it
# directly to the Xiaomi code # directly to the Xiaomi code
return await data.async_poll(service_info.device) # Make sure the device we have is one that we can connect with
# in case its coming from a passive scanner
if service_info.connectable:
connectable_device = service_info.device
elif device := async_ble_device_from_address(
hass, service_info.device.address, True
):
connectable_device = device
else:
# We have no bluetooth controller that is in range of
# the device to poll it
raise RuntimeError(
f"No connectable device found for {service_info.device.address}"
)
return await data.async_poll(connectable_device)
coordinator = hass.data.setdefault(DOMAIN, {})[ coordinator = hass.data.setdefault(DOMAIN, {})[
entry.entry_id entry.entry_id
@ -78,6 +93,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
), ),
needs_poll_method=_needs_poll, needs_poll_method=_needs_poll,
poll_method=_async_poll, poll_method=_async_poll,
# We will take advertisements from non-connectable devices
# since we will trade the BLEDevice for a connectable one
# if we need to poll it
connectable=False,
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload( entry.async_on_unload(

View File

@ -232,7 +232,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
return self._async_get_or_create_entry() return self._async_get_or_create_entry()
current_addresses = self._async_current_ids() current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue

View File

@ -5,6 +5,7 @@
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"bluetooth": [ "bluetooth": [
{ {
"connectable": false,
"service_data_uuid": "0000fe95-0000-1000-8000-00805f9b34fb" "service_data_uuid": "0000fe95-0000-1000-8000-00805f9b34fb"
} }
], ],

View File

@ -6,7 +6,7 @@ from __future__ import annotations
# fmt: off # fmt: off
BLUETOOTH: list[dict[str, str | int | list[int]]] = [ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
{ {
"domain": "fjaraskupan", "domain": "fjaraskupan",
"manufacturer_id": 20296, "manufacturer_id": 20296,
@ -21,50 +21,60 @@ BLUETOOTH: list[dict[str, str | int | list[int]]] = [
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"local_name": "Govee*" "local_name": "Govee*",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"local_name": "GVH5*" "local_name": "GVH5*",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"local_name": "B5178*" "local_name": "B5178*",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"manufacturer_id": 6966, "manufacturer_id": 6966,
"service_uuid": "00008451-0000-1000-8000-00805f9b34fb" "service_uuid": "00008451-0000-1000-8000-00805f9b34fb",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"manufacturer_id": 26589, "manufacturer_id": 26589,
"service_uuid": "00008351-0000-1000-8000-00805f9b34fb" "service_uuid": "00008351-0000-1000-8000-00805f9b34fb",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"manufacturer_id": 18994, "manufacturer_id": 18994,
"service_uuid": "00008551-0000-1000-8000-00805f9b34fb" "service_uuid": "00008551-0000-1000-8000-00805f9b34fb",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"manufacturer_id": 818, "manufacturer_id": 818,
"service_uuid": "00008551-0000-1000-8000-00805f9b34fb" "service_uuid": "00008551-0000-1000-8000-00805f9b34fb",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"manufacturer_id": 59970, "manufacturer_id": 59970,
"service_uuid": "00008151-0000-1000-8000-00805f9b34fb" "service_uuid": "00008151-0000-1000-8000-00805f9b34fb",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"manufacturer_id": 14474, "manufacturer_id": 14474,
"service_uuid": "00008151-0000-1000-8000-00805f9b34fb" "service_uuid": "00008151-0000-1000-8000-00805f9b34fb",
"connectable": False
}, },
{ {
"domain": "govee_ble", "domain": "govee_ble",
"manufacturer_id": 10032, "manufacturer_id": 10032,
"service_uuid": "00008251-0000-1000-8000-00805f9b34fb" "service_uuid": "00008251-0000-1000-8000-00805f9b34fb",
"connectable": False
}, },
{ {
"domain": "homekit_controller", "domain": "homekit_controller",
@ -75,46 +85,57 @@ BLUETOOTH: list[dict[str, str | int | list[int]]] = [
}, },
{ {
"domain": "inkbird", "domain": "inkbird",
"local_name": "sps" "local_name": "sps",
"connectable": False
}, },
{ {
"domain": "inkbird", "domain": "inkbird",
"local_name": "Inkbird*" "local_name": "Inkbird*",
"connectable": False
}, },
{ {
"domain": "inkbird", "domain": "inkbird",
"local_name": "iBBQ*" "local_name": "iBBQ*",
"connectable": False
}, },
{ {
"domain": "inkbird", "domain": "inkbird",
"local_name": "xBBQ*" "local_name": "xBBQ*",
"connectable": False
}, },
{ {
"domain": "inkbird", "domain": "inkbird",
"local_name": "tps" "local_name": "tps",
"connectable": False
}, },
{ {
"domain": "moat", "domain": "moat",
"local_name": "Moat_S*" "local_name": "Moat_S*",
"connectable": False
}, },
{ {
"domain": "qingping", "domain": "qingping",
"local_name": "Qingping*" "local_name": "Qingping*",
"connectable": False
}, },
{ {
"domain": "sensorpush", "domain": "sensorpush",
"local_name": "SensorPush*" "local_name": "SensorPush*",
"connectable": False
}, },
{ {
"domain": "switchbot", "domain": "switchbot",
"service_data_uuid": "0000fd3d-0000-1000-8000-00805f9b34fb" "service_data_uuid": "0000fd3d-0000-1000-8000-00805f9b34fb",
"connectable": False
}, },
{ {
"domain": "switchbot", "domain": "switchbot",
"service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b" "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b",
"connectable": False
}, },
{ {
"domain": "xiaomi_ble", "domain": "xiaomi_ble",
"connectable": False,
"service_data_uuid": "0000fe95-0000-1000-8000-00805f9b34fb" "service_data_uuid": "0000fe95-0000-1000-8000-00805f9b34fb"
}, },
{ {

View File

@ -91,6 +91,7 @@ class BluetoothMatcherOptional(TypedDict, total=False):
service_data_uuid: str service_data_uuid: str
manufacturer_id: int manufacturer_id: int
manufacturer_data_start: list[int] manufacturer_data_start: list[int]
connectable: bool
class BluetoothMatcher(BluetoothMatcherRequired, BluetoothMatcherOptional): class BluetoothMatcher(BluetoothMatcherRequired, BluetoothMatcherOptional):

View File

@ -14,7 +14,7 @@ from __future__ import annotations
# fmt: off # fmt: off
BLUETOOTH: list[dict[str, str | int | list[int]]] = {} BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = {}
""".strip() """.strip()
@ -36,7 +36,11 @@ def generate_and_validate(integrations: list[dict[str, str]]):
for entry in match_types: for entry in match_types:
match_list.append({"domain": domain, **entry}) match_list.append({"domain": domain, **entry})
return BASE.format(json.dumps(match_list, indent=4)) return BASE.format(
json.dumps(match_list, indent=4)
.replace('": true', '": True')
.replace('": false', '": False')
)
def validate(integrations: dict[str, Integration], config: Config): def validate(integrations: dict[str, Integration], config: Config):

View File

@ -197,6 +197,7 @@ MANIFEST_SCHEMA = vol.Schema(
vol.Optional("bluetooth"): [ vol.Optional("bluetooth"): [
vol.Schema( vol.Schema(
{ {
vol.Optional("connectable"): bool,
vol.Optional("service_uuid"): vol.All(str, verify_lowercase), vol.Optional("service_uuid"): vol.All(str, verify_lowercase),
vol.Optional("service_data_uuid"): vol.All(str, verify_lowercase), vol.Optional("service_data_uuid"): vol.All(str, verify_lowercase),
vol.Optional("local_name"): vol.All(str), vol.Optional("local_name"): vol.All(str),

View File

@ -6,7 +6,12 @@ from unittest.mock import patch
from bleak.backends.scanner import AdvertisementData, BLEDevice from bleak.backends.scanner import AdvertisementData, BLEDevice
from homeassistant.components.bluetooth import DOMAIN, SOURCE_LOCAL, models from homeassistant.components.bluetooth import (
DOMAIN,
SOURCE_LOCAL,
async_get_advertisement_callback,
models,
)
from homeassistant.components.bluetooth.const import DEFAULT_ADDRESS from homeassistant.components.bluetooth.const import DEFAULT_ADDRESS
from homeassistant.components.bluetooth.manager import BluetoothManager from homeassistant.components.bluetooth.manager import BluetoothManager
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -20,38 +25,84 @@ def _get_manager() -> BluetoothManager:
return models.MANAGER return models.MANAGER
def inject_advertisement(device: BLEDevice, adv: AdvertisementData) -> None: def inject_advertisement(
hass: HomeAssistant, device: BLEDevice, adv: AdvertisementData
) -> None:
"""Inject an advertisement into the manager.""" """Inject an advertisement into the manager."""
return inject_advertisement_with_source(device, adv, SOURCE_LOCAL) return inject_advertisement_with_source(hass, device, adv, SOURCE_LOCAL)
def inject_advertisement_with_source( def inject_advertisement_with_source(
device: BLEDevice, adv: AdvertisementData, source: str hass: HomeAssistant, device: BLEDevice, adv: AdvertisementData, source: str
) -> None: ) -> None:
"""Inject an advertisement into the manager from a specific source.""" """Inject an advertisement into the manager from a specific source."""
inject_advertisement_with_time_and_source(device, adv, time.monotonic(), source) inject_advertisement_with_time_and_source(
hass, device, adv, time.monotonic(), source
)
def inject_advertisement_with_time_and_source( def inject_advertisement_with_time_and_source(
device: BLEDevice, adv: AdvertisementData, time: float, source: str hass: HomeAssistant,
device: BLEDevice,
adv: AdvertisementData,
time: float,
source: str,
) -> None: ) -> None:
"""Inject an advertisement into the manager from a specific source at a time.""" """Inject an advertisement into the manager from a specific source at a time."""
return _get_manager().scanner_adv_received(device, adv, time, source) inject_advertisement_with_time_and_source_connectable(
hass, device, adv, time, source, True
)
def inject_advertisement_with_time_and_source_connectable(
hass: HomeAssistant,
device: BLEDevice,
adv: AdvertisementData,
time: float,
source: str,
connectable: bool,
) -> None:
"""Inject an advertisement into the manager from a specific source at a time and connectable status."""
async_get_advertisement_callback(hass)(
models.BluetoothServiceInfoBleak(
name=adv.local_name or device.name or device.address,
address=device.address,
rssi=device.rssi,
manufacturer_data=adv.manufacturer_data,
service_data=adv.service_data,
service_uuids=adv.service_uuids,
source=source,
device=device,
advertisement=adv,
connectable=connectable,
time=time,
)
)
def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None: def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None:
"""Mock all the discovered devices from all the scanners.""" """Mock all the discovered devices from all the scanners."""
manager = _get_manager()
return patch.object( return patch.object(
manager, "async_all_discovered_devices", return_value=mock_discovered _get_manager(), "async_all_discovered_devices", return_value=mock_discovered
) )
def patch_history(mock_history: dict[str, models.BluetoothServiceInfoBleak]) -> None:
"""Patch the history."""
return patch.dict(_get_manager()._history, mock_history)
def patch_connectable_history(
mock_history: dict[str, models.BluetoothServiceInfoBleak]
) -> None:
"""Patch the connectable history."""
return patch.dict(_get_manager()._connectable_history, mock_history)
def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> None: def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> None:
"""Mock the combined best path to discovered devices from all the scanners.""" """Mock the combined best path to discovered devices from all the scanners."""
manager = _get_manager()
return patch.object( return patch.object(
manager, "async_discovered_devices", return_value=mock_discovered _get_manager(), "async_discovered_devices", return_value=mock_discovered
) )

View File

@ -1,7 +1,8 @@
"""Tests for the Bluetooth integration.""" """Tests for the Bluetooth integration."""
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from unittest.mock import MagicMock, patch import time
from unittest.mock import MagicMock, Mock, patch
from bleak import BleakError from bleak import BleakError
from bleak.backends.scanner import AdvertisementData, BLEDevice from bleak.backends.scanner import AdvertisementData, BLEDevice
@ -20,6 +21,7 @@ from homeassistant.components.bluetooth import (
) )
from homeassistant.components.bluetooth.const import ( from homeassistant.components.bluetooth.const import (
DEFAULT_ADDRESS, DEFAULT_ADDRESS,
DOMAIN,
SOURCE_LOCAL, SOURCE_LOCAL,
UNAVAILABLE_TRACK_SECONDS, UNAVAILABLE_TRACK_SECONDS,
) )
@ -33,6 +35,7 @@ from . import (
_get_manager, _get_manager,
async_setup_with_default_adapter, async_setup_with_default_adapter,
inject_advertisement, inject_advertisement,
inject_advertisement_with_time_and_source_connectable,
patch_discovered_devices, patch_discovered_devices,
) )
@ -228,7 +231,7 @@ async def test_discovery_match_by_service_uuid(
wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
inject_advertisement(wrong_device, wrong_adv) inject_advertisement(hass, wrong_device, wrong_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -238,13 +241,160 @@ async def test_discovery_match_by_service_uuid(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
) )
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "switchbot" assert mock_config_flow.mock_calls[0][1][0] == "switchbot"
def _domains_from_mock_config_flow(mock_config_flow: Mock) -> list[str]:
"""Get all the domains that were passed to async_init except bluetooth."""
return [call[1][0] for call in mock_config_flow.mock_calls if call[1][0] != DOMAIN]
async def test_discovery_match_by_service_uuid_connectable(
hass, mock_bleak_scanner_start, macos_adapter
):
"""Test bluetooth discovery match by service_uuid and the ble device is connectable."""
mock_bt = [
{
"domain": "switchbot",
"connectable": True,
"service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b",
}
]
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
await async_setup_with_default_adapter(hass)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_bleak_scanner_start.mock_calls) == 1
wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
inject_advertisement_with_time_and_source_connectable(
hass, wrong_device, wrong_adv, time.monotonic(), "any", True
)
await hass.async_block_till_done()
assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand")
switchbot_adv = AdvertisementData(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
inject_advertisement_with_time_and_source_connectable(
hass, switchbot_device, switchbot_adv, time.monotonic(), "any", True
)
await hass.async_block_till_done()
called_domains = _domains_from_mock_config_flow(mock_config_flow)
assert len(called_domains) == 1
assert called_domains == ["switchbot"]
async def test_discovery_match_by_service_uuid_not_connectable(
hass, mock_bleak_scanner_start, macos_adapter
):
"""Test bluetooth discovery match by service_uuid and the ble device is not connectable."""
mock_bt = [
{
"domain": "switchbot",
"connectable": True,
"service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b",
}
]
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
await async_setup_with_default_adapter(hass)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_bleak_scanner_start.mock_calls) == 1
wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
inject_advertisement_with_time_and_source_connectable(
hass, wrong_device, wrong_adv, time.monotonic(), "any", False
)
await hass.async_block_till_done()
assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand")
switchbot_adv = AdvertisementData(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
inject_advertisement_with_time_and_source_connectable(
hass, switchbot_device, switchbot_adv, time.monotonic(), "any", False
)
await hass.async_block_till_done()
assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0
async def test_discovery_match_by_name_connectable_false(
hass, mock_bleak_scanner_start, macos_adapter
):
"""Test bluetooth discovery match by name and the integration will take non-connectable devices."""
mock_bt = [
{
"domain": "qingping",
"connectable": False,
"local_name": "Qingping*",
}
]
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
await async_setup_with_default_adapter(hass)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_bleak_scanner_start.mock_calls) == 1
wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
inject_advertisement_with_time_and_source_connectable(
hass, wrong_device, wrong_adv, time.monotonic(), "any", False
)
await hass.async_block_till_done()
assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0
qingping_device = BLEDevice("44:44:33:11:23:45", "Qingping Motion & Light")
qingping_adv = AdvertisementData(
local_name="Qingping Motion & Light",
service_data={
"0000fdcd-0000-1000-8000-00805f9b34fb": b"H\x12\xcd\xd5`4-X\x08\x04\x01\xe8\x00\x00\x0f\x01{"
},
)
inject_advertisement_with_time_and_source_connectable(
hass, qingping_device, qingping_adv, time.monotonic(), "any", False
)
await hass.async_block_till_done()
assert _domains_from_mock_config_flow(mock_config_flow) == ["qingping"]
mock_config_flow.reset_mock()
# Make sure it will also take a connectable device
inject_advertisement_with_time_and_source_connectable(
hass, qingping_device, qingping_adv, time.monotonic(), "any", True
)
await hass.async_block_till_done()
assert _domains_from_mock_config_flow(mock_config_flow) == ["qingping"]
async def test_discovery_match_by_local_name( async def test_discovery_match_by_local_name(
hass, mock_bleak_scanner_start, macos_adapter hass, mock_bleak_scanner_start, macos_adapter
): ):
@ -264,7 +414,7 @@ async def test_discovery_match_by_local_name(
wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
inject_advertisement(wrong_device, wrong_adv) inject_advertisement(hass, wrong_device, wrong_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -272,7 +422,7 @@ async def test_discovery_match_by_local_name(
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=[])
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
@ -315,21 +465,21 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start(
# 1st discovery with no manufacturer data # 1st discovery with no manufacturer data
# should not trigger config flow # should not trigger config flow
inject_advertisement(hkc_device, hkc_adv_no_mfr_data) inject_advertisement(hass, hkc_device, hkc_adv_no_mfr_data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
mock_config_flow.reset_mock() mock_config_flow.reset_mock()
# 2nd discovery with manufacturer data # 2nd discovery with manufacturer data
# should trigger a config flow # should trigger a config flow
inject_advertisement(hkc_device, hkc_adv) inject_advertisement(hass, hkc_device, hkc_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "homekit_controller" assert mock_config_flow.mock_calls[0][1][0] == "homekit_controller"
mock_config_flow.reset_mock() mock_config_flow.reset_mock()
# 3rd discovery should not generate another flow # 3rd discovery should not generate another flow
inject_advertisement(hkc_device, hkc_adv) inject_advertisement(hass, hkc_device, hkc_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -340,7 +490,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start(
local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"} local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"}
) )
inject_advertisement(not_hkc_device, not_hkc_adv) inject_advertisement(hass, not_hkc_device, not_hkc_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -349,7 +499,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start(
local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"} local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"}
) )
inject_advertisement(not_apple_device, not_apple_adv) inject_advertisement(hass, not_apple_device, not_apple_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -422,21 +572,21 @@ async def test_discovery_match_by_service_data_uuid_then_others(
) )
# 1st discovery should not generate a flow because the # 1st discovery should not generate a flow because the
# service_data_uuid is not in the advertisement # service_data_uuid is not in the advertisement
inject_advertisement(device, adv_without_service_data_uuid) inject_advertisement(hass, device, adv_without_service_data_uuid)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
mock_config_flow.reset_mock() mock_config_flow.reset_mock()
# 2nd discovery should not generate a flow because the # 2nd discovery should not generate a flow because the
# service_data_uuid is not in the advertisement # service_data_uuid is not in the advertisement
inject_advertisement(device, adv_without_service_data_uuid) inject_advertisement(hass, device, adv_without_service_data_uuid)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
mock_config_flow.reset_mock() mock_config_flow.reset_mock()
# 3rd discovery should generate a flow because the # 3rd discovery should generate a flow because the
# manufacturer_data is in the advertisement # manufacturer_data is in the advertisement
inject_advertisement(device, adv_with_mfr_data) inject_advertisement(hass, device, adv_with_mfr_data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "other_domain" assert mock_config_flow.mock_calls[0][1][0] == "other_domain"
@ -445,7 +595,7 @@ async def test_discovery_match_by_service_data_uuid_then_others(
# 4th discovery should generate a flow because the # 4th discovery should generate a flow because the
# service_data_uuid is in the advertisement and # service_data_uuid is in the advertisement and
# we never saw a service_data_uuid before # we never saw a service_data_uuid before
inject_advertisement(device, adv_with_service_data_uuid) inject_advertisement(hass, device, adv_with_service_data_uuid)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "my_domain" assert mock_config_flow.mock_calls[0][1][0] == "my_domain"
@ -453,14 +603,14 @@ async def test_discovery_match_by_service_data_uuid_then_others(
# 5th discovery should not generate a flow because the # 5th discovery should not generate a flow because the
# we already saw an advertisement with the service_data_uuid # we already saw an advertisement with the service_data_uuid
inject_advertisement(device, adv_with_service_data_uuid) inject_advertisement(hass, device, adv_with_service_data_uuid)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
# 6th discovery should not generate a flow because the # 6th discovery should not generate a flow because the
# manufacturer_data is in the advertisement # manufacturer_data is in the advertisement
# and we saw manufacturer_data before # and we saw manufacturer_data before
inject_advertisement(device, adv_with_service_data_uuid_and_mfr_data) inject_advertisement(hass, device, adv_with_service_data_uuid_and_mfr_data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
mock_config_flow.reset_mock() mock_config_flow.reset_mock()
@ -469,7 +619,7 @@ async def test_discovery_match_by_service_data_uuid_then_others(
# service_uuids is in the advertisement # service_uuids is in the advertisement
# and we never saw service_uuids before # and we never saw service_uuids before
inject_advertisement( inject_advertisement(
device, adv_with_service_data_uuid_and_mfr_data_and_service_uuid hass, device, adv_with_service_data_uuid_and_mfr_data_and_service_uuid
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 2 assert len(mock_config_flow.mock_calls) == 2
@ -482,7 +632,7 @@ async def test_discovery_match_by_service_data_uuid_then_others(
# 8th discovery should not generate a flow # 8th discovery should not generate a flow
# since all fields have been seen at this point # since all fields have been seen at this point
inject_advertisement( inject_advertisement(
device, adv_with_service_data_uuid_and_mfr_data_and_service_uuid hass, device, adv_with_service_data_uuid_and_mfr_data_and_service_uuid
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -490,19 +640,19 @@ async def test_discovery_match_by_service_data_uuid_then_others(
# 9th discovery should not generate a flow # 9th discovery should not generate a flow
# since all fields have been seen at this point # since all fields have been seen at this point
inject_advertisement(device, adv_with_service_uuid) inject_advertisement(hass, device, adv_with_service_uuid)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
# 10th discovery should not generate a flow # 10th discovery should not generate a flow
# since all fields have been seen at this point # since all fields have been seen at this point
inject_advertisement(device, adv_with_service_data_uuid) inject_advertisement(hass, device, adv_with_service_data_uuid)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
# 11th discovery should not generate a flow # 11th discovery should not generate a flow
# since all fields have been seen at this point # since all fields have been seen at this point
inject_advertisement(device, adv_without_service_data_uuid) inject_advertisement(hass, device, adv_without_service_data_uuid)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -546,7 +696,7 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id(
# 1st discovery with matches service_uuid # 1st discovery with matches service_uuid
# should trigger config flow # should trigger config flow
inject_advertisement(device, adv_service_uuids) inject_advertisement(hass, device, adv_service_uuids)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "my_domain" assert mock_config_flow.mock_calls[0][1][0] == "my_domain"
@ -554,19 +704,19 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id(
# 2nd discovery with manufacturer data # 2nd discovery with manufacturer data
# should trigger a config flow # should trigger a config flow
inject_advertisement(device, adv_manufacturer_data) inject_advertisement(hass, device, adv_manufacturer_data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "my_domain" assert mock_config_flow.mock_calls[0][1][0] == "my_domain"
mock_config_flow.reset_mock() mock_config_flow.reset_mock()
# 3rd discovery should not generate another flow # 3rd discovery should not generate another flow
inject_advertisement(device, adv_service_uuids) inject_advertisement(hass, device, adv_service_uuids)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
# 4th discovery should not generate another flow # 4th discovery should not generate another flow
inject_advertisement(device, adv_manufacturer_data) inject_advertisement(hass, device, adv_manufacturer_data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -590,10 +740,10 @@ async def test_rediscovery(hass, mock_bleak_scanner_start, enable_bluetooth):
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
) )
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
@ -601,7 +751,7 @@ async def test_rediscovery(hass, mock_bleak_scanner_start, enable_bluetooth):
async_rediscover_address(hass, "44:44:33:11:23:45") async_rediscover_address(hass, "44:44:33:11:23:45")
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 2 assert len(mock_config_flow.mock_calls) == 2
@ -633,10 +783,10 @@ async def test_async_discovered_device_api(
wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
inject_advertisement(wrong_device, wrong_adv) inject_advertisement(hass, wrong_device, wrong_adv)
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=[])
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
wrong_device_went_unavailable = False wrong_device_went_unavailable = False
switchbot_device_went_unavailable = False switchbot_device_went_unavailable = False
@ -670,8 +820,8 @@ async def test_async_discovered_device_api(
assert wrong_device_went_unavailable is True assert wrong_device_went_unavailable is True
# See the devices again # See the devices again
inject_advertisement(wrong_device, wrong_adv) inject_advertisement(hass, wrong_device, wrong_adv)
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
# Cancel the callbacks # Cancel the callbacks
wrong_device_unavailable_cancel() wrong_device_unavailable_cancel()
switchbot_device_unavailable_cancel() switchbot_device_unavailable_cancel()
@ -688,10 +838,11 @@ async def test_async_discovered_device_api(
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" infos = list(service_infos)
assert service_infos[0].source == SOURCE_LOCAL assert infos[0].name == "wohand"
assert isinstance(service_infos[0].device, BLEDevice) assert infos[0].source == SOURCE_LOCAL
assert isinstance(service_infos[0].advertisement, AdvertisementData) assert isinstance(infos[0].device, BLEDevice)
assert isinstance(infos[0].advertisement, AdvertisementData)
assert bluetooth.async_address_present(hass, "44:44:33:11:23:42") is False assert bluetooth.async_address_present(hass, "44:44:33:11:23:42") is False
assert bluetooth.async_address_present(hass, "44:44:33:11:23:45") is True assert bluetooth.async_address_present(hass, "44:44:33:11:23:45") is True
@ -736,25 +887,25 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start, enable_bluetoo
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
) )
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
# 3rd callback raises ValueError but is still tracked # 3rd callback raises ValueError but is still tracked
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
cancel() cancel()
# 4th callback should not be tracked since we canceled # 4th callback should not be tracked since we canceled
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(callbacks) == 3 assert len(callbacks) == 3
@ -819,25 +970,25 @@ async def test_register_callback_by_address(
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
) )
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
# 3rd callback raises ValueError but is still tracked # 3rd callback raises ValueError but is still tracked
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
cancel() cancel()
# 4th callback should not be tracked since we canceled # 4th callback should not be tracked since we canceled
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
# Now register again with a callback that fails to # Now register again with a callback that fails to
@ -907,7 +1058,7 @@ async def test_register_callback_survives_reload(
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
) )
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
assert len(callbacks) == 1 assert len(callbacks) == 1
service_info: BluetoothServiceInfo = callbacks[0][0] service_info: BluetoothServiceInfo = callbacks[0][0]
assert service_info.name == "wohand" assert service_info.name == "wohand"
@ -918,7 +1069,7 @@ async def test_register_callback_survives_reload(
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
assert len(callbacks) == 2 assert len(callbacks) == 2
service_info: BluetoothServiceInfo = callbacks[1][0] service_info: BluetoothServiceInfo = callbacks[1][0]
assert service_info.name == "wohand" assert service_info.name == "wohand"
@ -955,9 +1106,9 @@ async def test_process_advertisements_bail_on_good_advertisement(
service_data={"00000d00-0000-1000-8000-00805f9b34fa": b"H\x10c"}, service_data={"00000d00-0000-1000-8000-00805f9b34fa": b"H\x10c"},
) )
inject_advertisement(device, adv) inject_advertisement(hass, device, adv)
inject_advertisement(device, adv) inject_advertisement(hass, device, adv)
inject_advertisement(device, adv) inject_advertisement(hass, device, adv)
await asyncio.sleep(0) await asyncio.sleep(0)
@ -997,14 +1148,14 @@ async def test_process_advertisements_ignore_bad_advertisement(
# The goal of this loop is to make sure that async_process_advertisements sees at least one # The goal of this loop is to make sure that async_process_advertisements sees at least one
# callback that returns False # callback that returns False
while not done.is_set(): while not done.is_set():
inject_advertisement(device, adv) inject_advertisement(hass, device, adv)
await asyncio.sleep(0) await asyncio.sleep(0)
# Set the return value and mutate the advertisement # Set the return value and mutate the advertisement
# Check that scan ends and correct advertisement data is returned # Check that scan ends and correct advertisement data is returned
return_value.set() return_value.set()
adv.service_data["00000d00-0000-1000-8000-00805f9b34fa"] = b"H\x10c" adv.service_data["00000d00-0000-1000-8000-00805f9b34fa"] = b"H\x10c"
inject_advertisement(device, adv) inject_advertisement(hass, device, adv)
await asyncio.sleep(0) await asyncio.sleep(0)
result = await handle result = await handle
@ -1062,7 +1213,7 @@ async def test_wrapped_instance_with_filter(
) )
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
discovered = await scanner.discover(timeout=0) discovered = await scanner.discover(timeout=0)
@ -1082,12 +1233,12 @@ async def test_wrapped_instance_with_filter(
assert len(discovered) == 0 assert len(discovered) == 0
assert discovered == [] assert discovered == []
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
assert len(detected) == 4 assert len(detected) == 4
# The filter we created in the wrapped scanner with should be respected # The filter we created in the wrapped scanner with should be respected
# and we should not get another callback # and we should not get another callback
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
assert len(detected) == 4 assert len(detected) == 4
@ -1129,14 +1280,14 @@ async def test_wrapped_instance_with_service_uuids(
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
for _ in range(2): for _ in range(2):
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(detected) == 2 assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected # The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback # and we should not get another callback
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
assert len(detected) == 2 assert len(detected) == 2
@ -1177,9 +1328,9 @@ async def test_wrapped_instance_with_broken_callbacks(
) )
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(detected) == 1 assert len(detected) == 1
@ -1222,14 +1373,14 @@ async def test_wrapped_instance_changes_uuids(
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
for _ in range(2): for _ in range(2):
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(detected) == 2 assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected # The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback # and we should not get another callback
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
assert len(detected) == 2 assert len(detected) == 2
@ -1271,14 +1422,14 @@ async def test_wrapped_instance_changes_filters(
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
for _ in range(2): for _ in range(2):
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(detected) == 2 assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected # The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback # and we should not get another callback
inject_advertisement(empty_device, empty_adv) inject_advertisement(hass, empty_device, empty_adv)
assert len(detected) == 2 assert len(detected) == 2
@ -1333,7 +1484,7 @@ async def test_async_ble_device_from_address(
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=[])
inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert ( assert (

View File

@ -24,7 +24,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason(
local_name="wohand_signal_100", service_uuids=[] local_name="wohand_signal_100", service_uuids=[]
) )
inject_advertisement_with_source( inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
) )
assert ( assert (
@ -37,7 +37,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason(
local_name="wohand_signal_99", service_uuids=[] local_name="wohand_signal_99", service_uuids=[]
) )
inject_advertisement_with_source( inject_advertisement_with_source(
switchbot_device_signal_99, switchbot_adv_signal_99, "hci0" hass, switchbot_device_signal_99, switchbot_adv_signal_99, "hci0"
) )
assert ( assert (
@ -50,7 +50,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason(
local_name="wohand_good_signal", service_uuids=[] local_name="wohand_good_signal", service_uuids=[]
) )
inject_advertisement_with_source( inject_advertisement_with_source(
switchbot_device_signal_98, switchbot_adv_signal_98, "hci1" hass, switchbot_device_signal_98, switchbot_adv_signal_98, "hci1"
) )
# should not switch to hci1 # should not switch to hci1
@ -70,7 +70,7 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth):
local_name="wohand_poor_signal", service_uuids=[] local_name="wohand_poor_signal", service_uuids=[]
) )
inject_advertisement_with_source( inject_advertisement_with_source(
switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
) )
assert ( assert (
@ -83,7 +83,7 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth):
local_name="wohand_good_signal", service_uuids=[] local_name="wohand_good_signal", service_uuids=[]
) )
inject_advertisement_with_source( inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1"
) )
assert ( assert (
@ -92,7 +92,7 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth):
) )
inject_advertisement_with_source( inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_poor_signal, "hci0" hass, switchbot_device_good_signal, switchbot_adv_poor_signal, "hci0"
) )
assert ( assert (
bluetooth.async_ble_device_from_address(hass, address) bluetooth.async_ble_device_from_address(hass, address)
@ -108,7 +108,7 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth):
) )
inject_advertisement_with_source( inject_advertisement_with_source(
switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0" hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0"
) )
assert ( assert (
bluetooth.async_ble_device_from_address(hass, address) bluetooth.async_ble_device_from_address(hass, address)
@ -129,6 +129,7 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth):
local_name="wohand_poor_signal_hci0", service_uuids=[] local_name="wohand_poor_signal_hci0", service_uuids=[]
) )
inject_advertisement_with_time_and_source( inject_advertisement_with_time_and_source(
hass,
switchbot_device_poor_signal_hci0, switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0, switchbot_adv_poor_signal_hci0,
start_time_monotonic, start_time_monotonic,
@ -147,6 +148,7 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth):
local_name="wohand_poor_signal_hci1", service_uuids=[] local_name="wohand_poor_signal_hci1", service_uuids=[]
) )
inject_advertisement_with_time_and_source( inject_advertisement_with_time_and_source(
hass,
switchbot_device_poor_signal_hci1, switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1, switchbot_adv_poor_signal_hci1,
start_time_monotonic, start_time_monotonic,
@ -163,6 +165,7 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth):
# even though the signal is poor because the device is now # even though the signal is poor because the device is now
# likely unreachable via hci0 # likely unreachable via hci0
inject_advertisement_with_time_and_source( inject_advertisement_with_time_and_source(
hass,
switchbot_device_poor_signal_hci1, switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1, switchbot_adv_poor_signal_hci1,
start_time_monotonic + STALE_ADVERTISEMENT_SECONDS + 1, start_time_monotonic + STALE_ADVERTISEMENT_SECONDS + 1,

View File

@ -20,7 +20,7 @@ from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import _get_manager, patch_all_discovered_devices from . import patch_all_discovered_devices, patch_history
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
@ -178,15 +178,9 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable(
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
scanner = _get_manager()
with patch_all_discovered_devices( with patch_all_discovered_devices(
[MagicMock(address="44:44:33:11:23:45")] [MagicMock(address="44:44:33:11:23:45")]
), patch.object( ), patch_history({"aa:bb:cc:dd:ee:ff": MagicMock()}):
scanner,
"history",
{"aa:bb:cc:dd:ee:ff": MagicMock()},
):
async_fire_time_changed( async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
) )
@ -198,11 +192,7 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable(
with patch_all_discovered_devices( with patch_all_discovered_devices(
[MagicMock(address="44:44:33:11:23:45")] [MagicMock(address="44:44:33:11:23:45")]
), patch.object( ), patch_history({"aa:bb:cc:dd:ee:ff": MagicMock()}):
scanner,
"history",
{"aa:bb:cc:dd:ee:ff": MagicMock()},
):
async_fire_time_changed( async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
) )

View File

@ -32,7 +32,7 @@ from homeassistant.helpers.entity import DeviceInfo
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import _get_manager, patch_all_discovered_devices from . import patch_all_discovered_devices, patch_connectable_history, patch_history
from tests.common import MockEntityPlatform, async_fire_time_changed from tests.common import MockEntityPlatform, async_fire_time_changed
@ -246,12 +246,9 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
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
assert processor.available is True assert processor.available is True
scanner = _get_manager()
with patch_all_discovered_devices( with patch_all_discovered_devices(
[MagicMock(address="44:44:33:11:23:45")] [MagicMock(address="44:44:33:11:23:45")]
), patch.object( ), patch_history({"aa:bb:cc:dd:ee:ff": MagicMock()}), patch_connectable_history(
scanner,
"history",
{"aa:bb:cc:dd:ee:ff": MagicMock()}, {"aa:bb:cc:dd:ee:ff": MagicMock()},
): ):
async_fire_time_changed( async_fire_time_changed(
@ -268,9 +265,7 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
with patch_all_discovered_devices( with patch_all_discovered_devices(
[MagicMock(address="44:44:33:11:23:45")] [MagicMock(address="44:44:33:11:23:45")]
), patch.object( ), patch_history({"aa:bb:cc:dd:ee:ff": MagicMock()}), patch_connectable_history(
scanner,
"history",
{"aa:bb:cc:dd:ee:ff": MagicMock()}, {"aa:bb:cc:dd:ee:ff": MagicMock()},
): ):
async_fire_time_changed( async_fire_time_changed(

View File

@ -90,6 +90,8 @@ async def test_preserve_new_tracked_device_name(
source="local", source="local",
device=BLEDevice(address, None), device=BLEDevice(address, None),
advertisement=AdvertisementData(local_name="empty"), advertisement=AdvertisementData(local_name="empty"),
time=0,
connectable=False,
) )
# Return with name when seen first time # Return with name when seen first time
mock_async_discovered_service_info.return_value = [device] mock_async_discovered_service_info.return_value = [device]
@ -113,6 +115,8 @@ async def test_preserve_new_tracked_device_name(
source="local", source="local",
device=BLEDevice(address, None), device=BLEDevice(address, None),
advertisement=AdvertisementData(local_name="empty"), advertisement=AdvertisementData(local_name="empty"),
time=0,
connectable=False,
) )
# Return with name when seen first time # Return with name when seen first time
mock_async_discovered_service_info.return_value = [device] mock_async_discovered_service_info.return_value = [device]
@ -155,6 +159,8 @@ async def test_tracking_battery_times_out(
source="local", source="local",
device=BLEDevice(address, None), device=BLEDevice(address, None),
advertisement=AdvertisementData(local_name="empty"), advertisement=AdvertisementData(local_name="empty"),
time=0,
connectable=False,
) )
# Return with name when seen first time # Return with name when seen first time
mock_async_discovered_service_info.return_value = [device] mock_async_discovered_service_info.return_value = [device]
@ -219,6 +225,8 @@ async def test_tracking_battery_fails(hass, mock_bluetooth, mock_device_tracker_
source="local", source="local",
device=BLEDevice(address, None), device=BLEDevice(address, None),
advertisement=AdvertisementData(local_name="empty"), advertisement=AdvertisementData(local_name="empty"),
time=0,
connectable=False,
) )
# Return with name when seen first time # Return with name when seen first time
mock_async_discovered_service_info.return_value = [device] mock_async_discovered_service_info.return_value = [device]
@ -285,6 +293,8 @@ async def test_tracking_battery_successful(
source="local", source="local",
device=BLEDevice(address, None), device=BLEDevice(address, None),
advertisement=AdvertisementData(local_name="empty"), advertisement=AdvertisementData(local_name="empty"),
time=0,
connectable=True,
) )
# Return with name when seen first time # Return with name when seen first time
mock_async_discovered_service_info.return_value = [device] mock_async_discovered_service_info.return_value = [device]

View File

@ -4,8 +4,18 @@
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth import SOURCE_LOCAL, BluetoothServiceInfoBleak from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
COOKER_SERVICE_INFO = BluetoothServiceInfoBleak.from_advertisement( COOKER_SERVICE_INFO = BluetoothServiceInfoBleak(
BLEDevice("1.1.1.1", "COOKERHOOD_FJAR"), AdvertisementData(), source=SOURCE_LOCAL name="COOKERHOOD_FJAR",
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
manufacturer_data={},
service_uuids=[],
service_data={},
source="local",
device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="COOKERHOOD_FJAR"),
advertisement=AdvertisementData(),
time=0,
connectable=True,
) )

View File

@ -69,6 +69,28 @@ WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
), ),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"), device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"),
time=0,
connectable=True,
)
WOHAND_SERVICE_INFO_NOT_CONNECTABLE = BluetoothServiceInfoBleak(
name="WoHand",
manufacturer_data={89: b"\xfd`0U\x92W"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="aa:bb:cc:dd:ee:ff",
rssi=-60,
source="local",
advertisement=AdvertisementData(
local_name="WoHand",
manufacturer_data={89: b"\xfd`0U\x92W"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"),
time=0,
connectable=False,
) )
@ -87,6 +109,8 @@ WOHAND_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
), ),
device=BLEDevice("798A8547-2A3D-C609-55FF-73FA824B923B", "WoHand"), device=BLEDevice("798A8547-2A3D-C609-55FF-73FA824B923B", "WoHand"),
time=0,
connectable=True,
) )
@ -105,6 +129,8 @@ WOHAND_SERVICE_ALT_ADDRESS_INFO = BluetoothServiceInfoBleak(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
), ),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"), device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"),
time=0,
connectable=True,
) )
WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak( WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoCurtain", name="WoCurtain",
@ -121,6 +147,8 @@ WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
), ),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoCurtain"), device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoCurtain"),
time=0,
connectable=True,
) )
WOSENSORTH_SERVICE_INFO = BluetoothServiceInfoBleak( WOSENSORTH_SERVICE_INFO = BluetoothServiceInfoBleak(
@ -136,6 +164,8 @@ WOSENSORTH_SERVICE_INFO = BluetoothServiceInfoBleak(
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"},
), ),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoSensorTH"), device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoSensorTH"),
time=0,
connectable=False,
) )
NOT_SWITCHBOT_INFO = BluetoothServiceInfoBleak( NOT_SWITCHBOT_INFO = BluetoothServiceInfoBleak(
@ -151,4 +181,6 @@ NOT_SWITCHBOT_INFO = BluetoothServiceInfoBleak(
service_data={}, service_data={},
), ),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "unknown"), device=BLEDevice("aa:bb:cc:dd:ee:ff", "unknown"),
time=0,
connectable=True,
) )

View File

@ -14,6 +14,7 @@ from . import (
WOHAND_ENCRYPTED_SERVICE_INFO, WOHAND_ENCRYPTED_SERVICE_INFO,
WOHAND_SERVICE_ALT_ADDRESS_INFO, WOHAND_SERVICE_ALT_ADDRESS_INFO,
WOHAND_SERVICE_INFO, WOHAND_SERVICE_INFO,
WOHAND_SERVICE_INFO_NOT_CONNECTABLE,
WOSENSORTH_SERVICE_INFO, WOSENSORTH_SERVICE_INFO,
init_integration, init_integration,
patch_async_setup_entry, patch_async_setup_entry,
@ -112,6 +113,17 @@ async def test_async_step_bluetooth_not_switchbot(hass):
assert result["reason"] == "not_supported" assert result["reason"] == "not_supported"
async def test_async_step_bluetooth_not_connectable(hass):
"""Test discovery via bluetooth and its not connectable switchbot."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=WOHAND_SERVICE_INFO_NOT_CONNECTABLE,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "not_supported"
async def test_user_setup_wohand(hass): async def test_user_setup_wohand(hass):
"""Test the user initiated form with password and valid mac.""" """Test the user initiated form with password and valid mac."""
@ -203,7 +215,12 @@ async def test_user_setup_wocurtain_or_bot(hass):
with patch( with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info", "homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO, WOHAND_SERVICE_ALT_ADDRESS_INFO], return_value=[
NOT_SWITCHBOT_INFO,
WOCURTAIN_SERVICE_INFO,
WOHAND_SERVICE_ALT_ADDRESS_INFO,
WOHAND_SERVICE_INFO_NOT_CONNECTABLE,
],
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
@ -234,7 +251,11 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass):
with patch( with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info", "homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO, WOHAND_ENCRYPTED_SERVICE_INFO], return_value=[
WOCURTAIN_SERVICE_INFO,
WOHAND_ENCRYPTED_SERVICE_INFO,
WOHAND_SERVICE_INFO_NOT_CONNECTABLE,
],
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}

View File

@ -15,6 +15,8 @@ NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfoBleak(
service_uuids=[], service_uuids=[],
source="local", source="local",
advertisement=AdvertisementData(local_name="Not it"), advertisement=AdvertisementData(local_name="Not it"),
time=0,
connectable=False,
) )
LYWSDCGQ_SERVICE_INFO = BluetoothServiceInfoBleak( LYWSDCGQ_SERVICE_INFO = BluetoothServiceInfoBleak(
@ -29,6 +31,8 @@ LYWSDCGQ_SERVICE_INFO = BluetoothServiceInfoBleak(
service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"],
source="local", source="local",
advertisement=AdvertisementData(local_name="Not it"), advertisement=AdvertisementData(local_name="Not it"),
time=0,
connectable=False,
) )
MMC_T201_1_SERVICE_INFO = BluetoothServiceInfoBleak( MMC_T201_1_SERVICE_INFO = BluetoothServiceInfoBleak(
@ -43,6 +47,8 @@ MMC_T201_1_SERVICE_INFO = BluetoothServiceInfoBleak(
service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"],
source="local", source="local",
advertisement=AdvertisementData(local_name="Not it"), advertisement=AdvertisementData(local_name="Not it"),
time=0,
connectable=False,
) )
JTYJGD03MI_SERVICE_INFO = BluetoothServiceInfoBleak( JTYJGD03MI_SERVICE_INFO = BluetoothServiceInfoBleak(
@ -57,6 +63,8 @@ JTYJGD03MI_SERVICE_INFO = BluetoothServiceInfoBleak(
service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"],
source="local", source="local",
advertisement=AdvertisementData(local_name="Not it"), advertisement=AdvertisementData(local_name="Not it"),
time=0,
connectable=False,
) )
YLKG07YL_SERVICE_INFO = BluetoothServiceInfoBleak( YLKG07YL_SERVICE_INFO = BluetoothServiceInfoBleak(
@ -71,6 +79,8 @@ YLKG07YL_SERVICE_INFO = BluetoothServiceInfoBleak(
service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"],
source="local", source="local",
advertisement=AdvertisementData(local_name="Not it"), advertisement=AdvertisementData(local_name="Not it"),
time=0,
connectable=False,
) )
MISSING_PAYLOAD_ENCRYPTED = BluetoothServiceInfoBleak( MISSING_PAYLOAD_ENCRYPTED = BluetoothServiceInfoBleak(
@ -85,10 +95,14 @@ MISSING_PAYLOAD_ENCRYPTED = BluetoothServiceInfoBleak(
service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"],
source="local", source="local",
advertisement=AdvertisementData(local_name="Not it"), advertisement=AdvertisementData(local_name="Not it"),
time=0,
connectable=False,
) )
def make_advertisement(address: str, payload: bytes) -> BluetoothServiceInfoBleak: def make_advertisement(
address: str, payload: bytes, connectable: bool = True
) -> BluetoothServiceInfoBleak:
"""Make a dummy advertisement.""" """Make a dummy advertisement."""
return BluetoothServiceInfoBleak( return BluetoothServiceInfoBleak(
name="Test Device", name="Test Device",
@ -102,4 +116,6 @@ def make_advertisement(address: str, payload: bytes) -> BluetoothServiceInfoBlea
service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"],
source="local", source="local",
advertisement=AdvertisementData(local_name="Test Device"), advertisement=AdvertisementData(local_name="Test Device"),
time=0,
connectable=connectable,
) )

View File

@ -2,7 +2,10 @@
from unittest.mock import patch from unittest.mock import patch
from homeassistant.components.bluetooth import BluetoothChange from homeassistant.components.bluetooth import (
BluetoothChange,
async_get_advertisement_callback,
)
from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.components.xiaomi_ble.const import DOMAIN from homeassistant.components.xiaomi_ble.const import DOMAIN
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT
@ -294,6 +297,190 @@ async def test_xiaomi_HHCCJCY01(hass):
await hass.async_block_till_done() await hass.async_block_till_done()
async def test_xiaomi_HHCCJCY01_not_connectable(hass):
"""This device has multiple advertisements before all sensors are visible but not connectable."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="C4:7C:8D:6A:3E:7B",
)
entry.add_to_hass(hass)
saved_callback = None
def _async_register_callback(_hass, _callback, _matcher, _mode):
nonlocal saved_callback
saved_callback = _callback
return lambda: None
with patch(
"homeassistant.components.bluetooth.update_coordinator.async_register_callback",
_async_register_callback,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A",
b"q \x98\x00fz>j\x8d|\xc4\r\x07\x10\x03\x00\x00\x00",
connectable=False,
),
BluetoothChange.ADVERTISEMENT,
)
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A",
b"q \x98\x00hz>j\x8d|\xc4\r\t\x10\x02W\x02",
connectable=False,
),
BluetoothChange.ADVERTISEMENT,
)
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A",
b"q \x98\x00Gz>j\x8d|\xc4\r\x08\x10\x01@",
connectable=False,
),
BluetoothChange.ADVERTISEMENT,
)
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A",
b"q \x98\x00iz>j\x8d|\xc4\r\x04\x10\x02\xf4\x00",
connectable=False,
),
BluetoothChange.ADVERTISEMENT,
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 4
illum_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_illuminance")
illum_sensor_attr = illum_sensor.attributes
assert illum_sensor.state == "0"
assert illum_sensor_attr[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Illuminance"
assert illum_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "lx"
assert illum_sensor_attr[ATTR_STATE_CLASS] == "measurement"
cond_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_conductivity")
cond_sensor_attribtes = cond_sensor.attributes
assert cond_sensor.state == "599"
assert (
cond_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Conductivity"
)
assert cond_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "µS/cm"
assert cond_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
moist_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_moisture")
moist_sensor_attribtes = moist_sensor.attributes
assert moist_sensor.state == "64"
assert moist_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Moisture"
assert moist_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%"
assert moist_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
temp_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_temperature")
temp_sensor_attribtes = temp_sensor.attributes
assert temp_sensor.state == "24.4"
assert (
temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Temperature"
)
assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C"
assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
# No battery sensor since its not connectable
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_xiaomi_HHCCJCY01_only_some_sources_connectable(hass):
"""This device has multiple advertisements before all sensors are visible and some sources are connectable."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="C4:7C:8D:6A:3E:7A",
)
entry.add_to_hass(hass)
saved_callback = async_get_advertisement_callback(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A",
b"q \x98\x00fz>j\x8d|\xc4\r\x07\x10\x03\x00\x00\x00",
connectable=True,
),
)
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A",
b"q \x98\x00hz>j\x8d|\xc4\r\t\x10\x02W\x02",
connectable=False,
),
)
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A",
b"q \x98\x00Gz>j\x8d|\xc4\r\x08\x10\x01@",
connectable=False,
),
)
saved_callback(
make_advertisement(
"C4:7C:8D:6A:3E:7A",
b"q \x98\x00iz>j\x8d|\xc4\r\x04\x10\x02\xf4\x00",
connectable=False,
),
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 5
illum_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_illuminance")
illum_sensor_attr = illum_sensor.attributes
assert illum_sensor.state == "0"
assert illum_sensor_attr[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Illuminance"
assert illum_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "lx"
assert illum_sensor_attr[ATTR_STATE_CLASS] == "measurement"
cond_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_conductivity")
cond_sensor_attribtes = cond_sensor.attributes
assert cond_sensor.state == "599"
assert (
cond_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Conductivity"
)
assert cond_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "µS/cm"
assert cond_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
moist_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_moisture")
moist_sensor_attribtes = moist_sensor.attributes
assert moist_sensor.state == "64"
assert moist_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Moisture"
assert moist_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%"
assert moist_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
temp_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_temperature")
temp_sensor_attribtes = temp_sensor.attributes
assert temp_sensor.state == "24.4"
assert (
temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Temperature"
)
assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C"
assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
batt_sensor = hass.states.get("sensor.plant_sensor_6a3e7a_battery")
batt_sensor_attribtes = batt_sensor.attributes
assert batt_sensor.state == "5"
assert batt_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 6A3E7A Battery"
assert batt_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%"
assert batt_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_xiaomi_CGDK2(hass): async def test_xiaomi_CGDK2(hass):
"""This device has encrypion so we need to retrieve its bindkey from the configentry.""" """This device has encrypion so we need to retrieve its bindkey from the configentry."""
entry = MockConfigEntry( entry = MockConfigEntry(

View File

@ -17,6 +17,8 @@ YALE_ACCESS_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak(
source="local", source="local",
device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"),
advertisement=AdvertisementData(), advertisement=AdvertisementData(),
time=0,
connectable=True,
) )
@ -33,6 +35,8 @@ LOCK_DISCOVERY_INFO_UUID_ADDRESS = BluetoothServiceInfoBleak(
source="local", source="local",
device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"),
advertisement=AdvertisementData(), advertisement=AdvertisementData(),
time=0,
connectable=True,
) )
OLD_FIRMWARE_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( OLD_FIRMWARE_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak(
@ -48,6 +52,8 @@ OLD_FIRMWARE_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak(
source="local", source="local",
device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"),
advertisement=AdvertisementData(), advertisement=AdvertisementData(),
time=0,
connectable=True,
) )
@ -64,4 +70,6 @@ NOT_YALE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
source="local", source="local",
device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"),
advertisement=AdvertisementData(), advertisement=AdvertisementData(),
time=0,
connectable=True,
) )