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:
parent
facd6ef765
commit
2c4e4428e9
@ -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
|
||||
|
@ -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),
|
||||
|
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
|
||||
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)
|
||||
|
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:
|
||||
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(),
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user