From 88bfd9480058fe019d89b322296008594a78c74f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Jun 2023 18:36:22 -0500 Subject: [PATCH] Add support for ESPHome raw bluetooth advertisements (#94138) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/esphome/__init__.py | 8 ++-- .../components/esphome/bluetooth/__init__.py | 19 +++++++--- .../components/esphome/bluetooth/client.py | 37 ++++++++++--------- .../components/esphome/bluetooth/scanner.py | 22 ++++++++++- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 2 +- 8 files changed, 60 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 297ce9b7882..41b1b780a1a 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -82,13 +82,13 @@ DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" @callback def _async_check_firmware_version( - hass: HomeAssistant, device_info: EsphomeDeviceInfo + hass: HomeAssistant, device_info: EsphomeDeviceInfo, api_version: APIVersion ) -> None: """Create or delete an the ble_firmware_outdated issue.""" # ESPHome device_info.mac_address is the unique_id issue = f"ble_firmware_outdated-{device_info.mac_address}" if ( - not device_info.bluetooth_proxy_version + not device_info.bluetooth_proxy_feature_flags_compat(api_version) # If the device has a project name its up to that project # to tell them about the firmware version update so we don't notify here or (device_info.project_name and device_info.project_name not in PROJECT_URLS) @@ -360,7 +360,7 @@ async def async_setup_entry( # noqa: C901 if entry_data.device_info.name: reconnect_logic.name = entry_data.device_info.name - if device_info.bluetooth_proxy_version: + 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) ) @@ -391,7 +391,7 @@ async def async_setup_entry( # noqa: C901 # Re-connection logic will trigger after this await cli.disconnect() else: - _async_check_firmware_version(hass, device_info) + _async_check_firmware_version(hass, device_info, entry_data.api_version) _async_check_using_api_password(hass, device_info, bool(password)) async def on_disconnect() -> None: diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index e62b54655c8..aea65f9358e 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -5,7 +5,7 @@ from collections.abc import Callable from functools import partial import logging -from aioesphomeapi import APIClient +from aioesphomeapi import APIClient, BluetoothProxyFeature from homeassistant.components.bluetooth import ( HaBluetoothConnector, @@ -59,13 +59,15 @@ async def async_connect_scanner( source = str(entry.unique_id) new_info_callback = async_get_advertisement_callback(hass) assert entry_data.device_info is not None - version = entry_data.device_info.bluetooth_proxy_version - connectable = version >= 2 + feature_flags = entry_data.device_info.bluetooth_proxy_feature_flags_compat( + entry_data.api_version + ) + connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS) _LOGGER.debug( - "%s [%s]: Connecting scanner version=%s, connectable=%s", + "%s [%s]: Connecting scanner feature_flags=%s, connectable=%s", entry.title, source, - version, + feature_flags, connectable, ) connector = HaBluetoothConnector( @@ -89,7 +91,12 @@ async def async_connect_scanner( async_register_scanner(hass, scanner, connectable), scanner.async_setup(), ] - await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) + if feature_flags & BluetoothProxyFeature.RAW_ADVERTISEMENTS: + await cli.subscribe_bluetooth_le_raw_advertisements( + scanner.async_on_raw_advertisements + ) + else: + await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) @hass_callback def _async_unload() -> None: diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 914021b467e..708d79e0eec 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( ESP_CONNECTION_ERROR_DESCRIPTION, ESPHOME_GATT_ERRORS, BLEConnectionError, + BluetoothProxyFeature, ) from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError from aioesphomeapi.core import BluetoothGATTAPIError @@ -42,10 +43,6 @@ 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 -MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 -MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE = 5 - DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -158,7 +155,10 @@ class ESPHomeClient(BaseBleakClient): 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 + self._device_info = device_info + self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( + self.entry_data.api_version + ) self._address_type = address_or_ble_device.details["address_type"] self._source_name = f"{config_entry.title} [{self._source}]" @@ -247,7 +247,7 @@ class ESPHomeClient(BaseBleakClient): self._mtu = domain_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 self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING and domain_data.get_gatt_services_cache(self._address_as_int) and self._mtu ) @@ -319,7 +319,7 @@ class ESPHomeClient(BaseBleakClient): _on_bluetooth_connection_state, timeout=timeout, has_cache=has_cache, - version=self._connection_version, + feature_flags=self._feature_flags, address_type=self._address_type, ) ) @@ -397,9 +397,10 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def pair(self, *args: Any, **kwargs: Any) -> bool: """Attempt to pair.""" - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + if not self._feature_flags & BluetoothProxyFeature.PAIRING: raise NotImplementedError( - "Pairing is not available in ESPHome with version {self._connection_version}." + "Pairing is not available in this version ESPHome; " + f"Upgrade the ESPHome version on the {self._device_info.name} device." ) response = await self._client.bluetooth_device_pair(self._address_as_int) if response.paired: @@ -413,9 +414,10 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def unpair(self) -> bool: """Attempt to unpair.""" - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + if not self._feature_flags & BluetoothProxyFeature.PAIRING: raise NotImplementedError( - "Unpairing is not available in ESPHome with version {self._connection_version}." + "Unpairing is not available in this version ESPHome; " + f"Upgrade the ESPHome version on the {self._device_info.name} device." ) response = await self._client.bluetooth_device_unpair(self._address_as_int) if response.success: @@ -441,7 +443,7 @@ class ESPHomeClient(BaseBleakClient): # because the esp has already wiped the services list to # save memory. if ( - self._connection_version >= MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE + self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING or dangerous_use_bleak_cache ) and (cached_services := domain_data.get_gatt_services_cache(address_as_int)): _LOGGER.debug( @@ -524,12 +526,11 @@ class ESPHomeClient(BaseBleakClient): """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) - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE: + if not self._feature_flags & BluetoothProxyFeature.CACHE_CLEARING: _LOGGER.warning( - "On device cache clear is not available with ESPHome Bluetooth version %s, " - "version %s is needed; Only memory cache will be cleared", - self._connection_version, - MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE, + "On device cache clear is not available with this ESPHome version; " + "Upgrade the ESPHome version on the device %s; Only memory cache will be cleared", + self._device_info.name, ) return True response = await self._client.bluetooth_device_clear_cache(self._address_as_int) @@ -673,7 +674,7 @@ class ESPHomeClient(BaseBleakClient): lambda handle, data: callback(data), ) - if self._connection_version < MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE: + if not self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING: return # For connection v3 we are responsible for enabling notifications diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 6151ed30429..85ab991df4e 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -1,8 +1,8 @@ """Bluetooth scanner for esphome.""" from __future__ import annotations -from aioesphomeapi import BluetoothLEAdvertisement -from bluetooth_data_tools import int_to_bluetooth_address +from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisement +from bluetooth_data_tools import int_to_bluetooth_address, parse_advertisement_data from homeassistant.components.bluetooth import BaseHaRemoteScanner from homeassistant.core import callback @@ -25,3 +25,21 @@ class ESPHomeScanner(BaseHaRemoteScanner): None, {"address_type": adv.address_type}, ) + + @callback + def async_on_raw_advertisements( + self, advertisements: list[BluetoothLERawAdvertisement] + ) -> None: + """Call the registered callback.""" + for adv in advertisements: + parsed = parse_advertisement_data((adv.data,)) + self._async_on_advertisement( + int_to_bluetooth_address(adv.address), + adv.rssi, + parsed.local_name, + parsed.service_uuids, + parsed.service_data, + parsed.manufacturer_data, + None, + {"address_type": adv.address_type}, + ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index c6e430d7845..fa18c14aa46 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.9.0", + "aioesphomeapi==14.0.0", "bluetooth-data-tools==0.4.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index e6a7d89dca3..68eb41caacb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.9.0 +aioesphomeapi==14.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1374e80c7e..82dabe8af1a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.9.0 +aioesphomeapi==14.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 23f140587c7..3f8df691573 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -63,7 +63,7 @@ def mock_device_info() -> DeviceInfo: return DeviceInfo( uses_password=False, name="test", - bluetooth_proxy_version=0, + legacy_bluetooth_proxy_version=0, mac_address="11:22:33:44:55:aa", esphome_version="1.0.0", )