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/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

View File

@ -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),

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

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: 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(),
} }

View File

@ -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:

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.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."""

View File

@ -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(