mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Add support for scanners that do not provide connectable devices (#77132)
This commit is contained in:
parent
61ff1b786b
commit
3938015c93
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
async_track_time_interval(
|
||||||
self.hass,
|
self.hass,
|
||||||
_async_check_unavailable,
|
_async_check_unavailable,
|
||||||
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
|
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
|
||||||
|
|
||||||
|
if connectable:
|
||||||
|
self._connectable_history[address] = service_info
|
||||||
|
# Bleak callbacks must get a connectable device
|
||||||
|
|
||||||
for callback_filters in self._bleak_callbacks:
|
for callback_filters in self._bleak_callbacks:
|
||||||
_dispatch_bleak_callback(*callback_filters, device, advertisement_data)
|
_dispatch_bleak_callback(*callback_filters, device, advertisement_data)
|
||||||
|
|
||||||
matched_domains = self._integration_matcher.match_domains(
|
matched_domains = self._integration_matcher.match_domains(service_info)
|
||||||
device, advertisement_data
|
|
||||||
)
|
|
||||||
_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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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."""
|
||||||
|
@ -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
|
||||||
|
@ -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),
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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"],
|
||||||
|
@ -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
|
||||||
|
@ -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"],
|
||||||
|
@ -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
|
||||||
|
@ -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"],
|
||||||
|
@ -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
|
||||||
|
@ -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"],
|
||||||
|
@ -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
|
||||||
|
@ -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"],
|
||||||
|
@ -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():
|
||||||
|
@ -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,7 +147,8 @@ 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):
|
||||||
|
for discovery_info in async_discovered_service_info(self.hass, connectable):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if (
|
if (
|
||||||
format_unique_id(address) in current_addresses
|
format_unique_id(address) in current_addresses
|
||||||
@ -143,7 +158,13 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
parsed = parse_advertisement_data(
|
parsed = parse_advertisement_data(
|
||||||
discovery_info.device, discovery_info.advertisement
|
discovery_info.device, discovery_info.advertisement
|
||||||
)
|
)
|
||||||
if parsed and parsed.data.get("modelName") in SUPPORTED_MODEL_TYPES:
|
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
|
self._discovered_advs[address] = parsed
|
||||||
|
|
||||||
if not self._discovered_advs:
|
if not self._discovered_advs:
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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),
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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 (
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
|
@ -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]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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}
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user