From fb98128b9fa4b4e0cc0316d5c1f77157bb12b0f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Nov 2022 16:56:18 -1000 Subject: [PATCH] Add support for esphome ble client connections v3 (#82815) --- .../components/esphome/bluetooth/client.py | 76 +++++++++++++++++-- .../components/esphome/entry_data.py | 11 +++ .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index ef3a6197a37..7bd893f643e 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -36,6 +36,13 @@ DISCONNECT_TIMEOUT = 5.0 CONNECT_FREE_SLOT_TIMEOUT = 2.0 GATT_READ_TIMEOUT = 30.0 +# CCCD (Characteristic Client Config Descriptor) +CCCD_UUID = "00002902-0000-1000-8000-00805f9b34fb" +CCCD_NOTIFY_BYTES = b"\x01\x00" +CCCD_INDICATE_BYTES = b"\x02\x00" + +MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 + DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -137,6 +144,9 @@ class ESPHomeClient(BaseBleakClient): self._cancel_connection_state: CALLBACK_TYPE | None = None self._notify_cancels: dict[int, Callable[[], Coroutine[Any, Any, None]]] = {} self._disconnected_event: asyncio.Event | None = None + device_info = self.entry_data.device_info + assert device_info is not None + self._connection_version = device_info.bluetooth_proxy_version def __str__(self) -> str: """Return the string representation of the client.""" @@ -206,7 +216,14 @@ class ESPHomeClient(BaseBleakClient): Boolean representing connection status. """ await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT) - + entry_data = self.entry_data + self._mtu = entry_data.get_gatt_mtu_cache(self._address_as_int) + has_cache = bool( + dangerous_use_bleak_cache + and self._connection_version >= MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE + and entry_data.get_gatt_services_cache(self._address_as_int) + and self._mtu + ) connected_future: asyncio.Future[bool] = asyncio.Future() def _on_bluetooth_connection_state( @@ -224,7 +241,9 @@ class ESPHomeClient(BaseBleakClient): ) if connected: self._is_connected = True - self._mtu = mtu + if not self._mtu: + self._mtu = mtu + entry_data.set_gatt_mtu_cache(self._address_as_int, mtu) else: self._async_ble_device_disconnected() @@ -258,7 +277,7 @@ class ESPHomeClient(BaseBleakClient): self._ble_device.name, self._ble_device.address, ) - self.entry_data.disconnect_callbacks.append(self._async_esp_disconnected) + entry_data.disconnect_callbacks.append(self._async_esp_disconnected) connected_future.set_result(connected) timeout = kwargs.get("timeout", self._timeout) @@ -271,6 +290,8 @@ class ESPHomeClient(BaseBleakClient): self._address_as_int, _on_bluetooth_connection_state, timeout=timeout, + has_cache=has_cache, + version=self._connection_version, ) ) except Exception: # pylint: disable=broad-except @@ -344,9 +365,13 @@ class ESPHomeClient(BaseBleakClient): """ address_as_int = self._address_as_int entry_data = self.entry_data - if dangerous_use_bleak_cache and ( - cached_services := entry_data.get_gatt_services_cache(address_as_int) - ): + # If the connection version >= 3, we must use the cache + # because the esp has already wiped the services list to + # save memory. + if ( + self._connection_version >= MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE + or dangerous_use_bleak_cache + ) and (cached_services := entry_data.get_gatt_services_cache(address_as_int)): _LOGGER.debug( "%s: %s - %s: Cached services hit", self._source, @@ -516,6 +541,14 @@ class ESPHomeClient(BaseBleakClient): f"characteristic:{characteristic.uuid} " f"handle:{ble_handle}" ) + if ( + "notify" not in characteristic.properties + and "indicate" not in characteristic.properties + ): + raise BleakError( + f"Characteristic {characteristic.uuid} does not have notify or indicate property set." + ) + cancel_coro = await self._client.bluetooth_gatt_start_notify( self._address_as_int, ble_handle, @@ -523,6 +556,37 @@ class ESPHomeClient(BaseBleakClient): ) self._notify_cancels[ble_handle] = cancel_coro + if self._connection_version < MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE: + return + + # For connection v3 we are responsible for enabling notifications + # on the cccd (characteristic client config descriptor) handle since + # the esp32 will not have resolved the characteristic descriptors to + # save memory since doing so can exhaust the memory and cause a soft + # reset + cccd_descriptor = characteristic.get_descriptor(CCCD_UUID) + if not cccd_descriptor: + raise BleakError( + f"Characteristic {characteristic.uuid} does not have a " + "characteristic client config descriptor." + ) + + _LOGGER.debug( + "%s: %s - %s: Writing to CCD descriptor %s for notifications with properties=%s", + self._source, + self._ble_device.name, + self._ble_device.address, + cccd_descriptor.handle, + characteristic.properties, + ) + supports_notify = "notify" in characteristic.properties + await self._client.bluetooth_gatt_write_descriptor( + self._address_as_int, + cccd_descriptor.handle, + CCCD_NOTIFY_BYTES if supports_notify else CCCD_INDICATE_BYTES, + wait_for_response=False, + ) + @api_error_as_bleak_error async def stop_notify( self, diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index faa9074b880..89377ba9a6a 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -98,6 +98,9 @@ class RuntimeEntryData: _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return] ) + _gatt_mtu_cache: MutableMapping[int, int] = field( + default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return] + ) @property def name(self) -> str: @@ -116,6 +119,14 @@ class RuntimeEntryData: """Set the BleakGATTServiceCollection for the given address.""" self._gatt_services_cache[address] = services + 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 + @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: """Update the BLE connection limits.""" diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7dbb4e99670..b3885326e90 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==12.0.1"], + "requirements": ["aioesphomeapi==12.1.0"], "zeroconf": ["_esphomelib._tcp.local."], "dhcp": [{ "registered_devices": true }], "codeowners": ["@OttoWinter", "@jesserockz"], diff --git a/requirements_all.txt b/requirements_all.txt index 15a7774bd62..77cacd4e398 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==12.0.1 +aioesphomeapi==12.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44bb0e61c47..f4e0969b05c 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==12.0.1 +aioesphomeapi==12.1.0 # homeassistant.components.flo aioflo==2021.11.0