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-07-19 22:33:28 +02:00
|
|
|
from collections.abc import Awaitable, Coroutine
|
2023-07-15 23:16:44 +02:00
|
|
|
from functools import partial
|
2023-07-19 22:33:28 +02: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,
|
|
|
|
AlarmControlPanelStateResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
BinarySensorStateResponse,
|
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,
|
|
|
|
ClimateStateResponse,
|
|
|
|
CoverCommandRequest,
|
|
|
|
CoverStateResponse,
|
|
|
|
DeviceInfoRequest,
|
|
|
|
DeviceInfoResponse,
|
|
|
|
ExecuteServiceArgument,
|
|
|
|
ExecuteServiceRequest,
|
|
|
|
FanCommandRequest,
|
|
|
|
FanStateResponse,
|
|
|
|
HomeassistantServiceResponse,
|
|
|
|
HomeAssistantStateResponse,
|
|
|
|
LightCommandRequest,
|
|
|
|
LightStateResponse,
|
2023-06-12 00:39:49 +02:00
|
|
|
ListEntitiesAlarmControlPanelResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
ListEntitiesBinarySensorResponse,
|
2021-11-29 01:59:23 +01:00
|
|
|
ListEntitiesButtonResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
ListEntitiesCameraResponse,
|
|
|
|
ListEntitiesClimateResponse,
|
|
|
|
ListEntitiesCoverResponse,
|
|
|
|
ListEntitiesDoneResponse,
|
|
|
|
ListEntitiesFanResponse,
|
|
|
|
ListEntitiesLightResponse,
|
2022-01-11 02:29:19 +01:00
|
|
|
ListEntitiesLockResponse,
|
2022-05-18 03:28:40 +02:00
|
|
|
ListEntitiesMediaPlayerResponse,
|
2021-06-29 12:42:38 +02:00
|
|
|
ListEntitiesNumberResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
ListEntitiesRequest,
|
2021-07-26 20:51:12 +02:00
|
|
|
ListEntitiesSelectResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
ListEntitiesSensorResponse,
|
|
|
|
ListEntitiesServicesResponse,
|
2021-09-09 03:11:51 +02:00
|
|
|
ListEntitiesSirenResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
ListEntitiesSwitchResponse,
|
|
|
|
ListEntitiesTextSensorResponse,
|
2022-01-11 02:29:19 +01:00
|
|
|
LockCommandRequest,
|
|
|
|
LockStateResponse,
|
2022-05-18 03:28:40 +02:00
|
|
|
MediaPlayerCommandRequest,
|
|
|
|
MediaPlayerStateResponse,
|
2021-06-29 12:42:38 +02:00
|
|
|
NumberCommandRequest,
|
|
|
|
NumberStateResponse,
|
2021-07-26 20:51:12 +02:00
|
|
|
SelectCommandRequest,
|
|
|
|
SelectStateResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
SensorStateResponse,
|
2021-09-09 03:11:51 +02:00
|
|
|
SirenCommandRequest,
|
|
|
|
SirenStateResponse,
|
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,
|
|
|
|
SwitchStateResponse,
|
|
|
|
TextSensorStateResponse,
|
2023-03-26 22:35:11 +02:00
|
|
|
UnsubscribeBluetoothLEAdvertisementsRequest,
|
2023-09-26 21:34:07 +02:00
|
|
|
VoiceAssistantAudioSettings,
|
2023-04-11 05:57:35 +02:00
|
|
|
VoiceAssistantEventData,
|
|
|
|
VoiceAssistantEventResponse,
|
|
|
|
VoiceAssistantRequest,
|
|
|
|
VoiceAssistantResponse,
|
2021-06-18 17:57:02 +02:00
|
|
|
)
|
2021-06-30 17:00:22 +02:00
|
|
|
from .connection import APIConnection, ConnectionParams
|
2022-10-31 21:32:40 +01:00
|
|
|
from .core import (
|
|
|
|
APIConnectionError,
|
|
|
|
BluetoothGATTAPIError,
|
|
|
|
TimeoutAPIError,
|
2023-10-15 04:03:12 +02:00
|
|
|
UnhandledAPIConnectionError,
|
2022-10-31 21:32:40 +01:00
|
|
|
to_human_readable_address,
|
|
|
|
)
|
2021-06-30 17:00:22 +02:00
|
|
|
from .host_resolver import ZeroconfInstanceType
|
|
|
|
from .model import (
|
2023-06-12 00:39:49 +02:00
|
|
|
AlarmControlPanelCommand,
|
|
|
|
AlarmControlPanelEntityState,
|
|
|
|
AlarmControlPanelInfo,
|
2021-06-18 17:57:02 +02:00
|
|
|
APIVersion,
|
|
|
|
BinarySensorInfo,
|
|
|
|
BinarySensorState,
|
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-11-29 01:59:23 +01:00
|
|
|
ButtonInfo,
|
2021-06-18 17:57:02 +02:00
|
|
|
CameraInfo,
|
|
|
|
CameraState,
|
|
|
|
ClimateFanMode,
|
|
|
|
ClimateInfo,
|
|
|
|
ClimateMode,
|
2021-06-23 23:40:41 +02:00
|
|
|
ClimatePreset,
|
2021-06-18 17:57:02 +02:00
|
|
|
ClimateState,
|
|
|
|
ClimateSwingMode,
|
|
|
|
CoverInfo,
|
|
|
|
CoverState,
|
|
|
|
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,
|
|
|
|
FanInfo,
|
|
|
|
FanSpeed,
|
|
|
|
FanState,
|
|
|
|
HomeassistantServiceCall,
|
|
|
|
LegacyCoverCommand,
|
|
|
|
LightInfo,
|
|
|
|
LightState,
|
2022-01-11 02:29:19 +01:00
|
|
|
LockCommand,
|
|
|
|
LockEntityState,
|
|
|
|
LockInfo,
|
2021-06-29 15:36:14 +02:00
|
|
|
LogLevel,
|
2022-05-18 03:28:40 +02:00
|
|
|
MediaPlayerCommand,
|
|
|
|
MediaPlayerEntityState,
|
|
|
|
MediaPlayerInfo,
|
2021-06-29 12:42:38 +02:00
|
|
|
NumberInfo,
|
|
|
|
NumberState,
|
2021-07-26 20:51:12 +02:00
|
|
|
SelectInfo,
|
|
|
|
SelectState,
|
2021-06-18 17:57:02 +02:00
|
|
|
SensorInfo,
|
|
|
|
SensorState,
|
2021-09-09 03:11:51 +02:00
|
|
|
SirenInfo,
|
|
|
|
SirenState,
|
2021-06-18 17:57:02 +02:00
|
|
|
SwitchInfo,
|
|
|
|
SwitchState,
|
|
|
|
TextSensorInfo,
|
|
|
|
TextSensorState,
|
|
|
|
UserService,
|
|
|
|
UserServiceArgType,
|
2023-04-11 05:57:35 +02:00
|
|
|
VoiceAssistantCommand,
|
|
|
|
VoiceAssistantEventType,
|
2023-07-10 01:06:06 +02:00
|
|
|
make_ble_raw_advertisement_processor,
|
2021-06-18 17:57:02 +02:00
|
|
|
)
|
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-08-16 05:19:46 +02:00
|
|
|
SUBSCRIBE_STATES_RESPONSE_TYPES: dict[Any, type[EntityState]] = {
|
|
|
|
BinarySensorStateResponse: BinarySensorState,
|
|
|
|
CoverStateResponse: CoverState,
|
|
|
|
FanStateResponse: FanState,
|
|
|
|
LightStateResponse: LightState,
|
|
|
|
NumberStateResponse: NumberState,
|
|
|
|
SelectStateResponse: SelectState,
|
|
|
|
SensorStateResponse: SensorState,
|
|
|
|
SirenStateResponse: SirenState,
|
|
|
|
SwitchStateResponse: SwitchState,
|
|
|
|
TextSensorStateResponse: TextSensorState,
|
|
|
|
ClimateStateResponse: ClimateState,
|
|
|
|
LockStateResponse: LockEntityState,
|
|
|
|
MediaPlayerStateResponse: MediaPlayerEntityState,
|
|
|
|
AlarmControlPanelStateResponse: AlarmControlPanelEntityState,
|
|
|
|
}
|
|
|
|
SUBSCRIBE_STATES_MSG_TYPES = (*SUBSCRIBE_STATES_RESPONSE_TYPES, CameraImageResponse)
|
|
|
|
|
|
|
|
LIST_ENTITIES_SERVICES_RESPONSE_TYPES: dict[Any, type[EntityInfo] | None] = {
|
|
|
|
ListEntitiesBinarySensorResponse: BinarySensorInfo,
|
|
|
|
ListEntitiesButtonResponse: ButtonInfo,
|
|
|
|
ListEntitiesCoverResponse: CoverInfo,
|
|
|
|
ListEntitiesFanResponse: FanInfo,
|
|
|
|
ListEntitiesLightResponse: LightInfo,
|
|
|
|
ListEntitiesNumberResponse: NumberInfo,
|
|
|
|
ListEntitiesSelectResponse: SelectInfo,
|
|
|
|
ListEntitiesSensorResponse: SensorInfo,
|
|
|
|
ListEntitiesSirenResponse: SirenInfo,
|
|
|
|
ListEntitiesSwitchResponse: SwitchInfo,
|
|
|
|
ListEntitiesTextSensorResponse: TextSensorInfo,
|
|
|
|
ListEntitiesServicesResponse: None,
|
|
|
|
ListEntitiesCameraResponse: CameraInfo,
|
|
|
|
ListEntitiesClimateResponse: ClimateInfo,
|
|
|
|
ListEntitiesLockResponse: LockInfo,
|
|
|
|
ListEntitiesMediaPlayerResponse: MediaPlayerInfo,
|
|
|
|
ListEntitiesAlarmControlPanelResponse: AlarmControlPanelInfo,
|
|
|
|
}
|
|
|
|
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
|
|
|
|
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-07-15 23:16:44 +02:00
|
|
|
__slots__ = ("_params", "_connection", "_cached_name", "_background_tasks", "_loop")
|
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,
|
2021-06-30 17:00:22 +02:00
|
|
|
zeroconf_instance: ZeroconfInstanceType = 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.
|
|
|
|
"""
|
2019-01-04 18:35:38 +01:00
|
|
|
self._params = ConnectionParams(
|
|
|
|
address=address,
|
|
|
|
port=port,
|
|
|
|
password=password,
|
|
|
|
client_info=client_info,
|
|
|
|
keepalive=keepalive,
|
2021-06-18 17:57:02 +02:00
|
|
|
zeroconf_instance=zeroconf_instance,
|
2021-09-14 12:44:52 +02:00
|
|
|
# treat empty psk string as missing (like password)
|
|
|
|
noise_psk=noise_psk or None,
|
2022-01-20 12:03:36 +01:00
|
|
|
expected_name=expected_name,
|
2019-01-04 18:35:38 +01:00
|
|
|
)
|
2023-07-19 22:33:28 +02:00
|
|
|
self._connection: APIConnection | None = None
|
|
|
|
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()
|
2021-06-30 17:10:30 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
@property
|
|
|
|
def _log_name(self) -> str:
|
2023-10-15 04:03:12 +02:00
|
|
|
if self._cached_name is not None and not self.address.endswith(".local"):
|
2021-06-30 17:10:30 +02:00
|
|
|
return f"{self._cached_name} @ {self.address}"
|
|
|
|
return self.address
|
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."""
|
|
|
|
if not self._cached_name:
|
|
|
|
self._cached_name = name
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def connect(
|
|
|
|
self,
|
2023-07-19 22:33:28 +02:00
|
|
|
on_stop: Callable[[bool], Awaitable[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)
|
|
|
|
|
|
|
|
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:
|
2021-06-30 17:10:30 +02:00
|
|
|
raise APIConnectionError(f"Already connected to {self._log_name}!")
|
2019-01-04 18:35:38 +01:00
|
|
|
|
2023-03-06 05:54:54 +01:00
|
|
|
async def _on_stop(expected_disconnect: bool) -> None:
|
2021-10-21 19:20:05 +02:00
|
|
|
# Hook into on_stop handler to clear connection when stopped
|
2019-01-04 18:35:38 +01:00
|
|
|
self._connection = None
|
2021-10-21 19:20:05 +02:00
|
|
|
if on_stop is not None:
|
2023-03-06 05:54:54 +01:00
|
|
|
await on_stop(expected_disconnect)
|
2019-01-04 18:35:38 +01:00
|
|
|
|
2022-12-13 21:31:12 +01:00
|
|
|
self._connection = APIConnection(
|
|
|
|
self._params, _on_stop, log_name=self._log_name
|
|
|
|
)
|
2019-01-04 18:35:38 +01:00
|
|
|
|
|
|
|
try:
|
2023-10-15 04:03:12 +02:00
|
|
|
await self._connection.start_connection()
|
2019-01-04 18:35:38 +01:00
|
|
|
except APIConnectionError:
|
2021-10-21 19:24:03 +02:00
|
|
|
self._connection = None
|
2019-01-04 18:35:38 +01:00
|
|
|
raise
|
|
|
|
except Exception as e:
|
2021-10-21 19:24:03 +02:00
|
|
|
self._connection = None
|
2023-10-15 04:03:12 +02:00
|
|
|
raise UnhandledAPIConnectionError(
|
|
|
|
f"Unexpected error while connecting to {self._log_name}: {e}"
|
|
|
|
) from e
|
|
|
|
|
|
|
|
async def finish_connection(
|
|
|
|
self,
|
|
|
|
login: bool = False,
|
|
|
|
) -> None:
|
|
|
|
"""Finish connecting to the device."""
|
|
|
|
assert self._connection is not None
|
|
|
|
try:
|
|
|
|
await self._connection.finish_connection(login=login)
|
|
|
|
except APIConnectionError:
|
|
|
|
self._connection = None
|
|
|
|
raise
|
|
|
|
except Exception as e:
|
|
|
|
self._connection = None
|
|
|
|
raise UnhandledAPIConnectionError(
|
2021-06-30 17:10:30 +02:00
|
|
|
f"Unexpected error while connecting to {self._log_name}: {e}"
|
|
|
|
) from e
|
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:
|
|
|
|
await self._connection.force_disconnect()
|
|
|
|
else:
|
|
|
|
await self._connection.disconnect()
|
2019-01-04 18:35:38 +01:00
|
|
|
|
2023-07-15 22:34:46 +02:00
|
|
|
def _check_authenticated(self) -> None:
|
|
|
|
connection = self._connection
|
|
|
|
if not connection:
|
2021-06-30 17:10:30 +02: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-07-15 22:34:46 +02:00
|
|
|
f"Authenticated connection not ready yet for {self._log_name}; "
|
|
|
|
f"current state is {connection.connection_state}!"
|
2022-12-13 21:31:12 +01:00
|
|
|
)
|
2019-01-04 18:35:38 +01:00
|
|
|
|
|
|
|
async def device_info(self) -> DeviceInfo:
|
2023-10-15 04:03:12 +02:00
|
|
|
self._check_authenticated()
|
2023-07-15 22:34:46 +02:00
|
|
|
connection = self._connection
|
2023-10-15 04:03:12 +02:00
|
|
|
assert connection is not None
|
2023-07-15 22:34:46 +02:00
|
|
|
resp = await 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)
|
|
|
|
self._cached_name = info.name
|
2023-07-15 22:34:46 +02:00
|
|
|
connection.set_log_name(self._log_name)
|
2021-06-30 17:10:30 +02:00
|
|
|
return info
|
2019-01-04 18:35:38 +01:00
|
|
|
|
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]]:
|
2018-12-13 21:34:57 +01:00
|
|
|
self._check_authenticated()
|
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
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2019-01-04 18:35:38 +01:00
|
|
|
resp = await self._connection.send_message_await_response_complex(
|
2022-12-02 20:36:58 +01:00
|
|
|
ListEntitiesRequest(), do_append, do_stop, msg_types, timeout=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
|
|
|
|
2021-06-29 15:36:14 +02:00
|
|
|
async def subscribe_states(self, on_state: Callable[[EntityState], None]) -> None:
|
2018-12-13 21:34:57 +01:00
|
|
|
self._check_authenticated()
|
2023-07-19 22:33:28 +02:00
|
|
|
image_stream: dict[int, list[bytes]] = {}
|
2023-08-16 05:19:46 +02:00
|
|
|
response_types = SUBSCRIBE_STATES_RESPONSE_TYPES
|
|
|
|
msg_types = SUBSCRIBE_STATES_MSG_TYPES
|
2019-01-19 15:10:00 +01:00
|
|
|
|
2023-07-10 01:06:06 +02:00
|
|
|
def _on_state_msg(msg: message.Message) -> None:
|
2022-12-02 20:36:58 +01:00
|
|
|
msg_type = type(msg)
|
|
|
|
cls = response_types.get(msg_type)
|
|
|
|
if cls:
|
|
|
|
on_state(cls.from_pb(msg))
|
2023-07-04 01:26:52 +02:00
|
|
|
elif msg_type is CameraImageResponse:
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
assert isinstance(msg, CameraImageResponse)
|
|
|
|
msg_key = msg.key
|
2023-07-19 22:33:28 +02:00
|
|
|
data_parts: list[bytes] | None = image_stream.get(msg_key)
|
2023-07-04 01:26:52 +02:00
|
|
|
if not data_parts:
|
|
|
|
data_parts = []
|
|
|
|
image_stream[msg_key] = data_parts
|
|
|
|
|
2023-07-17 21:47:06 +02:00
|
|
|
data_parts.append(msg.data)
|
2019-01-19 15:10:00 +01:00
|
|
|
if msg.done:
|
2021-07-09 09:19:39 +02:00
|
|
|
# Return CameraState with the merged data
|
2023-07-19 22:33:28 +02:00
|
|
|
image_data = b"".join(data_parts)
|
2023-07-04 01:26:52 +02:00
|
|
|
del image_stream[msg_key]
|
2023-07-19 00:23:07 +02:00
|
|
|
on_state(CameraState(key=msg.key, data=image_data)) # type: ignore[call-arg]
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message_callback_response(
|
2023-07-10 01:06:06 +02:00
|
|
|
SubscribeStatesRequest(), _on_state_msg, 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:
|
2018-12-13 21:34:57 +01:00
|
|
|
self._check_authenticated()
|
2021-06-18 17:57:02 +02:00
|
|
|
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
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._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:
|
2018-12-16 18:03:03 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2023-07-10 01:06:06 +02:00
|
|
|
def _on_home_assistant_service_response(
|
|
|
|
msg: HomeassistantServiceResponse,
|
|
|
|
) -> None:
|
2022-12-02 20:36:58 +01:00
|
|
|
on_service_call(HomeassistantServiceCall.from_pb(msg))
|
2018-12-16 18:03:03 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message_callback_response(
|
2022-12-02 20:36:58 +01:00
|
|
|
SubscribeHomeassistantServicesRequest(),
|
2023-07-10 01:06:06 +02:00
|
|
|
_on_home_assistant_service_response,
|
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,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
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:
|
|
|
|
self._check_authenticated()
|
2022-12-02 20:36:58 +01:00
|
|
|
msg_types = (response_type, BluetoothGATTErrorResponse)
|
2022-10-30 23:38:24 +01:00
|
|
|
assert self._connection is not None
|
|
|
|
|
2023-07-18 21:11:06 +02:00
|
|
|
message_filter = partial(self._filter_bluetooth_message, address, handle)
|
2022-10-30 23:38:24 +01:00
|
|
|
resp = await self._connection.send_message_await_response_complex(
|
2023-07-18 21:11:06 +02:00
|
|
|
request, message_filter, message_filter, msg_types, timeout=timeout
|
2022-10-30 23:38:24 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
if isinstance(resp[0], BluetoothGATTErrorResponse):
|
|
|
|
raise BluetoothGATTAPIError(BluetoothGATTError.from_pb(resp[0]))
|
|
|
|
|
|
|
|
return resp[0]
|
|
|
|
|
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]:
|
2022-08-22 05:27:46 +02:00
|
|
|
self._check_authenticated()
|
2022-12-02 20:36:58 +01:00
|
|
|
msg_types = (BluetoothLEAdvertisementResponse,)
|
2022-08-22 05:27:46 +02:00
|
|
|
|
2023-07-10 01:06:06 +02:00
|
|
|
def _on_bluetooth_le_advertising_response(
|
|
|
|
msg: BluetoothLEAdvertisementResponse,
|
|
|
|
) -> None:
|
2023-01-06 03:23:40 +01:00
|
|
|
on_bluetooth_le_advertisement(BluetoothLEAdvertisement.from_pb(msg)) # type: ignore[misc]
|
2022-08-22 05:27:46 +02:00
|
|
|
|
|
|
|
assert self._connection is not None
|
2023-07-15 23:16:44 +02:00
|
|
|
unsub_callback = self._connection.send_message_callback_response(
|
2023-06-07 07:42:57 +02:00
|
|
|
SubscribeBluetoothLEAdvertisementsRequest(flags=0),
|
2023-07-10 01:06:06 +02:00
|
|
|
_on_bluetooth_le_advertising_response,
|
2023-06-07 07:42:57 +02:00
|
|
|
msg_types,
|
|
|
|
)
|
|
|
|
|
|
|
|
def unsub() -> None:
|
|
|
|
if self._connection is not None:
|
2023-07-15 23:16:44 +02:00
|
|
|
unsub_callback()
|
2023-06-07 07:42:57 +02:00
|
|
|
self._connection.send_message(
|
|
|
|
UnsubscribeBluetoothLEAdvertisementsRequest()
|
|
|
|
)
|
|
|
|
|
|
|
|
return unsub
|
|
|
|
|
|
|
|
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]:
|
|
|
|
self._check_authenticated()
|
|
|
|
msg_types = (BluetoothLERawAdvertisementsResponse,)
|
|
|
|
|
|
|
|
assert self._connection is not None
|
2023-07-10 01:06:06 +02:00
|
|
|
on_msg = make_ble_raw_advertisement_processor(on_advertisements)
|
2023-07-15 23:16:44 +02:00
|
|
|
unsub_callback = self._connection.send_message_callback_response(
|
2023-06-07 07:42:57 +02:00
|
|
|
SubscribeBluetoothLEAdvertisementsRequest(
|
|
|
|
flags=BluetoothProxySubscriptionFlag.RAW_ADVERTISEMENTS
|
|
|
|
),
|
|
|
|
on_msg,
|
|
|
|
msg_types,
|
2022-08-22 05:27:46 +02:00
|
|
|
)
|
|
|
|
|
2022-09-28 18:50:37 +02:00
|
|
|
def unsub() -> None:
|
2022-10-03 01:32:13 +02:00
|
|
|
if self._connection is not None:
|
2023-07-15 23:16:44 +02:00
|
|
|
unsub_callback()
|
2023-03-26 22:35:11 +02:00
|
|
|
self._connection.send_message(
|
|
|
|
UnsubscribeBluetoothLEAdvertisementsRequest()
|
|
|
|
)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
return unsub
|
|
|
|
|
|
|
|
async def subscribe_bluetooth_connections_free(
|
|
|
|
self, on_bluetooth_connections_free_update: Callable[[int, int], None]
|
|
|
|
) -> Callable[[], None]:
|
|
|
|
self._check_authenticated()
|
2022-12-02 20:36:58 +01:00
|
|
|
msg_types = (BluetoothConnectionsFreeResponse,)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-07-10 01:06:06 +02:00
|
|
|
def _on_bluetooth_connections_free_response(
|
|
|
|
msg: BluetoothConnectionsFreeResponse,
|
|
|
|
) -> None:
|
2023-07-21 10:33:07 +02:00
|
|
|
on_bluetooth_connections_free_update(msg.free, msg.limit)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
assert self._connection is not None
|
2023-07-15 23:16:44 +02:00
|
|
|
return self._connection.send_message_callback_response(
|
2023-07-10 01:06:06 +02:00
|
|
|
SubscribeBluetoothConnectionsFreeRequest(),
|
|
|
|
_on_bluetooth_connections_free_response,
|
|
|
|
msg_types,
|
2022-09-28 18:50:37 +02:00
|
|
|
)
|
|
|
|
|
2023-07-15 23:16:44 +02:00
|
|
|
def _handle_timeout(self, fut: asyncio.Future[None]) -> None:
|
|
|
|
"""Handle a timeout."""
|
|
|
|
if fut.done():
|
|
|
|
return
|
2023-07-19 21:25:32 +02:00
|
|
|
fut.set_exception(asyncio.TimeoutError)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-07-15 23:16:44 +02:00
|
|
|
def _on_bluetooth_device_connection_response(
|
|
|
|
self,
|
|
|
|
connect_future: asyncio.Future[None],
|
|
|
|
address: int,
|
|
|
|
on_bluetooth_connection_state: Callable[[bool, int, int], None],
|
|
|
|
msg: BluetoothDeviceConnectionResponse,
|
|
|
|
) -> None:
|
|
|
|
"""Handle a BluetoothDeviceConnectionResponse message.""" ""
|
2023-07-21 10:33:07 +02:00
|
|
|
if address == msg.address:
|
|
|
|
on_bluetooth_connection_state(msg.connected, msg.mtu, msg.error)
|
2023-07-15 23:16:44 +02:00
|
|
|
# Resolve on ANY connection state since we do not want
|
|
|
|
# to wait the whole timeout if the device disconnects
|
|
|
|
# or we get an error.
|
|
|
|
if not connect_future.done():
|
|
|
|
connect_future.set_result(None)
|
|
|
|
|
|
|
|
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]:
|
|
|
|
self._check_authenticated()
|
2022-12-02 20:36:58 +01:00
|
|
|
msg_types = (BluetoothDeviceConnectionResponse,)
|
2023-07-15 23:16:44 +02:00
|
|
|
debug = _LOGGER.isEnabledFor(logging.DEBUG)
|
|
|
|
connect_future: asyncio.Future[None] = self._loop.create_future()
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
assert self._connection is not None
|
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-07-15 23:16:44 +02:00
|
|
|
if debug:
|
|
|
|
_LOGGER.debug("%s: Using connection version %s", address, request_type)
|
|
|
|
|
|
|
|
unsub = self._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(
|
|
|
|
self._on_bluetooth_device_connection_response,
|
|
|
|
connect_future,
|
|
|
|
address,
|
|
|
|
on_bluetooth_connection_state,
|
|
|
|
),
|
2022-12-02 20:36:58 +01:00
|
|
|
msg_types,
|
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(
|
|
|
|
loop.time() + timeout, self._handle_timeout, connect_future
|
|
|
|
)
|
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:
|
|
|
|
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)
|
|
|
|
if debug:
|
|
|
|
_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-07-19 21:25:32 +02:00
|
|
|
if not connect_ok:
|
|
|
|
try:
|
|
|
|
unsub()
|
|
|
|
except (KeyError, ValueError):
|
|
|
|
_LOGGER.warning(
|
|
|
|
"%s: Bluetooth device connection canceled but already unsubscribed",
|
|
|
|
addr,
|
|
|
|
)
|
|
|
|
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:
|
|
|
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
|
|
_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:
|
|
|
|
self._check_authenticated()
|
|
|
|
msg_types = (
|
|
|
|
BluetoothDevicePairingResponse,
|
|
|
|
BluetoothDeviceConnectionResponse,
|
|
|
|
)
|
|
|
|
|
|
|
|
assert self._connection is not None
|
|
|
|
|
|
|
|
def predicate_func(msg: message.Message) -> bool:
|
2023-07-15 19:55:07 +02:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
assert isinstance(msg, msg_types)
|
2023-03-06 19:07:58 +01:00
|
|
|
if msg.address != address:
|
|
|
|
return False
|
|
|
|
if isinstance(msg, BluetoothDeviceConnectionResponse):
|
|
|
|
raise APIConnectionError(
|
|
|
|
"Peripheral changed connections status while pairing"
|
|
|
|
)
|
|
|
|
return True
|
|
|
|
|
2023-07-21 10:33:07 +02:00
|
|
|
[response] = await self._connection.send_message_await_response_complex(
|
2023-03-06 19:07:58 +01:00
|
|
|
BluetoothDeviceRequest(
|
|
|
|
address=address, request_type=BluetoothDeviceRequestType.PAIR
|
|
|
|
),
|
|
|
|
predicate_func,
|
|
|
|
predicate_func,
|
|
|
|
msg_types,
|
|
|
|
timeout=timeout,
|
|
|
|
)
|
|
|
|
return BluetoothDevicePairing.from_pb(response)
|
|
|
|
|
|
|
|
async def bluetooth_device_unpair(
|
|
|
|
self, address: int, timeout: float = DEFAULT_BLE_TIMEOUT
|
|
|
|
) -> BluetoothDeviceUnpairing:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
assert self._connection is not None
|
|
|
|
|
|
|
|
def predicate_func(msg: BluetoothDeviceUnpairingResponse) -> bool:
|
|
|
|
return bool(msg.address == address)
|
|
|
|
|
2023-07-21 10:33:07 +02:00
|
|
|
[response] = await self._connection.send_message_await_response_complex(
|
2023-03-06 19:07:58 +01:00
|
|
|
BluetoothDeviceRequest(
|
|
|
|
address=address, request_type=BluetoothDeviceRequestType.UNPAIR
|
|
|
|
),
|
|
|
|
predicate_func,
|
|
|
|
predicate_func,
|
|
|
|
(BluetoothDeviceUnpairingResponse,),
|
|
|
|
timeout=timeout,
|
|
|
|
)
|
|
|
|
return BluetoothDeviceUnpairing.from_pb(response)
|
|
|
|
|
2023-03-26 23:47:21 +02:00
|
|
|
async def bluetooth_device_clear_cache(
|
|
|
|
self, address: int, timeout: float = DEFAULT_BLE_TIMEOUT
|
|
|
|
) -> BluetoothDeviceClearCache:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
assert self._connection is not None
|
|
|
|
|
|
|
|
def predicate_func(msg: BluetoothDeviceClearCacheResponse) -> bool:
|
|
|
|
return bool(msg.address == address)
|
|
|
|
|
2023-07-21 10:33:07 +02:00
|
|
|
[response] = await self._connection.send_message_await_response_complex(
|
2023-03-26 23:47:21 +02:00
|
|
|
BluetoothDeviceRequest(
|
|
|
|
address=address, request_type=BluetoothDeviceRequestType.CLEAR_CACHE
|
|
|
|
),
|
|
|
|
predicate_func,
|
|
|
|
predicate_func,
|
|
|
|
(BluetoothDeviceClearCacheResponse,),
|
|
|
|
timeout=timeout,
|
|
|
|
)
|
|
|
|
return BluetoothDeviceClearCache.from_pb(response)
|
|
|
|
|
2023-05-07 16:05:51 +02:00
|
|
|
async def bluetooth_device_disconnect(
|
|
|
|
self, address: int, timeout: float = DEFAULT_BLE_DISCONNECT_TIMEOUT
|
|
|
|
) -> None:
|
2022-09-28 18:50:37 +02:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2023-05-07 16:05:51 +02:00
|
|
|
def predicate_func(msg: BluetoothDeviceConnectionResponse) -> bool:
|
|
|
|
return bool(msg.address == address and not msg.connected)
|
|
|
|
|
2022-09-28 18:50:37 +02:00
|
|
|
assert self._connection is not None
|
2023-05-07 16:05:51 +02:00
|
|
|
await self._connection.send_message_await_response_complex(
|
2022-09-28 18:50:37 +02:00
|
|
|
BluetoothDeviceRequest(
|
|
|
|
address=address,
|
|
|
|
request_type=BluetoothDeviceRequestType.DISCONNECT,
|
2023-05-07 16:05:51 +02:00
|
|
|
),
|
|
|
|
predicate_func,
|
|
|
|
predicate_func,
|
|
|
|
(BluetoothDeviceConnectionResponse,),
|
|
|
|
timeout=timeout,
|
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:
|
2022-09-28 18:50:37 +02:00
|
|
|
self._check_authenticated()
|
2022-12-02 20:36:58 +01:00
|
|
|
msg_types = (
|
|
|
|
BluetoothGATTGetServicesResponse,
|
|
|
|
BluetoothGATTGetServicesDoneResponse,
|
|
|
|
BluetoothGATTErrorResponse,
|
|
|
|
)
|
|
|
|
append_types = (BluetoothGATTGetServicesResponse, BluetoothGATTErrorResponse)
|
|
|
|
stop_types = (BluetoothGATTGetServicesDoneResponse, BluetoothGATTErrorResponse)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
def do_append(msg: message.Message) -> bool:
|
2022-12-02 20:36:58 +01:00
|
|
|
return isinstance(msg, append_types) and msg.address == address
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
def do_stop(msg: message.Message) -> bool:
|
2022-12-02 20:36:58 +01:00
|
|
|
return isinstance(msg, stop_types) and msg.address == address
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
assert self._connection is not None
|
|
|
|
resp = await self._connection.send_message_await_response_complex(
|
2022-10-25 05:12:52 +02:00
|
|
|
BluetoothGATTGetServicesRequest(address=address),
|
|
|
|
do_append,
|
|
|
|
do_stop,
|
2022-12-02 20:36:58 +01:00
|
|
|
msg_types,
|
2022-10-25 05:12:52 +02:00
|
|
|
timeout=DEFAULT_BLE_TIMEOUT,
|
2022-09-28 18:50:37 +02:00
|
|
|
)
|
|
|
|
services = []
|
|
|
|
for msg in resp:
|
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:
|
|
|
|
req = BluetoothGATTReadRequest()
|
|
|
|
req.address = address
|
2022-10-30 23:38:24 +01:00
|
|
|
req.handle = handle
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2022-10-30 23:38:24 +01:00
|
|
|
resp = await self._send_bluetooth_message_await_response(
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
req,
|
|
|
|
BluetoothGATTReadResponse,
|
|
|
|
timeout=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
|
|
|
|
|
|
|
async def bluetooth_gatt_write(
|
|
|
|
self,
|
|
|
|
address: int,
|
2022-10-30 23:38:24 +01:00
|
|
|
handle: int,
|
2022-09-28 18:50:37 +02:00
|
|
|
data: bytes,
|
|
|
|
response: bool,
|
2022-11-06 20:32:32 +01:00
|
|
|
timeout: float = DEFAULT_BLE_TIMEOUT,
|
2022-09-28 18:50:37 +02:00
|
|
|
) -> None:
|
|
|
|
req = BluetoothGATTWriteRequest()
|
|
|
|
req.address = address
|
2022-10-30 23:38:24 +01:00
|
|
|
req.handle = handle
|
2022-09-28 18:50:37 +02:00
|
|
|
req.response = response
|
|
|
|
req.data = data
|
|
|
|
|
2022-11-30 23:36:58 +01:00
|
|
|
if not response:
|
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
2022-11-30 23:36:58 +01:00
|
|
|
return
|
|
|
|
|
2022-10-30 23:38:24 +01:00
|
|
|
await self._send_bluetooth_message_await_response(
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
req,
|
|
|
|
BluetoothGATTWriteResponse,
|
|
|
|
timeout=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."""
|
2022-09-28 18:50:37 +02:00
|
|
|
req = BluetoothGATTReadDescriptorRequest()
|
|
|
|
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,
|
|
|
|
timeout=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
|
|
|
|
|
|
|
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:
|
|
|
|
req = BluetoothGATTWriteDescriptorRequest()
|
|
|
|
req.address = address
|
|
|
|
req.handle = handle
|
|
|
|
req.data = data
|
|
|
|
|
2022-11-29 03:06:13 +01:00
|
|
|
if not wait_for_response:
|
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._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,
|
|
|
|
timeout=timeout,
|
|
|
|
)
|
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-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.
|
|
|
|
"""
|
2022-10-30 23:38:24 +01:00
|
|
|
|
|
|
|
await self._send_bluetooth_message_await_response(
|
|
|
|
address,
|
|
|
|
handle,
|
|
|
|
BluetoothGATTNotifyRequest(address=address, handle=handle, enable=True),
|
|
|
|
BluetoothGATTNotifyResponse,
|
|
|
|
)
|
2022-09-28 18:50:37 +02:00
|
|
|
|
2023-07-10 01:06:06 +02:00
|
|
|
def _on_bluetooth_gatt_notify_data_response(
|
|
|
|
msg: BluetoothGATTNotifyDataResponse,
|
|
|
|
) -> None:
|
2023-07-21 10:33:07 +02:00
|
|
|
if address == msg.address and handle == msg.handle:
|
|
|
|
on_bluetooth_gatt_notify(handle, bytearray(msg.data))
|
2022-09-28 18:50:37 +02:00
|
|
|
|
|
|
|
assert self._connection is not None
|
2022-12-02 20:36:58 +01:00
|
|
|
remove_callback = self._connection.add_message_callback(
|
2023-07-10 01:06:06 +02:00
|
|
|
_on_bluetooth_gatt_notify_data_response, (BluetoothGATTNotifyDataResponse,)
|
2022-12-02 20:36:58 +01:00
|
|
|
)
|
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
|
|
|
|
|
|
|
self._check_authenticated()
|
|
|
|
|
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:
|
2018-12-18 14:53:52 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2023-07-10 01:06:06 +02:00
|
|
|
def _on_subscribe_home_assistant_state_response(
|
|
|
|
msg: SubscribeHomeAssistantStateResponse,
|
|
|
|
) -> None:
|
2022-12-02 20:36:58 +01:00
|
|
|
on_state_sub(msg.entity_id, msg.attribute)
|
2018-12-18 14:53:52 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message_callback_response(
|
2022-12-02 20:36:58 +01:00
|
|
|
SubscribeHomeAssistantStatesRequest(),
|
2023-07-10 01:06:06 +02:00
|
|
|
_on_subscribe_home_assistant_state_response,
|
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:
|
2018-12-18 14:53:52 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._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:
|
2018-12-13 21:34:57 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
req = CoverCommandRequest()
|
2018-12-13 21:34:57 +01:00
|
|
|
req.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
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._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:
|
2018-12-13 21:34:57 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
req = FanCommandRequest()
|
2018-12-13 21:34:57 +01:00
|
|
|
req.key = key
|
|
|
|
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
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def light_command(
|
|
|
|
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:
|
2018-12-13 21:34:57 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
req = LightCommandRequest()
|
2018-12-13 21:34:57 +01:00
|
|
|
req.key = key
|
|
|
|
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
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
2018-12-13 21:34:57 +01:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def switch_command(self, key: int, state: bool) -> None:
|
2018-12-13 21:34:57 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
req = SwitchCommandRequest()
|
2018-12-13 21:34:57 +01:00
|
|
|
req.key = key
|
|
|
|
req.state = state
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
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:
|
2019-03-27 22:10:33 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
req = ClimateCommandRequest()
|
2019-03-27 22:10:33 +01:00
|
|
|
req.key = key
|
|
|
|
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
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
2019-03-27 22:10:33 +01:00
|
|
|
|
2021-06-29 12:42:38 +02:00
|
|
|
async def number_command(self, key: int, state: float) -> None:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
req = NumberCommandRequest()
|
|
|
|
req.key = key
|
|
|
|
req.state = state
|
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
2021-06-29 12:42:38 +02:00
|
|
|
|
2021-07-26 20:51:12 +02:00
|
|
|
async def select_command(self, key: int, state: str) -> None:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
req = SelectCommandRequest()
|
|
|
|
req.key = key
|
|
|
|
req.state = state
|
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
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:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
req = SirenCommandRequest()
|
|
|
|
req.key = key
|
|
|
|
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
|
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
2021-09-09 03:11:51 +02:00
|
|
|
|
2021-11-29 01:59:23 +01:00
|
|
|
async def button_command(self, key: int) -> None:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
req = ButtonCommandRequest()
|
|
|
|
req.key = key
|
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
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:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
req = LockCommandRequest()
|
|
|
|
req.key = key
|
|
|
|
req.command = command
|
|
|
|
if code is not None:
|
|
|
|
req.code = code
|
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._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:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
req = MediaPlayerCommandRequest()
|
|
|
|
req.key = key
|
|
|
|
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
|
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
2022-05-18 03:28:40 +02:00
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
async def execute_service(
|
|
|
|
self, service: UserService, data: ExecuteServiceDataType
|
|
|
|
) -> None:
|
2019-02-24 18:16:12 +01:00
|
|
|
self._check_authenticated()
|
|
|
|
|
2021-06-18 17:57:02 +02:00
|
|
|
req = ExecuteServiceRequest()
|
2019-02-24 18:16:12 +01:00
|
|
|
req.key = service.key
|
|
|
|
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)
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._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:
|
|
|
|
req = CameraImageRequest()
|
2019-03-09 11:02:44 +01:00
|
|
|
req.single = single
|
|
|
|
req.stream = stream
|
2021-06-18 17:57:02 +02:00
|
|
|
assert self._connection is not None
|
2023-01-06 05:24:10 +01:00
|
|
|
self._connection.send_message(req)
|
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[
|
|
|
|
[str, int, VoiceAssistantAudioSettings], Coroutine[Any, Any, int | None]
|
|
|
|
],
|
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.
|
|
|
|
"""
|
|
|
|
self._check_authenticated()
|
|
|
|
|
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-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
|
|
|
|
|
|
|
assert self._connection is not None
|
|
|
|
|
|
|
|
self._connection.send_message(SubscribeVoiceAssistantRequest(subscribe=True))
|
|
|
|
|
|
|
|
remove_callback = self._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:
|
|
|
|
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:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
req = VoiceAssistantEventResponse()
|
|
|
|
req.event_type = event_type
|
|
|
|
|
|
|
|
data_args = []
|
|
|
|
if data is not None:
|
|
|
|
for name, value in data.items():
|
|
|
|
arg = VoiceAssistantEventData()
|
|
|
|
arg.name = name
|
|
|
|
arg.value = value
|
|
|
|
data_args.append(arg)
|
|
|
|
|
|
|
|
# pylint: disable=no-member
|
|
|
|
req.data.extend(data_args)
|
|
|
|
|
|
|
|
assert self._connection is not None
|
|
|
|
self._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:
|
|
|
|
self._check_authenticated()
|
|
|
|
|
|
|
|
req = AlarmControlPanelCommandRequest()
|
|
|
|
req.key = key
|
|
|
|
req.command = command
|
|
|
|
if code is not None:
|
|
|
|
req.code = code
|
|
|
|
assert self._connection is not None
|
|
|
|
self._connection.send_message(req)
|