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