diff --git a/.coveragerc b/.coveragerc index 67ac5c8e811a18eb0e376cee981cc9a0f7d4f7d4..6c31546e718384511b6c2624949ab8074b29ec33 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index acf8d33b6e072fca43cbbb79bb3bc9638f5a827c..90f1bac8de24ce6eab7bde3c07cffd17923f8f8b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -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: diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4061333b4f31652f8c662f7b293e624303b8c327 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -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 diff --git a/homeassistant/components/esphome/bluetooth/characteristic.py b/homeassistant/components/esphome/bluetooth/characteristic.py new file mode 100644 index 0000000000000000000000000000000000000000..0db73dd3d5f92431c9f3afe5976ce4021c80a8a4 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/characteristic.py @@ -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) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py new file mode 100644 index 0000000000000000000000000000000000000000..2eb722bdddfa2144cebf4d122514b62c5b1c8be5 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -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() diff --git a/homeassistant/components/esphome/bluetooth/descriptor.py b/homeassistant/components/esphome/bluetooth/descriptor.py new file mode 100644 index 0000000000000000000000000000000000000000..0ba1163974086b751521553a3ea248511e6c81d6 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/descriptor.py @@ -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 diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth/scanner.py similarity index 78% rename from homeassistant/components/esphome/bluetooth.py rename to homeassistant/components/esphome/bluetooth/scanner.py index 71dfc798ffd15e95cd0faff972519d0f22621ad0..fbd5f185907fbb9ad0bebb06fe01153124aec68c 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -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, ) ) diff --git a/homeassistant/components/esphome/bluetooth/service.py b/homeassistant/components/esphome/bluetooth/service.py new file mode 100644 index 0000000000000000000000000000000000000000..5df7d2bf6030267182e4cc390dc7ad603b06c157 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/service.py @@ -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) diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index 9fabcf17d7809fea2db20b16f90b44e442fa5850..acaa76185e76a81a72381a397cee8419fb28319b 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -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. diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 80fd855379e3e3cc59af5696eca45aad2f63c7b0..d85e12845dae7ee3196c27899efacd4a2d027cd2 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -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( diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4739c2904acb60a06565fbd73dc9280ce79a62f8..d094b8518eff08e13a0862e83525842ab0d4a79e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -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"], diff --git a/requirements_all.txt b/requirements_all.txt index 4f4e5fa77171e2a671ecbd9100cb17ba5f9b324b..1c88b6cb19fa63f63dc36ceb0d651b18e87b3359 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69189f3e03908c12a0b9f335ba0f48e70a4dc8f6..1559c17bc84e937da99007987719638470a27880 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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