mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 06:07:17 +00:00
Decouple more of ESPHome Bluetooth support (#96502)
* Decouple more of ESPHome Bluetooth support The goal is to be able to move more of this into an external library * Decouple more of ESPHome Bluetooth support The goal is to be able to move more of this into an external library * Decouple more of ESPHome Bluetooth support The goal is to be able to move more of this into an external library * Decouple more of ESPHome Bluetooth support The goal is to be able to move more of this into an external library * Decouple more of ESPHome Bluetooth support The goal is to be able to move more of this into an external library * fix diag * remove need for hass in the client * refactor * decouple more * decouple more * decouple more * decouple more * decouple more * remove unreachable code * remove unreachable code
This commit is contained in:
parent
facd6ef765
commit
2c4e4428e9
@ -305,7 +305,6 @@ omit =
|
|||||||
homeassistant/components/escea/climate.py
|
homeassistant/components/escea/climate.py
|
||||||
homeassistant/components/escea/discovery.py
|
homeassistant/components/escea/discovery.py
|
||||||
homeassistant/components/esphome/bluetooth/*
|
homeassistant/components/esphome/bluetooth/*
|
||||||
homeassistant/components/esphome/domain_data.py
|
|
||||||
homeassistant/components/esphome/entry_data.py
|
homeassistant/components/esphome/entry_data.py
|
||||||
homeassistant/components/esphome/manager.py
|
homeassistant/components/esphome/manager.py
|
||||||
homeassistant/components/etherscan/sensor.py
|
homeassistant/components/etherscan/sensor.py
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
"""Bluetooth support for esphome."""
|
"""Bluetooth support for esphome."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -16,36 +15,35 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
|
||||||
|
|
||||||
from ..entry_data import RuntimeEntryData
|
from ..entry_data import RuntimeEntryData
|
||||||
from .client import ESPHomeClient
|
from .cache import ESPHomeBluetoothCache
|
||||||
|
from .client import (
|
||||||
|
ESPHomeClient,
|
||||||
|
ESPHomeClientData,
|
||||||
|
)
|
||||||
|
from .device import ESPHomeBluetoothDevice
|
||||||
from .scanner import ESPHomeScanner
|
from .scanner import ESPHomeScanner
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@hass_callback
|
@hass_callback
|
||||||
def _async_can_connect_factory(
|
def _async_can_connect(
|
||||||
entry_data: RuntimeEntryData, source: str
|
entry_data: RuntimeEntryData, bluetooth_device: ESPHomeBluetoothDevice, source: str
|
||||||
) -> Callable[[], bool]:
|
) -> bool:
|
||||||
"""Create a can_connect function for a specific RuntimeEntryData instance."""
|
"""Check if a given source can make another connection."""
|
||||||
|
can_connect = bool(entry_data.available and bluetooth_device.ble_connections_free)
|
||||||
@hass_callback
|
_LOGGER.debug(
|
||||||
def _async_can_connect() -> bool:
|
(
|
||||||
"""Check if a given source can make another connection."""
|
"%s [%s]: Checking can connect, available=%s, ble_connections_free=%s"
|
||||||
can_connect = bool(entry_data.available and entry_data.ble_connections_free)
|
" result=%s"
|
||||||
_LOGGER.debug(
|
),
|
||||||
(
|
entry_data.name,
|
||||||
"%s [%s]: Checking can connect, available=%s, ble_connections_free=%s"
|
source,
|
||||||
" result=%s"
|
entry_data.available,
|
||||||
),
|
bluetooth_device.ble_connections_free,
|
||||||
entry_data.name,
|
can_connect,
|
||||||
source,
|
)
|
||||||
entry_data.available,
|
return can_connect
|
||||||
entry_data.ble_connections_free,
|
|
||||||
can_connect,
|
|
||||||
)
|
|
||||||
return can_connect
|
|
||||||
|
|
||||||
return _async_can_connect
|
|
||||||
|
|
||||||
|
|
||||||
async def async_connect_scanner(
|
async def async_connect_scanner(
|
||||||
@ -53,16 +51,20 @@ async def async_connect_scanner(
|
|||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
cli: APIClient,
|
cli: APIClient,
|
||||||
entry_data: RuntimeEntryData,
|
entry_data: RuntimeEntryData,
|
||||||
|
cache: ESPHomeBluetoothCache,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Connect scanner."""
|
"""Connect scanner."""
|
||||||
assert entry.unique_id is not None
|
assert entry.unique_id is not None
|
||||||
source = str(entry.unique_id)
|
source = str(entry.unique_id)
|
||||||
new_info_callback = async_get_advertisement_callback(hass)
|
new_info_callback = async_get_advertisement_callback(hass)
|
||||||
assert entry_data.device_info is not None
|
device_info = entry_data.device_info
|
||||||
feature_flags = entry_data.device_info.bluetooth_proxy_feature_flags_compat(
|
assert device_info is not None
|
||||||
|
feature_flags = device_info.bluetooth_proxy_feature_flags_compat(
|
||||||
entry_data.api_version
|
entry_data.api_version
|
||||||
)
|
)
|
||||||
connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS)
|
connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS)
|
||||||
|
bluetooth_device = ESPHomeBluetoothDevice(entry_data.name, device_info.mac_address)
|
||||||
|
entry_data.bluetooth_device = bluetooth_device
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s [%s]: Connecting scanner feature_flags=%s, connectable=%s",
|
"%s [%s]: Connecting scanner feature_flags=%s, connectable=%s",
|
||||||
entry.title,
|
entry.title,
|
||||||
@ -70,22 +72,35 @@ async def async_connect_scanner(
|
|||||||
feature_flags,
|
feature_flags,
|
||||||
connectable,
|
connectable,
|
||||||
)
|
)
|
||||||
|
client_data = ESPHomeClientData(
|
||||||
|
bluetooth_device=bluetooth_device,
|
||||||
|
cache=cache,
|
||||||
|
client=cli,
|
||||||
|
device_info=device_info,
|
||||||
|
api_version=entry_data.api_version,
|
||||||
|
title=entry.title,
|
||||||
|
scanner=None,
|
||||||
|
disconnect_callbacks=entry_data.disconnect_callbacks,
|
||||||
|
)
|
||||||
connector = HaBluetoothConnector(
|
connector = HaBluetoothConnector(
|
||||||
# MyPy doesn't like partials, but this is correct
|
# MyPy doesn't like partials, but this is correct
|
||||||
# https://github.com/python/mypy/issues/1484
|
# https://github.com/python/mypy/issues/1484
|
||||||
client=partial(ESPHomeClient, config_entry=entry), # type: ignore[arg-type]
|
client=partial(ESPHomeClient, client_data=client_data), # type: ignore[arg-type]
|
||||||
source=source,
|
source=source,
|
||||||
can_connect=_async_can_connect_factory(entry_data, source),
|
can_connect=hass_callback(
|
||||||
|
partial(_async_can_connect, entry_data, bluetooth_device, source)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
scanner = ESPHomeScanner(
|
scanner = ESPHomeScanner(
|
||||||
hass, source, entry.title, new_info_callback, connector, connectable
|
hass, source, entry.title, new_info_callback, connector, connectable
|
||||||
)
|
)
|
||||||
|
client_data.scanner = scanner
|
||||||
if connectable:
|
if connectable:
|
||||||
# If its connectable be sure not to register the scanner
|
# If its connectable be sure not to register the scanner
|
||||||
# until we know the connection is fully setup since otherwise
|
# until we know the connection is fully setup since otherwise
|
||||||
# there is a race condition where the connection can fail
|
# there is a race condition where the connection can fail
|
||||||
await cli.subscribe_bluetooth_connections_free(
|
await cli.subscribe_bluetooth_connections_free(
|
||||||
entry_data.async_update_ble_connection_limits
|
bluetooth_device.async_update_ble_connection_limits
|
||||||
)
|
)
|
||||||
unload_callbacks = [
|
unload_callbacks = [
|
||||||
async_register_scanner(hass, scanner, connectable),
|
async_register_scanner(hass, scanner, connectable),
|
||||||
|
50
homeassistant/components/esphome/bluetooth/cache.py
Normal file
50
homeassistant/components/esphome/bluetooth/cache.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""Bluetooth cache for esphome."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import MutableMapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from bleak.backends.service import BleakGATTServiceCollection
|
||||||
|
from lru import LRU # pylint: disable=no-name-in-module
|
||||||
|
|
||||||
|
MAX_CACHED_SERVICES = 128
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ESPHomeBluetoothCache:
|
||||||
|
"""Shared cache between all ESPHome bluetooth devices."""
|
||||||
|
|
||||||
|
_gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field(
|
||||||
|
default_factory=lambda: LRU(MAX_CACHED_SERVICES)
|
||||||
|
)
|
||||||
|
_gatt_mtu_cache: MutableMapping[int, int] = field(
|
||||||
|
default_factory=lambda: LRU(MAX_CACHED_SERVICES)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_gatt_services_cache(
|
||||||
|
self, address: int
|
||||||
|
) -> BleakGATTServiceCollection | None:
|
||||||
|
"""Get the BleakGATTServiceCollection for the given address."""
|
||||||
|
return self._gatt_services_cache.get(address)
|
||||||
|
|
||||||
|
def set_gatt_services_cache(
|
||||||
|
self, address: int, services: BleakGATTServiceCollection
|
||||||
|
) -> None:
|
||||||
|
"""Set the BleakGATTServiceCollection for the given address."""
|
||||||
|
self._gatt_services_cache[address] = services
|
||||||
|
|
||||||
|
def clear_gatt_services_cache(self, address: int) -> None:
|
||||||
|
"""Clear the BleakGATTServiceCollection for the given address."""
|
||||||
|
self._gatt_services_cache.pop(address, None)
|
||||||
|
|
||||||
|
def get_gatt_mtu_cache(self, address: int) -> int | None:
|
||||||
|
"""Get the mtu cache for the given address."""
|
||||||
|
return self._gatt_mtu_cache.get(address)
|
||||||
|
|
||||||
|
def set_gatt_mtu_cache(self, address: int, mtu: int) -> None:
|
||||||
|
"""Set the mtu cache for the given address."""
|
||||||
|
self._gatt_mtu_cache[address] = mtu
|
||||||
|
|
||||||
|
def clear_gatt_mtu_cache(self, address: int) -> None:
|
||||||
|
"""Clear the mtu cache for the given address."""
|
||||||
|
self._gatt_mtu_cache.pop(address, None)
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
import contextlib
|
import contextlib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, TypeVar, cast
|
from typing import Any, TypeVar, cast
|
||||||
import uuid
|
import uuid
|
||||||
@ -11,8 +13,11 @@ import uuid
|
|||||||
from aioesphomeapi import (
|
from aioesphomeapi import (
|
||||||
ESP_CONNECTION_ERROR_DESCRIPTION,
|
ESP_CONNECTION_ERROR_DESCRIPTION,
|
||||||
ESPHOME_GATT_ERRORS,
|
ESPHOME_GATT_ERRORS,
|
||||||
|
APIClient,
|
||||||
|
APIVersion,
|
||||||
BLEConnectionError,
|
BLEConnectionError,
|
||||||
BluetoothProxyFeature,
|
BluetoothProxyFeature,
|
||||||
|
DeviceInfo,
|
||||||
)
|
)
|
||||||
from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError
|
from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError
|
||||||
from aioesphomeapi.core import BluetoothGATTAPIError
|
from aioesphomeapi.core import BluetoothGATTAPIError
|
||||||
@ -24,13 +29,13 @@ from bleak.backends.device import BLEDevice
|
|||||||
from bleak.backends.service import BleakGATTServiceCollection
|
from bleak.backends.service import BleakGATTServiceCollection
|
||||||
from bleak.exc import BleakError
|
from bleak.exc import BleakError
|
||||||
|
|
||||||
from homeassistant.components.bluetooth import async_scanner_by_source
|
from homeassistant.core import CALLBACK_TYPE
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
|
||||||
|
|
||||||
from ..domain_data import DomainData
|
from .cache import ESPHomeBluetoothCache
|
||||||
from .characteristic import BleakGATTCharacteristicESPHome
|
from .characteristic import BleakGATTCharacteristicESPHome
|
||||||
from .descriptor import BleakGATTDescriptorESPHome
|
from .descriptor import BleakGATTDescriptorESPHome
|
||||||
|
from .device import ESPHomeBluetoothDevice
|
||||||
|
from .scanner import ESPHomeScanner
|
||||||
from .service import BleakGATTServiceESPHome
|
from .service import BleakGATTServiceESPHome
|
||||||
|
|
||||||
DEFAULT_MTU = 23
|
DEFAULT_MTU = 23
|
||||||
@ -118,6 +123,20 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType:
|
|||||||
return cast(_WrapFuncType, _async_wrap_bluetooth_operation)
|
return cast(_WrapFuncType, _async_wrap_bluetooth_operation)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ESPHomeClientData:
|
||||||
|
"""Define a class that stores client data for an esphome client."""
|
||||||
|
|
||||||
|
bluetooth_device: ESPHomeBluetoothDevice
|
||||||
|
cache: ESPHomeBluetoothCache
|
||||||
|
client: APIClient
|
||||||
|
device_info: DeviceInfo
|
||||||
|
api_version: APIVersion
|
||||||
|
title: str
|
||||||
|
scanner: ESPHomeScanner | None
|
||||||
|
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ESPHomeClient(BaseBleakClient):
|
class ESPHomeClient(BaseBleakClient):
|
||||||
"""ESPHome Bleak Client."""
|
"""ESPHome Bleak Client."""
|
||||||
|
|
||||||
@ -125,36 +144,38 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
self,
|
self,
|
||||||
address_or_ble_device: BLEDevice | str,
|
address_or_ble_device: BLEDevice | str,
|
||||||
*args: Any,
|
*args: Any,
|
||||||
config_entry: ConfigEntry,
|
client_data: ESPHomeClientData,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the ESPHomeClient."""
|
"""Initialize the ESPHomeClient."""
|
||||||
|
device_info = client_data.device_info
|
||||||
|
self._disconnect_callbacks = client_data.disconnect_callbacks
|
||||||
assert isinstance(address_or_ble_device, BLEDevice)
|
assert isinstance(address_or_ble_device, BLEDevice)
|
||||||
super().__init__(address_or_ble_device, *args, **kwargs)
|
super().__init__(address_or_ble_device, *args, **kwargs)
|
||||||
self._hass: HomeAssistant = kwargs["hass"]
|
self._loop = asyncio.get_running_loop()
|
||||||
self._ble_device = address_or_ble_device
|
self._ble_device = address_or_ble_device
|
||||||
self._address_as_int = mac_to_int(self._ble_device.address)
|
self._address_as_int = mac_to_int(self._ble_device.address)
|
||||||
assert self._ble_device.details is not None
|
assert self._ble_device.details is not None
|
||||||
self._source = self._ble_device.details["source"]
|
self._source = self._ble_device.details["source"]
|
||||||
self.domain_data = DomainData.get(self._hass)
|
self._cache = client_data.cache
|
||||||
self.entry_data = self.domain_data.get_entry_data(config_entry)
|
self._bluetooth_device = client_data.bluetooth_device
|
||||||
self._client = self.entry_data.client
|
self._client = client_data.client
|
||||||
self._is_connected = False
|
self._is_connected = False
|
||||||
self._mtu: int | None = None
|
self._mtu: int | None = None
|
||||||
self._cancel_connection_state: CALLBACK_TYPE | None = None
|
self._cancel_connection_state: CALLBACK_TYPE | None = None
|
||||||
self._notify_cancels: dict[
|
self._notify_cancels: dict[
|
||||||
int, tuple[Callable[[], Coroutine[Any, Any, None]], Callable[[], None]]
|
int, tuple[Callable[[], Coroutine[Any, Any, None]], Callable[[], None]]
|
||||||
] = {}
|
] = {}
|
||||||
self._loop = asyncio.get_running_loop()
|
|
||||||
self._disconnected_futures: set[asyncio.Future[None]] = set()
|
self._disconnected_futures: set[asyncio.Future[None]] = set()
|
||||||
device_info = self.entry_data.device_info
|
self._device_info = client_data.device_info
|
||||||
assert device_info is not None
|
|
||||||
self._device_info = device_info
|
|
||||||
self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat(
|
self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat(
|
||||||
self.entry_data.api_version
|
client_data.api_version
|
||||||
)
|
)
|
||||||
self._address_type = address_or_ble_device.details["address_type"]
|
self._address_type = address_or_ble_device.details["address_type"]
|
||||||
self._source_name = f"{config_entry.title} [{self._source}]"
|
self._source_name = f"{client_data.title} [{self._source}]"
|
||||||
|
scanner = client_data.scanner
|
||||||
|
assert scanner is not None
|
||||||
|
self._scanner = scanner
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Return the string representation of the client."""
|
"""Return the string representation of the client."""
|
||||||
@ -206,14 +227,14 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
self._async_call_bleak_disconnected_callback()
|
self._async_call_bleak_disconnected_callback()
|
||||||
|
|
||||||
def _async_esp_disconnected(self) -> None:
|
def _async_esp_disconnected(self) -> None:
|
||||||
"""Handle the esp32 client disconnecting from hass."""
|
"""Handle the esp32 client disconnecting from us."""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s: %s - %s: ESP device disconnected",
|
"%s: %s - %s: ESP device disconnected",
|
||||||
self._source_name,
|
self._source_name,
|
||||||
self._ble_device.name,
|
self._ble_device.name,
|
||||||
self._ble_device.address,
|
self._ble_device.address,
|
||||||
)
|
)
|
||||||
self.entry_data.disconnect_callbacks.remove(self._async_esp_disconnected)
|
self._disconnect_callbacks.remove(self._async_esp_disconnected)
|
||||||
self._async_ble_device_disconnected()
|
self._async_ble_device_disconnected()
|
||||||
|
|
||||||
def _async_call_bleak_disconnected_callback(self) -> None:
|
def _async_call_bleak_disconnected_callback(self) -> None:
|
||||||
@ -222,6 +243,65 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
self._disconnected_callback()
|
self._disconnected_callback()
|
||||||
self._disconnected_callback = None
|
self._disconnected_callback = None
|
||||||
|
|
||||||
|
def _on_bluetooth_connection_state(
|
||||||
|
self,
|
||||||
|
connected_future: asyncio.Future[bool],
|
||||||
|
connected: bool,
|
||||||
|
mtu: int,
|
||||||
|
error: int,
|
||||||
|
) -> None:
|
||||||
|
"""Handle a connect or disconnect."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s",
|
||||||
|
self._source_name,
|
||||||
|
self._ble_device.name,
|
||||||
|
self._ble_device.address,
|
||||||
|
connected,
|
||||||
|
mtu,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
if connected:
|
||||||
|
self._is_connected = True
|
||||||
|
if not self._mtu:
|
||||||
|
self._mtu = mtu
|
||||||
|
self._cache.set_gatt_mtu_cache(self._address_as_int, mtu)
|
||||||
|
else:
|
||||||
|
self._async_ble_device_disconnected()
|
||||||
|
|
||||||
|
if connected_future.done():
|
||||||
|
return
|
||||||
|
|
||||||
|
if error:
|
||||||
|
try:
|
||||||
|
ble_connection_error = BLEConnectionError(error)
|
||||||
|
ble_connection_error_name = ble_connection_error.name
|
||||||
|
human_error = ESP_CONNECTION_ERROR_DESCRIPTION[ble_connection_error]
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
ble_connection_error_name = str(error)
|
||||||
|
human_error = ESPHOME_GATT_ERRORS.get(
|
||||||
|
error, f"Unknown error code {error}"
|
||||||
|
)
|
||||||
|
connected_future.set_exception(
|
||||||
|
BleakError(
|
||||||
|
f"Error {ble_connection_error_name} while connecting:"
|
||||||
|
f" {human_error}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not connected:
|
||||||
|
connected_future.set_exception(BleakError("Disconnected"))
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s: %s - %s: connected, registering for disconnected callbacks",
|
||||||
|
self._source_name,
|
||||||
|
self._ble_device.name,
|
||||||
|
self._ble_device.address,
|
||||||
|
)
|
||||||
|
self._disconnect_callbacks.append(self._async_esp_disconnected)
|
||||||
|
connected_future.set_result(connected)
|
||||||
|
|
||||||
@api_error_as_bleak_error
|
@api_error_as_bleak_error
|
||||||
async def connect(
|
async def connect(
|
||||||
self, dangerous_use_bleak_cache: bool = False, **kwargs: Any
|
self, dangerous_use_bleak_cache: bool = False, **kwargs: Any
|
||||||
@ -236,82 +316,24 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
Boolean representing connection status.
|
Boolean representing connection status.
|
||||||
"""
|
"""
|
||||||
await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT)
|
await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT)
|
||||||
domain_data = self.domain_data
|
cache = self._cache
|
||||||
entry_data = self.entry_data
|
|
||||||
|
|
||||||
self._mtu = domain_data.get_gatt_mtu_cache(self._address_as_int)
|
self._mtu = cache.get_gatt_mtu_cache(self._address_as_int)
|
||||||
has_cache = bool(
|
has_cache = bool(
|
||||||
dangerous_use_bleak_cache
|
dangerous_use_bleak_cache
|
||||||
and self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING
|
and self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING
|
||||||
and domain_data.get_gatt_services_cache(self._address_as_int)
|
and cache.get_gatt_services_cache(self._address_as_int)
|
||||||
and self._mtu
|
and self._mtu
|
||||||
)
|
)
|
||||||
connected_future: asyncio.Future[bool] = asyncio.Future()
|
connected_future: asyncio.Future[bool] = self._loop.create_future()
|
||||||
|
|
||||||
def _on_bluetooth_connection_state(
|
|
||||||
connected: bool, mtu: int, error: int
|
|
||||||
) -> None:
|
|
||||||
"""Handle a connect or disconnect."""
|
|
||||||
_LOGGER.debug(
|
|
||||||
"%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s",
|
|
||||||
self._source_name,
|
|
||||||
self._ble_device.name,
|
|
||||||
self._ble_device.address,
|
|
||||||
connected,
|
|
||||||
mtu,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
if connected:
|
|
||||||
self._is_connected = True
|
|
||||||
if not self._mtu:
|
|
||||||
self._mtu = mtu
|
|
||||||
domain_data.set_gatt_mtu_cache(self._address_as_int, mtu)
|
|
||||||
else:
|
|
||||||
self._async_ble_device_disconnected()
|
|
||||||
|
|
||||||
if connected_future.done():
|
|
||||||
return
|
|
||||||
|
|
||||||
if error:
|
|
||||||
try:
|
|
||||||
ble_connection_error = BLEConnectionError(error)
|
|
||||||
ble_connection_error_name = ble_connection_error.name
|
|
||||||
human_error = ESP_CONNECTION_ERROR_DESCRIPTION[ble_connection_error]
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
ble_connection_error_name = str(error)
|
|
||||||
human_error = ESPHOME_GATT_ERRORS.get(
|
|
||||||
error, f"Unknown error code {error}"
|
|
||||||
)
|
|
||||||
connected_future.set_exception(
|
|
||||||
BleakError(
|
|
||||||
f"Error {ble_connection_error_name} while connecting:"
|
|
||||||
f" {human_error}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not connected:
|
|
||||||
connected_future.set_exception(BleakError("Disconnected"))
|
|
||||||
return
|
|
||||||
|
|
||||||
_LOGGER.debug(
|
|
||||||
"%s: %s - %s: connected, registering for disconnected callbacks",
|
|
||||||
self._source_name,
|
|
||||||
self._ble_device.name,
|
|
||||||
self._ble_device.address,
|
|
||||||
)
|
|
||||||
entry_data.disconnect_callbacks.append(self._async_esp_disconnected)
|
|
||||||
connected_future.set_result(connected)
|
|
||||||
|
|
||||||
timeout = kwargs.get("timeout", self._timeout)
|
timeout = kwargs.get("timeout", self._timeout)
|
||||||
if not (scanner := async_scanner_by_source(self._hass, self._source)):
|
with self._scanner.connecting():
|
||||||
raise BleakError("Scanner disappeared for {self._source_name}")
|
|
||||||
with scanner.connecting():
|
|
||||||
try:
|
try:
|
||||||
self._cancel_connection_state = (
|
self._cancel_connection_state = (
|
||||||
await self._client.bluetooth_device_connect(
|
await self._client.bluetooth_device_connect(
|
||||||
self._address_as_int,
|
self._address_as_int,
|
||||||
_on_bluetooth_connection_state,
|
partial(self._on_bluetooth_connection_state, connected_future),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
has_cache=has_cache,
|
has_cache=has_cache,
|
||||||
feature_flags=self._feature_flags,
|
feature_flags=self._feature_flags,
|
||||||
@ -366,7 +388,8 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
|
|
||||||
async def _wait_for_free_connection_slot(self, timeout: float) -> None:
|
async def _wait_for_free_connection_slot(self, timeout: float) -> None:
|
||||||
"""Wait for a free connection slot."""
|
"""Wait for a free connection slot."""
|
||||||
if self.entry_data.ble_connections_free:
|
bluetooth_device = self._bluetooth_device
|
||||||
|
if bluetooth_device.ble_connections_free:
|
||||||
return
|
return
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s: %s - %s: Out of connection slots, waiting for a free one",
|
"%s: %s - %s: Out of connection slots, waiting for a free one",
|
||||||
@ -375,7 +398,7 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
self._ble_device.address,
|
self._ble_device.address,
|
||||||
)
|
)
|
||||||
async with async_timeout.timeout(timeout):
|
async with async_timeout.timeout(timeout):
|
||||||
await self.entry_data.wait_for_ble_connections_free()
|
await bluetooth_device.wait_for_ble_connections_free()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
@ -432,14 +455,14 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
with this device's services tree.
|
with this device's services tree.
|
||||||
"""
|
"""
|
||||||
address_as_int = self._address_as_int
|
address_as_int = self._address_as_int
|
||||||
domain_data = self.domain_data
|
cache = self._cache
|
||||||
# If the connection version >= 3, we must use the cache
|
# If the connection version >= 3, we must use the cache
|
||||||
# because the esp has already wiped the services list to
|
# because the esp has already wiped the services list to
|
||||||
# save memory.
|
# save memory.
|
||||||
if (
|
if (
|
||||||
self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING
|
self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING
|
||||||
or dangerous_use_bleak_cache
|
or dangerous_use_bleak_cache
|
||||||
) and (cached_services := domain_data.get_gatt_services_cache(address_as_int)):
|
) and (cached_services := cache.get_gatt_services_cache(address_as_int)):
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s: %s - %s: Cached services hit",
|
"%s: %s - %s: Cached services hit",
|
||||||
self._source_name,
|
self._source_name,
|
||||||
@ -498,7 +521,7 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
self._ble_device.name,
|
self._ble_device.name,
|
||||||
self._ble_device.address,
|
self._ble_device.address,
|
||||||
)
|
)
|
||||||
domain_data.set_gatt_services_cache(address_as_int, services)
|
cache.set_gatt_services_cache(address_as_int, services)
|
||||||
return services
|
return services
|
||||||
|
|
||||||
def _resolve_characteristic(
|
def _resolve_characteristic(
|
||||||
@ -518,8 +541,9 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
@api_error_as_bleak_error
|
@api_error_as_bleak_error
|
||||||
async def clear_cache(self) -> bool:
|
async def clear_cache(self) -> bool:
|
||||||
"""Clear the GATT cache."""
|
"""Clear the GATT cache."""
|
||||||
self.domain_data.clear_gatt_services_cache(self._address_as_int)
|
cache = self._cache
|
||||||
self.domain_data.clear_gatt_mtu_cache(self._address_as_int)
|
cache.clear_gatt_services_cache(self._address_as_int)
|
||||||
|
cache.clear_gatt_mtu_cache(self._address_as_int)
|
||||||
if not self._feature_flags & BluetoothProxyFeature.CACHE_CLEARING:
|
if not self._feature_flags & BluetoothProxyFeature.CACHE_CLEARING:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"On device cache clear is not available with this ESPHome version; "
|
"On device cache clear is not available with this ESPHome version; "
|
||||||
@ -734,5 +758,5 @@ class ESPHomeClient(BaseBleakClient):
|
|||||||
self._ble_device.name,
|
self._ble_device.name,
|
||||||
self._ble_device.address,
|
self._ble_device.address,
|
||||||
)
|
)
|
||||||
if not self._hass.loop.is_closed():
|
if not self._loop.is_closed():
|
||||||
self._hass.loop.call_soon_threadsafe(self._async_disconnected_cleanup)
|
self._loop.call_soon_threadsafe(self._async_disconnected_cleanup)
|
||||||
|
54
homeassistant/components/esphome/bluetooth/device.py
Normal file
54
homeassistant/components/esphome/bluetooth/device.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""Bluetooth device models for esphome."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ESPHomeBluetoothDevice:
|
||||||
|
"""Bluetooth data for a specific ESPHome device."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
mac_address: str
|
||||||
|
ble_connections_free: int = 0
|
||||||
|
ble_connections_limit: int = 0
|
||||||
|
_ble_connection_free_futures: list[asyncio.Future[int]] = field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_update_ble_connection_limits(self, free: int, limit: int) -> None:
|
||||||
|
"""Update the BLE connection limits."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s [%s]: BLE connection limits: used=%s free=%s limit=%s",
|
||||||
|
self.name,
|
||||||
|
self.mac_address,
|
||||||
|
limit - free,
|
||||||
|
free,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
self.ble_connections_free = free
|
||||||
|
self.ble_connections_limit = limit
|
||||||
|
if not free:
|
||||||
|
return
|
||||||
|
for fut in self._ble_connection_free_futures:
|
||||||
|
# If wait_for_ble_connections_free gets cancelled, it will
|
||||||
|
# leave a future in the list. We need to check if it's done
|
||||||
|
# before setting the result.
|
||||||
|
if not fut.done():
|
||||||
|
fut.set_result(free)
|
||||||
|
self._ble_connection_free_futures.clear()
|
||||||
|
|
||||||
|
async def wait_for_ble_connections_free(self) -> int:
|
||||||
|
"""Wait until there are free BLE connections."""
|
||||||
|
if self.ble_connections_free > 0:
|
||||||
|
return self.ble_connections_free
|
||||||
|
fut: asyncio.Future[int] = asyncio.Future()
|
||||||
|
self._ble_connection_free_futures.append(fut)
|
||||||
|
return await fut
|
@ -30,12 +30,14 @@ async def async_get_config_entry_diagnostics(
|
|||||||
if (storage_data := await entry_data.store.async_load()) is not None:
|
if (storage_data := await entry_data.store.async_load()) is not None:
|
||||||
diag["storage_data"] = storage_data
|
diag["storage_data"] = storage_data
|
||||||
|
|
||||||
if config_entry.unique_id and (
|
if (
|
||||||
scanner := async_scanner_by_source(hass, config_entry.unique_id)
|
config_entry.unique_id
|
||||||
|
and (scanner := async_scanner_by_source(hass, config_entry.unique_id))
|
||||||
|
and (bluetooth_device := entry_data.bluetooth_device)
|
||||||
):
|
):
|
||||||
diag["bluetooth"] = {
|
diag["bluetooth"] = {
|
||||||
"connections_free": entry_data.ble_connections_free,
|
"connections_free": bluetooth_device.ble_connections_free,
|
||||||
"connections_limit": entry_data.ble_connections_limit,
|
"connections_limit": bluetooth_device.ble_connections_limit,
|
||||||
"scanner": await scanner.async_diagnostics(),
|
"scanner": await scanner.async_diagnostics(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,65 +1,31 @@
|
|||||||
"""Support for esphome domain data."""
|
"""Support for esphome domain data."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import MutableMapping
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from bleak.backends.service import BleakGATTServiceCollection
|
|
||||||
from lru import LRU # pylint: disable=no-name-in-module
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.json import JSONEncoder
|
from homeassistant.helpers.json import JSONEncoder
|
||||||
|
|
||||||
|
from .bluetooth.cache import ESPHomeBluetoothCache
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .entry_data import ESPHomeStorage, RuntimeEntryData
|
from .entry_data import ESPHomeStorage, RuntimeEntryData
|
||||||
|
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
MAX_CACHED_SERVICES = 128
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class DomainData:
|
class DomainData:
|
||||||
"""Define a class that stores global esphome data in hass.data[DOMAIN]."""
|
"""Define a class that stores global esphome data in hass.data[DOMAIN]."""
|
||||||
|
|
||||||
_entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict)
|
_entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict)
|
||||||
_stores: dict[str, ESPHomeStorage] = field(default_factory=dict)
|
_stores: dict[str, ESPHomeStorage] = field(default_factory=dict)
|
||||||
_gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field(
|
bluetooth_cache: ESPHomeBluetoothCache = field(
|
||||||
default_factory=lambda: LRU(MAX_CACHED_SERVICES)
|
default_factory=ESPHomeBluetoothCache
|
||||||
)
|
)
|
||||||
_gatt_mtu_cache: MutableMapping[int, int] = field(
|
|
||||||
default_factory=lambda: LRU(MAX_CACHED_SERVICES)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_gatt_services_cache(
|
|
||||||
self, address: int
|
|
||||||
) -> BleakGATTServiceCollection | None:
|
|
||||||
"""Get the BleakGATTServiceCollection for the given address."""
|
|
||||||
return self._gatt_services_cache.get(address)
|
|
||||||
|
|
||||||
def set_gatt_services_cache(
|
|
||||||
self, address: int, services: BleakGATTServiceCollection
|
|
||||||
) -> None:
|
|
||||||
"""Set the BleakGATTServiceCollection for the given address."""
|
|
||||||
self._gatt_services_cache[address] = services
|
|
||||||
|
|
||||||
def clear_gatt_services_cache(self, address: int) -> None:
|
|
||||||
"""Clear the BleakGATTServiceCollection for the given address."""
|
|
||||||
self._gatt_services_cache.pop(address, None)
|
|
||||||
|
|
||||||
def get_gatt_mtu_cache(self, address: int) -> int | None:
|
|
||||||
"""Get the mtu cache for the given address."""
|
|
||||||
return self._gatt_mtu_cache.get(address)
|
|
||||||
|
|
||||||
def set_gatt_mtu_cache(self, address: int, mtu: int) -> None:
|
|
||||||
"""Set the mtu cache for the given address."""
|
|
||||||
self._gatt_mtu_cache[address] = mtu
|
|
||||||
|
|
||||||
def clear_gatt_mtu_cache(self, address: int) -> None:
|
|
||||||
"""Clear the mtu cache for the given address."""
|
|
||||||
self._gatt_mtu_cache.pop(address, None)
|
|
||||||
|
|
||||||
def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
|
def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
|
||||||
"""Return the runtime entry data associated with this config entry.
|
"""Return the runtime entry data associated with this config entry.
|
||||||
@ -70,8 +36,7 @@ class DomainData:
|
|||||||
|
|
||||||
def set_entry_data(self, entry: ConfigEntry, entry_data: RuntimeEntryData) -> None:
|
def set_entry_data(self, entry: ConfigEntry, entry_data: RuntimeEntryData) -> None:
|
||||||
"""Set the runtime entry data associated with this config entry."""
|
"""Set the runtime entry data associated with this config entry."""
|
||||||
if entry.entry_id in self._entry_datas:
|
assert entry.entry_id not in self._entry_datas, "Entry data already set!"
|
||||||
raise ValueError("Entry data for this entry is already set")
|
|
||||||
self._entry_datas[entry.entry_id] = entry_data
|
self._entry_datas[entry.entry_id] = entry_data
|
||||||
|
|
||||||
def pop_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
|
def pop_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
|
||||||
|
@ -40,6 +40,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
|||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
|
from .bluetooth.device import ESPHomeBluetoothDevice
|
||||||
from .dashboard import async_get_dashboard
|
from .dashboard import async_get_dashboard
|
||||||
|
|
||||||
INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()}
|
INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()}
|
||||||
@ -80,7 +81,7 @@ class ESPHomeStorage(Store[StoreData]):
|
|||||||
"""ESPHome Storage."""
|
"""ESPHome Storage."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class RuntimeEntryData:
|
class RuntimeEntryData:
|
||||||
"""Store runtime data for esphome config entries."""
|
"""Store runtime data for esphome config entries."""
|
||||||
|
|
||||||
@ -97,6 +98,7 @@ class RuntimeEntryData:
|
|||||||
available: bool = False
|
available: bool = False
|
||||||
expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep)
|
expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep)
|
||||||
device_info: DeviceInfo | None = None
|
device_info: DeviceInfo | None = None
|
||||||
|
bluetooth_device: ESPHomeBluetoothDevice | None = None
|
||||||
api_version: APIVersion = field(default_factory=APIVersion)
|
api_version: APIVersion = field(default_factory=APIVersion)
|
||||||
cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||||
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list)
|
||||||
@ -107,11 +109,6 @@ class RuntimeEntryData:
|
|||||||
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
_storage_contents: StoreData | None = None
|
_storage_contents: StoreData | None = None
|
||||||
_pending_storage: Callable[[], StoreData] | None = None
|
_pending_storage: Callable[[], StoreData] | None = None
|
||||||
ble_connections_free: int = 0
|
|
||||||
ble_connections_limit: int = 0
|
|
||||||
_ble_connection_free_futures: list[asyncio.Future[int]] = field(
|
|
||||||
default_factory=list
|
|
||||||
)
|
|
||||||
assist_pipeline_update_callbacks: list[Callable[[], None]] = field(
|
assist_pipeline_update_callbacks: list[Callable[[], None]] = field(
|
||||||
default_factory=list
|
default_factory=list
|
||||||
)
|
)
|
||||||
@ -196,37 +193,6 @@ class RuntimeEntryData:
|
|||||||
|
|
||||||
return _unsub
|
return _unsub
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_update_ble_connection_limits(self, free: int, limit: int) -> None:
|
|
||||||
"""Update the BLE connection limits."""
|
|
||||||
_LOGGER.debug(
|
|
||||||
"%s [%s]: BLE connection limits: used=%s free=%s limit=%s",
|
|
||||||
self.name,
|
|
||||||
self.device_info.mac_address if self.device_info else "unknown",
|
|
||||||
limit - free,
|
|
||||||
free,
|
|
||||||
limit,
|
|
||||||
)
|
|
||||||
self.ble_connections_free = free
|
|
||||||
self.ble_connections_limit = limit
|
|
||||||
if not free:
|
|
||||||
return
|
|
||||||
for fut in self._ble_connection_free_futures:
|
|
||||||
# If wait_for_ble_connections_free gets cancelled, it will
|
|
||||||
# leave a future in the list. We need to check if it's done
|
|
||||||
# before setting the result.
|
|
||||||
if not fut.done():
|
|
||||||
fut.set_result(free)
|
|
||||||
self._ble_connection_free_futures.clear()
|
|
||||||
|
|
||||||
async def wait_for_ble_connections_free(self) -> int:
|
|
||||||
"""Wait until there are free BLE connections."""
|
|
||||||
if self.ble_connections_free > 0:
|
|
||||||
return self.ble_connections_free
|
|
||||||
fut: asyncio.Future[int] = asyncio.Future()
|
|
||||||
self._ble_connection_free_futures.append(fut)
|
|
||||||
return await fut
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_set_assist_pipeline_state(self, state: bool) -> None:
|
def async_set_assist_pipeline_state(self, state: bool) -> None:
|
||||||
"""Set the assist pipeline state."""
|
"""Set the assist pipeline state."""
|
||||||
|
@ -390,7 +390,9 @@ class ESPHomeManager:
|
|||||||
|
|
||||||
if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version):
|
if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version):
|
||||||
entry_data.disconnect_callbacks.append(
|
entry_data.disconnect_callbacks.append(
|
||||||
await async_connect_scanner(hass, entry, cli, entry_data)
|
await async_connect_scanner(
|
||||||
|
hass, entry, cli, entry_data, self.domain_data.bluetooth_cache
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.device_id = _async_setup_device_registry(
|
self.device_id = _async_setup_device_registry(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user