Add ESPHome BleakClient (#78911)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
J. Nick Koston 2022-09-28 08:06:30 -10:00 committed by GitHub
parent 9c3b40dad1
commit 7042d6d35b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 705 additions and 28 deletions

View File

@ -328,7 +328,7 @@ omit =
homeassistant/components/escea/discovery.py
homeassistant/components/esphome/__init__.py
homeassistant/components/esphome/binary_sensor.py
homeassistant/components/esphome/bluetooth.py
homeassistant/components/esphome/bluetooth/*
homeassistant/components/esphome/button.py
homeassistant/components/esphome/camera.py
homeassistant/components/esphome/climate.py

View File

@ -237,7 +237,9 @@ async def async_setup_entry( # noqa: C901
await cli.subscribe_service_calls(async_on_service_call)
await cli.subscribe_home_assistant_states(async_on_state_subscription)
if entry_data.device_info.has_bluetooth_proxy:
await async_connect_scanner(hass, entry, cli)
entry_data.disconnect_callbacks.append(
await async_connect_scanner(hass, entry, cli, entry_data)
)
hass.async_create_task(entry_data.async_save_to_store())
except APIConnectionError as err:

View File

@ -0,0 +1,82 @@
"""Bluetooth support for esphome."""
from __future__ import annotations
import logging
from aioesphomeapi import APIClient
from awesomeversion import AwesomeVersion
from homeassistant.components.bluetooth import (
HaBluetoothConnector,
async_get_advertisement_callback,
async_register_scanner,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
async_get_hass,
callback as hass_callback,
)
from ..domain_data import DomainData
from ..entry_data import RuntimeEntryData
from .client import ESPHomeClient
from .scanner import ESPHomeScanner
CONNECTABLE_MIN_VERSION = AwesomeVersion("2022.10.0-dev")
_LOGGER = logging.getLogger(__name__)
@hass_callback
def async_can_connect(source: str) -> bool:
"""Check if a given source can make another connection."""
domain_data = DomainData.get(async_get_hass())
entry = domain_data.get_by_unique_id(source)
entry_data = domain_data.get_entry_data(entry)
_LOGGER.debug(
"Checking if %s can connect, available=%s, ble_connections_free=%s",
source,
entry_data.available,
entry_data.ble_connections_free,
)
return bool(entry_data.available and entry_data.ble_connections_free)
async def async_connect_scanner(
hass: HomeAssistant,
entry: ConfigEntry,
cli: APIClient,
entry_data: RuntimeEntryData,
) -> CALLBACK_TYPE:
"""Connect scanner."""
assert entry.unique_id is not None
source = str(entry.unique_id)
new_info_callback = async_get_advertisement_callback(hass)
connectable = bool(
entry_data.device_info
and AwesomeVersion(entry_data.device_info.esphome_version)
>= CONNECTABLE_MIN_VERSION
)
connector = HaBluetoothConnector(
client=ESPHomeClient,
source=source,
can_connect=lambda: async_can_connect(source),
)
scanner = ESPHomeScanner(hass, source, new_info_callback, connector, connectable)
unload_callbacks = [
async_register_scanner(hass, scanner, connectable),
scanner.async_setup(),
]
await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement)
if connectable:
await cli.subscribe_bluetooth_connections_free(
entry_data.async_update_ble_connection_limits
)
@hass_callback
def _async_unload() -> None:
for callback in unload_callbacks:
callback()
return _async_unload

View File

@ -0,0 +1,95 @@
"""BleakGATTCharacteristicESPHome."""
from __future__ import annotations
import contextlib
from uuid import UUID
from aioesphomeapi.model import BluetoothGATTCharacteristic
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.descriptor import BleakGATTDescriptor
PROPERTY_MASKS = {
2**n: prop
for n, prop in enumerate(
(
"broadcast",
"read",
"write-without-response",
"write",
"notify",
"indicate",
"authenticated-signed-writes",
"extended-properties",
"reliable-writes",
"writable-auxiliaries",
)
)
}
class BleakGATTCharacteristicESPHome(BleakGATTCharacteristic):
"""GATT Characteristic implementation for the ESPHome backend."""
obj: BluetoothGATTCharacteristic
def __init__(
self,
obj: BluetoothGATTCharacteristic,
max_write_without_response_size: int,
service_uuid: str,
service_handle: int,
) -> None:
"""Init a BleakGATTCharacteristicESPHome."""
super().__init__(obj, max_write_without_response_size)
self.__descriptors: list[BleakGATTDescriptor] = []
self.__service_uuid: str = service_uuid
self.__service_handle: int = service_handle
char_props = self.obj.properties
self.__props: list[str] = [
prop for mask, prop in PROPERTY_MASKS.items() if char_props & mask
]
@property
def service_uuid(self) -> str:
"""Uuid of the Service containing this characteristic."""
return self.__service_uuid
@property
def service_handle(self) -> int:
"""Integer handle of the Service containing this characteristic."""
return self.__service_handle
@property
def handle(self) -> int:
"""Integer handle for this characteristic."""
return self.obj.handle
@property
def uuid(self) -> str:
"""Uuid of this characteristic."""
return self.obj.uuid
@property
def properties(self) -> list[str]:
"""Properties of this characteristic."""
return self.__props
@property
def descriptors(self) -> list[BleakGATTDescriptor]:
"""List of descriptors for this service."""
return self.__descriptors
def get_descriptor(self, specifier: int | str | UUID) -> BleakGATTDescriptor | None:
"""Get a descriptor by handle (int) or UUID (str or uuid.UUID)."""
with contextlib.suppress(StopIteration):
if isinstance(specifier, int):
return next(filter(lambda x: x.handle == specifier, self.descriptors))
return next(filter(lambda x: x.uuid == str(specifier), self.descriptors))
return None
def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None:
"""Add a :py:class:`~BleakGATTDescriptor` to the characteristic.
Should not be used by end user, but rather by `bleak` itself.
"""
self.__descriptors.append(descriptor)

View File

@ -0,0 +1,385 @@
"""Bluetooth client for esphome."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
import logging
from typing import Any, TypeVar, cast
import uuid
from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.client import BaseBleakClient, NotifyCallback
from bleak.backends.device import BLEDevice
from bleak.backends.service import BleakGATTServiceCollection
from bleak.exc import BleakError
from homeassistant.core import CALLBACK_TYPE, async_get_hass, callback as hass_callback
from ..domain_data import DomainData
from ..entry_data import RuntimeEntryData
from .characteristic import BleakGATTCharacteristicESPHome
from .descriptor import BleakGATTDescriptorESPHome
from .service import BleakGATTServiceESPHome
DEFAULT_MTU = 23
GATT_HEADER_SIZE = 3
DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE
_LOGGER = logging.getLogger(__name__)
_WrapFuncType = TypeVar( # pylint: disable=invalid-name
"_WrapFuncType", bound=Callable[..., Any]
)
def mac_to_int(address: str) -> int:
"""Convert a mac address to an integer."""
return int(address.replace(":", ""), 16)
def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType:
"""Define a wrapper throw esphome api errors as BleakErrors."""
async def _async_wrap_bluetooth_operation(
self: "ESPHomeClient", *args: Any, **kwargs: Any
) -> Any:
try:
return await func(self, *args, **kwargs)
except TimeoutAPIError as err:
raise asyncio.TimeoutError(str(err)) from err
except APIConnectionError as err:
raise BleakError(str(err)) from err
return cast(_WrapFuncType, _async_wrap_bluetooth_operation)
class ESPHomeClient(BaseBleakClient):
"""ESPHome Bleak Client."""
def __init__(
self, address_or_ble_device: BLEDevice | str, *args: Any, **kwargs: Any
) -> None:
"""Initialize the ESPHomeClient."""
assert isinstance(address_or_ble_device, BLEDevice)
super().__init__(address_or_ble_device, *args, **kwargs)
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(async_get_hass())
self._client = self._async_get_entry_data().client
self._is_connected = False
self._mtu: int | None = None
self._cancel_connection_state: CALLBACK_TYPE | None = None
self._notify_cancels: dict[int, Callable[[], Coroutine[Any, Any, None]]] = {}
def __str__(self) -> str:
"""Return the string representation of the client."""
return f"ESPHomeClient ({self.address})"
def _unsubscribe_connection_state(self) -> None:
"""Unsubscribe from connection state updates."""
if not self._cancel_connection_state:
return
try:
self._cancel_connection_state()
except (AssertionError, ValueError) as ex:
_LOGGER.debug(
"Failed to unsubscribe from connection state (likely connection dropped): %s",
ex,
)
self._cancel_connection_state = None
@hass_callback
def _async_get_entry_data(self) -> RuntimeEntryData:
"""Get the entry data."""
config_entry = self.domain_data.get_by_unique_id(self._source)
return self.domain_data.get_entry_data(config_entry)
def _async_ble_device_disconnected(self) -> None:
"""Handle the BLE device disconnecting from the ESP."""
_LOGGER.debug("%s: BLE device disconnected", self._source)
self._is_connected = False
self.services = BleakGATTServiceCollection() # type: ignore[no-untyped-call]
self._async_call_bleak_disconnected_callback()
self._unsubscribe_connection_state()
def _async_esp_disconnected(self) -> None:
"""Handle the esp32 client disconnecting from hass."""
_LOGGER.debug("%s: ESP device disconnected", self._source)
entry_data = self._async_get_entry_data()
entry_data.disconnect_callbacks.remove(self._async_esp_disconnected)
self._async_ble_device_disconnected()
def _async_call_bleak_disconnected_callback(self) -> None:
"""Call the disconnected callback to inform the bleak consumer."""
if self._disconnected_callback:
self._disconnected_callback(self)
self._disconnected_callback = None
@api_error_as_bleak_error
async def connect(
self, dangerous_use_bleak_cache: bool = False, **kwargs: Any
) -> bool:
"""Connect to a specified Peripheral.
Keyword Args:
timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0.
Returns:
Boolean representing connection status.
"""
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(
"Connection state changed: connected=%s mtu=%s error=%s",
connected,
mtu,
error,
)
if connected:
self._is_connected = True
self._mtu = mtu
else:
self._async_ble_device_disconnected()
if connected_future.done():
return
if error:
connected_future.set_exception(
BleakError(f"Error while connecting: {error}")
)
return
if not connected:
connected_future.set_exception(BleakError("Disconnected"))
return
entry_data = self._async_get_entry_data()
entry_data.disconnect_callbacks.append(self._async_esp_disconnected)
connected_future.set_result(connected)
timeout = kwargs.get("timeout", self._timeout)
self._cancel_connection_state = await self._client.bluetooth_device_connect(
self._address_as_int,
_on_bluetooth_connection_state,
timeout=timeout,
)
await connected_future
await self.get_services(dangerous_use_bleak_cache=dangerous_use_bleak_cache)
return True
@api_error_as_bleak_error
async def disconnect(self) -> bool:
"""Disconnect from the peripheral device."""
self._unsubscribe_connection_state()
await self._client.bluetooth_device_disconnect(self._address_as_int)
return True
@property
def is_connected(self) -> bool:
"""Is Connected."""
return self._is_connected
@property
def mtu_size(self) -> int:
"""Get ATT MTU size for active connection."""
return self._mtu or DEFAULT_MTU
@api_error_as_bleak_error
async def pair(self, *args: Any, **kwargs: Any) -> bool:
"""Attempt to pair."""
raise NotImplementedError("Pairing is not available in ESPHome.")
@api_error_as_bleak_error
async def unpair(self) -> bool:
"""Attempt to unpair."""
raise NotImplementedError("Pairing is not available in ESPHome.")
@api_error_as_bleak_error
async def get_services(
self, dangerous_use_bleak_cache: bool = False, **kwargs: Any
) -> BleakGATTServiceCollection:
"""Get all services registered for this GATT server.
Returns:
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
"""
address_as_int = self._address_as_int
domain_data = self.domain_data
if dangerous_use_bleak_cache and (
cached_services := domain_data.get_gatt_services_cache(address_as_int)
):
_LOGGER.debug(
"Cached services hit for %s - %s",
self._ble_device.name,
self._ble_device.address,
)
self.services = cached_services
return self.services
_LOGGER.debug(
"Cached services miss for %s - %s",
self._ble_device.name,
self._ble_device.address,
)
esphome_services = await self._client.bluetooth_gatt_get_services(
address_as_int
)
max_write_without_response = self.mtu_size - GATT_HEADER_SIZE
services = BleakGATTServiceCollection() # type: ignore[no-untyped-call]
for service in esphome_services.services:
services.add_service(BleakGATTServiceESPHome(service))
for characteristic in service.characteristics:
services.add_characteristic(
BleakGATTCharacteristicESPHome(
characteristic,
max_write_without_response,
service.uuid,
service.handle,
)
)
for descriptor in characteristic.descriptors:
services.add_descriptor(
BleakGATTDescriptorESPHome(
descriptor,
characteristic.uuid,
characteristic.handle,
)
)
self.services = services
_LOGGER.debug(
"Cached services saved for %s - %s",
self._ble_device.name,
self._ble_device.address,
)
domain_data.set_gatt_services_cache(address_as_int, services)
return services
def _resolve_characteristic(
self, char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID
) -> BleakGATTCharacteristic:
"""Resolve a characteristic specifier to a BleakGATTCharacteristic object."""
if not isinstance(char_specifier, BleakGATTCharacteristic):
characteristic = self.services.get_characteristic(char_specifier)
else:
characteristic = char_specifier
if not characteristic:
raise BleakError(f"Characteristic {char_specifier} was not found!")
return characteristic
@api_error_as_bleak_error
async def read_gatt_char(
self,
char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID,
**kwargs: Any,
) -> bytearray:
"""Perform read operation on the specified GATT characteristic.
Args:
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to read from,
specified by either integer handle, UUID or directly by the
BleakGATTCharacteristic object representing it.
Returns:
(bytearray) The read data.
"""
characteristic = self._resolve_characteristic(char_specifier)
return await self._client.bluetooth_gatt_read(
self._address_as_int, characteristic.handle
)
@api_error_as_bleak_error
async def read_gatt_descriptor(self, handle: int, **kwargs: Any) -> bytearray:
"""Perform read operation on the specified GATT descriptor.
Args:
handle (int): The handle of the descriptor to read from.
Returns:
(bytearray) The read data.
"""
return await self._client.bluetooth_gatt_read_descriptor(
self._address_as_int, handle
)
@api_error_as_bleak_error
async def write_gatt_char(
self,
char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID,
data: bytes | bytearray | memoryview,
response: bool = False,
) -> None:
"""Perform a write operation of the specified GATT characteristic.
Args:
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to write
to, specified by either integer handle, UUID or directly by the
BleakGATTCharacteristic object representing it.
data (bytes or bytearray): The data to send.
response (bool): If write-with-response operation should be done. Defaults to `False`.
"""
characteristic = self._resolve_characteristic(char_specifier)
await self._client.bluetooth_gatt_write(
self._address_as_int, characteristic.handle, bytes(data), response
)
@api_error_as_bleak_error
async def write_gatt_descriptor(
self, handle: int, data: bytes | bytearray | memoryview
) -> None:
"""Perform a write operation on the specified GATT descriptor.
Args:
handle (int): The handle of the descriptor to read from.
data (bytes or bytearray): The data to send.
"""
await self._client.bluetooth_gatt_write_descriptor(
self._address_as_int, handle, bytes(data)
)
@api_error_as_bleak_error
async def start_notify(
self,
characteristic: BleakGATTCharacteristic,
callback: NotifyCallback,
**kwargs: Any,
) -> None:
"""Activate notifications/indications on a characteristic.
Callbacks must accept two inputs. The first will be a integer handle of the characteristic generating the
data and the second will be a ``bytearray`` containing the data sent from the connected server.
.. code-block:: python
def callback(sender: int, data: bytearray):
print(f"{sender}: {data}")
client.start_notify(char_uuid, callback)
Args:
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to activate
notifications/indications on a characteristic, specified by either integer handle,
UUID or directly by the BleakGATTCharacteristic object representing it.
callback (function): The function to be called on notification.
"""
cancel_coro = await self._client.bluetooth_gatt_start_notify(
self._address_as_int,
characteristic.handle,
lambda handle, data: callback(data),
)
self._notify_cancels[characteristic.handle] = cancel_coro
@api_error_as_bleak_error
async def stop_notify(
self,
char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID,
) -> None:
"""Deactivate notification/indication on a specified characteristic.
Args:
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to deactivate
notification/indication on, specified by either integer handle, UUID or
directly by the BleakGATTCharacteristic object representing it.
"""
characteristic = self._resolve_characteristic(char_specifier)
coro = self._notify_cancels.pop(characteristic.handle)
await coro()

View File

@ -0,0 +1,42 @@
"""BleakGATTDescriptorESPHome."""
from __future__ import annotations
from aioesphomeapi.model import BluetoothGATTDescriptor
from bleak.backends.descriptor import BleakGATTDescriptor
class BleakGATTDescriptorESPHome(BleakGATTDescriptor):
"""GATT Descriptor implementation for ESPHome backend."""
obj: BluetoothGATTDescriptor
def __init__(
self,
obj: BluetoothGATTDescriptor,
characteristic_uuid: str,
characteristic_handle: int,
) -> None:
"""Init a BleakGATTDescriptorESPHome."""
super().__init__(obj)
self.__characteristic_uuid: str = characteristic_uuid
self.__characteristic_handle: int = characteristic_handle
@property
def characteristic_handle(self) -> int:
"""Handle for the characteristic that this descriptor belongs to."""
return self.__characteristic_handle
@property
def characteristic_uuid(self) -> str:
"""UUID for the characteristic that this descriptor belongs to."""
return self.__characteristic_uuid
@property
def uuid(self) -> str:
"""UUID for this descriptor."""
return self.obj.uuid
@property
def handle(self) -> int:
"""Integer handle for this descriptor."""
return self.obj.handle

View File

@ -7,38 +7,27 @@ from datetime import timedelta
import re
import time
from aioesphomeapi import APIClient, BluetoothLEAdvertisement
from aioesphomeapi import BluetoothLEAdvertisement
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth import (
BaseHaScanner,
async_get_advertisement_callback,
async_register_scanner,
)
from homeassistant.components.bluetooth import BaseHaScanner, HaBluetoothConnector
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_interval
ADV_STALE_TIME = 180 # seconds
# We have to set this quite high as we don't know
# when devices fall out of the esphome device's stack
# like we do with BlueZ so its safer to assume its available
# since if it does go out of range and it is in range
# of another device the timeout is much shorter and it will
# switch over to using that adapter anyways.
ADV_STALE_TIME = 60 * 15 # seconds
TWO_CHAR = re.compile("..")
async def async_connect_scanner(
hass: HomeAssistant, entry: ConfigEntry, cli: APIClient
) -> None:
"""Connect scanner."""
assert entry.unique_id is not None
new_info_callback = async_get_advertisement_callback(hass)
scanner = ESPHomeScannner(hass, entry.unique_id, new_info_callback)
entry.async_on_unload(async_register_scanner(hass, scanner, False))
entry.async_on_unload(scanner.async_setup())
await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement)
class ESPHomeScannner(BaseHaScanner):
class ESPHomeScanner(BaseHaScanner):
"""Scanner for esphome."""
def __init__(
@ -46,6 +35,8 @@ class ESPHomeScannner(BaseHaScanner):
hass: HomeAssistant,
scanner_id: str,
new_info_callback: Callable[[BluetoothServiceInfoBleak], None],
connector: HaBluetoothConnector,
connectable: bool,
) -> None:
"""Initialize the scanner."""
self._hass = hass
@ -53,6 +44,11 @@ class ESPHomeScannner(BaseHaScanner):
self._discovered_devices: dict[str, BLEDevice] = {}
self._discovered_device_timestamps: dict[str, float] = {}
self._source = scanner_id
self._connector = connector
self._connectable = connectable
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
if connectable:
self._details["connector"] = connector
@callback
def async_setup(self) -> CALLBACK_TYPE:
@ -96,7 +92,7 @@ class ESPHomeScannner(BaseHaScanner):
device = BLEDevice( # type: ignore[no-untyped-call]
address=address,
name=adv.name,
details={},
details=self._details,
rssi=adv.rssi,
)
self._discovered_devices[address] = device
@ -112,7 +108,7 @@ class ESPHomeScannner(BaseHaScanner):
source=self._source,
device=device,
advertisement=advertisement_data,
connectable=False,
connectable=self._connectable,
time=now,
)
)

View File

@ -0,0 +1,40 @@
"""BleakGATTServiceESPHome."""
from __future__ import annotations
from aioesphomeapi.model import BluetoothGATTService
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.service import BleakGATTService
class BleakGATTServiceESPHome(BleakGATTService):
"""GATT Characteristic implementation for the ESPHome backend."""
obj: BluetoothGATTService
def __init__(self, obj: BluetoothGATTService) -> None:
"""Init a BleakGATTServiceESPHome."""
super().__init__(obj) # type: ignore[no-untyped-call]
self.__characteristics: list[BleakGATTCharacteristic] = []
self.__handle: int = self.obj.handle
@property
def handle(self) -> int:
"""Integer handle of this service."""
return self.__handle
@property
def uuid(self) -> str:
"""UUID for this service."""
return self.obj.uuid
@property
def characteristics(self) -> list[BleakGATTCharacteristic]:
"""List of characteristics for this service."""
return self.__characteristics
def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None:
"""Add a :py:class:`~BleakGATTCharacteristicESPHome` to the service.
Should not be used by end user, but rather by `bleak` itself.
"""
self.__characteristics.append(characteristic)

View File

@ -1,9 +1,13 @@
"""Support for esphome domain data."""
from __future__ import annotations
from collections.abc import MutableMapping
from dataclasses import dataclass, field
from typing import TypeVar, cast
from bleak.backends.service import BleakGATTServiceCollection
from lru import LRU # pylint: disable=no-name-in-module
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
@ -13,6 +17,8 @@ from .entry_data import RuntimeEntryData
STORAGE_VERSION = 1
DOMAIN = "esphome"
MAX_CACHED_SERVICES = 128
_DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData")
@ -23,6 +29,25 @@ class DomainData:
_entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict)
_stores: dict[str, Store] = field(default_factory=dict)
_entry_by_unique_id: dict[str, ConfigEntry] = field(default_factory=dict)
_gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field(
default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return]
)
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 get_by_unique_id(self, unique_id: str) -> ConfigEntry:
"""Get the config entry by its unique ID."""
return self._entry_by_unique_id[unique_id]
def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
"""Return the runtime entry data associated with this config entry.

View File

@ -87,6 +87,16 @@ class RuntimeEntryData:
loaded_platforms: set[str] = field(default_factory=set)
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_storage_contents: dict[str, Any] | None = None
ble_connections_free: int = 0
ble_connections_limit: int = 0
@callback
def async_update_ble_connection_limits(self, free: int, limit: int) -> None:
"""Update the BLE connection limits."""
name = self.device_info.name if self.device_info else self.entry_id
_LOGGER.debug("%s: BLE connection limits: %s/%s", name, free, limit)
self.ble_connections_free = free
self.ble_connections_limit = limit
@callback
def async_remove_entity(

View File

@ -3,7 +3,7 @@
"name": "ESPHome",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/esphome",
"requirements": ["aioesphomeapi==10.13.0"],
"requirements": ["aioesphomeapi==10.14.0"],
"zeroconf": ["_esphomelib._tcp.local."],
"dhcp": [{ "registered_devices": true }],
"codeowners": ["@OttoWinter", "@jesserockz"],

View File

@ -156,7 +156,7 @@ aioecowitt==2022.09.3
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==10.13.0
aioesphomeapi==10.14.0
# homeassistant.components.flo
aioflo==2021.11.0

View File

@ -143,7 +143,7 @@ aioecowitt==2022.09.3
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==10.13.0
aioesphomeapi==10.14.0
# homeassistant.components.flo
aioflo==2021.11.0