diff --git a/.coveragerc b/.coveragerc index 14773947be16aba3fe0c6e17f051ba9db58b31e6..693083081f4f3f4348bdce0a51428a85314d8df7 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 5d7b0efc18d985b841ae580241b9c7e7340c3c09..9df1d1af7d9ed889e3e2c984ac54a51bed35d546 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 0000000000000000000000000000000000000000..94351293c7b06b4aeec4f3a7027499483c3b6713 --- /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 cf748b271701894aa11f7e709573d4248395eae7..4739c2904acb60a06565fbd73dc9280ce79a62f8 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 6aed6cc56f44ec12f287fe994273238835bbceac..56e2fdd72c8731087d6934dac37582d977437872 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 bf605143b33e0cf2ca64cc25646d2f13293291ee..c97130ee6aafebf721e382b348557232f376a07b 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