From 7f001cc1d1dec87a46d7f783c10b0b72917e2ee6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <nick@koston.org> Date: Tue, 23 Aug 2022 04:41:50 -1000 Subject: [PATCH] ESPHome BLE scanner support (#77123) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .coveragerc | 1 + homeassistant/components/esphome/__init__.py | 4 + homeassistant/components/esphome/bluetooth.py | 113 ++++++++++++++++++ .../components/esphome/manifest.json | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/esphome/bluetooth.py diff --git a/.coveragerc b/.coveragerc index 14773947be1..693083081f4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -317,6 +317,7 @@ omit = homeassistant/components/escea/__init__.py homeassistant/components/esphome/__init__.py homeassistant/components/esphome/binary_sensor.py + homeassistant/components/esphome/bluetooth.py 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 5d7b0efc18d..9df1d1af7d9 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -52,6 +52,8 @@ from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.storage import Store from homeassistant.helpers.template import Template +from .bluetooth import async_connect_scanner + # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData @@ -286,6 +288,8 @@ async def async_setup_entry( # noqa: C901 await cli.subscribe_states(entry_data.async_update_state) 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) hass.async_create_task(entry_data.async_save_to_store()) except APIConnectionError as err: diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py new file mode 100644 index 00000000000..94351293c7b --- /dev/null +++ b/homeassistant/components/esphome/bluetooth.py @@ -0,0 +1,113 @@ +"""Bluetooth scanner for esphome.""" + +from collections.abc import Callable +import datetime +from datetime import timedelta +import re +import time + +from aioesphomeapi import APIClient, 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.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 + +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): + """Scanner for esphome.""" + + def __init__( + self, + hass: HomeAssistant, + scanner_id: str, + new_info_callback: Callable[[BluetoothServiceInfoBleak], None], + ) -> None: + """Initialize the scanner.""" + self._hass = hass + self._new_info_callback = new_info_callback + self._discovered_devices: dict[str, BLEDevice] = {} + self._discovered_device_timestamps: dict[str, float] = {} + self._source = scanner_id + + @callback + def async_setup(self) -> CALLBACK_TYPE: + """Set up the scanner.""" + return async_track_time_interval( + self._hass, self._async_expire_devices, timedelta(seconds=30) + ) + + def _async_expire_devices(self, _datetime: datetime.datetime) -> None: + """Expire old devices.""" + now = time.monotonic() + expired = [ + address + for address, timestamp in self._discovered_device_timestamps.items() + if now - timestamp > ADV_STALE_TIME + ] + for address in expired: + del self._discovered_devices[address] + del self._discovered_device_timestamps[address] + + @property + def discovered_devices(self) -> list[BLEDevice]: + """Return a list of discovered devices.""" + return list(self._discovered_devices.values()) + + @callback + def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: + """Call the registered callback.""" + now = time.monotonic() + address = ":".join(TWO_CHAR.findall("%012X" % adv.address)) # must be upper + advertisement_data = AdvertisementData( # type: ignore[no-untyped-call] + local_name=None if adv.name == "" else adv.name, + manufacturer_data=adv.manufacturer_data, + service_data=adv.service_data, + service_uuids=adv.service_uuids, + ) + device = BLEDevice( # type: ignore[no-untyped-call] + address=address, + name=adv.name, + details={}, + rssi=adv.rssi, + ) + self._discovered_devices[address] = device + self._discovered_device_timestamps[address] = now + self._new_info_callback( + BluetoothServiceInfoBleak( + name=advertisement_data.local_name or device.name or device.address, + address=device.address, + rssi=device.rssi, + manufacturer_data=advertisement_data.manufacturer_data, + service_data=advertisement_data.service_data, + service_uuids=advertisement_data.service_uuids, + source=self._source, + device=device, + advertisement=advertisement_data, + connectable=False, + time=now, + ) + ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index cf748b27170..4739c2904ac 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,11 +3,11 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==10.11.0"], + "requirements": ["aioesphomeapi==10.13.0"], "zeroconf": ["_esphomelib._tcp.local."], "dhcp": [{ "registered_devices": true }], "codeowners": ["@OttoWinter", "@jesserockz"], - "after_dependencies": ["zeroconf", "tag"], + "after_dependencies": ["bluetooth", "zeroconf", "tag"], "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6aed6cc56f4..56e2fdd72c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -150,7 +150,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==10.11.0 +aioesphomeapi==10.13.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf605143b33..c97130ee6aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -137,7 +137,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==10.11.0 +aioesphomeapi==10.13.0 # homeassistant.components.flo aioflo==2021.11.0