diff --git a/.coveragerc b/.coveragerc index 9e5541a07bc..a5397971d1f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index aea65f9358e..4acd335c1b8 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -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), diff --git a/homeassistant/components/esphome/bluetooth/cache.py b/homeassistant/components/esphome/bluetooth/cache.py new file mode 100644 index 00000000000..3ec29121382 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/cache.py @@ -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) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 35e66ea7e47..ee629eed6f9 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -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) diff --git a/homeassistant/components/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py new file mode 100644 index 00000000000..8d060151dbf --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/device.py @@ -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 diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 292d1921abf..a984d057c0c 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -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(), } diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index aacda108398..3203964fdc1 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -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: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 3391d02a829..2d147d243f2 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -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.""" diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 026d0315238..4741eaaa6fb 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -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(