1
mirror of https://github.com/home-assistant/core synced 2024-10-04 07:58:43 +02: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:
J. Nick Koston 2023-07-21 15:41:50 -05:00 committed by GitHub
parent facd6ef765
commit 2c4e4428e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 280 additions and 203 deletions

View File

@ -305,7 +305,6 @@ omit =
homeassistant/components/escea/climate.py
homeassistant/components/escea/discovery.py
homeassistant/components/esphome/bluetooth/*
homeassistant/components/esphome/domain_data.py
homeassistant/components/esphome/entry_data.py
homeassistant/components/esphome/manager.py
homeassistant/components/etherscan/sensor.py

View File

@ -1,7 +1,6 @@
"""Bluetooth support for esphome."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
import logging
@ -16,36 +15,35 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
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
_LOGGER = logging.getLogger(__name__)
@hass_callback
def _async_can_connect_factory(
entry_data: RuntimeEntryData, source: str
) -> Callable[[], bool]:
"""Create a can_connect function for a specific RuntimeEntryData instance."""
@hass_callback
def _async_can_connect() -> bool:
"""Check if a given source can make another connection."""
can_connect = bool(entry_data.available and entry_data.ble_connections_free)
_LOGGER.debug(
(
"%s [%s]: Checking can connect, available=%s, ble_connections_free=%s"
" result=%s"
),
entry_data.name,
source,
entry_data.available,
entry_data.ble_connections_free,
can_connect,
)
return can_connect
return _async_can_connect
def _async_can_connect(
entry_data: RuntimeEntryData, bluetooth_device: ESPHomeBluetoothDevice, source: str
) -> bool:
"""Check if a given source can make another connection."""
can_connect = bool(entry_data.available and bluetooth_device.ble_connections_free)
_LOGGER.debug(
(
"%s [%s]: Checking can connect, available=%s, ble_connections_free=%s"
" result=%s"
),
entry_data.name,
source,
entry_data.available,
bluetooth_device.ble_connections_free,
can_connect,
)
return can_connect
async def async_connect_scanner(
@ -53,16 +51,20 @@ async def async_connect_scanner(
entry: ConfigEntry,
cli: APIClient,
entry_data: RuntimeEntryData,
cache: ESPHomeBluetoothCache,
) -> CALLBACK_TYPE:
"""Connect scanner."""
assert entry.unique_id is not None
source = str(entry.unique_id)
new_info_callback = async_get_advertisement_callback(hass)
assert entry_data.device_info is not None
feature_flags = entry_data.device_info.bluetooth_proxy_feature_flags_compat(
device_info = entry_data.device_info
assert device_info is not None
feature_flags = device_info.bluetooth_proxy_feature_flags_compat(
entry_data.api_version
)
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(
"%s [%s]: Connecting scanner feature_flags=%s, connectable=%s",
entry.title,
@ -70,22 +72,35 @@ async def async_connect_scanner(
feature_flags,
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(
# MyPy doesn't like partials, but this is correct
# 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,
can_connect=_async_can_connect_factory(entry_data, source),
can_connect=hass_callback(
partial(_async_can_connect, entry_data, bluetooth_device, source)
),
)
scanner = ESPHomeScanner(
hass, source, entry.title, new_info_callback, connector, connectable
)
client_data.scanner = scanner
if connectable:
# If its connectable be sure not to register the scanner
# until we know the connection is fully setup since otherwise
# there is a race condition where the connection can fail
await cli.subscribe_bluetooth_connections_free(
entry_data.async_update_ble_connection_limits
bluetooth_device.async_update_ble_connection_limits
)
unload_callbacks = [
async_register_scanner(hass, scanner, connectable),

View 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)

View File

@ -4,6 +4,8 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass, field
from functools import partial
import logging
from typing import Any, TypeVar, cast
import uuid
@ -11,8 +13,11 @@ import uuid
from aioesphomeapi import (
ESP_CONNECTION_ERROR_DESCRIPTION,
ESPHOME_GATT_ERRORS,
APIClient,
APIVersion,
BLEConnectionError,
BluetoothProxyFeature,
DeviceInfo,
)
from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError
from aioesphomeapi.core import BluetoothGATTAPIError
@ -24,13 +29,13 @@ from bleak.backends.device import BLEDevice
from bleak.backends.service import BleakGATTServiceCollection
from bleak.exc import BleakError
from homeassistant.components.bluetooth import async_scanner_by_source
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.core import CALLBACK_TYPE
from ..domain_data import DomainData
from .cache import ESPHomeBluetoothCache
from .characteristic import BleakGATTCharacteristicESPHome
from .descriptor import BleakGATTDescriptorESPHome
from .device import ESPHomeBluetoothDevice
from .scanner import ESPHomeScanner
from .service import BleakGATTServiceESPHome
DEFAULT_MTU = 23
@ -118,6 +123,20 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType:
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):
"""ESPHome Bleak Client."""
@ -125,36 +144,38 @@ class ESPHomeClient(BaseBleakClient):
self,
address_or_ble_device: BLEDevice | str,
*args: Any,
config_entry: ConfigEntry,
client_data: ESPHomeClientData,
**kwargs: Any,
) -> None:
"""Initialize the ESPHomeClient."""
device_info = client_data.device_info
self._disconnect_callbacks = client_data.disconnect_callbacks
assert isinstance(address_or_ble_device, BLEDevice)
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._address_as_int = mac_to_int(self._ble_device.address)
assert self._ble_device.details is not None
self._source = self._ble_device.details["source"]
self.domain_data = DomainData.get(self._hass)
self.entry_data = self.domain_data.get_entry_data(config_entry)
self._client = self.entry_data.client
self._cache = client_data.cache
self._bluetooth_device = client_data.bluetooth_device
self._client = client_data.client
self._is_connected = False
self._mtu: int | None = None
self._cancel_connection_state: CALLBACK_TYPE | None = None
self._notify_cancels: dict[
int, tuple[Callable[[], Coroutine[Any, Any, None]], Callable[[], None]]
] = {}
self._loop = asyncio.get_running_loop()
self._disconnected_futures: set[asyncio.Future[None]] = set()
device_info = self.entry_data.device_info
assert device_info is not None
self._device_info = device_info
self._device_info = client_data.device_info
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._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:
"""Return the string representation of the client."""
@ -206,14 +227,14 @@ class ESPHomeClient(BaseBleakClient):
self._async_call_bleak_disconnected_callback()
def _async_esp_disconnected(self) -> None:
"""Handle the esp32 client disconnecting from hass."""
"""Handle the esp32 client disconnecting from us."""
_LOGGER.debug(
"%s: %s - %s: ESP device disconnected",
self._source_name,
self._ble_device.name,
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()
def _async_call_bleak_disconnected_callback(self) -> None:
@ -222,6 +243,65 @@ class ESPHomeClient(BaseBleakClient):
self._disconnected_callback()
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
async def connect(
self, dangerous_use_bleak_cache: bool = False, **kwargs: Any
@ -236,82 +316,24 @@ class ESPHomeClient(BaseBleakClient):
Boolean representing connection status.
"""
await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT)
domain_data = self.domain_data
entry_data = self.entry_data
cache = self._cache
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(
dangerous_use_bleak_cache
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
)
connected_future: asyncio.Future[bool] = asyncio.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)
connected_future: asyncio.Future[bool] = self._loop.create_future()
timeout = kwargs.get("timeout", self._timeout)
if not (scanner := async_scanner_by_source(self._hass, self._source)):
raise BleakError("Scanner disappeared for {self._source_name}")
with scanner.connecting():
with self._scanner.connecting():
try:
self._cancel_connection_state = (
await self._client.bluetooth_device_connect(
self._address_as_int,
_on_bluetooth_connection_state,
partial(self._on_bluetooth_connection_state, connected_future),
timeout=timeout,
has_cache=has_cache,
feature_flags=self._feature_flags,
@ -366,7 +388,8 @@ class ESPHomeClient(BaseBleakClient):
async def _wait_for_free_connection_slot(self, timeout: float) -> None:
"""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
_LOGGER.debug(
"%s: %s - %s: Out of connection slots, waiting for a free one",
@ -375,7 +398,7 @@ class ESPHomeClient(BaseBleakClient):
self._ble_device.address,
)
async with async_timeout.timeout(timeout):
await self.entry_data.wait_for_ble_connections_free()
await bluetooth_device.wait_for_ble_connections_free()
@property
def is_connected(self) -> bool:
@ -432,14 +455,14 @@ class ESPHomeClient(BaseBleakClient):
with this device's services tree.
"""
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
# because the esp has already wiped the services list to
# save memory.
if (
self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING
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(
"%s: %s - %s: Cached services hit",
self._source_name,
@ -498,7 +521,7 @@ class ESPHomeClient(BaseBleakClient):
self._ble_device.name,
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
def _resolve_characteristic(
@ -518,8 +541,9 @@ class ESPHomeClient(BaseBleakClient):
@api_error_as_bleak_error
async def clear_cache(self) -> bool:
"""Clear the GATT cache."""
self.domain_data.clear_gatt_services_cache(self._address_as_int)
self.domain_data.clear_gatt_mtu_cache(self._address_as_int)
cache = self._cache
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:
_LOGGER.warning(
"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.address,
)
if not self._hass.loop.is_closed():
self._hass.loop.call_soon_threadsafe(self._async_disconnected_cleanup)
if not self._loop.is_closed():
self._loop.call_soon_threadsafe(self._async_disconnected_cleanup)

View 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

View File

@ -30,12 +30,14 @@ async def async_get_config_entry_diagnostics(
if (storage_data := await entry_data.store.async_load()) is not None:
diag["storage_data"] = storage_data
if config_entry.unique_id and (
scanner := async_scanner_by_source(hass, config_entry.unique_id)
if (
config_entry.unique_id
and (scanner := async_scanner_by_source(hass, config_entry.unique_id))
and (bluetooth_device := entry_data.bluetooth_device)
):
diag["bluetooth"] = {
"connections_free": entry_data.ble_connections_free,
"connections_limit": entry_data.ble_connections_limit,
"connections_free": bluetooth_device.ble_connections_free,
"connections_limit": bluetooth_device.ble_connections_limit,
"scanner": await scanner.async_diagnostics(),
}

View File

@ -1,65 +1,31 @@
"""Support for esphome domain data."""
from __future__ import annotations
from collections.abc import MutableMapping
from dataclasses import dataclass, field
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 homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
from .bluetooth.cache import ESPHomeBluetoothCache
from .const import DOMAIN
from .entry_data import ESPHomeStorage, RuntimeEntryData
STORAGE_VERSION = 1
MAX_CACHED_SERVICES = 128
@dataclass
@dataclass(slots=True)
class DomainData:
"""Define a class that stores global esphome data in hass.data[DOMAIN]."""
_entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict)
_stores: dict[str, ESPHomeStorage] = field(default_factory=dict)
_gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field(
default_factory=lambda: LRU(MAX_CACHED_SERVICES)
bluetooth_cache: ESPHomeBluetoothCache = field(
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:
"""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:
"""Set the runtime entry data associated with this config entry."""
if entry.entry_id in self._entry_datas:
raise ValueError("Entry data for this entry is already set")
assert entry.entry_id not in self._entry_datas, "Entry data already set!"
self._entry_datas[entry.entry_id] = entry_data
def pop_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:

View File

@ -40,6 +40,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
from .bluetooth.device import ESPHomeBluetoothDevice
from .dashboard import async_get_dashboard
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."""
@dataclass
@dataclass(slots=True)
class RuntimeEntryData:
"""Store runtime data for esphome config entries."""
@ -97,6 +98,7 @@ class RuntimeEntryData:
available: bool = False
expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep)
device_info: DeviceInfo | None = None
bluetooth_device: ESPHomeBluetoothDevice | None = None
api_version: APIVersion = field(default_factory=APIVersion)
cleanup_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)
_storage_contents: 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(
default_factory=list
)
@ -196,37 +193,6 @@ class RuntimeEntryData:
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
def async_set_assist_pipeline_state(self, state: bool) -> None:
"""Set the assist pipeline state."""

View File

@ -390,7 +390,9 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version):
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(