2023-11-28 15:03:08 +01:00
|
|
|
# pylint: disable=unidiomatic-typecheck
|
2023-07-19 22:33:28 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2022-09-28 18:50:37 +02:00
|
|
|
import asyncio
|
2018-12-13 21:34:57 +01:00
|
|
|
import logging
|
2023-11-11 21:19:04 +01:00
|
|
|
from collections.abc import Awaitable, Coroutine
|
2023-07-15 23:16:44 +02:00
|
|
|
from functools import partial
|
2023-11-11 21:19:04 +01:00
|
|
|
from typing import TYPE_CHECKING, Any, Callable, Union, cast
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
from google.protobuf import message
|
|
|
|
|
2021-06-30 17:00:22 +02:00
|
|
|
from .api_pb2 import ( # type: ignore
|
2023-06-12 00:39:49 +02:00
|
|
|
AlarmControlPanelCommandRequest,
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothConnectionsFreeResponse,
|
2023-03-26 23:47:21 +02:00
|
|
|
BluetoothDeviceClearCacheResponse,
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothDeviceConnectionResponse,
|
2023-03-06 19:07:58 +01:00
|
|
|
BluetoothDevicePairingResponse,
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothDeviceRequest,
|
2023-03-06 19:07:58 +01:00
|
|
|
BluetoothDeviceUnpairingResponse,
|
2022-10-30 23:38:24 +01:00
|
|
|
BluetoothGATTErrorResponse,
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothGATTGetServicesDoneResponse,
|
|
|
|
BluetoothGATTGetServicesRequest,
|
|
|
|
BluetoothGATTGetServicesResponse,
|
|
|
|
BluetoothGATTNotifyDataResponse,
|
|
|
|
BluetoothGATTNotifyRequest,
|
2022-10-30 23:38:24 +01:00
|
|
|
BluetoothGATTNotifyResponse,
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothGATTReadDescriptorRequest,
|
|
|
|
BluetoothGATTReadRequest,
|
|
|
|
BluetoothGATTReadResponse,
|
|
|
|
BluetoothGATTWriteDescriptorRequest,
|
|
|
|
BluetoothGATTWriteRequest,
|
2022-10-30 23:38:24 +01:00
|
|
|
BluetoothGATTWriteResponse,
|
2022-08-22 05:27:46 +02:00
|
|
|
BluetoothLEAdvertisementResponse,
|
2023-07-27 15:17:40 +02:00
|
|
|
BluetoothLERawAdvertisement,
|
2023-06-07 07:42:57 +02:00
|
|
|
BluetoothLERawAdvertisementsResponse,
|
2021-11-29 01:59:23 +01:00
|
|
|
ButtonCommandRequest,
|
2021-06-18 17:57:02 +02:00
|
|
|
CameraImageRequest,
|
|
|
|
CameraImageResponse,
|
|
|
|
ClimateCommandRequest,
|
|
|
|
CoverCommandRequest,
|
|
|
|
DeviceInfoRequest,
|
|
|
|
DeviceInfoResponse,
|
|
|
|
ExecuteServiceArgument,
|
|
|
|
ExecuteServiceRequest,
|
|
|
|
FanCommandRequest,
|
|
|
|
HomeassistantServiceResponse,
|
|
|
|
HomeAssistantStateResponse,
|
|
|
|
LightCommandRequest,
|
|
|
|
ListEntitiesDoneResponse,
|
|
|
|
ListEntitiesRequest,
|
|
|
|
ListEntitiesServicesResponse,
|
2022-01-11 02:29:19 +01:00
|
|
|
LockCommandRequest,
|
2022-05-18 03:28:40 +02:00
|
|
|
MediaPlayerCommandRequest,
|
2021-06-29 12:42:38 +02:00
|
|
|
NumberCommandRequest,
|
2021-07-26 20:51:12 +02:00
|
|
|
SelectCommandRequest,
|
2021-09-09 03:11:51 +02:00
|
|
|
SirenCommandRequest,
|
2022-09-28 18:50:37 +02:00
|
|
|
SubscribeBluetoothConnectionsFreeRequest,
|
2022-08-22 05:27:46 +02:00
|
|
|
SubscribeBluetoothLEAdvertisementsRequest,
|
2021-06-18 17:57:02 +02:00
|
|
|
SubscribeHomeassistantServicesRequest,
|
|
|
|
SubscribeHomeAssistantStateResponse,
|
|
|
|
SubscribeHomeAssistantStatesRequest,
|
|
|
|
SubscribeLogsRequest,
|
|
|
|
SubscribeLogsResponse,
|
|
|
|
SubscribeStatesRequest,
|
2023-04-11 05:57:35 +02:00
|
|
|
SubscribeVoiceAssistantRequest,
|
2021-06-18 17:57:02 +02:00
|
|
|
SwitchCommandRequest,
|
2023-10-25 04:35:04 +02:00
|
|
|
TextCommandRequest,
|
2023-03-26 22:35:11 +02:00
|
|
|
UnsubscribeBluetoothLEAdvertisementsRequest,
|
2023-04-11 05:57:35 +02:00
|
|
|
VoiceAssistantEventData,
|
|
|
|
VoiceAssistantEventResponse,
|
|
|
|
VoiceAssistantRequest,
|
|
|
|
VoiceAssistantResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
)
|
2023-11-23 18:23:51 +01:00
|
|
|
from .client_callbacks import (
|
|
|
|
on_ble_raw_advertisement_response,
|
|
|
|
on_bluetooth_connections_free_response,
|
2023-11-26 22:21:54 +01:00
|
|
|
on_bluetooth_device_connection_response,
|
2023-11-23 18:23:51 +01:00
|
|
|
on_bluetooth_gatt_notify_data_response,
|
|
|
|
on_bluetooth_le_advertising_response,
|
|
|
|
on_home_assistant_service_response,
|
|
|
|
on_state_msg,
|
2023-11-23 18:37:25 +01:00
|
|
|
on_subscribe_home_assistant_state_response,
|
2023-11-23 18:23:51 +01:00
|
|
|
)
|
2023-11-28 06:51:38 +01:00
|
|
|
from .connection import APIConnection, ConnectionParams, handle_timeout
|
2022-10-31 21:32:40 +01:00
|
|
|
from .core import (
|
|
|
|
APIConnectionError,
|
2023-11-28 14:23:21 +01:00
|
|
|
BluetoothConnectionDroppedError,
|
2022-10-31 21:32:40 +01:00
|
|
|
BluetoothGATTAPIError,
|
|
|
|
TimeoutAPIError,
|
|
|
|
to_human_readable_address,
|
2023-11-28 14:23:21 +01:00
|
|
|
to_human_readable_gatt_error,
|
2022-10-31 21:32:40 +01:00
|
|
|
)
|
2021-06-30 17:00:22 +02:00
|
|
|
from .model import (
|
2023-06-12 00:39:49 +02:00
|
|
|
AlarmControlPanelCommand,
|
2021-06-18 17:57:02 +02:00
|
|
|
APIVersion,
|
2023-03-26 23:47:21 +02:00
|
|
|
BluetoothDeviceClearCache,
|
2023-03-06 19:07:58 +01:00
|
|
|
BluetoothDevicePairing,
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothDeviceRequestType,
|
2023-03-06 19:07:58 +01:00
|
|
|
BluetoothDeviceUnpairing,
|
2022-10-30 23:38:24 +01:00
|
|
|
BluetoothGATTError,
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothGATTServices,
|
2022-08-22 05:27:46 +02:00
|
|
|
BluetoothLEAdvertisement,
|
2023-06-07 07:42:57 +02:00
|
|
|
BluetoothProxyFeature,
|
|
|
|
BluetoothProxySubscriptionFlag,
|
2021-06-18 17:57:02 +02:00
|
|
|
ClimateFanMode,
|
|
|
|
ClimateMode,
|
2021-06-23 23:40:41 +02:00
|
|
|
ClimatePreset,
|
2021-06-18 17:57:02 +02:00
|
|
|
ClimateSwingMode,
|
|
|
|
DeviceInfo,
|
|
|
|
EntityInfo,
|
2021-06-29 15:36:14 +02:00
|
|
|
EntityState,
|
2022-09-29 20:25:18 +02:00
|
|
|
ESPHomeBluetoothGATTServices,
|
2021-06-18 17:57:02 +02:00
|
|
|
FanDirection,
|
|
|
|
FanSpeed,
|
|
|
|
HomeassistantServiceCall,
|
|
|
|
LegacyCoverCommand,
|
2022-01-11 02:29:19 +01:00
|
|
|
LockCommand,
|
2021-06-29 15:36:14 +02:00
|
|
|
LogLevel,
|
2022-05-18 03:28:40 +02:00
|
|
|
MediaPlayerCommand,
|
2021-06-18 17:57:02 +02:00
|
|
|
UserService,
|
|
|
|
UserServiceArgType,
|
|
|
|
)
|
2023-11-27 00:03:23 +01:00
|
|
|
from .model import VoiceAssistantAudioSettings as VoiceAssistantAudioSettingsModel
|
2023-11-28 14:23:21 +01:00
|
|
|
from .model import (
|
|
|
|
VoiceAssistantCommand,
|
|
|
|
VoiceAssistantEventType,
|
|
|
|
message_types_to_names,
|
|
|
|
)
|
2023-11-23 18:23:51 +01:00
|
|
|
from .model_conversions import (
|
|
|
|
LIST_ENTITIES_SERVICES_RESPONSE_TYPES,
|
|
|
|
SUBSCRIBE_STATES_RESPONSE_TYPES,
|
|
|
|
)
|
2023-11-17 20:11:36 +01:00
|
|
|
from .util import build_log_name
|
|
|
|
from .zeroconf import ZeroconfInstanceType, ZeroconfManager
|
2018-12-13 21:34:57 +01:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2022-10-25 05:12:52 +02:00
|
|
|
DEFAULT_BLE_TIMEOUT = 30.0
|
2023-09-03 15:28:39 +02:00
|
|
|
DEFAULT_BLE_DISCONNECT_TIMEOUT = 20.0
|
2022-10-25 05:12:52 +02:00
|
|
|
|
2023-03-06 05:56:22 +01:00
|
|
|
# We send a ping every 20 seconds, and the timeout ratio is 4.5x the
|
|
|
|
# ping interval. This means that if we don't receive a ping for 90.0
|
|
|
|
# seconds, we'll consider the connection dead and reconnect.
|
|
|
|
#
|
|
|
|
# This was chosen because the 20s is around the expected time for a
|
|
|
|
# device to reboot and reconnect to wifi, and 90 seconds is the absolute
|
|
|
|
# maximum time a device can take to respond when its behind + the WiFi
|
|
|
|
# connection is poor.
|
|
|
|
KEEP_ALIVE_FREQUENCY = 20.0
|
|
|
|
|
2023-11-23 18:23:51 +01:00
|
|
|
|
2023-08-16 05:19:46 +02:00
|
|
|
SUBSCRIBE_STATES_MSG_TYPES = (*SUBSCRIBE_STATES_RESPONSE_TYPES, CameraImageResponse)
|
|
|
|
|
|
|
|
LIST_ENTITIES_MSG_TYPES = (
|
|
|
|
ListEntitiesDoneResponse,
|
|
|
|
*LIST_ENTITIES_SERVICES_RESPONSE_TYPES,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2023-07-19 22:33:28 +02:00
|
|
|
ExecuteServiceDataType = dict[
|
|
|
|
str, Union[bool, int, float, str, list[bool], list[int], list[float], list[str]]
|
2021-06-18 17:57:02 +02:00
|
|
|
]
|
|
|
|
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2023-11-09 00:26:51 +01:00
|
|
|
def _stringify_or_none(value: str | None) -> str | None:
|
|
|
|
"""Convert a string like object to a str or None.
|
|
|
|
|
|
|
|
The noise_psk is sometimes passed into
|
|
|
|
the client as an Estr, but we want to pass it
|
|
|
|
to the API as a string or None.
|
|
|
|
"""
|
|
|
|
return None if value is None else str(value)
|
|
|
|
|
|
|
|
|
2021-06-30 17:00:22 +02:00
|
|
|
# pylint: disable=too-many-public-methods
|
2019-01-04 18:35:38 +01:00
|
|
|
class APIClient:
|
2023-11-25 14:51:48 +01:00
|
|
|
"""The ESPHome API client.
|
|
|
|
|
|
|
|
This class is the main entrypoint for interacting with the API.
|
|
|
|
|
|
|
|
It is recommended to use this class in combination with the
|
|
|
|
ReconnectLogic class to automatically reconnect to the device
|
|
|
|
if the connection is lost.
|
|
|
|
"""
|
|
|
|
|
2023-10-15 05:04:27 +02:00
|
|
|
__slots__ = (
|
2023-11-25 14:51:48 +01:00
|
|
|
"_debug_enabled",
|
2023-10-15 05:04:27 +02:00
|
|
|
"_params",
|
|
|
|
"_connection",
|
2023-11-17 20:11:36 +01:00
|
|
|
"cached_name",
|
2023-10-15 05:04:27 +02:00
|
|
|
"_background_tasks",
|
|
|
|
"_loop",
|
2023-11-25 14:11:34 +01:00
|
|
|
"_on_stop_task",
|
2023-11-17 20:11:36 +01:00
|
|
|
"log_name",
|
2023-10-15 05:04:27 +02:00
|
|
|
)
|
2023-07-01 23:31:58 +02:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
address: str,
|
|
|
|
port: int,
|
2023-07-19 22:33:28 +02:00
|
|
|
password: str | None,
|
2021-06-18 17:57:02 +02:00
|
|
|
*,
|
|
|
|
client_info: str = "aioesphomeapi",
|
2023-03-06 05:56:22 +01:00
|
|
|
keepalive: float = KEEP_ALIVE_FREQUENCY,
|
2023-11-17 20:11:36 +01:00
|
|
|
zeroconf_instance: ZeroconfInstanceType | None = None,
|
2023-07-19 22:33:28 +02:00
|
|
|
noise_psk: str | None = None,
|
|
|
|
expected_name: str | None = None,
|
2023-08-16 05:19:46 +02:00
|
|
|
) -> None:
|
2022-01-20 12:03:36 +01:00
|
|
|
"""Create a client, this object is shared across sessions.
|
|
|
|
|
|
|
|
:param address: The address to connect to; for example an IP address
|
|
|
|
or .local name for mDNS lookup.
|
|
|
|
:param port: The port to connect to
|
|
|
|
:param password: Optional password to send to the device for authentication
|
|
|
|
:param client_info: User Agent string to send.
|
|
|
|
:param keepalive: The keepalive time in seconds (ping interval) for detecting stale connections.
|
|
|
|
Every keepalive seconds a ping is sent, if no pong is received the connection is closed.
|
|
|
|
:param zeroconf_instance: Pass a zeroconf instance to use if an mDNS lookup is necessary.
|
|
|
|
:param noise_psk: Encryption preshared key for noise transport encrypted sessions.
|
|
|
|
:param expected_name: Require the devices name to match the given expected name.
|
|
|
|
Can be used to prevent accidentally connecting to a different device if
|
|
|
|
IP passed as address but DHCP reassigned IP.
|
|
|
|
"""
|
2023-11-25 14:51:48 +01:00
|
|
|
self._debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
|
2019-01-04 18:35:38 +01:00
|
|
|
self._params = ConnectionParams(
|
2023-11-09 00:26:51 +01:00
|
|
|
address=str(address),
|
2019-01-04 18:35:38 +01:00
|
|
|
port=port,
|
|
|
|
password=password,
|
|
|
|
client_info=client_info,
|
|
|
|
keepalive=keepalive,
|
2023-11-17 20:11:36 +01:00
|
|
|
zeroconf_manager=ZeroconfManager(zeroconf_instance),
|
2023-11-09 01:00:28 +01:00
|
|
|
# treat empty '' psk string as missing (like password)
|
|
|
|
noise_psk=_stringify_or_none(noise_psk) or None,
|
|
|
|
expected_name=_stringify_or_none(expected_name) or None,
|
2019-01-04 18:35:38 +01:00
|
|
|
)
|
2023-07-19 22:33:28 +02:00
|
|
|
self._connection: APIConnection | None = None
|
2023-11-17 20:11:36 +01:00
|
|
|
self.cached_name: str | None = None
|
2023-04-21 02:30:28 +02:00
|
|
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
2023-07-15 23:16:44 +02:00
|
|
|
self._loop = asyncio.get_event_loop()
|
2023-11-25 14:11:34 +01:00
|
|
|
self._on_stop_task: asyncio.Task[None] | None = None
|
2023-10-15 05:04:27 +02:00
|
|
|
self._set_log_name()
|
2021-06-30 17:10:30 +02:00
|
|
|
|
2023-11-25 14:51:48 +01:00
|
|
|
def set_debug(self, enabled: bool) -> None:
|
|
|
|
"""Enable debug logging."""
|
|
|
|
self._debug_enabled = enabled
|
|
|
|
if self._connection:
|
|
|
|
self._connection.set_debug(enabled)
|
|
|
|
|
2023-11-17 20:11:36 +01:00
|
|
|
@property
|
|
|
|
def zeroconf_manager(self) -> ZeroconfManager:
|
|
|
|
return self._params.zeroconf_manager
|
|
|
|
|
2022-01-20 12:03:36 +01:00
|
|
|
@property
|
2023-07-19 22:33:28 +02:00
|
|
|
def expected_name(self) -> str | None:
|
2022-01-20 12:03:36 +01:00
|
|
|
return self._params.expected_name
|
|
|
|
|
|
|
|
@expected_name.setter
|
2023-07-19 22:33:28 +02:00
|
|
|
def expected_name(self, value: str | None) -> None:
|
2022-01-20 12:03:36 +01:00
|
|
|
self._params.expected_name = value
|
|
|
|
|
2021-06-30 17:10:30 +02:00
|
|
|
@property
|
|
|
|
def address(self) -> str:
|
|
|
|
return self._params.address
|
|
|
|
|
2023-10-15 05:04:27 +02:00
|
|
|
def _set_log_name(self) -> None:
|
|
|
|
"""Set the log name of the device."""
|
2023-11-17 20:11:36 +01:00
|
|
|
resolved_address: str | None = None
|
|
|
|
if self._connection and self._connection.resolved_addr_info:
|
|
|
|
resolved_address = self._connection.resolved_addr_info.sockaddr.address
|
|
|
|
self.log_name = build_log_name(
|
|
|
|
self.cached_name,
|
|
|
|
self.address,
|
|
|
|
resolved_address,
|
|
|
|
)
|
|
|
|
if self._connection:
|
|
|
|
self._connection.set_log_name(self.log_name)
|
2019-01-04 18:35:38 +01:00
|
|
|
|
2023-04-25 22:11:40 +02:00
|
|
|
def set_cached_name_if_unset(self, name: str) -> None:
|
|
|
|
"""Set the cached name of the device if not set."""
|
2023-11-17 20:11:36 +01:00
|
|
|
if not self.cached_name:
|
|
|
|
self.cached_name = name
|
2023-10-15 05:04:27 +02:00
|
|
|
self._set_log_name()
|
2023-04-25 22:11:40 +02:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def connect(
|
|
|
|
self,
|
2023-11-25 14:11:34 +01:00
|
|
|
on_stop: Callable[[bool], Coroutine[Any, Any, None]] | None = None,
|
2021-06-18 17:57:02 +02:00
|
|
|
login: bool = False,
|
|
|
|
) -> None:
|
2023-10-15 04:03:12 +02:00
|
|
|
"""Connect to the device."""
|
|
|
|
await self.start_connection(on_stop)
|
|
|
|
await self.finish_connection(login)
|
|
|
|
|
2023-11-25 14:11:34 +01:00
|
|
|
def _on_stop(
|
|
|
|
self,
|
|
|
|
on_stop: Callable[[bool], Coroutine[Any, Any, None]] | None,
|
|
|
|
expected_disconnect: bool,
|
|
|
|
) -> None:
|
|
|
|
# Hook into on_stop handler to clear connection when stopped
|
|
|
|
self._connection = None
|
|
|
|
if on_stop:
|
|
|
|
self._on_stop_task = asyncio.create_task(
|
|
|
|
on_stop(expected_disconnect),
|
|
|
|
name=f"{self.log_name} aioesphomeapi on_stop",
|
|
|
|
)
|
|
|
|
self._on_stop_task.add_done_callback(self._remove_on_stop_task)
|
|
|
|
|
|
|
|
def _remove_on_stop_task(self, _fut: asyncio.Future[None]) -> None:
|
|
|
|
"""Remove the stop task.
|
|
|
|
|
|
|
|
We need to do this because the asyncio does not hold
|
|
|
|
a strong reference to the task, so it can be garbage
|
|
|
|
collected unexpectedly.
|
|
|
|
"""
|
|
|
|
self._on_stop_task = None
|
|
|
|
|
2023-10-15 04:03:12 +02:00
|
|
|
async def start_connection(
|
|
|
|
self,
|
|
|
|
on_stop: Callable[[bool], Awaitable[None]] | None = None,
|
|
|
|
) -> None:
|
|
|
|
"""Start connecting to the device."""
|
2019-01-04 18:35:38 +01:00
|
|
|
if self._connection is not None:
|
2023-11-17 20:11:36 +01:00
|
|
|
raise APIConnectionError(f"Already connected to {self.log_name}!")
|
2019-01-04 18:35:38 +01:00
|
|
|
|
2023-11-25 14:11:34 +01:00
|
|
|
self._connection = APIConnection(
|
2023-11-25 14:51:48 +01:00
|
|
|
self._params,
|
|
|
|
partial(self._on_stop, on_stop),
|
|
|
|
self._debug_enabled,
|
|
|
|
self.log_name,
|
2023-11-25 14:11:34 +01:00
|
|
|
)
|
2019-01-04 18:35:38 +01:00
|
|
|
|
|
|
|
try:
|
2023-10-15 04:03:12 +02:00
|
|
|
await self._connection.start_connection()
|
2023-11-25 16:33:43 +01:00
|
|
|
except Exception:
|
2021-10-21 19:24:03 +02:00
|
|
|
self._connection = None
|
2019-01-04 18:35:38 +01:00
|
|
|
raise
|
2023-11-17 20:11:36 +01:00
|
|
|
# If we resolved the address, we should set the log name now
|
|
|
|
if self._connection.resolved_addr_info:
|
|
|
|
self._set_log_name()
|
2023-10-15 04:03:12 +02:00
|
|
|
|
|
|
|
async def finish_connection(
|
|
|
|
self,
|
|
|
|
login: bool = False,
|
|
|
|
) -> None:
|
|
|
|
"""Finish connecting to the device."""
|
2023-11-22 21:54:01 +01:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
assert self._connection is not None
|
2023-10-15 04:03:12 +02:00
|
|
|
try:
|
|
|
|
await self._connection.finish_connection(login=login)
|
2023-11-25 16:33:43 +01:00
|
|
|
except Exception:
|
2023-10-15 04:03:12 +02:00
|
|
|
self._connection = None
|
|
|
|
raise
|
2023-11-17 20:11:36 +01:00
|
|
|
if received_name := self._connection.received_name:
|
|
|
|
self._set_name_from_device(received_name)
|
2019-01-04 18:35:38 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def disconnect(self, force: bool = False) -> None:
|
2019-01-04 18:35:38 +01:00
|
|
|
if self._connection is None:
|
|
|
|
return
|
2021-10-04 12:12:43 +02:00
|
|
|
if force:
|
2023-11-25 15:39:04 +01:00
|
|
|
self._connection.force_disconnect()
|
2021-10-04 12:12:43 +02:00
|
|
|
else:
|
|
|
|
await self._connection.disconnect()
|
2019-01-04 18:35:38 +01:00
|
|
|
|
2023-11-22 22:32:22 +01:00
|
|
|
def _get_connection(self) -> APIConnection:
|
2023-07-15 22:34:46 +02:00
|
|
|
connection = self._connection
|
|
|
|
if not connection:
|
2023-11-17 20:11:36 +01:00
|
|
|
raise APIConnectionError(f"Not connected to {self.log_name}!")
|
2023-07-15 22:34:46 +02:00
|
|
|
if not connection.is_connected:
|
2022-12-13 21:31:12 +01:00
|
|
|
raise APIConnectionError(
|
2023-11-17 20:11:36 +01:00
|
|
|
f"Authenticated connection not ready yet for {self.log_name}; "
|
2023-07-15 22:34:46 +02:00
|
|
|
f"current state is {connection.connection_state}!"
|
2022-12-13 21:31:12 +01:00
|
|
|
)
|
2023-11-22 22:22:10 +01:00
|
|
|
return connection
|
2019-01-04 18:35:38 +01:00
|
|
|
|
|
|
|
async def device_info(self) -> DeviceInfo:
|
2023-11-22 22:32:22 +01:00
|
|
|
resp = await self._get_connection().send_message_await_response(
|
2021-06-18 17:57:02 +02:00
|
|
|
DeviceInfoRequest(), DeviceInfoResponse
|
|
|
|
)
|
2021-06-30 17:10:30 +02:00
|
|
|
info = DeviceInfo.from_pb(resp)
|
2023-11-17 20:11:36 +01:00
|
|
|
self._set_name_from_device(info.name)
|
2021-06-30 17:10:30 +02:00
|
|
|
return info
|
2019-01-04 18:35:38 +01:00
|
|
|
|
2023-11-17 20:11:36 +01:00
|
|
|
def _set_name_from_device(self, name: str) -> None:
|
|
|
|
"""Set the name from a DeviceInfo message."""
|
|
|
|
self.cached_name = name
|
|
|
|
self._set_log_name()
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def list_entities_services(
|
|
|
|
self,
|
2023-07-19 22:33:28 +02:00
|
|
|
) -> tuple[list[EntityInfo], list[UserService]]:
|
2023-08-16 05:19:46 +02:00
|
|
|
response_types = LIST_ENTITIES_SERVICES_RESPONSE_TYPES
|
|
|
|
msg_types = LIST_ENTITIES_MSG_TYPES
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
def do_append(msg: message.Message) -> bool:
|
2022-12-02 20:36:58 +01:00
|
|
|
return not isinstance(msg, ListEntitiesDoneResponse)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
def do_stop(msg: message.Message) -> bool:
|
|
|
|
return isinstance(msg, ListEntitiesDoneResponse)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2023-11-22 22:32:22 +01:00
|
|
|
resp = await self._get_connection().send_messages_await_response_complex(
|
2023-11-21 13:08:48 +01:00
|
|
|
(ListEntitiesRequest(),), do_append, do_stop, msg_types, 60
|
2021-06-18 17:57:02 +02:00
|
|
|
)
|
2023-07-19 22:33:28 +02:00
|
|
|
entities: list[EntityInfo] = []
|
|
|
|
services: list[UserService] = []
|
2018-12-13 21:34:57 +01:00
|
|
|
for msg in resp:
|
2021-06-18 17:57:02 +02:00
|
|
|
if isinstance(msg, ListEntitiesServicesResponse):
|
2021-06-29 15:36:14 +02:00
|
|
|
services.append(UserService.from_pb(msg))
|
2019-02-24 18:16:12 +01:00
|
|
|
continue
|
2022-12-02 20:36:58 +01:00
|
|
|
cls = response_types[type(msg)]
|
2021-06-29 15:36:14 +02:00
|
|
|
assert cls is not None
|
|
|
|
entities.append(cls.from_pb(msg))
|
2019-02-24 18:16:12 +01:00
|
|
|
return entities, services
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2023-11-23 18:06:42 +01:00
|
|
|
async def subscribe_states(self, on_state: Callable[[EntityState], None]) -> None:
|
|
|
|
"""Subscribe to state updates."""
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message_callback_response(
|
2023-11-23 18:06:42 +01:00
|
|
|
SubscribeStatesRequest(),
|
2023-11-23 18:23:51 +01:00
|
|
|
partial(on_state_msg, on_state, {}),
|
2023-11-23 18:06:42 +01:00
|
|
|
SUBSCRIBE_STATES_MSG_TYPES,
|
2021-06-18 17:57:02 +02:00
|
|
|
)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def subscribe_logs(
|
|
|
|
self,
|
|
|
|
on_log: Callable[[SubscribeLogsResponse], None],
|
2023-07-19 22:33:28 +02:00
|
|
|
log_level: LogLevel | None = None,
|
|
|
|
dump_config: bool | None = None,
|
2021-06-18 17:57:02 +02:00
|
|
|
) -> None:
|
|
|
|
req = SubscribeLogsRequest()
|
2018-12-13 21:34:57 +01:00
|
|
|
if log_level is not None:
|
|
|
|
req.level = log_level
|
2021-09-08 23:12:07 +02:00
|
|
|
if dump_config is not None:
|
|
|
|
req.dump_config = dump_config
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message_callback_response(
|
2023-02-12 23:42:14 +01:00
|
|
|
req, on_log, (SubscribeLogsResponse,)
|
2022-12-02 20:36:58 +01:00
|
|
|
)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def subscribe_service_calls(
|
|
|
|
self, on_service_call: Callable[[HomeassistantServiceCall], None]
|
|
|
|
) -> None:
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message_callback_response(
|
2022-12-02 20:36:58 +01:00
|
|
|
SubscribeHomeassistantServicesRequest(),
|
2023-11-23 18:23:51 +01:00
|
|
|
partial(on_home_assistant_service_response, on_service_call),
|
2022-12-02 20:36:58 +01:00
|
|
|
(HomeassistantServiceResponse,),
|
2021-05-12 10:57:01 +02:00
|
|
|
)
|
2018-12-16 18:03:03 +01:00
|
|
|
|
2023-07-18 21:11:06 +02:00
|
|
|
def _filter_bluetooth_message(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
handle: int,
|
|
|
|
msg: message.Message,
|
|
|
|
) -> bool:
|
|
|
|
"""Handle a Bluetooth message."""
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
assert isinstance(
|
|
|
|
msg,
|
|
|
|
(
|
|
|
|
BluetoothGATTErrorResponse,
|
|
|
|
BluetoothGATTNotifyResponse,
|
|
|
|
BluetoothGATTReadResponse,
|
|
|
|
BluetoothGATTWriteResponse,
|
2023-11-28 14:42:21 +01:00
|
|
|
BluetoothDeviceConnectionResponse,
|
2023-07-18 21:11:06 +02:00
|
|
|
),
|
|
|
|
)
|
2023-11-28 15:03:08 +01:00
|
|
|
if type(msg) is BluetoothDeviceConnectionResponse:
|
2023-11-28 14:42:21 +01:00
|
|
|
return bool(msg.address == address)
|
2023-07-18 21:11:06 +02:00
|
|
|
return bool(msg.address == address and msg.handle == handle)
|
|
|
|
|
2022-10-30 23:38:24 +01:00
|
|
|
async def _send_bluetooth_message_await_response(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
handle: int,
|
|
|
|
request: message.Message,
|
2023-07-19 22:33:28 +02:00
|
|
|
response_type: (
|
|
|
|
type[BluetoothGATTNotifyResponse]
|
|
|
|
| type[BluetoothGATTReadResponse]
|
|
|
|
| type[BluetoothGATTWriteResponse]
|
|
|
|
),
|
2022-10-30 23:38:24 +01:00
|
|
|
timeout: float = 10.0,
|
|
|
|
) -> message.Message:
|
2023-07-18 21:11:06 +02:00
|
|
|
message_filter = partial(self._filter_bluetooth_message, address, handle)
|
2023-11-28 14:42:21 +01:00
|
|
|
msg_types = (response_type, BluetoothGATTErrorResponse)
|
2023-11-26 20:59:07 +01:00
|
|
|
[resp] = await self._get_connection().send_messages_await_response_complex(
|
2023-11-23 18:27:16 +01:00
|
|
|
(request,),
|
|
|
|
message_filter,
|
|
|
|
message_filter,
|
2023-11-28 14:42:21 +01:00
|
|
|
(*msg_types, BluetoothDeviceConnectionResponse),
|
2023-11-23 18:27:16 +01:00
|
|
|
timeout,
|
2022-10-30 23:38:24 +01:00
|
|
|
)
|
|
|
|
|
2023-11-28 15:03:08 +01:00
|
|
|
if type(resp) is BluetoothGATTErrorResponse:
|
2023-11-26 20:59:07 +01:00
|
|
|
raise BluetoothGATTAPIError(BluetoothGATTError.from_pb(resp))
|
2022-10-30 23:38:24 +01:00
|
|
|
|
2023-11-28 14:42:21 +01:00
|
|
|
self._raise_for_ble_connection_change(address, resp, msg_types)
|
|
|
|
|
2023-11-26 20:59:07 +01:00
|
|
|
return resp
|
2022-10-30 23:38:24 +01:00
|
|
|
|
2023-11-26 21:08:05 +01:00
|
|
|
def _unsub_bluetooth_advertisements(
|
|
|
|
self, unsub_callback: Callable[[], None]
|
|
|
|
) -> None:
|
|
|
|
"""Unsubscribe Bluetooth advertisements if connected."""
|
|
|
|
if self._connection is not None:
|
|
|
|
unsub_callback()
|
|
|
|
self._connection.send_message(UnsubscribeBluetoothLEAdvertisementsRequest())
|
|
|
|
|
2022-08-22 05:27:46 +02:00
|
|
|
async def subscribe_bluetooth_le_advertisements(
|
|
|
|
self, on_bluetooth_le_advertisement: Callable[[BluetoothLEAdvertisement], None]
|
2022-09-28 18:50:37 +02:00
|
|
|
) -> Callable[[], None]:
|
2023-11-22 22:32:22 +01:00
|
|
|
unsub_callback = self._get_connection().send_message_callback_response(
|
|
|
|
SubscribeBluetoothLEAdvertisementsRequest(flags=0),
|
2023-11-23 17:20:56 +01:00
|
|
|
partial(
|
2023-11-23 18:23:51 +01:00
|
|
|
on_bluetooth_le_advertising_response,
|
2023-11-23 17:20:56 +01:00
|
|
|
on_bluetooth_le_advertisement,
|
|
|
|
),
|
2023-11-23 17:34:11 +01:00
|
|
|
(BluetoothLEAdvertisementResponse,),
|
2023-06-07 07:42:57 +02:00
|
|
|
)
|
2023-11-26 21:08:05 +01:00
|
|
|
return partial(self._unsub_bluetooth_advertisements, unsub_callback)
|
2023-06-07 07:42:57 +02:00
|
|
|
|
|
|
|
async def subscribe_bluetooth_le_raw_advertisements(
|
2023-07-19 22:33:28 +02:00
|
|
|
self, on_advertisements: Callable[[list[BluetoothLERawAdvertisement]], None]
|
2023-06-07 07:42:57 +02:00
|
|
|
) -> Callable[[], None]:
|
2023-11-22 22:32:22 +01:00
|
|
|
unsub_callback = self._get_connection().send_message_callback_response(
|
|
|
|
SubscribeBluetoothLEAdvertisementsRequest(
|
|
|
|
flags=BluetoothProxySubscriptionFlag.RAW_ADVERTISEMENTS
|
|
|
|
),
|
2023-11-23 18:23:51 +01:00
|
|
|
partial(on_ble_raw_advertisement_response, on_advertisements),
|
2023-11-23 17:34:11 +01:00
|
|
|
(BluetoothLERawAdvertisementsResponse,),
|
2022-08-22 05:27:46 +02:00
|
|
|
)
|
2023-11-26 21:08:05 +01:00
|
|
|
return partial(self._unsub_bluetooth_advertisements, unsub_callback)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
async def subscribe_bluetooth_connections_free(
|
|
|
|
self, on_bluetooth_connections_free_update: Callable[[int, int], None]
|
|
|
|
) -> Callable[[], None]:
|
2023-11-22 22:32:22 +01:00
|
|
|
return self._get_connection().send_message_callback_response(
|
2023-07-10 01:06:06 +02:00
|
|
|
SubscribeBluetoothConnectionsFreeRequest(),
|
2023-11-23 17:49:24 +01:00
|
|
|
partial(
|
2023-11-23 18:23:51 +01:00
|
|
|
on_bluetooth_connections_free_response,
|
2023-11-23 17:49:24 +01:00
|
|
|
on_bluetooth_connections_free_update,
|
|
|
|
),
|
|
|
|
(BluetoothConnectionsFreeResponse,),
|
2022-09-28 18:50:37 +02:00
|
|
|
)
|
|
|
|
|
2023-07-15 23:16:44 +02:00
|
|
|
async def bluetooth_device_connect( # pylint: disable=too-many-locals, too-many-branches
|
2022-09-28 18:50:37 +02:00
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
on_bluetooth_connection_state: Callable[[bool, int, int], None],
|
2022-10-25 05:12:52 +02:00
|
|
|
timeout: float = DEFAULT_BLE_TIMEOUT,
|
2022-11-22 19:32:04 +01:00
|
|
|
disconnect_timeout: float = DEFAULT_BLE_DISCONNECT_TIMEOUT,
|
2023-06-07 07:42:57 +02:00
|
|
|
feature_flags: int = 0,
|
2022-11-29 03:06:13 +01:00
|
|
|
has_cache: bool = False,
|
2023-07-19 22:33:28 +02:00
|
|
|
address_type: int | None = None,
|
2022-09-28 18:50:37 +02:00
|
|
|
) -> Callable[[], None]:
|
2023-07-15 23:16:44 +02:00
|
|
|
connect_future: asyncio.Future[None] = self._loop.create_future()
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2022-11-29 03:06:13 +01:00
|
|
|
if has_cache:
|
2023-06-07 07:42:57 +02:00
|
|
|
# REMOTE_CACHING feature with cache: requestor has services and mtu cached
|
2022-11-29 03:06:13 +01:00
|
|
|
request_type = BluetoothDeviceRequestType.CONNECT_V3_WITH_CACHE
|
2023-06-07 07:42:57 +02:00
|
|
|
elif feature_flags & BluetoothProxyFeature.REMOTE_CACHING:
|
|
|
|
# REMOTE_CACHING feature without cache: esp will wipe the service list after sending to save memory
|
2022-11-29 03:06:13 +01:00
|
|
|
request_type = BluetoothDeviceRequestType.CONNECT_V3_WITHOUT_CACHE
|
|
|
|
else:
|
2023-07-15 23:16:44 +02:00
|
|
|
# Device does not support REMOTE_CACHING feature: esp will hold the service list in memory for the duration
|
2022-11-29 03:06:13 +01:00
|
|
|
# of the connection. This can crash the esp if the service list is too large.
|
|
|
|
request_type = BluetoothDeviceRequestType.CONNECT
|
|
|
|
|
2023-11-25 14:51:48 +01:00
|
|
|
if self._debug_enabled:
|
2023-07-15 23:16:44 +02:00
|
|
|
_LOGGER.debug("%s: Using connection version %s", address, request_type)
|
|
|
|
|
2023-11-22 22:32:22 +01:00
|
|
|
unsub = self._get_connection().send_message_callback_response(
|
2022-11-29 05:00:27 +01:00
|
|
|
BluetoothDeviceRequest(
|
|
|
|
address=address,
|
|
|
|
request_type=request_type,
|
|
|
|
has_address_type=address_type is not None,
|
|
|
|
address_type=address_type or 0,
|
|
|
|
),
|
2023-07-15 23:16:44 +02:00
|
|
|
partial(
|
2023-11-26 22:21:54 +01:00
|
|
|
on_bluetooth_device_connection_response,
|
2023-07-15 23:16:44 +02:00
|
|
|
connect_future,
|
|
|
|
address,
|
|
|
|
on_bluetooth_connection_state,
|
|
|
|
),
|
2023-11-23 18:27:16 +01:00
|
|
|
(BluetoothDeviceConnectionResponse,),
|
2022-09-28 18:50:37 +02:00
|
|
|
)
|
|
|
|
|
2023-07-19 21:25:32 +02:00
|
|
|
loop = self._loop
|
2023-09-04 19:56:23 +02:00
|
|
|
timeout_handle = loop.call_at(
|
2023-11-26 22:21:54 +01:00
|
|
|
loop.time() + timeout, handle_timeout, connect_future
|
2023-09-04 19:56:23 +02:00
|
|
|
)
|
2023-07-19 21:25:32 +02:00
|
|
|
timeout_expired = False
|
|
|
|
connect_ok = False
|
2022-09-28 18:50:37 +02:00
|
|
|
try:
|
2023-07-19 21:25:32 +02:00
|
|
|
await connect_future
|
|
|
|
connect_ok = True
|
|
|
|
except asyncio.TimeoutError as err:
|
2023-11-26 22:21:54 +01:00
|
|
|
# If the timeout expires, make sure
|
|
|
|
# to unsub before calling _bluetooth_device_disconnect_guard_timeout
|
|
|
|
# so that the disconnect message is not propagated back to the caller
|
|
|
|
# since we are going to raise a TimeoutAPIError.
|
|
|
|
unsub()
|
2023-07-19 21:25:32 +02:00
|
|
|
timeout_expired = True
|
|
|
|
# Disconnect before raising the exception to ensure
|
|
|
|
# the slot is recovered before the timeout is raised
|
|
|
|
# to avoid race were we run out even though we have a slot.
|
|
|
|
addr = to_human_readable_address(address)
|
2023-11-25 14:51:48 +01:00
|
|
|
if self._debug_enabled:
|
2023-07-19 21:25:32 +02:00
|
|
|
_LOGGER.debug("%s: Connecting timed out, waiting for disconnect", addr)
|
|
|
|
disconnect_timed_out = (
|
|
|
|
not await self._bluetooth_device_disconnect_guard_timeout(
|
|
|
|
address, disconnect_timeout
|
2022-10-31 21:32:40 +01:00
|
|
|
)
|
2023-07-19 21:25:32 +02:00
|
|
|
)
|
|
|
|
raise TimeoutAPIError(
|
|
|
|
f"Timeout waiting for connect response while connecting to {addr} "
|
|
|
|
f"after {timeout}s, disconnect timed out: {disconnect_timed_out}, "
|
|
|
|
f" after {disconnect_timeout}s"
|
|
|
|
) from err
|
2023-07-15 23:16:44 +02:00
|
|
|
finally:
|
2023-11-26 22:21:54 +01:00
|
|
|
if not connect_ok and not timeout_expired:
|
|
|
|
unsub()
|
2023-07-19 21:25:32 +02:00
|
|
|
if not timeout_expired:
|
|
|
|
timeout_handle.cancel()
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
return unsub
|
|
|
|
|
2023-07-19 21:25:32 +02:00
|
|
|
async def _bluetooth_device_disconnect_guard_timeout(
|
|
|
|
self, address: int, timeout: float
|
|
|
|
) -> bool:
|
|
|
|
"""Disconnect from a Bluetooth device and guard against timeout.
|
|
|
|
|
|
|
|
Return true if the disconnect was successful, false if it timed out.
|
|
|
|
"""
|
|
|
|
try:
|
|
|
|
await self.bluetooth_device_disconnect(address, timeout=timeout)
|
|
|
|
except TimeoutAPIError:
|
2023-11-25 14:51:48 +01:00
|
|
|
if self._debug_enabled:
|
2023-07-19 21:25:32 +02:00
|
|
|
_LOGGER.debug(
|
|
|
|
"%s: Disconnect timed out: %s",
|
|
|
|
to_human_readable_address(address),
|
|
|
|
timeout,
|
|
|
|
)
|
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
2023-03-06 19:07:58 +01:00
|
|
|
async def bluetooth_device_pair(
|
|
|
|
self, address: int, timeout: float = DEFAULT_BLE_TIMEOUT
|
|
|
|
) -> BluetoothDevicePairing:
|
2023-11-11 00:14:00 +01:00
|
|
|
return BluetoothDevicePairing.from_pb(
|
2023-11-28 14:23:21 +01:00
|
|
|
await self._bluetooth_device_request_watch_connection(
|
2023-11-11 00:14:00 +01:00
|
|
|
address,
|
|
|
|
BluetoothDeviceRequestType.PAIR,
|
2023-11-28 14:23:21 +01:00
|
|
|
(BluetoothDevicePairingResponse,),
|
2023-11-11 00:14:00 +01:00
|
|
|
timeout,
|
|
|
|
)
|
2023-03-06 19:07:58 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
async def bluetooth_device_unpair(
|
|
|
|
self, address: int, timeout: float = DEFAULT_BLE_TIMEOUT
|
|
|
|
) -> BluetoothDeviceUnpairing:
|
2023-11-11 00:14:00 +01:00
|
|
|
return BluetoothDeviceUnpairing.from_pb(
|
2023-11-28 14:23:21 +01:00
|
|
|
await self._bluetooth_device_request_watch_connection(
|
2023-11-11 00:14:00 +01:00
|
|
|
address,
|
|
|
|
BluetoothDeviceRequestType.UNPAIR,
|
|
|
|
(BluetoothDeviceUnpairingResponse,),
|
|
|
|
timeout,
|
|
|
|
)
|
2023-03-06 19:07:58 +01:00
|
|
|
)
|
|
|
|
|
2023-03-26 23:47:21 +02:00
|
|
|
async def bluetooth_device_clear_cache(
|
|
|
|
self, address: int, timeout: float = DEFAULT_BLE_TIMEOUT
|
|
|
|
) -> BluetoothDeviceClearCache:
|
2023-11-11 00:14:00 +01:00
|
|
|
return BluetoothDeviceClearCache.from_pb(
|
2023-11-28 14:23:21 +01:00
|
|
|
await self._bluetooth_device_request_watch_connection(
|
2023-11-11 00:14:00 +01:00
|
|
|
address,
|
|
|
|
BluetoothDeviceRequestType.CLEAR_CACHE,
|
|
|
|
(BluetoothDeviceClearCacheResponse,),
|
|
|
|
timeout,
|
|
|
|
)
|
2023-03-26 23:47:21 +02:00
|
|
|
)
|
|
|
|
|
2023-05-07 16:05:51 +02:00
|
|
|
async def bluetooth_device_disconnect(
|
|
|
|
self, address: int, timeout: float = DEFAULT_BLE_DISCONNECT_TIMEOUT
|
|
|
|
) -> None:
|
2023-11-11 00:14:00 +01:00
|
|
|
"""Disconnect from a Bluetooth device."""
|
|
|
|
await self._bluetooth_device_request(
|
|
|
|
address,
|
|
|
|
BluetoothDeviceRequestType.DISCONNECT,
|
|
|
|
lambda msg: msg.address == address and not msg.connected,
|
|
|
|
(BluetoothDeviceConnectionResponse,),
|
|
|
|
timeout,
|
|
|
|
)
|
2023-05-07 16:05:51 +02:00
|
|
|
|
2023-11-28 14:23:21 +01:00
|
|
|
async def _bluetooth_device_request_watch_connection(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
request_type: BluetoothDeviceRequestType,
|
|
|
|
msg_types: tuple[type[message.Message], ...],
|
|
|
|
timeout: float,
|
|
|
|
) -> message.Message:
|
|
|
|
"""Send a BluetoothDeviceRequest watch for the connection state to change."""
|
|
|
|
response = await self._bluetooth_device_request(
|
|
|
|
address,
|
|
|
|
request_type,
|
|
|
|
lambda msg: msg.address == address,
|
|
|
|
(BluetoothDeviceConnectionResponse, *msg_types),
|
|
|
|
timeout,
|
|
|
|
)
|
2023-11-28 14:42:21 +01:00
|
|
|
self._raise_for_ble_connection_change(address, response, msg_types)
|
2023-11-28 14:23:21 +01:00
|
|
|
return response
|
|
|
|
|
|
|
|
def _raise_for_ble_connection_change(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
response: BluetoothDeviceConnectionResponse,
|
|
|
|
msg_types: tuple[type[message.Message], ...],
|
|
|
|
) -> None:
|
|
|
|
"""Raise an exception if the connection status changed."""
|
2023-11-28 15:03:08 +01:00
|
|
|
if type(response) is not BluetoothDeviceConnectionResponse:
|
2023-11-28 14:42:21 +01:00
|
|
|
return
|
2023-11-28 14:23:21 +01:00
|
|
|
response_names = message_types_to_names(msg_types)
|
|
|
|
human_readable_address = to_human_readable_address(address)
|
|
|
|
raise BluetoothConnectionDroppedError(
|
|
|
|
f"Peripheral {human_readable_address} changed connection status while waiting for "
|
|
|
|
f"{response_names}: {to_human_readable_gatt_error(response.error)} "
|
|
|
|
f"({response.error})"
|
|
|
|
)
|
|
|
|
|
2023-11-11 00:14:00 +01:00
|
|
|
async def _bluetooth_device_request(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
request_type: BluetoothDeviceRequestType,
|
|
|
|
predicate_func: Callable[[BluetoothDeviceConnectionResponse], bool],
|
|
|
|
msg_types: tuple[type[message.Message], ...],
|
|
|
|
timeout: float,
|
|
|
|
) -> message.Message:
|
2023-11-28 14:23:21 +01:00
|
|
|
"""Send a BluetoothDeviceRequest and wait for a response."""
|
2023-11-22 22:32:22 +01:00
|
|
|
[response] = await self._get_connection().send_messages_await_response_complex(
|
2023-11-10 02:17:53 +01:00
|
|
|
(
|
|
|
|
BluetoothDeviceRequest(
|
|
|
|
address=address,
|
2023-11-11 00:14:00 +01:00
|
|
|
request_type=request_type,
|
2023-11-10 02:17:53 +01:00
|
|
|
),
|
2023-05-07 16:05:51 +02:00
|
|
|
),
|
|
|
|
predicate_func,
|
|
|
|
predicate_func,
|
2023-11-11 00:14:00 +01:00
|
|
|
msg_types,
|
|
|
|
timeout,
|
2022-09-28 18:50:37 +02:00
|
|
|
)
|
2023-11-11 00:14:00 +01:00
|
|
|
return response
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2022-09-29 20:25:18 +02:00
|
|
|
async def bluetooth_gatt_get_services(
|
|
|
|
self, address: int
|
|
|
|
) -> ESPHomeBluetoothGATTServices:
|
2023-11-28 15:03:08 +01:00
|
|
|
append_types = (
|
|
|
|
BluetoothDeviceConnectionResponse,
|
|
|
|
BluetoothGATTGetServicesResponse,
|
|
|
|
BluetoothGATTErrorResponse,
|
|
|
|
)
|
|
|
|
stop_types = (
|
|
|
|
BluetoothDeviceConnectionResponse,
|
|
|
|
BluetoothGATTGetServicesDoneResponse,
|
|
|
|
BluetoothGATTErrorResponse,
|
|
|
|
)
|
|
|
|
msg_types = (
|
|
|
|
BluetoothGATTGetServicesResponse,
|
|
|
|
BluetoothGATTGetServicesDoneResponse,
|
|
|
|
BluetoothGATTErrorResponse,
|
|
|
|
)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-11-28 15:03:08 +01:00
|
|
|
def do_append(
|
|
|
|
msg: BluetoothDeviceConnectionResponse
|
|
|
|
| BluetoothGATTGetServicesResponse
|
|
|
|
| BluetoothGATTGetServicesDoneResponse
|
|
|
|
| BluetoothGATTErrorResponse,
|
|
|
|
) -> bool:
|
|
|
|
return type(msg) in append_types and msg.address == address
|
|
|
|
|
|
|
|
def do_stop(
|
|
|
|
msg: BluetoothDeviceConnectionResponse
|
|
|
|
| BluetoothGATTGetServicesResponse
|
|
|
|
| BluetoothGATTGetServicesDoneResponse
|
|
|
|
| BluetoothGATTErrorResponse,
|
|
|
|
) -> bool:
|
|
|
|
return type(msg) in stop_types and msg.address == address
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-11-22 22:32:22 +01:00
|
|
|
resp = await self._get_connection().send_messages_await_response_complex(
|
2023-11-10 02:17:53 +01:00
|
|
|
(BluetoothGATTGetServicesRequest(address=address),),
|
2022-10-25 05:12:52 +02:00
|
|
|
do_append,
|
|
|
|
do_stop,
|
2023-11-28 15:03:08 +01:00
|
|
|
(*msg_types, BluetoothDeviceConnectionResponse),
|
2023-11-21 13:08:48 +01:00
|
|
|
DEFAULT_BLE_TIMEOUT,
|
2022-09-28 18:50:37 +02:00
|
|
|
)
|
|
|
|
services = []
|
|
|
|
for msg in resp:
|
2023-11-28 15:03:08 +01:00
|
|
|
self._raise_for_ble_connection_change(address, msg, msg_types)
|
2022-10-30 23:38:24 +01:00
|
|
|
if isinstance(msg, BluetoothGATTErrorResponse):
|
|
|
|
raise BluetoothGATTAPIError(BluetoothGATTError.from_pb(msg))
|
2022-09-28 18:50:37 +02:00
|
|
|
services.extend(BluetoothGATTServices.from_pb(msg).services)
|
2022-10-30 23:38:24 +01:00
|
|
|
|
2023-07-19 00:23:07 +02:00
|
|
|
return ESPHomeBluetoothGATTServices(address=address, services=services) # type: ignore[call-arg]
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
async def bluetooth_gatt_read(
|
2022-10-25 05:12:52 +02:00
|
|
|
self,
|
|
|
|
address: int,
|
2022-10-30 23:38:24 +01:00
|
|
|
handle: int,
|
2022-10-25 05:12:52 +02:00
|
|
|
timeout: float = DEFAULT_BLE_TIMEOUT,
|
2022-09-28 18:50:37 +02:00
|
|
|
) -> bytearray:
|
2023-11-21 23:24:54 +01:00
|
|
|
return await self._bluetooth_gatt_read(
|
|
|
|
BluetoothGATTReadRequest(),
|
2022-10-30 23:38:24 +01:00
|
|
|
address,
|
|
|
|
handle,
|
2023-11-21 23:24:54 +01:00
|
|
|
timeout,
|
2022-09-28 18:50:37 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
async def bluetooth_gatt_read_descriptor(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
handle: int,
|
2022-10-25 05:12:52 +02:00
|
|
|
timeout: float = DEFAULT_BLE_TIMEOUT,
|
2022-09-28 18:50:37 +02:00
|
|
|
) -> bytearray:
|
2023-07-21 10:33:07 +02:00
|
|
|
"""Read a GATT descriptor."""
|
2023-11-21 23:24:54 +01:00
|
|
|
return await self._bluetooth_gatt_read(
|
|
|
|
BluetoothGATTReadDescriptorRequest(),
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
timeout,
|
|
|
|
)
|
|
|
|
|
|
|
|
async def _bluetooth_gatt_read(
|
|
|
|
self,
|
|
|
|
req: BluetoothGATTReadDescriptorRequest | BluetoothGATTReadRequest,
|
|
|
|
address: int,
|
|
|
|
handle: int,
|
|
|
|
timeout: float,
|
|
|
|
) -> bytearray:
|
|
|
|
"""Perform a GATT read."""
|
2022-09-28 18:50:37 +02:00
|
|
|
req.address = address
|
|
|
|
req.handle = handle
|
2022-10-30 23:38:24 +01:00
|
|
|
resp = await self._send_bluetooth_message_await_response(
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
req,
|
|
|
|
BluetoothGATTReadResponse,
|
2023-11-28 15:07:33 +01:00
|
|
|
timeout,
|
2022-09-28 18:50:37 +02:00
|
|
|
)
|
2023-07-21 10:33:07 +02:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
assert isinstance(resp, BluetoothGATTReadResponse)
|
|
|
|
return bytearray(resp.data)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-11-28 15:16:53 +01:00
|
|
|
async def bluetooth_gatt_write(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
handle: int,
|
|
|
|
data: bytes,
|
|
|
|
response: bool,
|
|
|
|
timeout: float = DEFAULT_BLE_TIMEOUT,
|
|
|
|
) -> None:
|
|
|
|
await self._bluetooth_gatt_write(
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
BluetoothGATTWriteRequest(response=response, data=data),
|
|
|
|
timeout,
|
|
|
|
response,
|
|
|
|
)
|
|
|
|
|
2022-09-28 18:50:37 +02:00
|
|
|
async def bluetooth_gatt_write_descriptor(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
handle: int,
|
|
|
|
data: bytes,
|
2022-11-06 20:32:32 +01:00
|
|
|
timeout: float = DEFAULT_BLE_TIMEOUT,
|
2022-11-29 03:06:13 +01:00
|
|
|
wait_for_response: bool = True,
|
2022-09-28 18:50:37 +02:00
|
|
|
) -> None:
|
2023-11-28 15:16:53 +01:00
|
|
|
await self._bluetooth_gatt_write(
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
BluetoothGATTWriteDescriptorRequest(data=data),
|
|
|
|
timeout,
|
|
|
|
wait_for_response,
|
2023-11-25 17:18:04 +01:00
|
|
|
)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-11-28 15:16:53 +01:00
|
|
|
async def _bluetooth_gatt_write(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
handle: int,
|
|
|
|
req: BluetoothGATTWriteDescriptorRequest | BluetoothGATTWriteRequest,
|
|
|
|
timeout: float,
|
|
|
|
wait_for_response: bool,
|
|
|
|
) -> None:
|
|
|
|
"""Perform a GATT write to a char or descriptor."""
|
|
|
|
req.address = address
|
|
|
|
req.handle = handle
|
2022-11-29 03:06:13 +01:00
|
|
|
if not wait_for_response:
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2022-11-29 03:06:13 +01:00
|
|
|
return
|
2022-10-30 23:38:24 +01:00
|
|
|
await self._send_bluetooth_message_await_response(
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
req,
|
|
|
|
BluetoothGATTWriteResponse,
|
2023-11-28 15:07:33 +01:00
|
|
|
timeout,
|
2022-10-30 23:38:24 +01:00
|
|
|
)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
async def bluetooth_gatt_start_notify(
|
|
|
|
self,
|
|
|
|
address: int,
|
|
|
|
handle: int,
|
|
|
|
on_bluetooth_gatt_notify: Callable[[int, bytearray], None],
|
2023-11-28 15:07:33 +01:00
|
|
|
timeout: float = 10.0,
|
2023-07-19 22:33:28 +02:00
|
|
|
) -> tuple[Callable[[], Coroutine[Any, Any, None]], Callable[[], None]]:
|
2022-12-02 20:51:03 +01:00
|
|
|
"""Start a notify session for a GATT characteristic.
|
|
|
|
|
|
|
|
Returns two functions that can be used to stop the notify.
|
|
|
|
|
|
|
|
The first function is a coroutine that can be awaited to stop the notify.
|
|
|
|
|
|
|
|
The second function is a callback that can be called to remove the notify
|
|
|
|
callbacks without stopping the notify session on the remote device, which
|
|
|
|
should be used when the connection is lost.
|
|
|
|
"""
|
2023-11-22 22:32:22 +01:00
|
|
|
remove_callback = self._get_connection().add_message_callback(
|
2023-11-23 16:46:56 +01:00
|
|
|
partial(
|
2023-11-23 18:23:51 +01:00
|
|
|
on_bluetooth_gatt_notify_data_response,
|
2023-11-23 16:46:56 +01:00
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
on_bluetooth_gatt_notify,
|
|
|
|
),
|
|
|
|
(BluetoothGATTNotifyDataResponse,),
|
2022-12-02 20:36:58 +01:00
|
|
|
)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-11-23 16:46:56 +01:00
|
|
|
try:
|
|
|
|
await self._send_bluetooth_message_await_response(
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
BluetoothGATTNotifyRequest(address=address, handle=handle, enable=True),
|
|
|
|
BluetoothGATTNotifyResponse,
|
2023-11-28 15:07:33 +01:00
|
|
|
timeout,
|
2023-11-23 16:46:56 +01:00
|
|
|
)
|
|
|
|
except Exception:
|
|
|
|
remove_callback()
|
|
|
|
raise
|
|
|
|
|
2022-09-28 18:50:37 +02:00
|
|
|
async def stop_notify() -> None:
|
2022-10-03 01:32:13 +02:00
|
|
|
if self._connection is None:
|
|
|
|
return
|
|
|
|
|
2022-10-30 23:38:24 +01:00
|
|
|
remove_callback()
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothGATTNotifyRequest(address=address, handle=handle, enable=False)
|
|
|
|
)
|
|
|
|
|
2022-12-02 20:51:03 +01:00
|
|
|
return stop_notify, remove_callback
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2021-05-12 10:57:01 +02:00
|
|
|
async def subscribe_home_assistant_states(
|
2023-07-19 22:33:28 +02:00
|
|
|
self, on_state_sub: Callable[[str, str | None], None]
|
2021-05-12 10:57:01 +02:00
|
|
|
) -> None:
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message_callback_response(
|
2022-12-02 20:36:58 +01:00
|
|
|
SubscribeHomeAssistantStatesRequest(),
|
2023-11-23 18:37:25 +01:00
|
|
|
partial(on_subscribe_home_assistant_state_response, on_state_sub),
|
2022-12-02 20:36:58 +01:00
|
|
|
(SubscribeHomeAssistantStateResponse,),
|
2021-05-12 10:57:01 +02:00
|
|
|
)
|
2018-12-18 14:53:52 +01:00
|
|
|
|
2021-05-12 10:57:01 +02:00
|
|
|
async def send_home_assistant_state(
|
2023-07-19 22:33:28 +02:00
|
|
|
self, entity_id: str, attribute: str | None, state: str
|
2021-05-12 10:57:01 +02:00
|
|
|
) -> None:
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(
|
2021-06-18 17:57:02 +02:00
|
|
|
HomeAssistantStateResponse(
|
2021-05-12 10:57:01 +02:00
|
|
|
entity_id=entity_id,
|
|
|
|
state=state,
|
|
|
|
attribute=attribute,
|
|
|
|
)
|
|
|
|
)
|
2018-12-18 14:53:52 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def cover_command(
|
|
|
|
self,
|
|
|
|
key: int,
|
2023-07-19 22:33:28 +02:00
|
|
|
position: float | None = None,
|
|
|
|
tilt: float | None = None,
|
2021-06-18 17:57:02 +02:00
|
|
|
stop: bool = False,
|
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = CoverCommandRequest(key=key)
|
2021-06-18 17:57:02 +02:00
|
|
|
apiv = cast(APIVersion, self.api_version)
|
|
|
|
if apiv >= APIVersion(1, 1):
|
2019-04-07 19:03:26 +02:00
|
|
|
if position is not None:
|
|
|
|
req.has_position = True
|
|
|
|
req.position = position
|
|
|
|
if tilt is not None:
|
|
|
|
req.has_tilt = True
|
|
|
|
req.tilt = tilt
|
|
|
|
if stop:
|
|
|
|
req.stop = stop
|
|
|
|
else:
|
|
|
|
if stop:
|
|
|
|
req.legacy_command = LegacyCoverCommand.STOP
|
2021-07-12 20:09:17 +02:00
|
|
|
req.has_legacy_command = True
|
2019-04-07 19:03:26 +02:00
|
|
|
elif position == 1.0:
|
|
|
|
req.legacy_command = LegacyCoverCommand.OPEN
|
2021-07-12 20:09:17 +02:00
|
|
|
req.has_legacy_command = True
|
|
|
|
elif position == 0.0:
|
2019-04-07 19:03:26 +02:00
|
|
|
req.legacy_command = LegacyCoverCommand.CLOSE
|
2021-07-12 20:09:17 +02:00
|
|
|
req.has_legacy_command = True
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def fan_command(
|
|
|
|
self,
|
|
|
|
key: int,
|
2023-07-19 22:33:28 +02:00
|
|
|
state: bool | None = None,
|
|
|
|
speed: FanSpeed | None = None,
|
|
|
|
speed_level: int | None = None,
|
|
|
|
oscillating: bool | None = None,
|
|
|
|
direction: FanDirection | None = None,
|
2021-06-18 17:57:02 +02:00
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = FanCommandRequest(key=key)
|
2018-12-13 21:34:57 +01:00
|
|
|
if state is not None:
|
|
|
|
req.has_state = True
|
|
|
|
req.state = state
|
|
|
|
if speed is not None:
|
|
|
|
req.has_speed = True
|
|
|
|
req.speed = speed
|
2021-03-14 01:21:09 +01:00
|
|
|
if speed_level is not None:
|
|
|
|
req.has_speed_level = True
|
|
|
|
req.speed_level = speed_level
|
2018-12-13 21:34:57 +01:00
|
|
|
if oscillating is not None:
|
|
|
|
req.has_oscillating = True
|
|
|
|
req.oscillating = oscillating
|
2020-12-14 04:16:37 +01:00
|
|
|
if direction is not None:
|
|
|
|
req.has_direction = True
|
|
|
|
req.direction = direction
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2023-11-22 21:54:01 +01:00
|
|
|
async def light_command( # pylint: disable=too-many-branches
|
2021-06-18 17:57:02 +02:00
|
|
|
self,
|
|
|
|
key: int,
|
2023-07-19 22:33:28 +02:00
|
|
|
state: bool | None = None,
|
|
|
|
brightness: float | None = None,
|
|
|
|
color_mode: int | None = None,
|
|
|
|
color_brightness: float | None = None,
|
|
|
|
rgb: tuple[float, float, float] | None = None,
|
|
|
|
white: float | None = None,
|
|
|
|
color_temperature: float | None = None,
|
|
|
|
cold_white: float | None = None,
|
|
|
|
warm_white: float | None = None,
|
|
|
|
transition_length: float | None = None,
|
|
|
|
flash_length: float | None = None,
|
|
|
|
effect: str | None = None,
|
2021-06-18 17:57:02 +02:00
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = LightCommandRequest(key=key)
|
2018-12-13 21:34:57 +01:00
|
|
|
if state is not None:
|
|
|
|
req.has_state = True
|
|
|
|
req.state = state
|
|
|
|
if brightness is not None:
|
|
|
|
req.has_brightness = True
|
|
|
|
req.brightness = brightness
|
2021-07-29 19:16:25 +02:00
|
|
|
if color_mode is not None:
|
|
|
|
req.has_color_mode = True
|
|
|
|
req.color_mode = color_mode
|
|
|
|
if color_brightness is not None:
|
|
|
|
req.has_color_brightness = True
|
|
|
|
req.color_brightness = color_brightness
|
2018-12-13 21:34:57 +01:00
|
|
|
if rgb is not None:
|
|
|
|
req.has_rgb = True
|
|
|
|
req.red = rgb[0]
|
|
|
|
req.green = rgb[1]
|
|
|
|
req.blue = rgb[2]
|
|
|
|
if white is not None:
|
|
|
|
req.has_white = True
|
|
|
|
req.white = white
|
|
|
|
if color_temperature is not None:
|
|
|
|
req.has_color_temperature = True
|
|
|
|
req.color_temperature = color_temperature
|
2021-07-29 19:16:25 +02:00
|
|
|
if cold_white is not None:
|
|
|
|
req.has_cold_white = True
|
|
|
|
req.cold_white = cold_white
|
|
|
|
if warm_white is not None:
|
|
|
|
req.has_warm_white = True
|
|
|
|
req.warm_white = warm_white
|
2018-12-13 21:34:57 +01:00
|
|
|
if transition_length is not None:
|
|
|
|
req.has_transition_length = True
|
2019-01-19 15:25:31 +01:00
|
|
|
req.transition_length = int(round(transition_length * 1000))
|
2018-12-13 21:34:57 +01:00
|
|
|
if flash_length is not None:
|
|
|
|
req.has_flash_length = True
|
2019-01-19 15:25:31 +01:00
|
|
|
req.flash_length = int(round(flash_length * 1000))
|
2018-12-13 21:34:57 +01:00
|
|
|
if effect is not None:
|
|
|
|
req.has_effect = True
|
|
|
|
req.effect = effect
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2023-11-22 22:22:10 +01:00
|
|
|
async def switch_command(self, key: int, state: bool) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
self._get_connection().send_message(SwitchCommandRequest(key=key, state=state))
|
2019-02-24 18:16:12 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def climate_command(
|
|
|
|
self,
|
|
|
|
key: int,
|
2023-07-19 22:33:28 +02:00
|
|
|
mode: ClimateMode | None = None,
|
|
|
|
target_temperature: float | None = None,
|
|
|
|
target_temperature_low: float | None = None,
|
|
|
|
target_temperature_high: float | None = None,
|
|
|
|
fan_mode: ClimateFanMode | None = None,
|
|
|
|
swing_mode: ClimateSwingMode | None = None,
|
|
|
|
custom_fan_mode: str | None = None,
|
|
|
|
preset: ClimatePreset | None = None,
|
|
|
|
custom_preset: str | None = None,
|
2021-06-18 17:57:02 +02:00
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = ClimateCommandRequest(key=key)
|
2019-03-27 22:10:33 +01:00
|
|
|
if mode is not None:
|
|
|
|
req.has_mode = True
|
|
|
|
req.mode = mode
|
|
|
|
if target_temperature is not None:
|
|
|
|
req.has_target_temperature = True
|
|
|
|
req.target_temperature = target_temperature
|
|
|
|
if target_temperature_low is not None:
|
|
|
|
req.has_target_temperature_low = True
|
|
|
|
req.target_temperature_low = target_temperature_low
|
|
|
|
if target_temperature_high is not None:
|
|
|
|
req.has_target_temperature_high = True
|
|
|
|
req.target_temperature_high = target_temperature_high
|
2019-11-16 16:34:14 +01:00
|
|
|
if fan_mode is not None:
|
|
|
|
req.has_fan_mode = True
|
|
|
|
req.fan_mode = fan_mode
|
|
|
|
if swing_mode is not None:
|
|
|
|
req.has_swing_mode = True
|
|
|
|
req.swing_mode = swing_mode
|
2021-06-23 23:40:41 +02:00
|
|
|
if custom_fan_mode is not None:
|
|
|
|
req.has_custom_fan_mode = True
|
|
|
|
req.custom_fan_mode = custom_fan_mode
|
|
|
|
if preset is not None:
|
|
|
|
apiv = cast(APIVersion, self.api_version)
|
|
|
|
if apiv < APIVersion(1, 5):
|
|
|
|
req.has_legacy_away = True
|
|
|
|
req.legacy_away = preset == ClimatePreset.AWAY
|
|
|
|
else:
|
|
|
|
req.has_preset = True
|
|
|
|
req.preset = preset
|
|
|
|
if custom_preset is not None:
|
|
|
|
req.has_custom_preset = True
|
|
|
|
req.custom_preset = custom_preset
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2021-06-29 12:42:38 +02:00
|
|
|
|
2023-11-22 22:22:10 +01:00
|
|
|
async def number_command(self, key: int, state: float) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
self._get_connection().send_message(NumberCommandRequest(key=key, state=state))
|
2021-07-26 20:51:12 +02:00
|
|
|
|
2023-11-22 22:22:10 +01:00
|
|
|
async def select_command(self, key: int, state: str) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
self._get_connection().send_message(SelectCommandRequest(key=key, state=state))
|
2021-07-26 20:51:12 +02:00
|
|
|
|
2021-09-09 03:11:51 +02:00
|
|
|
async def siren_command(
|
|
|
|
self,
|
|
|
|
key: int,
|
2023-07-19 22:33:28 +02:00
|
|
|
state: bool | None = None,
|
|
|
|
tone: str | None = None,
|
|
|
|
volume: float | None = None,
|
|
|
|
duration: int | None = None,
|
2021-09-09 03:11:51 +02:00
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = SirenCommandRequest(key=key)
|
2021-09-09 03:11:51 +02:00
|
|
|
if state is not None:
|
|
|
|
req.state = state
|
|
|
|
req.has_state = True
|
|
|
|
if tone is not None:
|
|
|
|
req.tone = tone
|
|
|
|
req.has_tone = True
|
|
|
|
if volume is not None:
|
|
|
|
req.volume = volume
|
|
|
|
req.has_volume = True
|
|
|
|
if duration is not None:
|
|
|
|
req.duration = duration
|
|
|
|
req.has_duration = True
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2021-11-29 01:59:23 +01:00
|
|
|
|
2023-11-22 22:22:10 +01:00
|
|
|
async def button_command(self, key: int) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
self._get_connection().send_message(ButtonCommandRequest(key=key))
|
2021-11-29 01:59:23 +01:00
|
|
|
|
2022-01-11 02:29:19 +01:00
|
|
|
async def lock_command(
|
|
|
|
self,
|
|
|
|
key: int,
|
|
|
|
command: LockCommand,
|
2023-07-19 22:33:28 +02:00
|
|
|
code: str | None = None,
|
2022-01-11 02:29:19 +01:00
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = LockCommandRequest(key=key, command=command)
|
2022-01-11 02:29:19 +01:00
|
|
|
if code is not None:
|
|
|
|
req.code = code
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2022-01-11 02:29:19 +01:00
|
|
|
|
2022-05-18 03:28:40 +02:00
|
|
|
async def media_player_command(
|
|
|
|
self,
|
|
|
|
key: int,
|
|
|
|
*,
|
2023-07-19 22:33:28 +02:00
|
|
|
command: MediaPlayerCommand | None = None,
|
|
|
|
volume: float | None = None,
|
|
|
|
media_url: str | None = None,
|
2022-05-18 03:28:40 +02:00
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = MediaPlayerCommandRequest(key=key)
|
2022-05-18 03:28:40 +02:00
|
|
|
if command is not None:
|
|
|
|
req.command = command
|
|
|
|
req.has_command = True
|
|
|
|
if volume is not None:
|
|
|
|
req.volume = volume
|
|
|
|
req.has_volume = True
|
|
|
|
if media_url is not None:
|
|
|
|
req.media_url = media_url
|
|
|
|
req.has_media_url = True
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2023-10-25 04:35:04 +02:00
|
|
|
|
2023-11-22 22:22:10 +01:00
|
|
|
async def text_command(self, key: int, state: str) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
self._get_connection().send_message(TextCommandRequest(key=key, state=state))
|
2023-10-25 04:35:04 +02:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def execute_service(
|
|
|
|
self, service: UserService, data: ExecuteServiceDataType
|
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = ExecuteServiceRequest(key=service.key)
|
2019-02-24 18:16:12 +01:00
|
|
|
args = []
|
|
|
|
for arg_desc in service.args:
|
2021-06-18 17:57:02 +02:00
|
|
|
arg = ExecuteServiceArgument()
|
2019-02-24 18:16:12 +01:00
|
|
|
val = data[arg_desc.name]
|
2021-06-18 17:57:02 +02:00
|
|
|
apiv = cast(APIVersion, self.api_version)
|
|
|
|
int_type = "int_" if apiv >= APIVersion(1, 3) else "legacy_int"
|
2019-06-17 23:40:23 +02:00
|
|
|
map_single = {
|
2021-06-18 17:57:02 +02:00
|
|
|
UserServiceArgType.BOOL: "bool_",
|
2019-06-17 23:40:23 +02:00
|
|
|
UserServiceArgType.INT: int_type,
|
2021-06-18 17:57:02 +02:00
|
|
|
UserServiceArgType.FLOAT: "float_",
|
|
|
|
UserServiceArgType.STRING: "string_",
|
2019-06-17 23:40:23 +02:00
|
|
|
}
|
|
|
|
map_array = {
|
2021-06-18 17:57:02 +02:00
|
|
|
UserServiceArgType.BOOL_ARRAY: "bool_array",
|
|
|
|
UserServiceArgType.INT_ARRAY: "int_array",
|
|
|
|
UserServiceArgType.FLOAT_ARRAY: "float_array",
|
|
|
|
UserServiceArgType.STRING_ARRAY: "string_array",
|
2019-06-17 23:40:23 +02:00
|
|
|
}
|
2021-06-29 15:36:14 +02:00
|
|
|
if arg_desc.type in map_array:
|
|
|
|
attr = getattr(arg, map_array[arg_desc.type])
|
2019-06-17 23:40:23 +02:00
|
|
|
attr.extend(val)
|
|
|
|
else:
|
2021-06-29 15:36:14 +02:00
|
|
|
assert arg_desc.type in map_single
|
|
|
|
setattr(arg, map_single[arg_desc.type], val)
|
2019-06-17 23:40:23 +02:00
|
|
|
|
2019-02-24 18:16:12 +01:00
|
|
|
args.append(arg)
|
2020-07-14 20:00:12 +02:00
|
|
|
# pylint: disable=no-member
|
2019-02-24 18:16:12 +01:00
|
|
|
req.args.extend(args)
|
2023-11-22 22:22:10 +01:00
|
|
|
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2019-03-09 11:02:44 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def _request_image(
|
|
|
|
self, *, single: bool = False, stream: bool = False
|
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
self._get_connection().send_message(
|
|
|
|
CameraImageRequest(single=single, stream=stream)
|
|
|
|
)
|
2019-03-09 11:02:44 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def request_single_image(self) -> None:
|
2019-03-09 11:02:44 +01:00
|
|
|
await self._request_image(single=True)
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def request_image_stream(self) -> None:
|
2019-03-09 11:02:44 +01:00
|
|
|
await self._request_image(stream=True)
|
2019-04-07 19:03:26 +02:00
|
|
|
|
|
|
|
@property
|
2023-07-19 22:33:28 +02:00
|
|
|
def api_version(self) -> APIVersion | None:
|
2019-04-07 19:03:26 +02:00
|
|
|
if self._connection is None:
|
|
|
|
return None
|
|
|
|
return self._connection.api_version
|
2023-04-11 05:57:35 +02:00
|
|
|
|
|
|
|
async def subscribe_voice_assistant(
|
|
|
|
self,
|
2023-09-26 21:34:07 +02:00
|
|
|
handle_start: Callable[
|
2023-11-27 00:03:23 +01:00
|
|
|
[str, int, VoiceAssistantAudioSettingsModel],
|
|
|
|
Coroutine[Any, Any, int | None],
|
2023-09-26 21:34:07 +02:00
|
|
|
],
|
2023-04-11 05:57:35 +02:00
|
|
|
handle_stop: Callable[[], Coroutine[Any, Any, None]],
|
|
|
|
) -> Callable[[], None]:
|
|
|
|
"""Subscribes to voice assistant messages from the device.
|
|
|
|
|
|
|
|
handle_start: called when the devices requests a server to send audio data to.
|
2023-04-21 02:30:28 +02:00
|
|
|
This callback is asynchronous and returns the port number the server is started on.
|
2023-04-11 05:57:35 +02:00
|
|
|
|
|
|
|
handle_stop: called when the device has stopped sending audio data and the pipeline should be closed.
|
|
|
|
|
|
|
|
Returns a callback to unsubscribe.
|
|
|
|
"""
|
2023-11-22 22:32:22 +01:00
|
|
|
connection = self._get_connection()
|
2023-04-11 05:57:35 +02:00
|
|
|
|
2023-07-19 22:33:28 +02:00
|
|
|
start_task: asyncio.Task[int | None] | None = None
|
2023-04-11 05:57:35 +02:00
|
|
|
|
2023-07-19 22:33:28 +02:00
|
|
|
def _started(fut: asyncio.Task[int | None]) -> None:
|
2023-04-11 05:57:35 +02:00
|
|
|
if self._connection is not None and not fut.cancelled():
|
|
|
|
port = fut.result()
|
|
|
|
if port is not None:
|
|
|
|
self._connection.send_message(VoiceAssistantResponse(port=port))
|
|
|
|
else:
|
|
|
|
_LOGGER.error("Server could not be started")
|
|
|
|
self._connection.send_message(VoiceAssistantResponse(error=True))
|
|
|
|
|
2023-07-10 01:06:06 +02:00
|
|
|
def _on_voice_assistant_request(msg: VoiceAssistantRequest) -> None:
|
2023-11-27 00:54:18 +01:00
|
|
|
nonlocal start_task
|
|
|
|
|
2023-04-11 05:57:35 +02:00
|
|
|
command = VoiceAssistantCommand.from_pb(msg)
|
|
|
|
if command.start:
|
2023-05-30 04:17:55 +02:00
|
|
|
start_task = asyncio.create_task(
|
2023-09-26 21:34:07 +02:00
|
|
|
handle_start(
|
|
|
|
command.conversation_id, command.flags, command.audio_settings
|
|
|
|
)
|
2023-05-30 04:17:55 +02:00
|
|
|
)
|
2023-04-21 02:30:28 +02:00
|
|
|
start_task.add_done_callback(_started)
|
|
|
|
# We hold a reference to the start_task in unsub function
|
|
|
|
# so we don't need to add it to the background tasks.
|
2023-04-11 05:57:35 +02:00
|
|
|
else:
|
2023-04-21 02:30:28 +02:00
|
|
|
stop_task = asyncio.create_task(handle_stop())
|
|
|
|
self._background_tasks.add(stop_task)
|
|
|
|
stop_task.add_done_callback(self._background_tasks.discard)
|
2023-04-11 05:57:35 +02:00
|
|
|
|
2023-11-22 22:22:10 +01:00
|
|
|
connection.send_message(SubscribeVoiceAssistantRequest(subscribe=True))
|
2023-04-11 05:57:35 +02:00
|
|
|
|
2023-11-22 22:22:10 +01:00
|
|
|
remove_callback = connection.add_message_callback(
|
2023-07-10 01:06:06 +02:00
|
|
|
_on_voice_assistant_request, (VoiceAssistantRequest,)
|
2023-04-11 05:57:35 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
def unsub() -> None:
|
2023-11-27 00:54:18 +01:00
|
|
|
nonlocal start_task
|
|
|
|
|
2023-04-11 05:57:35 +02:00
|
|
|
if self._connection is not None:
|
|
|
|
remove_callback()
|
|
|
|
self._connection.send_message(
|
|
|
|
SubscribeVoiceAssistantRequest(subscribe=False)
|
|
|
|
)
|
|
|
|
|
2023-04-21 02:30:28 +02:00
|
|
|
if start_task is not None and not start_task.cancelled():
|
2023-07-19 22:37:59 +02:00
|
|
|
start_task.cancel("Unsubscribing from voice assistant")
|
2023-04-11 05:57:35 +02:00
|
|
|
|
|
|
|
return unsub
|
|
|
|
|
|
|
|
def send_voice_assistant_event(
|
2023-07-19 22:33:28 +02:00
|
|
|
self, event_type: VoiceAssistantEventType, data: dict[str, str] | None
|
2023-04-11 05:57:35 +02:00
|
|
|
) -> None:
|
2023-11-26 23:45:38 +01:00
|
|
|
req = VoiceAssistantEventResponse(event_type=event_type)
|
2023-04-11 05:57:35 +02:00
|
|
|
if data is not None:
|
2023-11-26 23:45:38 +01:00
|
|
|
# pylint: disable=no-member
|
|
|
|
req.data.extend(
|
|
|
|
[
|
|
|
|
VoiceAssistantEventData(name=name, value=value)
|
|
|
|
for name, value in data.items()
|
|
|
|
]
|
|
|
|
)
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|
2023-06-12 00:39:49 +02:00
|
|
|
|
|
|
|
async def alarm_control_panel_command(
|
|
|
|
self,
|
|
|
|
key: int,
|
|
|
|
command: AlarmControlPanelCommand,
|
2023-07-19 22:33:28 +02:00
|
|
|
code: str | None = None,
|
2023-06-12 00:39:49 +02:00
|
|
|
) -> None:
|
2023-11-28 13:44:24 +01:00
|
|
|
req = AlarmControlPanelCommandRequest(key=key, command=command)
|
2023-06-12 00:39:49 +02:00
|
|
|
if code is not None:
|
|
|
|
req.code = code
|
2023-11-22 22:32:22 +01:00
|
|
|
self._get_connection().send_message(req)
|