ESPHome BLE scanner support (#77123)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
J. Nick Koston 2022-08-23 04:41:50 -10:00 committed by GitHub
parent c975146146
commit 7f001cc1d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 122 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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