Add support for ESPHome raw bluetooth advertisements (#94138)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
J. Nick Koston 2023-06-07 18:36:22 -05:00 committed by GitHub
parent 8e9eb400bf
commit 88bfd94800
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 60 additions and 34 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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},
)

View File

@ -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"
],

View File

@ -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

View File

@ -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

View File

@ -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",
)