mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Add ESPHome BleakClient (#78911)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
9c3b40dad1
commit
7042d6d35b
@ -328,7 +328,7 @@ omit =
|
|||||||
homeassistant/components/escea/discovery.py
|
homeassistant/components/escea/discovery.py
|
||||||
homeassistant/components/esphome/__init__.py
|
homeassistant/components/esphome/__init__.py
|
||||||
homeassistant/components/esphome/binary_sensor.py
|
homeassistant/components/esphome/binary_sensor.py
|
||||||
homeassistant/components/esphome/bluetooth.py
|
homeassistant/components/esphome/bluetooth/*
|
||||||
homeassistant/components/esphome/button.py
|
homeassistant/components/esphome/button.py
|
||||||
homeassistant/components/esphome/camera.py
|
homeassistant/components/esphome/camera.py
|
||||||
homeassistant/components/esphome/climate.py
|
homeassistant/components/esphome/climate.py
|
||||||
|
@ -237,7 +237,9 @@ async def async_setup_entry( # noqa: C901
|
|||||||
await cli.subscribe_service_calls(async_on_service_call)
|
await cli.subscribe_service_calls(async_on_service_call)
|
||||||
await cli.subscribe_home_assistant_states(async_on_state_subscription)
|
await cli.subscribe_home_assistant_states(async_on_state_subscription)
|
||||||
if entry_data.device_info.has_bluetooth_proxy:
|
if entry_data.device_info.has_bluetooth_proxy:
|
||||||
await async_connect_scanner(hass, entry, cli)
|
entry_data.disconnect_callbacks.append(
|
||||||
|
await async_connect_scanner(hass, entry, cli, entry_data)
|
||||||
|
)
|
||||||
|
|
||||||
hass.async_create_task(entry_data.async_save_to_store())
|
hass.async_create_task(entry_data.async_save_to_store())
|
||||||
except APIConnectionError as err:
|
except APIConnectionError as err:
|
||||||
|
82
homeassistant/components/esphome/bluetooth/__init__.py
Normal file
82
homeassistant/components/esphome/bluetooth/__init__.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"""Bluetooth support for esphome."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient
|
||||||
|
from awesomeversion import AwesomeVersion
|
||||||
|
|
||||||
|
from homeassistant.components.bluetooth import (
|
||||||
|
HaBluetoothConnector,
|
||||||
|
async_get_advertisement_callback,
|
||||||
|
async_register_scanner,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import (
|
||||||
|
CALLBACK_TYPE,
|
||||||
|
HomeAssistant,
|
||||||
|
async_get_hass,
|
||||||
|
callback as hass_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..domain_data import DomainData
|
||||||
|
from ..entry_data import RuntimeEntryData
|
||||||
|
from .client import ESPHomeClient
|
||||||
|
from .scanner import ESPHomeScanner
|
||||||
|
|
||||||
|
CONNECTABLE_MIN_VERSION = AwesomeVersion("2022.10.0-dev")
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@hass_callback
|
||||||
|
def async_can_connect(source: str) -> bool:
|
||||||
|
"""Check if a given source can make another connection."""
|
||||||
|
domain_data = DomainData.get(async_get_hass())
|
||||||
|
entry = domain_data.get_by_unique_id(source)
|
||||||
|
entry_data = domain_data.get_entry_data(entry)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Checking if %s can connect, available=%s, ble_connections_free=%s",
|
||||||
|
source,
|
||||||
|
entry_data.available,
|
||||||
|
entry_data.ble_connections_free,
|
||||||
|
)
|
||||||
|
return bool(entry_data.available and entry_data.ble_connections_free)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_connect_scanner(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
cli: APIClient,
|
||||||
|
entry_data: RuntimeEntryData,
|
||||||
|
) -> CALLBACK_TYPE:
|
||||||
|
"""Connect scanner."""
|
||||||
|
assert entry.unique_id is not None
|
||||||
|
source = str(entry.unique_id)
|
||||||
|
new_info_callback = async_get_advertisement_callback(hass)
|
||||||
|
connectable = bool(
|
||||||
|
entry_data.device_info
|
||||||
|
and AwesomeVersion(entry_data.device_info.esphome_version)
|
||||||
|
>= CONNECTABLE_MIN_VERSION
|
||||||
|
)
|
||||||
|
connector = HaBluetoothConnector(
|
||||||
|
client=ESPHomeClient,
|
||||||
|
source=source,
|
||||||
|
can_connect=lambda: async_can_connect(source),
|
||||||
|
)
|
||||||
|
scanner = ESPHomeScanner(hass, source, new_info_callback, connector, connectable)
|
||||||
|
unload_callbacks = [
|
||||||
|
async_register_scanner(hass, scanner, connectable),
|
||||||
|
scanner.async_setup(),
|
||||||
|
]
|
||||||
|
await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement)
|
||||||
|
if connectable:
|
||||||
|
await cli.subscribe_bluetooth_connections_free(
|
||||||
|
entry_data.async_update_ble_connection_limits
|
||||||
|
)
|
||||||
|
|
||||||
|
@hass_callback
|
||||||
|
def _async_unload() -> None:
|
||||||
|
for callback in unload_callbacks:
|
||||||
|
callback()
|
||||||
|
|
||||||
|
return _async_unload
|
95
homeassistant/components/esphome/bluetooth/characteristic.py
Normal file
95
homeassistant/components/esphome/bluetooth/characteristic.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"""BleakGATTCharacteristicESPHome."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from aioesphomeapi.model import BluetoothGATTCharacteristic
|
||||||
|
from bleak.backends.characteristic import BleakGATTCharacteristic
|
||||||
|
from bleak.backends.descriptor import BleakGATTDescriptor
|
||||||
|
|
||||||
|
PROPERTY_MASKS = {
|
||||||
|
2**n: prop
|
||||||
|
for n, prop in enumerate(
|
||||||
|
(
|
||||||
|
"broadcast",
|
||||||
|
"read",
|
||||||
|
"write-without-response",
|
||||||
|
"write",
|
||||||
|
"notify",
|
||||||
|
"indicate",
|
||||||
|
"authenticated-signed-writes",
|
||||||
|
"extended-properties",
|
||||||
|
"reliable-writes",
|
||||||
|
"writable-auxiliaries",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BleakGATTCharacteristicESPHome(BleakGATTCharacteristic):
|
||||||
|
"""GATT Characteristic implementation for the ESPHome backend."""
|
||||||
|
|
||||||
|
obj: BluetoothGATTCharacteristic
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
obj: BluetoothGATTCharacteristic,
|
||||||
|
max_write_without_response_size: int,
|
||||||
|
service_uuid: str,
|
||||||
|
service_handle: int,
|
||||||
|
) -> None:
|
||||||
|
"""Init a BleakGATTCharacteristicESPHome."""
|
||||||
|
super().__init__(obj, max_write_without_response_size)
|
||||||
|
self.__descriptors: list[BleakGATTDescriptor] = []
|
||||||
|
self.__service_uuid: str = service_uuid
|
||||||
|
self.__service_handle: int = service_handle
|
||||||
|
char_props = self.obj.properties
|
||||||
|
self.__props: list[str] = [
|
||||||
|
prop for mask, prop in PROPERTY_MASKS.items() if char_props & mask
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_uuid(self) -> str:
|
||||||
|
"""Uuid of the Service containing this characteristic."""
|
||||||
|
return self.__service_uuid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_handle(self) -> int:
|
||||||
|
"""Integer handle of the Service containing this characteristic."""
|
||||||
|
return self.__service_handle
|
||||||
|
|
||||||
|
@property
|
||||||
|
def handle(self) -> int:
|
||||||
|
"""Integer handle for this characteristic."""
|
||||||
|
return self.obj.handle
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uuid(self) -> str:
|
||||||
|
"""Uuid of this characteristic."""
|
||||||
|
return self.obj.uuid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def properties(self) -> list[str]:
|
||||||
|
"""Properties of this characteristic."""
|
||||||
|
return self.__props
|
||||||
|
|
||||||
|
@property
|
||||||
|
def descriptors(self) -> list[BleakGATTDescriptor]:
|
||||||
|
"""List of descriptors for this service."""
|
||||||
|
return self.__descriptors
|
||||||
|
|
||||||
|
def get_descriptor(self, specifier: int | str | UUID) -> BleakGATTDescriptor | None:
|
||||||
|
"""Get a descriptor by handle (int) or UUID (str or uuid.UUID)."""
|
||||||
|
with contextlib.suppress(StopIteration):
|
||||||
|
if isinstance(specifier, int):
|
||||||
|
return next(filter(lambda x: x.handle == specifier, self.descriptors))
|
||||||
|
return next(filter(lambda x: x.uuid == str(specifier), self.descriptors))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None:
|
||||||
|
"""Add a :py:class:`~BleakGATTDescriptor` to the characteristic.
|
||||||
|
|
||||||
|
Should not be used by end user, but rather by `bleak` itself.
|
||||||
|
"""
|
||||||
|
self.__descriptors.append(descriptor)
|
385
homeassistant/components/esphome/bluetooth/client.py
Normal file
385
homeassistant/components/esphome/bluetooth/client.py
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
"""Bluetooth client for esphome."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
import logging
|
||||||
|
from typing import Any, TypeVar, cast
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError
|
||||||
|
from bleak.backends.characteristic import BleakGATTCharacteristic
|
||||||
|
from bleak.backends.client import BaseBleakClient, NotifyCallback
|
||||||
|
from bleak.backends.device import BLEDevice
|
||||||
|
from bleak.backends.service import BleakGATTServiceCollection
|
||||||
|
from bleak.exc import BleakError
|
||||||
|
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, async_get_hass, callback as hass_callback
|
||||||
|
|
||||||
|
from ..domain_data import DomainData
|
||||||
|
from ..entry_data import RuntimeEntryData
|
||||||
|
from .characteristic import BleakGATTCharacteristicESPHome
|
||||||
|
from .descriptor import BleakGATTDescriptorESPHome
|
||||||
|
from .service import BleakGATTServiceESPHome
|
||||||
|
|
||||||
|
DEFAULT_MTU = 23
|
||||||
|
GATT_HEADER_SIZE = 3
|
||||||
|
DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_WrapFuncType = TypeVar( # pylint: disable=invalid-name
|
||||||
|
"_WrapFuncType", bound=Callable[..., Any]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mac_to_int(address: str) -> int:
|
||||||
|
"""Convert a mac address to an integer."""
|
||||||
|
return int(address.replace(":", ""), 16)
|
||||||
|
|
||||||
|
|
||||||
|
def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType:
|
||||||
|
"""Define a wrapper throw esphome api errors as BleakErrors."""
|
||||||
|
|
||||||
|
async def _async_wrap_bluetooth_operation(
|
||||||
|
self: "ESPHomeClient", *args: Any, **kwargs: Any
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
return await func(self, *args, **kwargs)
|
||||||
|
except TimeoutAPIError as err:
|
||||||
|
raise asyncio.TimeoutError(str(err)) from err
|
||||||
|
except APIConnectionError as err:
|
||||||
|
raise BleakError(str(err)) from err
|
||||||
|
|
||||||
|
return cast(_WrapFuncType, _async_wrap_bluetooth_operation)
|
||||||
|
|
||||||
|
|
||||||
|
class ESPHomeClient(BaseBleakClient):
|
||||||
|
"""ESPHome Bleak Client."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, address_or_ble_device: BLEDevice | str, *args: Any, **kwargs: Any
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the ESPHomeClient."""
|
||||||
|
assert isinstance(address_or_ble_device, BLEDevice)
|
||||||
|
super().__init__(address_or_ble_device, *args, **kwargs)
|
||||||
|
self._ble_device = address_or_ble_device
|
||||||
|
self._address_as_int = mac_to_int(self._ble_device.address)
|
||||||
|
assert self._ble_device.details is not None
|
||||||
|
self._source = self._ble_device.details["source"]
|
||||||
|
self.domain_data = DomainData.get(async_get_hass())
|
||||||
|
self._client = self._async_get_entry_data().client
|
||||||
|
self._is_connected = False
|
||||||
|
self._mtu: int | None = None
|
||||||
|
self._cancel_connection_state: CALLBACK_TYPE | None = None
|
||||||
|
self._notify_cancels: dict[int, Callable[[], Coroutine[Any, Any, None]]] = {}
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return the string representation of the client."""
|
||||||
|
return f"ESPHomeClient ({self.address})"
|
||||||
|
|
||||||
|
def _unsubscribe_connection_state(self) -> None:
|
||||||
|
"""Unsubscribe from connection state updates."""
|
||||||
|
if not self._cancel_connection_state:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._cancel_connection_state()
|
||||||
|
except (AssertionError, ValueError) as ex:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Failed to unsubscribe from connection state (likely connection dropped): %s",
|
||||||
|
ex,
|
||||||
|
)
|
||||||
|
self._cancel_connection_state = None
|
||||||
|
|
||||||
|
@hass_callback
|
||||||
|
def _async_get_entry_data(self) -> RuntimeEntryData:
|
||||||
|
"""Get the entry data."""
|
||||||
|
config_entry = self.domain_data.get_by_unique_id(self._source)
|
||||||
|
return self.domain_data.get_entry_data(config_entry)
|
||||||
|
|
||||||
|
def _async_ble_device_disconnected(self) -> None:
|
||||||
|
"""Handle the BLE device disconnecting from the ESP."""
|
||||||
|
_LOGGER.debug("%s: BLE device disconnected", self._source)
|
||||||
|
self._is_connected = False
|
||||||
|
self.services = BleakGATTServiceCollection() # type: ignore[no-untyped-call]
|
||||||
|
self._async_call_bleak_disconnected_callback()
|
||||||
|
self._unsubscribe_connection_state()
|
||||||
|
|
||||||
|
def _async_esp_disconnected(self) -> None:
|
||||||
|
"""Handle the esp32 client disconnecting from hass."""
|
||||||
|
_LOGGER.debug("%s: ESP device disconnected", self._source)
|
||||||
|
entry_data = self._async_get_entry_data()
|
||||||
|
entry_data.disconnect_callbacks.remove(self._async_esp_disconnected)
|
||||||
|
self._async_ble_device_disconnected()
|
||||||
|
|
||||||
|
def _async_call_bleak_disconnected_callback(self) -> None:
|
||||||
|
"""Call the disconnected callback to inform the bleak consumer."""
|
||||||
|
if self._disconnected_callback:
|
||||||
|
self._disconnected_callback(self)
|
||||||
|
self._disconnected_callback = None
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def connect(
|
||||||
|
self, dangerous_use_bleak_cache: bool = False, **kwargs: Any
|
||||||
|
) -> bool:
|
||||||
|
"""Connect to a specified Peripheral.
|
||||||
|
|
||||||
|
Keyword Args:
|
||||||
|
timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0.
|
||||||
|
Returns:
|
||||||
|
Boolean representing connection status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
connected_future: asyncio.Future[bool] = asyncio.Future()
|
||||||
|
|
||||||
|
def _on_bluetooth_connection_state(
|
||||||
|
connected: bool, mtu: int, error: int
|
||||||
|
) -> None:
|
||||||
|
"""Handle a connect or disconnect."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Connection state changed: connected=%s mtu=%s error=%s",
|
||||||
|
connected,
|
||||||
|
mtu,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
if connected:
|
||||||
|
self._is_connected = True
|
||||||
|
self._mtu = mtu
|
||||||
|
else:
|
||||||
|
self._async_ble_device_disconnected()
|
||||||
|
|
||||||
|
if connected_future.done():
|
||||||
|
return
|
||||||
|
|
||||||
|
if error:
|
||||||
|
connected_future.set_exception(
|
||||||
|
BleakError(f"Error while connecting: {error}")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not connected:
|
||||||
|
connected_future.set_exception(BleakError("Disconnected"))
|
||||||
|
return
|
||||||
|
|
||||||
|
entry_data = self._async_get_entry_data()
|
||||||
|
entry_data.disconnect_callbacks.append(self._async_esp_disconnected)
|
||||||
|
connected_future.set_result(connected)
|
||||||
|
|
||||||
|
timeout = kwargs.get("timeout", self._timeout)
|
||||||
|
self._cancel_connection_state = await self._client.bluetooth_device_connect(
|
||||||
|
self._address_as_int,
|
||||||
|
_on_bluetooth_connection_state,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
await connected_future
|
||||||
|
await self.get_services(dangerous_use_bleak_cache=dangerous_use_bleak_cache)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def disconnect(self) -> bool:
|
||||||
|
"""Disconnect from the peripheral device."""
|
||||||
|
self._unsubscribe_connection_state()
|
||||||
|
await self._client.bluetooth_device_disconnect(self._address_as_int)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Is Connected."""
|
||||||
|
return self._is_connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mtu_size(self) -> int:
|
||||||
|
"""Get ATT MTU size for active connection."""
|
||||||
|
return self._mtu or DEFAULT_MTU
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def pair(self, *args: Any, **kwargs: Any) -> bool:
|
||||||
|
"""Attempt to pair."""
|
||||||
|
raise NotImplementedError("Pairing is not available in ESPHome.")
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def unpair(self) -> bool:
|
||||||
|
"""Attempt to unpair."""
|
||||||
|
raise NotImplementedError("Pairing is not available in ESPHome.")
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def get_services(
|
||||||
|
self, dangerous_use_bleak_cache: bool = False, **kwargs: Any
|
||||||
|
) -> BleakGATTServiceCollection:
|
||||||
|
"""Get all services registered for this GATT server.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
|
||||||
|
"""
|
||||||
|
address_as_int = self._address_as_int
|
||||||
|
domain_data = self.domain_data
|
||||||
|
if dangerous_use_bleak_cache and (
|
||||||
|
cached_services := domain_data.get_gatt_services_cache(address_as_int)
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Cached services hit for %s - %s",
|
||||||
|
self._ble_device.name,
|
||||||
|
self._ble_device.address,
|
||||||
|
)
|
||||||
|
self.services = cached_services
|
||||||
|
return self.services
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Cached services miss for %s - %s",
|
||||||
|
self._ble_device.name,
|
||||||
|
self._ble_device.address,
|
||||||
|
)
|
||||||
|
esphome_services = await self._client.bluetooth_gatt_get_services(
|
||||||
|
address_as_int
|
||||||
|
)
|
||||||
|
max_write_without_response = self.mtu_size - GATT_HEADER_SIZE
|
||||||
|
services = BleakGATTServiceCollection() # type: ignore[no-untyped-call]
|
||||||
|
for service in esphome_services.services:
|
||||||
|
services.add_service(BleakGATTServiceESPHome(service))
|
||||||
|
for characteristic in service.characteristics:
|
||||||
|
services.add_characteristic(
|
||||||
|
BleakGATTCharacteristicESPHome(
|
||||||
|
characteristic,
|
||||||
|
max_write_without_response,
|
||||||
|
service.uuid,
|
||||||
|
service.handle,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for descriptor in characteristic.descriptors:
|
||||||
|
services.add_descriptor(
|
||||||
|
BleakGATTDescriptorESPHome(
|
||||||
|
descriptor,
|
||||||
|
characteristic.uuid,
|
||||||
|
characteristic.handle,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.services = services
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Cached services saved for %s - %s",
|
||||||
|
self._ble_device.name,
|
||||||
|
self._ble_device.address,
|
||||||
|
)
|
||||||
|
domain_data.set_gatt_services_cache(address_as_int, services)
|
||||||
|
return services
|
||||||
|
|
||||||
|
def _resolve_characteristic(
|
||||||
|
self, char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID
|
||||||
|
) -> BleakGATTCharacteristic:
|
||||||
|
"""Resolve a characteristic specifier to a BleakGATTCharacteristic object."""
|
||||||
|
if not isinstance(char_specifier, BleakGATTCharacteristic):
|
||||||
|
characteristic = self.services.get_characteristic(char_specifier)
|
||||||
|
else:
|
||||||
|
characteristic = char_specifier
|
||||||
|
if not characteristic:
|
||||||
|
raise BleakError(f"Characteristic {char_specifier} was not found!")
|
||||||
|
return characteristic
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def read_gatt_char(
|
||||||
|
self,
|
||||||
|
char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> bytearray:
|
||||||
|
"""Perform read operation on the specified GATT characteristic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to read from,
|
||||||
|
specified by either integer handle, UUID or directly by the
|
||||||
|
BleakGATTCharacteristic object representing it.
|
||||||
|
Returns:
|
||||||
|
(bytearray) The read data.
|
||||||
|
"""
|
||||||
|
characteristic = self._resolve_characteristic(char_specifier)
|
||||||
|
return await self._client.bluetooth_gatt_read(
|
||||||
|
self._address_as_int, characteristic.handle
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def read_gatt_descriptor(self, handle: int, **kwargs: Any) -> bytearray:
|
||||||
|
"""Perform read operation on the specified GATT descriptor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
handle (int): The handle of the descriptor to read from.
|
||||||
|
Returns:
|
||||||
|
(bytearray) The read data.
|
||||||
|
"""
|
||||||
|
return await self._client.bluetooth_gatt_read_descriptor(
|
||||||
|
self._address_as_int, handle
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def write_gatt_char(
|
||||||
|
self,
|
||||||
|
char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID,
|
||||||
|
data: bytes | bytearray | memoryview,
|
||||||
|
response: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Perform a write operation of the specified GATT characteristic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to write
|
||||||
|
to, specified by either integer handle, UUID or directly by the
|
||||||
|
BleakGATTCharacteristic object representing it.
|
||||||
|
data (bytes or bytearray): The data to send.
|
||||||
|
response (bool): If write-with-response operation should be done. Defaults to `False`.
|
||||||
|
"""
|
||||||
|
characteristic = self._resolve_characteristic(char_specifier)
|
||||||
|
await self._client.bluetooth_gatt_write(
|
||||||
|
self._address_as_int, characteristic.handle, bytes(data), response
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def write_gatt_descriptor(
|
||||||
|
self, handle: int, data: bytes | bytearray | memoryview
|
||||||
|
) -> None:
|
||||||
|
"""Perform a write operation on the specified GATT descriptor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
handle (int): The handle of the descriptor to read from.
|
||||||
|
data (bytes or bytearray): The data to send.
|
||||||
|
"""
|
||||||
|
await self._client.bluetooth_gatt_write_descriptor(
|
||||||
|
self._address_as_int, handle, bytes(data)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def start_notify(
|
||||||
|
self,
|
||||||
|
characteristic: BleakGATTCharacteristic,
|
||||||
|
callback: NotifyCallback,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Activate notifications/indications on a characteristic.
|
||||||
|
|
||||||
|
Callbacks must accept two inputs. The first will be a integer handle of the characteristic generating the
|
||||||
|
data and the second will be a ``bytearray`` containing the data sent from the connected server.
|
||||||
|
.. code-block:: python
|
||||||
|
def callback(sender: int, data: bytearray):
|
||||||
|
print(f"{sender}: {data}")
|
||||||
|
client.start_notify(char_uuid, callback)
|
||||||
|
Args:
|
||||||
|
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to activate
|
||||||
|
notifications/indications on a characteristic, specified by either integer handle,
|
||||||
|
UUID or directly by the BleakGATTCharacteristic object representing it.
|
||||||
|
callback (function): The function to be called on notification.
|
||||||
|
"""
|
||||||
|
cancel_coro = await self._client.bluetooth_gatt_start_notify(
|
||||||
|
self._address_as_int,
|
||||||
|
characteristic.handle,
|
||||||
|
lambda handle, data: callback(data),
|
||||||
|
)
|
||||||
|
self._notify_cancels[characteristic.handle] = cancel_coro
|
||||||
|
|
||||||
|
@api_error_as_bleak_error
|
||||||
|
async def stop_notify(
|
||||||
|
self,
|
||||||
|
char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Deactivate notification/indication on a specified characteristic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to deactivate
|
||||||
|
notification/indication on, specified by either integer handle, UUID or
|
||||||
|
directly by the BleakGATTCharacteristic object representing it.
|
||||||
|
"""
|
||||||
|
characteristic = self._resolve_characteristic(char_specifier)
|
||||||
|
coro = self._notify_cancels.pop(characteristic.handle)
|
||||||
|
await coro()
|
42
homeassistant/components/esphome/bluetooth/descriptor.py
Normal file
42
homeassistant/components/esphome/bluetooth/descriptor.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""BleakGATTDescriptorESPHome."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aioesphomeapi.model import BluetoothGATTDescriptor
|
||||||
|
from bleak.backends.descriptor import BleakGATTDescriptor
|
||||||
|
|
||||||
|
|
||||||
|
class BleakGATTDescriptorESPHome(BleakGATTDescriptor):
|
||||||
|
"""GATT Descriptor implementation for ESPHome backend."""
|
||||||
|
|
||||||
|
obj: BluetoothGATTDescriptor
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
obj: BluetoothGATTDescriptor,
|
||||||
|
characteristic_uuid: str,
|
||||||
|
characteristic_handle: int,
|
||||||
|
) -> None:
|
||||||
|
"""Init a BleakGATTDescriptorESPHome."""
|
||||||
|
super().__init__(obj)
|
||||||
|
self.__characteristic_uuid: str = characteristic_uuid
|
||||||
|
self.__characteristic_handle: int = characteristic_handle
|
||||||
|
|
||||||
|
@property
|
||||||
|
def characteristic_handle(self) -> int:
|
||||||
|
"""Handle for the characteristic that this descriptor belongs to."""
|
||||||
|
return self.__characteristic_handle
|
||||||
|
|
||||||
|
@property
|
||||||
|
def characteristic_uuid(self) -> str:
|
||||||
|
"""UUID for the characteristic that this descriptor belongs to."""
|
||||||
|
return self.__characteristic_uuid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uuid(self) -> str:
|
||||||
|
"""UUID for this descriptor."""
|
||||||
|
return self.obj.uuid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def handle(self) -> int:
|
||||||
|
"""Integer handle for this descriptor."""
|
||||||
|
return self.obj.handle
|
@ -7,38 +7,27 @@ from datetime import timedelta
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from aioesphomeapi import APIClient, BluetoothLEAdvertisement
|
from aioesphomeapi import BluetoothLEAdvertisement
|
||||||
from bleak.backends.device import BLEDevice
|
from bleak.backends.device import BLEDevice
|
||||||
from bleak.backends.scanner import AdvertisementData
|
from bleak.backends.scanner import AdvertisementData
|
||||||
|
|
||||||
from homeassistant.components.bluetooth import (
|
from homeassistant.components.bluetooth import BaseHaScanner, HaBluetoothConnector
|
||||||
BaseHaScanner,
|
|
||||||
async_get_advertisement_callback,
|
|
||||||
async_register_scanner,
|
|
||||||
)
|
|
||||||
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
|
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
|
|
||||||
ADV_STALE_TIME = 180 # seconds
|
# We have to set this quite high as we don't know
|
||||||
|
# when devices fall out of the esphome device's stack
|
||||||
|
# like we do with BlueZ so its safer to assume its available
|
||||||
|
# since if it does go out of range and it is in range
|
||||||
|
# of another device the timeout is much shorter and it will
|
||||||
|
# switch over to using that adapter anyways.
|
||||||
|
ADV_STALE_TIME = 60 * 15 # seconds
|
||||||
|
|
||||||
TWO_CHAR = re.compile("..")
|
TWO_CHAR = re.compile("..")
|
||||||
|
|
||||||
|
|
||||||
async def async_connect_scanner(
|
class ESPHomeScanner(BaseHaScanner):
|
||||||
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."""
|
"""Scanner for esphome."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -46,6 +35,8 @@ class ESPHomeScannner(BaseHaScanner):
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
scanner_id: str,
|
scanner_id: str,
|
||||||
new_info_callback: Callable[[BluetoothServiceInfoBleak], None],
|
new_info_callback: Callable[[BluetoothServiceInfoBleak], None],
|
||||||
|
connector: HaBluetoothConnector,
|
||||||
|
connectable: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the scanner."""
|
"""Initialize the scanner."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
@ -53,6 +44,11 @@ class ESPHomeScannner(BaseHaScanner):
|
|||||||
self._discovered_devices: dict[str, BLEDevice] = {}
|
self._discovered_devices: dict[str, BLEDevice] = {}
|
||||||
self._discovered_device_timestamps: dict[str, float] = {}
|
self._discovered_device_timestamps: dict[str, float] = {}
|
||||||
self._source = scanner_id
|
self._source = scanner_id
|
||||||
|
self._connector = connector
|
||||||
|
self._connectable = connectable
|
||||||
|
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
|
||||||
|
if connectable:
|
||||||
|
self._details["connector"] = connector
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_setup(self) -> CALLBACK_TYPE:
|
def async_setup(self) -> CALLBACK_TYPE:
|
||||||
@ -96,7 +92,7 @@ class ESPHomeScannner(BaseHaScanner):
|
|||||||
device = BLEDevice( # type: ignore[no-untyped-call]
|
device = BLEDevice( # type: ignore[no-untyped-call]
|
||||||
address=address,
|
address=address,
|
||||||
name=adv.name,
|
name=adv.name,
|
||||||
details={},
|
details=self._details,
|
||||||
rssi=adv.rssi,
|
rssi=adv.rssi,
|
||||||
)
|
)
|
||||||
self._discovered_devices[address] = device
|
self._discovered_devices[address] = device
|
||||||
@ -112,7 +108,7 @@ class ESPHomeScannner(BaseHaScanner):
|
|||||||
source=self._source,
|
source=self._source,
|
||||||
device=device,
|
device=device,
|
||||||
advertisement=advertisement_data,
|
advertisement=advertisement_data,
|
||||||
connectable=False,
|
connectable=self._connectable,
|
||||||
time=now,
|
time=now,
|
||||||
)
|
)
|
||||||
)
|
)
|
40
homeassistant/components/esphome/bluetooth/service.py
Normal file
40
homeassistant/components/esphome/bluetooth/service.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""BleakGATTServiceESPHome."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aioesphomeapi.model import BluetoothGATTService
|
||||||
|
from bleak.backends.characteristic import BleakGATTCharacteristic
|
||||||
|
from bleak.backends.service import BleakGATTService
|
||||||
|
|
||||||
|
|
||||||
|
class BleakGATTServiceESPHome(BleakGATTService):
|
||||||
|
"""GATT Characteristic implementation for the ESPHome backend."""
|
||||||
|
|
||||||
|
obj: BluetoothGATTService
|
||||||
|
|
||||||
|
def __init__(self, obj: BluetoothGATTService) -> None:
|
||||||
|
"""Init a BleakGATTServiceESPHome."""
|
||||||
|
super().__init__(obj) # type: ignore[no-untyped-call]
|
||||||
|
self.__characteristics: list[BleakGATTCharacteristic] = []
|
||||||
|
self.__handle: int = self.obj.handle
|
||||||
|
|
||||||
|
@property
|
||||||
|
def handle(self) -> int:
|
||||||
|
"""Integer handle of this service."""
|
||||||
|
return self.__handle
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uuid(self) -> str:
|
||||||
|
"""UUID for this service."""
|
||||||
|
return self.obj.uuid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def characteristics(self) -> list[BleakGATTCharacteristic]:
|
||||||
|
"""List of characteristics for this service."""
|
||||||
|
return self.__characteristics
|
||||||
|
|
||||||
|
def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None:
|
||||||
|
"""Add a :py:class:`~BleakGATTCharacteristicESPHome` to the service.
|
||||||
|
|
||||||
|
Should not be used by end user, but rather by `bleak` itself.
|
||||||
|
"""
|
||||||
|
self.__characteristics.append(characteristic)
|
@ -1,9 +1,13 @@
|
|||||||
"""Support for esphome domain data."""
|
"""Support for esphome domain data."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import MutableMapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TypeVar, cast
|
from typing import TypeVar, cast
|
||||||
|
|
||||||
|
from bleak.backends.service import BleakGATTServiceCollection
|
||||||
|
from lru import LRU # pylint: disable=no-name-in-module
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.json import JSONEncoder
|
from homeassistant.helpers.json import JSONEncoder
|
||||||
@ -13,6 +17,8 @@ from .entry_data import RuntimeEntryData
|
|||||||
|
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
DOMAIN = "esphome"
|
DOMAIN = "esphome"
|
||||||
|
MAX_CACHED_SERVICES = 128
|
||||||
|
|
||||||
_DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData")
|
_DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData")
|
||||||
|
|
||||||
|
|
||||||
@ -23,6 +29,25 @@ class DomainData:
|
|||||||
_entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict)
|
_entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict)
|
||||||
_stores: dict[str, Store] = field(default_factory=dict)
|
_stores: dict[str, Store] = field(default_factory=dict)
|
||||||
_entry_by_unique_id: dict[str, ConfigEntry] = field(default_factory=dict)
|
_entry_by_unique_id: dict[str, ConfigEntry] = field(default_factory=dict)
|
||||||
|
_gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field(
|
||||||
|
default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_gatt_services_cache(
|
||||||
|
self, address: int
|
||||||
|
) -> BleakGATTServiceCollection | None:
|
||||||
|
"""Get the BleakGATTServiceCollection for the given address."""
|
||||||
|
return self._gatt_services_cache.get(address)
|
||||||
|
|
||||||
|
def set_gatt_services_cache(
|
||||||
|
self, address: int, services: BleakGATTServiceCollection
|
||||||
|
) -> None:
|
||||||
|
"""Set the BleakGATTServiceCollection for the given address."""
|
||||||
|
self._gatt_services_cache[address] = services
|
||||||
|
|
||||||
|
def get_by_unique_id(self, unique_id: str) -> ConfigEntry:
|
||||||
|
"""Get the config entry by its unique ID."""
|
||||||
|
return self._entry_by_unique_id[unique_id]
|
||||||
|
|
||||||
def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
|
def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
|
||||||
"""Return the runtime entry data associated with this config entry.
|
"""Return the runtime entry data associated with this config entry.
|
||||||
|
@ -87,6 +87,16 @@ class RuntimeEntryData:
|
|||||||
loaded_platforms: set[str] = field(default_factory=set)
|
loaded_platforms: set[str] = field(default_factory=set)
|
||||||
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
_storage_contents: dict[str, Any] | None = None
|
_storage_contents: dict[str, Any] | None = None
|
||||||
|
ble_connections_free: int = 0
|
||||||
|
ble_connections_limit: int = 0
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_update_ble_connection_limits(self, free: int, limit: int) -> None:
|
||||||
|
"""Update the BLE connection limits."""
|
||||||
|
name = self.device_info.name if self.device_info else self.entry_id
|
||||||
|
_LOGGER.debug("%s: BLE connection limits: %s/%s", name, free, limit)
|
||||||
|
self.ble_connections_free = free
|
||||||
|
self.ble_connections_limit = limit
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_remove_entity(
|
def async_remove_entity(
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "ESPHome",
|
"name": "ESPHome",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/esphome",
|
"documentation": "https://www.home-assistant.io/integrations/esphome",
|
||||||
"requirements": ["aioesphomeapi==10.13.0"],
|
"requirements": ["aioesphomeapi==10.14.0"],
|
||||||
"zeroconf": ["_esphomelib._tcp.local."],
|
"zeroconf": ["_esphomelib._tcp.local."],
|
||||||
"dhcp": [{ "registered_devices": true }],
|
"dhcp": [{ "registered_devices": true }],
|
||||||
"codeowners": ["@OttoWinter", "@jesserockz"],
|
"codeowners": ["@OttoWinter", "@jesserockz"],
|
||||||
|
@ -156,7 +156,7 @@ aioecowitt==2022.09.3
|
|||||||
aioemonitor==1.0.5
|
aioemonitor==1.0.5
|
||||||
|
|
||||||
# homeassistant.components.esphome
|
# homeassistant.components.esphome
|
||||||
aioesphomeapi==10.13.0
|
aioesphomeapi==10.14.0
|
||||||
|
|
||||||
# homeassistant.components.flo
|
# homeassistant.components.flo
|
||||||
aioflo==2021.11.0
|
aioflo==2021.11.0
|
||||||
|
@ -143,7 +143,7 @@ aioecowitt==2022.09.3
|
|||||||
aioemonitor==1.0.5
|
aioemonitor==1.0.5
|
||||||
|
|
||||||
# homeassistant.components.esphome
|
# homeassistant.components.esphome
|
||||||
aioesphomeapi==10.13.0
|
aioesphomeapi==10.14.0
|
||||||
|
|
||||||
# homeassistant.components.flo
|
# homeassistant.components.flo
|
||||||
aioflo==2021.11.0
|
aioflo==2021.11.0
|
||||||
|
Loading…
x
Reference in New Issue
Block a user