mirror of
https://github.com/esphome/aioesphomeapi.git
synced 2024-11-22 12:05:12 +01:00
Big Refactor
This commit is contained in:
parent
6789949b99
commit
e74118923e
@ -1 +1,5 @@
|
||||
from .client import *
|
||||
from .connection import *
|
||||
from .core import *
|
||||
from .model import *
|
||||
from .util import *
|
||||
|
@ -1,5 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
|
||||
// ==================== BASE PACKETS ====================
|
||||
|
||||
// The Home Assistant protocol is structured as a simple
|
||||
// TCP socket with short binary messages encoded in the protocol buffers format
|
||||
// First, a message in this protocol has a specific format:
|
||||
@ -18,6 +21,7 @@ syntax = "proto3";
|
||||
|
||||
// Message sent at the beginning of each connection
|
||||
// Can only be sent by the client and only at the beginning of the connection
|
||||
// ID: 1
|
||||
message HelloRequest {
|
||||
// Description of client (like User Agent)
|
||||
// For example "Home Assistant"
|
||||
@ -28,6 +32,7 @@ message HelloRequest {
|
||||
|
||||
// Confirmation of successful connection request.
|
||||
// Can only be sent by the server and only at the beginning of the connection
|
||||
// ID: 2
|
||||
message HelloResponse {
|
||||
// The version of the API to use. The _client_ (for example Home Assistant) needs to check
|
||||
// for compatibility and if necessary adopt to an older API.
|
||||
@ -44,6 +49,7 @@ message HelloResponse {
|
||||
|
||||
// Message sent at the beginning of each connection to authenticate the client
|
||||
// Can only be sent by the client and only at the beginning of the connection
|
||||
// ID: 3
|
||||
message ConnectRequest {
|
||||
// The password to log in with
|
||||
string password = 1;
|
||||
@ -51,33 +57,40 @@ message ConnectRequest {
|
||||
|
||||
// Confirmation of successful connection. After this the connection is available for all traffic.
|
||||
// Can only be sent by the server and only at the beginning of the connection
|
||||
// ID: 4
|
||||
message ConnectResponse {
|
||||
bool invalid_password = 1;
|
||||
}
|
||||
|
||||
// Request to close the connection.
|
||||
// Can be sent by both the client and server
|
||||
// ID: 5
|
||||
message DisconnectRequest {
|
||||
// Do not close the connection before the acknowledgement arrives
|
||||
}
|
||||
|
||||
// ID: 6
|
||||
message DisconnectResponse {
|
||||
// Empty - Both parties are required to close the connection after this
|
||||
// message has been received.
|
||||
}
|
||||
|
||||
// ID: 7
|
||||
message PingRequest {
|
||||
// Empty
|
||||
}
|
||||
|
||||
// ID: 8
|
||||
message PingResponse {
|
||||
// Empty
|
||||
}
|
||||
|
||||
// ID: 9
|
||||
message DeviceInfoRequest {
|
||||
// Empty
|
||||
}
|
||||
|
||||
// ID: 10
|
||||
message DeviceInfoResponse {
|
||||
bool uses_password = 1;
|
||||
|
||||
@ -101,10 +114,21 @@ message DeviceInfoResponse {
|
||||
bool has_deep_sleep = 7;
|
||||
}
|
||||
|
||||
// ID: 11
|
||||
message ListEntitiesRequest {
|
||||
// Empty
|
||||
}
|
||||
// ID: 19
|
||||
message ListEntitiesDoneResponse {
|
||||
// Empty
|
||||
}
|
||||
// ID: 20
|
||||
message SubscribeStatesRequest {
|
||||
// Empty
|
||||
}
|
||||
|
||||
// ==================== BINARY SENSOR ====================
|
||||
// ID: 12
|
||||
message ListEntitiesBinarySensorResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
@ -114,14 +138,69 @@ message ListEntitiesBinarySensorResponse {
|
||||
string device_class = 5;
|
||||
bool is_status_binary_sensor = 6;
|
||||
}
|
||||
// ID: 21
|
||||
message BinarySensorStateResponse {
|
||||
fixed32 key = 1;
|
||||
bool state = 2;
|
||||
}
|
||||
|
||||
// ==================== COVER ====================
|
||||
// ID: 13
|
||||
message ListEntitiesCoverResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
|
||||
bool is_optimistic = 5;
|
||||
bool assumed_state = 5;
|
||||
bool supports_position = 6;
|
||||
bool supports_tilt = 7;
|
||||
string device_class = 8;
|
||||
}
|
||||
// ID: 22
|
||||
message CoverStateResponse {
|
||||
fixed32 key = 1;
|
||||
|
||||
// legacy: state has been removed in 1.13
|
||||
// clients/servers must still send/accept it until the next protocol change
|
||||
enum LegacyCoverState {
|
||||
OPEN = 0;
|
||||
CLOSED = 1;
|
||||
}
|
||||
LegacyCoverState legacy_state = 2;
|
||||
|
||||
float position = 3;
|
||||
float tilt = 4;
|
||||
enum CoverOperation {
|
||||
IDLE = 0;
|
||||
IS_OPENING = 1;
|
||||
IS_CLOSING = 2;
|
||||
}
|
||||
CoverOperation current_operation = 5;
|
||||
}
|
||||
// ID: 30
|
||||
message CoverCommandRequest {
|
||||
fixed32 key = 1;
|
||||
|
||||
// legacy: command has been removed in 1.13
|
||||
// clients/servers must still send/accept it until the next protocol change
|
||||
enum LegacyCoverCommand {
|
||||
OPEN = 0;
|
||||
CLOSE = 1;
|
||||
STOP = 2;
|
||||
}
|
||||
bool has_legacy_command = 2;
|
||||
LegacyCoverCommand legacy_command = 3;
|
||||
|
||||
bool has_position = 4;
|
||||
float position = 5;
|
||||
bool has_tilt = 6;
|
||||
float tilt = 7;
|
||||
bool stop = 8;
|
||||
}
|
||||
|
||||
// ==================== FAN ====================
|
||||
// ID: 14
|
||||
message ListEntitiesFanResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
@ -131,6 +210,31 @@ message ListEntitiesFanResponse {
|
||||
bool supports_oscillation = 5;
|
||||
bool supports_speed = 6;
|
||||
}
|
||||
enum FanSpeed {
|
||||
LOW = 0;
|
||||
MEDIUM = 1;
|
||||
HIGH = 2;
|
||||
}
|
||||
// ID: 23
|
||||
message FanStateResponse {
|
||||
fixed32 key = 1;
|
||||
bool state = 2;
|
||||
bool oscillating = 3;
|
||||
FanSpeed speed = 4;
|
||||
}
|
||||
// ID: 31
|
||||
message FanCommandRequest {
|
||||
fixed32 key = 1;
|
||||
bool has_state = 2;
|
||||
bool state = 3;
|
||||
bool has_speed = 4;
|
||||
FanSpeed speed = 5;
|
||||
bool has_oscillating = 6;
|
||||
bool oscillating = 7;
|
||||
}
|
||||
|
||||
// ==================== LIGHT ====================
|
||||
// ID: 15
|
||||
message ListEntitiesLightResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
@ -145,69 +249,7 @@ message ListEntitiesLightResponse {
|
||||
float max_mireds = 10;
|
||||
repeated string effects = 11;
|
||||
}
|
||||
message ListEntitiesSensorResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
|
||||
string icon = 5;
|
||||
string unit_of_measurement = 6;
|
||||
int32 accuracy_decimals = 7;
|
||||
}
|
||||
message ListEntitiesSwitchResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
|
||||
string icon = 5;
|
||||
bool optimistic = 6;
|
||||
}
|
||||
message ListEntitiesTextSensorResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
|
||||
string icon = 5;
|
||||
}
|
||||
message ListEntitiesCameraResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
}
|
||||
message ListEntitiesDoneResponse {
|
||||
// Empty
|
||||
}
|
||||
|
||||
message SubscribeStatesRequest {
|
||||
// Empty
|
||||
}
|
||||
message BinarySensorStateResponse {
|
||||
fixed32 key = 1;
|
||||
bool state = 2;
|
||||
}
|
||||
message CoverStateResponse {
|
||||
fixed32 key = 1;
|
||||
enum CoverState {
|
||||
OPEN = 0;
|
||||
CLOSED = 1;
|
||||
}
|
||||
CoverState state = 2;
|
||||
}
|
||||
enum FanSpeed {
|
||||
LOW = 0;
|
||||
MEDIUM = 1;
|
||||
HIGH = 2;
|
||||
}
|
||||
message FanStateResponse {
|
||||
fixed32 key = 1;
|
||||
bool state = 2;
|
||||
bool oscillating = 3;
|
||||
FanSpeed speed = 4;
|
||||
}
|
||||
// ID: 24
|
||||
message LightStateResponse {
|
||||
fixed32 key = 1;
|
||||
bool state = 2;
|
||||
@ -219,43 +261,7 @@ message LightStateResponse {
|
||||
float color_temperature = 8;
|
||||
string effect = 9;
|
||||
}
|
||||
message SensorStateResponse {
|
||||
fixed32 key = 1;
|
||||
float state = 2;
|
||||
}
|
||||
message SwitchStateResponse {
|
||||
fixed32 key = 1;
|
||||
bool state = 2;
|
||||
}
|
||||
message TextSensorStateResponse {
|
||||
fixed32 key = 1;
|
||||
string state = 2;
|
||||
}
|
||||
message CameraImageResponse {
|
||||
fixed32 key = 1;
|
||||
bytes data = 2;
|
||||
bool done = 3;
|
||||
}
|
||||
|
||||
message CoverCommandRequest {
|
||||
fixed32 key = 1;
|
||||
enum CoverCommand {
|
||||
OPEN = 0;
|
||||
CLOSE = 1;
|
||||
STOP = 2;
|
||||
}
|
||||
bool has_state = 2;
|
||||
CoverCommand command = 3;
|
||||
}
|
||||
message FanCommandRequest {
|
||||
fixed32 key = 1;
|
||||
bool has_state = 2;
|
||||
bool state = 3;
|
||||
bool has_speed = 4;
|
||||
FanSpeed speed = 5;
|
||||
bool has_oscillating = 6;
|
||||
bool oscillating = 7;
|
||||
}
|
||||
// ID: 32
|
||||
message LightCommandRequest {
|
||||
fixed32 key = 1;
|
||||
bool has_state = 2;
|
||||
@ -277,11 +283,64 @@ message LightCommandRequest {
|
||||
bool has_effect = 18;
|
||||
string effect = 19;
|
||||
}
|
||||
|
||||
// ==================== SENSOR ====================
|
||||
// ID: 16
|
||||
message ListEntitiesSensorResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
|
||||
string icon = 5;
|
||||
string unit_of_measurement = 6;
|
||||
int32 accuracy_decimals = 7;
|
||||
}
|
||||
// ID: 25
|
||||
message SensorStateResponse {
|
||||
fixed32 key = 1;
|
||||
float state = 2;
|
||||
}
|
||||
|
||||
// ==================== SWITCH ====================
|
||||
// ID: 17
|
||||
message ListEntitiesSwitchResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
|
||||
string icon = 5;
|
||||
bool assumed_state = 6;
|
||||
}
|
||||
// ID: 26
|
||||
message SwitchStateResponse {
|
||||
fixed32 key = 1;
|
||||
bool state = 2;
|
||||
}
|
||||
// ID: 33
|
||||
message SwitchCommandRequest {
|
||||
fixed32 key = 1;
|
||||
bool state = 2;
|
||||
}
|
||||
|
||||
// ==================== TEXT SENSOR ====================
|
||||
// ID: 18
|
||||
message ListEntitiesTextSensorResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
|
||||
string icon = 5;
|
||||
}
|
||||
// ID: 27
|
||||
message TextSensorStateResponse {
|
||||
fixed32 key = 1;
|
||||
string state = 2;
|
||||
}
|
||||
|
||||
// ==================== SUBSCRIBE LOGS ====================
|
||||
enum LogLevel {
|
||||
NONE = 0;
|
||||
ERROR = 1;
|
||||
@ -291,12 +350,12 @@ enum LogLevel {
|
||||
VERBOSE = 5;
|
||||
VERY_VERBOSE = 6;
|
||||
}
|
||||
|
||||
// ID: 28
|
||||
message SubscribeLogsRequest {
|
||||
LogLevel level = 1;
|
||||
bool dump_config = 2;
|
||||
}
|
||||
|
||||
// ID: 29
|
||||
message SubscribeLogsResponse {
|
||||
LogLevel level = 1;
|
||||
string tag = 2;
|
||||
@ -304,10 +363,13 @@ message SubscribeLogsResponse {
|
||||
bool send_failed = 4;
|
||||
}
|
||||
|
||||
// ==================== HOMEASSISTANT.SERVICE ====================
|
||||
// ID: 34
|
||||
message SubscribeServiceCallsRequest {
|
||||
|
||||
}
|
||||
|
||||
// ID: 35
|
||||
message ServiceCallResponse {
|
||||
string service = 1;
|
||||
map<string, string> data = 2;
|
||||
@ -315,32 +377,38 @@ message ServiceCallResponse {
|
||||
map<string, string> variables = 4;
|
||||
}
|
||||
|
||||
// ==================== IMPORT HOME ASSISTANT STATES ====================
|
||||
// 1. Client sends SubscribeHomeAssistantStatesRequest
|
||||
// 2. Server responds with zero or more SubscribeHomeAssistantStateResponse (async)
|
||||
// 3. Client sends HomeAssistantStateResponse for state changes.
|
||||
// ID: 38
|
||||
message SubscribeHomeAssistantStatesRequest {
|
||||
|
||||
}
|
||||
|
||||
// ID: 39
|
||||
message SubscribeHomeAssistantStateResponse {
|
||||
string entity_id = 1;
|
||||
}
|
||||
|
||||
// ID: 40
|
||||
message HomeAssistantStateResponse {
|
||||
string entity_id = 1;
|
||||
string state = 2;
|
||||
}
|
||||
|
||||
// ==================== IMPORT TIME ====================
|
||||
// ID: 36
|
||||
message GetTimeRequest {
|
||||
|
||||
}
|
||||
|
||||
// ID: 37
|
||||
message GetTimeResponse {
|
||||
fixed32 epoch_seconds = 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ==================== USER-DEFINES SERVICES ====================
|
||||
message ListEntitiesServicesArgument {
|
||||
string name = 1;
|
||||
enum Type {
|
||||
@ -351,36 +419,53 @@ message ListEntitiesServicesArgument {
|
||||
}
|
||||
Type type = 2;
|
||||
}
|
||||
// ID: 41
|
||||
message ListEntitiesServicesResponse {
|
||||
string name = 1;
|
||||
fixed32 key = 2;
|
||||
repeated ListEntitiesServicesArgument args = 3;
|
||||
}
|
||||
|
||||
message ExecuteServiceArgument {
|
||||
bool bool_ = 1;
|
||||
int32 int_ = 2;
|
||||
float float_ = 3;
|
||||
string string_ = 4;
|
||||
}
|
||||
|
||||
// ID: 42
|
||||
message ExecuteServiceRequest {
|
||||
fixed32 key = 1;
|
||||
repeated ExecuteServiceArgument args = 2;
|
||||
}
|
||||
|
||||
// ==================== CAMERA ====================
|
||||
// ID: 43
|
||||
message ListEntitiesCameraResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
string name = 3;
|
||||
string unique_id = 4;
|
||||
}
|
||||
|
||||
// ID: 44
|
||||
message CameraImageResponse {
|
||||
fixed32 key = 1;
|
||||
bytes data = 2;
|
||||
bool done = 3;
|
||||
}
|
||||
// ID: 45
|
||||
message CameraImageRequest {
|
||||
bool single = 1;
|
||||
bool stream = 2;
|
||||
}
|
||||
|
||||
// ==================== CLIMATE ====================
|
||||
enum ClimateMode {
|
||||
OFF = 0;
|
||||
AUTO = 1;
|
||||
COOL = 2;
|
||||
HEAT = 3;
|
||||
}
|
||||
|
||||
// ID: 46
|
||||
message ListEntitiesClimateResponse {
|
||||
string object_id = 1;
|
||||
fixed32 key = 2;
|
||||
@ -395,7 +480,7 @@ message ListEntitiesClimateResponse {
|
||||
float visual_temperature_step = 10;
|
||||
bool supports_away = 11;
|
||||
}
|
||||
|
||||
// ID: 47
|
||||
message ClimateStateResponse {
|
||||
fixed32 key = 1;
|
||||
ClimateMode mode = 2;
|
||||
@ -405,7 +490,7 @@ message ClimateStateResponse {
|
||||
float target_temperature_high = 6;
|
||||
bool away = 7;
|
||||
}
|
||||
|
||||
// ID: 48
|
||||
message ClimateCommandRequest {
|
||||
fixed32 key = 1;
|
||||
bool has_mode = 2;
|
||||
|
File diff suppressed because one or more lines are too long
@ -1,685 +1,14 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Callable, List, Optional, Tuple, Union, cast, Dict
|
||||
|
||||
import attr
|
||||
from google.protobuf import message
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
import aioesphomeapi.api_pb2 as pb
|
||||
from aioesphomeapi.connection import APIConnection, ConnectionParams
|
||||
from aioesphomeapi.core import APIConnectionError
|
||||
from aioesphomeapi.model import *
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APIConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
MESSAGE_TYPE_TO_PROTO = {
|
||||
1: pb.HelloRequest,
|
||||
2: pb.HelloResponse,
|
||||
3: pb.ConnectRequest,
|
||||
4: pb.ConnectResponse,
|
||||
5: pb.DisconnectRequest,
|
||||
6: pb.DisconnectResponse,
|
||||
7: pb.PingRequest,
|
||||
8: pb.PingResponse,
|
||||
9: pb.DeviceInfoRequest,
|
||||
10: pb.DeviceInfoResponse,
|
||||
11: pb.ListEntitiesRequest,
|
||||
12: pb.ListEntitiesBinarySensorResponse,
|
||||
13: pb.ListEntitiesCoverResponse,
|
||||
14: pb.ListEntitiesFanResponse,
|
||||
15: pb.ListEntitiesLightResponse,
|
||||
16: pb.ListEntitiesSensorResponse,
|
||||
17: pb.ListEntitiesSwitchResponse,
|
||||
18: pb.ListEntitiesTextSensorResponse,
|
||||
19: pb.ListEntitiesDoneResponse,
|
||||
20: pb.SubscribeStatesRequest,
|
||||
21: pb.BinarySensorStateResponse,
|
||||
22: pb.CoverStateResponse,
|
||||
23: pb.FanStateResponse,
|
||||
24: pb.LightStateResponse,
|
||||
25: pb.SensorStateResponse,
|
||||
26: pb.SwitchStateResponse,
|
||||
27: pb.TextSensorStateResponse,
|
||||
28: pb.SubscribeLogsRequest,
|
||||
29: pb.SubscribeLogsResponse,
|
||||
30: pb.CoverCommandRequest,
|
||||
31: pb.FanCommandRequest,
|
||||
32: pb.LightCommandRequest,
|
||||
33: pb.SwitchCommandRequest,
|
||||
34: pb.SubscribeServiceCallsRequest,
|
||||
35: pb.ServiceCallResponse,
|
||||
36: pb.GetTimeRequest,
|
||||
37: pb.GetTimeResponse,
|
||||
38: pb.SubscribeHomeAssistantStatesRequest,
|
||||
39: pb.SubscribeHomeAssistantStateResponse,
|
||||
40: pb.HomeAssistantStateResponse,
|
||||
41: pb.ListEntitiesServicesResponse,
|
||||
42: pb.ExecuteServiceRequest,
|
||||
43: pb.ListEntitiesCameraResponse,
|
||||
44: pb.CameraImageResponse,
|
||||
45: pb.CameraImageRequest,
|
||||
46: pb.ListEntitiesClimateResponse,
|
||||
47: pb.ClimateStateResponse,
|
||||
48: pb.ClimateCommandRequest,
|
||||
}
|
||||
|
||||
|
||||
def _varuint_to_bytes(value: int) -> bytes:
|
||||
if value <= 0x7F:
|
||||
return bytes([value])
|
||||
|
||||
ret = bytes()
|
||||
while value:
|
||||
temp = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
ret += bytes([temp | 0x80])
|
||||
else:
|
||||
ret += bytes([temp])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def _bytes_to_varuint(value: bytes) -> Optional[int]:
|
||||
result = 0
|
||||
bitpos = 0
|
||||
for val in value:
|
||||
result |= (val & 0x7F) << bitpos
|
||||
bitpos += 7
|
||||
if (val & 0x80) == 0:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_ip_address_getaddrinfo(eventloop: asyncio.events.AbstractEventLoop,
|
||||
host: str, port: int) -> Tuple[Any, ...]:
|
||||
try:
|
||||
res = await eventloop.getaddrinfo(host, port, family=socket.AF_INET,
|
||||
proto=socket.IPPROTO_TCP)
|
||||
except OSError as err:
|
||||
raise APIConnectionError("Error resolving IP address: {}".format(err))
|
||||
|
||||
if not res:
|
||||
raise APIConnectionError("Error resolving IP address: No matches!")
|
||||
|
||||
_, _, _, _, sockaddr = res[0]
|
||||
|
||||
return sockaddr
|
||||
|
||||
|
||||
async def resolve_ip_address(eventloop: asyncio.events.AbstractEventLoop,
|
||||
host: str, port: int) -> Tuple[Any, ...]:
|
||||
try:
|
||||
return await resolve_ip_address_getaddrinfo(eventloop, host, port)
|
||||
except APIConnectionError as err:
|
||||
if host.endswith('.local'):
|
||||
from aioesphomeapi.host_resolver import resolve_host
|
||||
|
||||
return await eventloop.run_in_executor(None, resolve_host, host), port
|
||||
raise err
|
||||
|
||||
|
||||
# Wrap some types in attr classes to make them serializable
|
||||
@attr.s
|
||||
class DeviceInfo:
|
||||
uses_password = attr.ib(type=bool)
|
||||
name = attr.ib(type=str)
|
||||
mac_address = attr.ib(type=str)
|
||||
esphome_core_version = attr.ib(type=str)
|
||||
compilation_time = attr.ib(type=str)
|
||||
model = attr.ib(type=str)
|
||||
has_deep_sleep = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class EntityInfo:
|
||||
object_id = attr.ib(type=str)
|
||||
key = attr.ib(type=int)
|
||||
name = attr.ib(type=str)
|
||||
unique_id = attr.ib(type=str)
|
||||
|
||||
|
||||
@attr.s
|
||||
class EntityState:
|
||||
key = attr.ib(type=int)
|
||||
|
||||
|
||||
@attr.s
|
||||
class BinarySensorInfo(EntityInfo):
|
||||
device_class = attr.ib(type=str)
|
||||
is_status_binary_sensor = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class BinarySensorState(EntityState):
|
||||
state = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class CoverInfo(EntityInfo):
|
||||
is_optimistic = attr.ib(type=bool)
|
||||
|
||||
|
||||
COVER_STATE_OPEN = 0
|
||||
COVER_SATE_CLOSED = 1
|
||||
COVER_STATES = [COVER_STATE_OPEN, COVER_SATE_CLOSED]
|
||||
|
||||
COVER_COMMAND_OPEN = 0
|
||||
COVER_COMMAND_CLOSE = 1
|
||||
COVER_COMMAND_STOP = 2
|
||||
COVER_COMMANDS = [COVER_COMMAND_OPEN, COVER_COMMAND_CLOSE, COVER_COMMAND_STOP]
|
||||
|
||||
|
||||
@attr.s
|
||||
class CoverState(EntityState):
|
||||
state = attr.ib(type=int, converter=int,
|
||||
validator=attr.validators.in_(COVER_STATES))
|
||||
|
||||
|
||||
@attr.s
|
||||
class FanInfo(EntityInfo):
|
||||
supports_oscillation = attr.ib(type=bool)
|
||||
supports_speed = attr.ib(type=bool)
|
||||
|
||||
|
||||
FAN_SPEED_LOW = 0
|
||||
FAN_SPEED_MEDIUM = 1
|
||||
FAN_SPEED_HIGH = 2
|
||||
FAN_SPEEDS = [FAN_SPEED_LOW, FAN_SPEED_MEDIUM, FAN_SPEED_HIGH]
|
||||
|
||||
|
||||
@attr.s
|
||||
class FanState(EntityState):
|
||||
state = attr.ib(type=bool)
|
||||
oscillating = attr.ib(type=bool)
|
||||
speed = attr.ib(type=int, converter=int,
|
||||
validator=attr.validators.in_(FAN_SPEEDS))
|
||||
|
||||
|
||||
@attr.s
|
||||
class LightInfo(EntityInfo):
|
||||
supports_brightness = attr.ib(type=bool)
|
||||
supports_rgb = attr.ib(type=bool)
|
||||
supports_white_value = attr.ib(type=bool)
|
||||
supports_color_temperature = attr.ib(type=bool)
|
||||
min_mireds = attr.ib(type=float)
|
||||
max_mireds = attr.ib(type=float)
|
||||
effects = attr.ib(type=List[str], converter=list)
|
||||
|
||||
|
||||
@attr.s
|
||||
class LightState(EntityState):
|
||||
state = attr.ib(type=bool)
|
||||
brightness = attr.ib(type=float)
|
||||
red = attr.ib(type=float)
|
||||
green = attr.ib(type=float)
|
||||
blue = attr.ib(type=float)
|
||||
white = attr.ib(type=float)
|
||||
color_temperature = attr.ib(type=float)
|
||||
effect = attr.ib(type=str)
|
||||
|
||||
|
||||
@attr.s
|
||||
class SensorInfo(EntityInfo):
|
||||
icon = attr.ib(type=str)
|
||||
unit_of_measurement = attr.ib(type=str)
|
||||
accuracy_decimals = attr.ib(type=int)
|
||||
|
||||
|
||||
@attr.s
|
||||
class SensorState(EntityState):
|
||||
state = attr.ib(type=float)
|
||||
|
||||
|
||||
@attr.s
|
||||
class SwitchInfo(EntityInfo):
|
||||
icon = attr.ib(type=str)
|
||||
optimistic = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class SwitchState(EntityState):
|
||||
state = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class TextSensorInfo(EntityInfo):
|
||||
icon = attr.ib(type=str)
|
||||
|
||||
|
||||
@attr.s
|
||||
class TextSensorState(EntityState):
|
||||
state = attr.ib(type=str)
|
||||
|
||||
|
||||
@attr.s
|
||||
class CameraInfo(EntityInfo):
|
||||
pass
|
||||
|
||||
|
||||
@attr.s
|
||||
class CameraState(EntityState):
|
||||
image = attr.ib(type=bytes)
|
||||
|
||||
|
||||
CLIMATE_MODE_OFF = 0
|
||||
CLIMATE_MODE_AUTO = 1
|
||||
CLIMATE_MODE_COOL = 2
|
||||
CLIMATE_MODE_HEAT = 3
|
||||
CLIMATE_MODES = [CLIMATE_MODE_OFF, CLIMATE_MODE_AUTO, CLIMATE_MODE_COOL, CLIMATE_MODE_HEAT]
|
||||
_validate_climate_mode = attr.validators.in_(CLIMATE_MODES)
|
||||
|
||||
|
||||
def _convert_climate_modes(value):
|
||||
return [int(val) for val in value]
|
||||
|
||||
|
||||
@attr.s
|
||||
class ClimateInfo(EntityInfo):
|
||||
supports_current_temperature = attr.ib(type=bool)
|
||||
supports_two_point_target_temperature = attr.ib(type=bool)
|
||||
supported_modes = attr.ib(type=List[int], converter=_convert_climate_modes)
|
||||
visual_min_temperature = attr.ib(type=float)
|
||||
visual_max_temperature = attr.ib(type=float)
|
||||
visual_temperature_step = attr.ib(type=float)
|
||||
supports_away = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class ClimateState(EntityState):
|
||||
mode = attr.ib(type=int, converter=int, validator=_validate_climate_mode)
|
||||
current_temperature = attr.ib(type=float)
|
||||
target_temperature = attr.ib(type=float)
|
||||
target_temperature_low = attr.ib(type=float)
|
||||
target_temperature_high = attr.ib(type=float)
|
||||
away = attr.ib(type=bool)
|
||||
|
||||
|
||||
COMPONENT_TYPE_TO_INFO = {
|
||||
'binary_sensor': BinarySensorInfo,
|
||||
'cover': CoverInfo,
|
||||
'fan': FanInfo,
|
||||
'light': LightInfo,
|
||||
'sensor': SensorInfo,
|
||||
'switch': SwitchInfo,
|
||||
'text_sensor': TextSensorInfo,
|
||||
'camera': CameraInfo,
|
||||
'climate': ClimateInfo,
|
||||
}
|
||||
|
||||
|
||||
@attr.s
|
||||
class ServiceCall:
|
||||
service = attr.ib(type=str)
|
||||
data = attr.ib(type=Dict[str, str], converter=dict)
|
||||
data_template = attr.ib(type=Dict[str, str], converter=dict)
|
||||
variables = attr.ib(type=Dict[str, str], converter=dict)
|
||||
|
||||
|
||||
USER_SERVICE_ARG_BOOL = 0
|
||||
USER_SERVICE_ARG_INT = 1
|
||||
USER_SERVICE_ARG_FLOAT = 2
|
||||
USER_SERVICE_ARG_STRING = 3
|
||||
USER_SERVICE_ARG_TYPES = [
|
||||
USER_SERVICE_ARG_BOOL, USER_SERVICE_ARG_INT, USER_SERVICE_ARG_FLOAT, USER_SERVICE_ARG_STRING
|
||||
]
|
||||
|
||||
|
||||
def _attr_obj_from_dict(cls, **kwargs):
|
||||
return cls(**{key: kwargs[key] for key in attr.fields_dict(cls)})
|
||||
|
||||
|
||||
@attr.s
|
||||
class UserServiceArg:
|
||||
name = attr.ib(type=str)
|
||||
type_ = attr.ib(type=int, converter=int,
|
||||
validator=attr.validators.in_(USER_SERVICE_ARG_TYPES))
|
||||
|
||||
|
||||
@attr.s
|
||||
class UserService:
|
||||
name = attr.ib(type=str)
|
||||
key = attr.ib(type=int)
|
||||
args = attr.ib(type=List[UserServiceArg], converter=list)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(dict_):
|
||||
args = []
|
||||
for arg in dict_.get('args', []):
|
||||
args.append(_attr_obj_from_dict(UserServiceArg, **arg))
|
||||
return UserService(
|
||||
name=dict_.get('name', ''),
|
||||
key=dict_.get('key', 0),
|
||||
args=args
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'key': self.key,
|
||||
'args': [attr.asdict(arg) for arg in self.args],
|
||||
}
|
||||
|
||||
|
||||
@attr.s
|
||||
class ConnectionParams:
|
||||
eventloop = attr.ib(type=asyncio.events.AbstractEventLoop)
|
||||
address = attr.ib(type=str)
|
||||
port = attr.ib(type=int)
|
||||
password = attr.ib(type=Optional[str])
|
||||
client_info = attr.ib(type=str)
|
||||
keepalive = attr.ib(type=float)
|
||||
|
||||
|
||||
class APIConnection:
|
||||
def __init__(self, params: ConnectionParams, on_stop):
|
||||
self._params = params
|
||||
self.on_stop = on_stop
|
||||
self._stopped = False
|
||||
self._socket = None # type: Optional[socket.socket]
|
||||
self._socket_reader = None # type: Optional[asyncio.StreamReader]
|
||||
self._socket_writer = None # type: Optional[asyncio.StreamWriter]
|
||||
self._write_lock = asyncio.Lock()
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
self._socket_connected = False
|
||||
self._state_lock = asyncio.Lock()
|
||||
|
||||
self._message_handlers = [] # type: List[Callable[[message], None]]
|
||||
|
||||
self._running_task = None # type: Optional[asyncio.Task]
|
||||
|
||||
def _start_ping(self) -> None:
|
||||
async def func() -> None:
|
||||
while self._connected:
|
||||
await asyncio.sleep(self._params.keepalive)
|
||||
|
||||
if not self._connected:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.ping()
|
||||
except APIConnectionError:
|
||||
_LOGGER.info("%s: Ping Failed!", self._params.address)
|
||||
await self._on_error()
|
||||
return
|
||||
|
||||
self._params.eventloop.create_task(func())
|
||||
|
||||
async def _close_socket(self) -> None:
|
||||
if not self._socket_connected:
|
||||
return
|
||||
async with self._write_lock:
|
||||
self._socket_writer.close()
|
||||
self._socket_writer = None
|
||||
self._socket_reader = None
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket_connected = False
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
_LOGGER.debug("%s: Closed socket", self._params.address)
|
||||
|
||||
async def stop(self, force: bool = False) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
if self._connected and not force:
|
||||
try:
|
||||
await self._disconnect()
|
||||
except APIConnectionError:
|
||||
pass
|
||||
self._stopped = True
|
||||
if self._running_task is not None:
|
||||
self._running_task.cancel()
|
||||
await self._close_socket()
|
||||
await self.on_stop()
|
||||
|
||||
async def _on_error(self) -> None:
|
||||
await self.stop(force=True)
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._stopped:
|
||||
raise APIConnectionError("Connection is closed!")
|
||||
if self._connected:
|
||||
raise APIConnectionError("Already connected!")
|
||||
|
||||
try:
|
||||
coro = resolve_ip_address(self._params.eventloop, self._params.address,
|
||||
self._params.port)
|
||||
sockaddr = await asyncio.wait_for(coro, 30.0)
|
||||
except APIConnectionError as err:
|
||||
await self._on_error()
|
||||
raise err
|
||||
except asyncio.TimeoutError:
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Timeout while resolving IP address")
|
||||
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._socket.setblocking(False)
|
||||
self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
|
||||
_LOGGER.debug("%s: Connecting to %s:%s (%s)", self._params.address,
|
||||
self._params.address, self._params.port, sockaddr)
|
||||
try:
|
||||
coro = self._params.eventloop.sock_connect(self._socket, sockaddr)
|
||||
await asyncio.wait_for(coro, 30.0)
|
||||
except OSError as err:
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Error connecting to {}: {}".format(sockaddr, err))
|
||||
except asyncio.TimeoutError:
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Timeout while connecting to {}".format(sockaddr))
|
||||
|
||||
_LOGGER.debug("%s: Opened socket for", self._params.address)
|
||||
self._socket_reader, self._socket_writer = await asyncio.open_connection(sock=self._socket)
|
||||
self._socket_connected = True
|
||||
self._params.eventloop.create_task(self.run_forever())
|
||||
|
||||
hello = pb.HelloRequest()
|
||||
hello.client_info = self._params.client_info
|
||||
try:
|
||||
resp = await self.send_message_await_response(hello, pb.HelloResponse)
|
||||
except APIConnectionError as err:
|
||||
await self._on_error()
|
||||
raise err
|
||||
_LOGGER.debug("%s: Successfully connected to %s ('%s' API=%s.%s)",
|
||||
self._params.address, self._params.address,
|
||||
resp.server_info, resp.api_version_major, resp.api_version_minor)
|
||||
self._connected = True
|
||||
|
||||
self._start_ping()
|
||||
|
||||
async def login(self) -> None:
|
||||
self._check_connected()
|
||||
if self._authenticated:
|
||||
raise APIConnectionError("Already logged in!")
|
||||
|
||||
connect = pb.ConnectRequest()
|
||||
if self._params.password is not None:
|
||||
connect.password = self._params.password
|
||||
resp = await self.send_message_await_response(connect, pb.ConnectResponse)
|
||||
if resp.invalid_password:
|
||||
raise APIConnectionError("Invalid password!")
|
||||
|
||||
self._authenticated = True
|
||||
|
||||
def _check_connected(self) -> None:
|
||||
if not self._connected:
|
||||
raise APIConnectionError("Must be connected!")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self._authenticated
|
||||
|
||||
async def _write(self, data: bytes) -> None:
|
||||
_LOGGER.debug("%s: Write: %s", self._params.address,
|
||||
' '.join('{:02X}'.format(x) for x in data))
|
||||
if not self._socket_connected:
|
||||
raise APIConnectionError("Socket is not connected")
|
||||
try:
|
||||
async with self._write_lock:
|
||||
self._socket_writer.write(data)
|
||||
await self._socket_writer.drain()
|
||||
except OSError as err:
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Error while writing data: {}".format(err))
|
||||
|
||||
async def send_message(self, msg: message.Message) -> None:
|
||||
for message_type, klass in MESSAGE_TYPE_TO_PROTO.items():
|
||||
if isinstance(msg, klass):
|
||||
break
|
||||
else:
|
||||
raise ValueError
|
||||
|
||||
encoded = msg.SerializeToString()
|
||||
_LOGGER.debug("%s: Sending %s: %s", self._params.address, type(msg), str(msg))
|
||||
req = bytes([0])
|
||||
req += _varuint_to_bytes(len(encoded))
|
||||
req += _varuint_to_bytes(message_type)
|
||||
req += encoded
|
||||
await self._write(req)
|
||||
|
||||
async def send_message_callback_response(self, send_msg: message.Message,
|
||||
on_message: Callable[[Any], None]) -> None:
|
||||
self._message_handlers.append(on_message)
|
||||
await self.send_message(send_msg)
|
||||
|
||||
async def send_message_await_response_complex(self, send_msg: message.Message,
|
||||
do_append: Callable[[Any], bool],
|
||||
do_stop: Callable[[Any], bool],
|
||||
timeout: float = 5.0) -> List[Any]:
|
||||
fut = self._params.eventloop.create_future()
|
||||
responses = []
|
||||
|
||||
def on_message(resp):
|
||||
if fut.done():
|
||||
return
|
||||
if do_append(resp):
|
||||
responses.append(resp)
|
||||
if do_stop(resp):
|
||||
fut.set_result(responses)
|
||||
|
||||
self._message_handlers.append(on_message)
|
||||
await self.send_message(send_msg)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(fut, timeout)
|
||||
except asyncio.TimeoutError:
|
||||
if self._stopped:
|
||||
raise APIConnectionError("Disconnected while waiting for API response!")
|
||||
raise APIConnectionError("Timeout while waiting for API response!")
|
||||
|
||||
try:
|
||||
self._message_handlers.remove(on_message)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return responses
|
||||
|
||||
async def send_message_await_response(self,
|
||||
send_msg: message.Message,
|
||||
response_type: Any, timeout: float = 5.0) -> Any:
|
||||
def is_response(msg):
|
||||
return isinstance(msg, response_type)
|
||||
|
||||
res = await self.send_message_await_response_complex(
|
||||
send_msg, is_response, is_response, timeout=timeout)
|
||||
if len(res) != 1:
|
||||
raise APIConnectionError("Expected one result, got {}".format(len(res)))
|
||||
|
||||
return res[0]
|
||||
|
||||
async def _recv(self, amount: int) -> bytes:
|
||||
if amount == 0:
|
||||
return bytes()
|
||||
|
||||
try:
|
||||
ret = await self._socket_reader.readexactly(amount)
|
||||
except (asyncio.IncompleteReadError, OSError, TimeoutError) as err:
|
||||
raise APIConnectionError("Error while receiving data: {}".format(err))
|
||||
|
||||
return ret
|
||||
|
||||
async def _recv_varint(self) -> int:
|
||||
raw = bytes()
|
||||
while not raw or raw[-1] & 0x80:
|
||||
raw += await self._recv(1)
|
||||
return cast(int, _bytes_to_varuint(raw))
|
||||
|
||||
async def _run_once(self) -> None:
|
||||
preamble = await self._recv(1)
|
||||
if preamble[0] != 0x00:
|
||||
raise APIConnectionError("Invalid preamble")
|
||||
|
||||
length = await self._recv_varint()
|
||||
msg_type = await self._recv_varint()
|
||||
|
||||
raw_msg = await self._recv(length)
|
||||
if msg_type not in MESSAGE_TYPE_TO_PROTO:
|
||||
_LOGGER.debug("%s: Skipping message type %s", self._params.address, msg_type)
|
||||
return
|
||||
|
||||
msg = MESSAGE_TYPE_TO_PROTO[msg_type]()
|
||||
try:
|
||||
msg.ParseFromString(raw_msg)
|
||||
except Exception as e:
|
||||
raise APIConnectionError("Invalid protobuf message: {}".format(e))
|
||||
_LOGGER.debug("%s: Got message of type %s: %s", self._params.address, type(msg), msg)
|
||||
for msg_handler in self._message_handlers[:]:
|
||||
msg_handler(msg)
|
||||
await self._handle_internal_messages(msg)
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._run_once()
|
||||
except APIConnectionError as err:
|
||||
_LOGGER.info("%s: Error while reading incoming messages: %s",
|
||||
self._params.address, err)
|
||||
await self._on_error()
|
||||
break
|
||||
except Exception as err:
|
||||
_LOGGER.info("%s: Unexpected error while reading incoming messages: %s",
|
||||
self._params.address, err)
|
||||
await self._on_error()
|
||||
break
|
||||
|
||||
async def _handle_internal_messages(self, msg: Any) -> None:
|
||||
if isinstance(msg, pb.DisconnectRequest):
|
||||
await self.send_message(pb.DisconnectResponse())
|
||||
await self.stop(force=True)
|
||||
elif isinstance(msg, pb.PingRequest):
|
||||
await self.send_message(pb.PingResponse())
|
||||
elif isinstance(msg, pb.GetTimeRequest):
|
||||
resp = pb.GetTimeResponse()
|
||||
resp.epoch_seconds = int(time.time())
|
||||
await self.send_message(resp)
|
||||
|
||||
async def ping(self) -> None:
|
||||
self._check_connected()
|
||||
await self.send_message_await_response(pb.PingRequest(), pb.PingResponse)
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
self._check_connected()
|
||||
|
||||
try:
|
||||
await self.send_message_await_response(pb.DisconnectRequest(), pb.DisconnectResponse)
|
||||
except APIConnectionError:
|
||||
pass
|
||||
|
||||
def _check_authenticated(self) -> None:
|
||||
if not self._authenticated:
|
||||
raise APIConnectionError("Must login first!")
|
||||
|
||||
|
||||
class APIClient:
|
||||
def __init__(self, eventloop, address: str, port: int, password: str, *,
|
||||
client_info: str = 'aioesphomeapi', keepalive: float = 15.0):
|
||||
@ -888,22 +217,37 @@ class APIClient:
|
||||
|
||||
async def cover_command(self,
|
||||
key: int,
|
||||
command: int
|
||||
position: Optional[float] = None,
|
||||
tilt: Optional[float] = None,
|
||||
stop: bool = False,
|
||||
) -> None:
|
||||
self._check_authenticated()
|
||||
|
||||
req = pb.CoverCommandRequest()
|
||||
req.key = key
|
||||
req.has_state = True
|
||||
if command not in COVER_COMMANDS:
|
||||
raise ValueError
|
||||
req.command = command
|
||||
if self.api_version >= APIVersion(1, 1):
|
||||
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:
|
||||
req.has_legacy_command = True
|
||||
if stop:
|
||||
req.legacy_command = LegacyCoverCommand.STOP
|
||||
elif position == 1.0:
|
||||
req.legacy_command = LegacyCoverCommand.OPEN
|
||||
else:
|
||||
req.legacy_command = LegacyCoverCommand.CLOSE
|
||||
await self._connection.send_message(req)
|
||||
|
||||
async def fan_command(self,
|
||||
key: int,
|
||||
state: Optional[bool] = None,
|
||||
speed: Optional[int] = None,
|
||||
speed: Optional[FanSpeed] = None,
|
||||
oscillating: Optional[bool] = None
|
||||
) -> None:
|
||||
self._check_authenticated()
|
||||
@ -915,8 +259,6 @@ class APIClient:
|
||||
req.state = state
|
||||
if speed is not None:
|
||||
req.has_speed = True
|
||||
if speed not in FAN_SPEEDS:
|
||||
raise ValueError
|
||||
req.speed = speed
|
||||
if oscillating is not None:
|
||||
req.has_oscillating = True
|
||||
@ -979,7 +321,7 @@ class APIClient:
|
||||
|
||||
async def climate_command(self,
|
||||
key: int,
|
||||
mode: Optional[int] = None,
|
||||
mode: Optional[ClimateMode] = None,
|
||||
target_temperature: Optional[float] = None,
|
||||
target_temperature_low: Optional[float] = None,
|
||||
target_temperature_high: Optional[float] = None,
|
||||
@ -1016,10 +358,10 @@ class APIClient:
|
||||
arg = pb.ExecuteServiceArgument()
|
||||
val = data[arg_desc.name]
|
||||
attr_ = {
|
||||
USER_SERVICE_ARG_BOOL: 'bool_',
|
||||
USER_SERVICE_ARG_INT: 'int_',
|
||||
USER_SERVICE_ARG_FLOAT: 'float_',
|
||||
USER_SERVICE_ARG_STRING: 'string_',
|
||||
UserServiceArgType.BOOL: 'bool_',
|
||||
UserServiceArgType.INT: 'int_',
|
||||
UserServiceArgType.FLOAT: 'float_',
|
||||
UserServiceArgType.STRING: 'string_',
|
||||
}[arg_desc.type_]
|
||||
setattr(arg, attr_, val)
|
||||
args.append(arg)
|
||||
@ -1037,3 +379,9 @@ class APIClient:
|
||||
|
||||
async def request_image_stream(self):
|
||||
await self._request_image(stream=True)
|
||||
|
||||
@property
|
||||
def api_version(self) -> Optional[APIVersion]:
|
||||
if self._connection is None:
|
||||
return None
|
||||
return self._connection.api_version
|
||||
|
341
aioesphomeapi/connection.py
Normal file
341
aioesphomeapi/connection.py
Normal file
@ -0,0 +1,341 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Callable, List, Optional, cast
|
||||
|
||||
import attr
|
||||
from google.protobuf import message
|
||||
|
||||
import aioesphomeapi.api_pb2 as pb
|
||||
from aioesphomeapi.core import APIConnectionError, MESSAGE_TYPE_TO_PROTO
|
||||
from aioesphomeapi.model import APIVersion
|
||||
from aioesphomeapi.util import _bytes_to_varuint, _varuint_to_bytes, resolve_ip_address
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@attr.s
|
||||
class ConnectionParams:
|
||||
eventloop = attr.ib(type=asyncio.events.AbstractEventLoop)
|
||||
address = attr.ib(type=str)
|
||||
port = attr.ib(type=int)
|
||||
password = attr.ib(type=Optional[str])
|
||||
client_info = attr.ib(type=str)
|
||||
keepalive = attr.ib(type=float)
|
||||
|
||||
|
||||
class APIConnection:
|
||||
def __init__(self, params: ConnectionParams, on_stop):
|
||||
self._params = params
|
||||
self.on_stop = on_stop
|
||||
self._stopped = False
|
||||
self._socket = None # type: Optional[socket.socket]
|
||||
self._socket_reader = None # type: Optional[asyncio.StreamReader]
|
||||
self._socket_writer = None # type: Optional[asyncio.StreamWriter]
|
||||
self._write_lock = asyncio.Lock()
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
self._socket_connected = False
|
||||
self._state_lock = asyncio.Lock()
|
||||
self._api_version = None # type: Optional[APIVersion]
|
||||
|
||||
self._message_handlers = [] # type: List[Callable[[message], None]]
|
||||
|
||||
self._running_task = None # type: Optional[asyncio.Task]
|
||||
|
||||
def _start_ping(self) -> None:
|
||||
async def func() -> None:
|
||||
while self._connected:
|
||||
await asyncio.sleep(self._params.keepalive)
|
||||
|
||||
if not self._connected:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.ping()
|
||||
except APIConnectionError:
|
||||
_LOGGER.info("%s: Ping Failed!", self._params.address)
|
||||
await self._on_error()
|
||||
return
|
||||
|
||||
self._params.eventloop.create_task(func())
|
||||
|
||||
async def _close_socket(self) -> None:
|
||||
if not self._socket_connected:
|
||||
return
|
||||
async with self._write_lock:
|
||||
self._socket_writer.close()
|
||||
self._socket_writer = None
|
||||
self._socket_reader = None
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket_connected = False
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
_LOGGER.debug("%s: Closed socket", self._params.address)
|
||||
|
||||
async def stop(self, force: bool = False) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
if self._connected and not force:
|
||||
try:
|
||||
await self._disconnect()
|
||||
except APIConnectionError:
|
||||
pass
|
||||
self._stopped = True
|
||||
if self._running_task is not None:
|
||||
self._running_task.cancel()
|
||||
await self._close_socket()
|
||||
await self.on_stop()
|
||||
|
||||
async def _on_error(self) -> None:
|
||||
await self.stop(force=True)
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._stopped:
|
||||
raise APIConnectionError("Connection is closed!")
|
||||
if self._connected:
|
||||
raise APIConnectionError("Already connected!")
|
||||
|
||||
try:
|
||||
coro = resolve_ip_address(self._params.eventloop, self._params.address,
|
||||
self._params.port)
|
||||
sockaddr = await asyncio.wait_for(coro, 30.0)
|
||||
except APIConnectionError as err:
|
||||
await self._on_error()
|
||||
raise err
|
||||
except asyncio.TimeoutError:
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Timeout while resolving IP address")
|
||||
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._socket.setblocking(False)
|
||||
self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
|
||||
_LOGGER.debug("%s: Connecting to %s:%s (%s)", self._params.address,
|
||||
self._params.address, self._params.port, sockaddr)
|
||||
try:
|
||||
coro = self._params.eventloop.sock_connect(self._socket, sockaddr)
|
||||
await asyncio.wait_for(coro, 30.0)
|
||||
except OSError as err:
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Error connecting to {}: {}".format(sockaddr, err))
|
||||
except asyncio.TimeoutError:
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Timeout while connecting to {}".format(sockaddr))
|
||||
|
||||
_LOGGER.debug("%s: Opened socket for", self._params.address)
|
||||
self._socket_reader, self._socket_writer = await asyncio.open_connection(sock=self._socket)
|
||||
self._socket_connected = True
|
||||
self._params.eventloop.create_task(self.run_forever())
|
||||
|
||||
hello = pb.HelloRequest()
|
||||
hello.client_info = self._params.client_info
|
||||
try:
|
||||
resp = await self.send_message_await_response(hello, pb.HelloResponse)
|
||||
except APIConnectionError as err:
|
||||
await self._on_error()
|
||||
raise err
|
||||
_LOGGER.debug("%s: Successfully connected ('%s' API=%s.%s)",
|
||||
self._params.address, resp.server_info, resp.api_version_major,
|
||||
resp.api_version_minor)
|
||||
self._api_version = APIVersion(resp.api_version_major, resp.api_version_minor)
|
||||
if self._api_version.major > 2:
|
||||
_LOGGER.error("%s: Incompatible version %s! Closing connection",
|
||||
self._api_version.major)
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Incompatible API version.")
|
||||
self._connected = True
|
||||
|
||||
self._start_ping()
|
||||
|
||||
async def login(self) -> None:
|
||||
self._check_connected()
|
||||
if self._authenticated:
|
||||
raise APIConnectionError("Already logged in!")
|
||||
|
||||
connect = pb.ConnectRequest()
|
||||
if self._params.password is not None:
|
||||
connect.password = self._params.password
|
||||
resp = await self.send_message_await_response(connect, pb.ConnectResponse)
|
||||
if resp.invalid_password:
|
||||
raise APIConnectionError("Invalid password!")
|
||||
|
||||
self._authenticated = True
|
||||
|
||||
def _check_connected(self) -> None:
|
||||
if not self._connected:
|
||||
raise APIConnectionError("Must be connected!")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self._authenticated
|
||||
|
||||
async def _write(self, data: bytes) -> None:
|
||||
_LOGGER.debug("%s: Write: %s", self._params.address,
|
||||
' '.join('{:02X}'.format(x) for x in data))
|
||||
if not self._socket_connected:
|
||||
raise APIConnectionError("Socket is not connected")
|
||||
try:
|
||||
async with self._write_lock:
|
||||
self._socket_writer.write(data)
|
||||
await self._socket_writer.drain()
|
||||
except OSError as err:
|
||||
await self._on_error()
|
||||
raise APIConnectionError("Error while writing data: {}".format(err))
|
||||
|
||||
async def send_message(self, msg: message.Message) -> None:
|
||||
for message_type, klass in MESSAGE_TYPE_TO_PROTO.items():
|
||||
if isinstance(msg, klass):
|
||||
break
|
||||
else:
|
||||
raise ValueError
|
||||
|
||||
encoded = msg.SerializeToString()
|
||||
_LOGGER.debug("%s: Sending %s: %s", self._params.address, type(msg), str(msg))
|
||||
req = bytes([0])
|
||||
req += _varuint_to_bytes(len(encoded))
|
||||
req += _varuint_to_bytes(message_type)
|
||||
req += encoded
|
||||
await self._write(req)
|
||||
|
||||
async def send_message_callback_response(self, send_msg: message.Message,
|
||||
on_message: Callable[[Any], None]) -> None:
|
||||
self._message_handlers.append(on_message)
|
||||
await self.send_message(send_msg)
|
||||
|
||||
async def send_message_await_response_complex(self, send_msg: message.Message,
|
||||
do_append: Callable[[Any], bool],
|
||||
do_stop: Callable[[Any], bool],
|
||||
timeout: float = 5.0) -> List[Any]:
|
||||
fut = self._params.eventloop.create_future()
|
||||
responses = []
|
||||
|
||||
def on_message(resp):
|
||||
if fut.done():
|
||||
return
|
||||
if do_append(resp):
|
||||
responses.append(resp)
|
||||
if do_stop(resp):
|
||||
fut.set_result(responses)
|
||||
|
||||
self._message_handlers.append(on_message)
|
||||
await self.send_message(send_msg)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(fut, timeout)
|
||||
except asyncio.TimeoutError:
|
||||
if self._stopped:
|
||||
raise APIConnectionError("Disconnected while waiting for API response!")
|
||||
raise APIConnectionError("Timeout while waiting for API response!")
|
||||
|
||||
try:
|
||||
self._message_handlers.remove(on_message)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return responses
|
||||
|
||||
async def send_message_await_response(self,
|
||||
send_msg: message.Message,
|
||||
response_type: Any, timeout: float = 5.0) -> Any:
|
||||
def is_response(msg):
|
||||
return isinstance(msg, response_type)
|
||||
|
||||
res = await self.send_message_await_response_complex(
|
||||
send_msg, is_response, is_response, timeout=timeout)
|
||||
if len(res) != 1:
|
||||
raise APIConnectionError("Expected one result, got {}".format(len(res)))
|
||||
|
||||
return res[0]
|
||||
|
||||
async def _recv(self, amount: int) -> bytes:
|
||||
if amount == 0:
|
||||
return bytes()
|
||||
|
||||
try:
|
||||
ret = await self._socket_reader.readexactly(amount)
|
||||
except (asyncio.IncompleteReadError, OSError, TimeoutError) as err:
|
||||
raise APIConnectionError("Error while receiving data: {}".format(err))
|
||||
|
||||
return ret
|
||||
|
||||
async def _recv_varint(self) -> int:
|
||||
raw = bytes()
|
||||
while not raw or raw[-1] & 0x80:
|
||||
raw += await self._recv(1)
|
||||
return cast(int, _bytes_to_varuint(raw))
|
||||
|
||||
async def _run_once(self) -> None:
|
||||
preamble = await self._recv(1)
|
||||
if preamble[0] != 0x00:
|
||||
raise APIConnectionError("Invalid preamble")
|
||||
|
||||
length = await self._recv_varint()
|
||||
msg_type = await self._recv_varint()
|
||||
|
||||
raw_msg = await self._recv(length)
|
||||
if msg_type not in MESSAGE_TYPE_TO_PROTO:
|
||||
_LOGGER.debug("%s: Skipping message type %s", self._params.address, msg_type)
|
||||
return
|
||||
|
||||
msg = MESSAGE_TYPE_TO_PROTO[msg_type]()
|
||||
try:
|
||||
msg.ParseFromString(raw_msg)
|
||||
except Exception as e:
|
||||
raise APIConnectionError("Invalid protobuf message: {}".format(e))
|
||||
_LOGGER.debug("%s: Got message of type %s: %s", self._params.address, type(msg), msg)
|
||||
for msg_handler in self._message_handlers[:]:
|
||||
msg_handler(msg)
|
||||
await self._handle_internal_messages(msg)
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._run_once()
|
||||
except APIConnectionError as err:
|
||||
_LOGGER.info("%s: Error while reading incoming messages: %s",
|
||||
self._params.address, err)
|
||||
await self._on_error()
|
||||
break
|
||||
except Exception as err:
|
||||
_LOGGER.info("%s: Unexpected error while reading incoming messages: %s",
|
||||
self._params.address, err)
|
||||
await self._on_error()
|
||||
break
|
||||
|
||||
async def _handle_internal_messages(self, msg: Any) -> None:
|
||||
if isinstance(msg, pb.DisconnectRequest):
|
||||
await self.send_message(pb.DisconnectResponse())
|
||||
await self.stop(force=True)
|
||||
elif isinstance(msg, pb.PingRequest):
|
||||
await self.send_message(pb.PingResponse())
|
||||
elif isinstance(msg, pb.GetTimeRequest):
|
||||
resp = pb.GetTimeResponse()
|
||||
resp.epoch_seconds = int(time.time())
|
||||
await self.send_message(resp)
|
||||
|
||||
async def ping(self) -> None:
|
||||
self._check_connected()
|
||||
await self.send_message_await_response(pb.PingRequest(), pb.PingResponse)
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
self._check_connected()
|
||||
|
||||
try:
|
||||
await self.send_message_await_response(pb.DisconnectRequest(), pb.DisconnectResponse)
|
||||
except APIConnectionError:
|
||||
pass
|
||||
|
||||
def _check_authenticated(self) -> None:
|
||||
if not self._authenticated:
|
||||
raise APIConnectionError("Must login first!")
|
||||
|
||||
@property
|
||||
def api_version(self) -> Optional[APIVersion]:
|
||||
return self._api_version
|
57
aioesphomeapi/core.py
Normal file
57
aioesphomeapi/core.py
Normal file
@ -0,0 +1,57 @@
|
||||
import aioesphomeapi.api_pb2 as pb
|
||||
|
||||
|
||||
class APIConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
MESSAGE_TYPE_TO_PROTO = {
|
||||
1: pb.HelloRequest,
|
||||
2: pb.HelloResponse,
|
||||
3: pb.ConnectRequest,
|
||||
4: pb.ConnectResponse,
|
||||
5: pb.DisconnectRequest,
|
||||
6: pb.DisconnectResponse,
|
||||
7: pb.PingRequest,
|
||||
8: pb.PingResponse,
|
||||
9: pb.DeviceInfoRequest,
|
||||
10: pb.DeviceInfoResponse,
|
||||
11: pb.ListEntitiesRequest,
|
||||
12: pb.ListEntitiesBinarySensorResponse,
|
||||
13: pb.ListEntitiesCoverResponse,
|
||||
14: pb.ListEntitiesFanResponse,
|
||||
15: pb.ListEntitiesLightResponse,
|
||||
16: pb.ListEntitiesSensorResponse,
|
||||
17: pb.ListEntitiesSwitchResponse,
|
||||
18: pb.ListEntitiesTextSensorResponse,
|
||||
19: pb.ListEntitiesDoneResponse,
|
||||
20: pb.SubscribeStatesRequest,
|
||||
21: pb.BinarySensorStateResponse,
|
||||
22: pb.CoverStateResponse,
|
||||
23: pb.FanStateResponse,
|
||||
24: pb.LightStateResponse,
|
||||
25: pb.SensorStateResponse,
|
||||
26: pb.SwitchStateResponse,
|
||||
27: pb.TextSensorStateResponse,
|
||||
28: pb.SubscribeLogsRequest,
|
||||
29: pb.SubscribeLogsResponse,
|
||||
30: pb.CoverCommandRequest,
|
||||
31: pb.FanCommandRequest,
|
||||
32: pb.LightCommandRequest,
|
||||
33: pb.SwitchCommandRequest,
|
||||
34: pb.SubscribeServiceCallsRequest,
|
||||
35: pb.ServiceCallResponse,
|
||||
36: pb.GetTimeRequest,
|
||||
37: pb.GetTimeResponse,
|
||||
38: pb.SubscribeHomeAssistantStatesRequest,
|
||||
39: pb.SubscribeHomeAssistantStateResponse,
|
||||
40: pb.HomeAssistantStateResponse,
|
||||
41: pb.ListEntitiesServicesResponse,
|
||||
42: pb.ExecuteServiceRequest,
|
||||
43: pb.ListEntitiesCameraResponse,
|
||||
44: pb.CameraImageResponse,
|
||||
45: pb.CameraImageRequest,
|
||||
46: pb.ListEntitiesClimateResponse,
|
||||
47: pb.ClimateStateResponse,
|
||||
48: pb.ClimateCommandRequest,
|
||||
}
|
273
aioesphomeapi/model.py
Normal file
273
aioesphomeapi/model.py
Normal file
@ -0,0 +1,273 @@
|
||||
import enum
|
||||
from typing import List, Dict
|
||||
|
||||
import attr
|
||||
|
||||
|
||||
@attr.s(cmp=True)
|
||||
class APIVersion:
|
||||
major = attr.ib(type=int)
|
||||
minor = attr.ib(type=int)
|
||||
|
||||
|
||||
@attr.s
|
||||
class DeviceInfo:
|
||||
uses_password = attr.ib(type=bool)
|
||||
name = attr.ib(type=str)
|
||||
mac_address = attr.ib(type=str)
|
||||
esphome_core_version = attr.ib(type=str)
|
||||
compilation_time = attr.ib(type=str)
|
||||
model = attr.ib(type=str)
|
||||
has_deep_sleep = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class EntityInfo:
|
||||
object_id = attr.ib(type=str)
|
||||
key = attr.ib(type=int)
|
||||
name = attr.ib(type=str)
|
||||
unique_id = attr.ib(type=str)
|
||||
|
||||
|
||||
@attr.s
|
||||
class EntityState:
|
||||
key = attr.ib(type=int)
|
||||
|
||||
|
||||
# ==================== BINARY SENSOR ====================
|
||||
@attr.s
|
||||
class BinarySensorInfo(EntityInfo):
|
||||
device_class = attr.ib(type=str)
|
||||
is_status_binary_sensor = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class BinarySensorState(EntityState):
|
||||
state = attr.ib(type=bool)
|
||||
|
||||
|
||||
# ==================== COVER ====================
|
||||
@attr.s
|
||||
class CoverInfo(EntityInfo):
|
||||
assumed_state = attr.ib(type=bool)
|
||||
supports_position = attr.ib(type=bool)
|
||||
supports_tilt = attr.ib(type=bool)
|
||||
device_class = attr.ib(type=str)
|
||||
|
||||
|
||||
class LegacyCoverState(enum.IntEnum):
|
||||
OPEN = 0
|
||||
CLOSED = 1
|
||||
|
||||
|
||||
class LegacyCoverCommand(enum.IntEnum):
|
||||
OPEN = 0
|
||||
CLOSE = 1
|
||||
STOP = 2
|
||||
|
||||
|
||||
class CoverOperation(enum.IntEnum):
|
||||
IDLE = 0
|
||||
IS_OPENING = 1
|
||||
IS_CLOSING = 2
|
||||
|
||||
|
||||
@attr.s
|
||||
class CoverState(EntityState):
|
||||
legacy_state = attr.ib(type=LegacyCoverState, converter=LegacyCoverState)
|
||||
position = attr.ib(type=float)
|
||||
tilt = attr.ib(type=float)
|
||||
current_operation = attr.ib(type=CoverOperation, converter=CoverOperation)
|
||||
|
||||
def is_closed(self, api_version: APIVersion):
|
||||
if api_version >= APIVersion(1, 1):
|
||||
return self.position == 0.0
|
||||
return self.legacy_state == LegacyCoverState.CLOSED
|
||||
|
||||
|
||||
# ==================== FAN ====================
|
||||
@attr.s
|
||||
class FanInfo(EntityInfo):
|
||||
supports_oscillation = attr.ib(type=bool)
|
||||
supports_speed = attr.ib(type=bool)
|
||||
|
||||
|
||||
class FanSpeed(enum.IntEnum):
|
||||
LOW = 0
|
||||
MEDIUM = 1
|
||||
HIGH = 2
|
||||
|
||||
|
||||
@attr.s
|
||||
class FanState(EntityState):
|
||||
state = attr.ib(type=bool)
|
||||
oscillating = attr.ib(type=bool)
|
||||
speed = attr.ib(type=FanSpeed, converter=FanSpeed)
|
||||
|
||||
|
||||
# ==================== LIGHT ====================
|
||||
@attr.s
|
||||
class LightInfo(EntityInfo):
|
||||
supports_brightness = attr.ib(type=bool)
|
||||
supports_rgb = attr.ib(type=bool)
|
||||
supports_white_value = attr.ib(type=bool)
|
||||
supports_color_temperature = attr.ib(type=bool)
|
||||
min_mireds = attr.ib(type=float)
|
||||
max_mireds = attr.ib(type=float)
|
||||
effects = attr.ib(type=List[str], converter=list)
|
||||
|
||||
|
||||
@attr.s
|
||||
class LightState(EntityState):
|
||||
state = attr.ib(type=bool)
|
||||
brightness = attr.ib(type=float)
|
||||
red = attr.ib(type=float)
|
||||
green = attr.ib(type=float)
|
||||
blue = attr.ib(type=float)
|
||||
white = attr.ib(type=float)
|
||||
color_temperature = attr.ib(type=float)
|
||||
effect = attr.ib(type=str)
|
||||
|
||||
|
||||
# ==================== SENSOR ====================
|
||||
@attr.s
|
||||
class SensorInfo(EntityInfo):
|
||||
icon = attr.ib(type=str)
|
||||
unit_of_measurement = attr.ib(type=str)
|
||||
accuracy_decimals = attr.ib(type=int)
|
||||
|
||||
|
||||
@attr.s
|
||||
class SensorState(EntityState):
|
||||
state = attr.ib(type=float)
|
||||
|
||||
|
||||
# ==================== SWITCH ====================
|
||||
@attr.s
|
||||
class SwitchInfo(EntityInfo):
|
||||
icon = attr.ib(type=str)
|
||||
assumed_state = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class SwitchState(EntityState):
|
||||
state = attr.ib(type=bool)
|
||||
|
||||
|
||||
# ==================== TEXT SENSOR ====================
|
||||
@attr.s
|
||||
class TextSensorInfo(EntityInfo):
|
||||
icon = attr.ib(type=str)
|
||||
|
||||
|
||||
@attr.s
|
||||
class TextSensorState(EntityState):
|
||||
state = attr.ib(type=str)
|
||||
|
||||
|
||||
# ==================== CAMERA ====================
|
||||
@attr.s
|
||||
class CameraInfo(EntityInfo):
|
||||
pass
|
||||
|
||||
|
||||
@attr.s
|
||||
class CameraState(EntityState):
|
||||
image = attr.ib(type=bytes)
|
||||
|
||||
|
||||
# ==================== CLIMATE ====================
|
||||
class ClimateMode(enum.IntEnum):
|
||||
OFF = 0
|
||||
AUTO = 1
|
||||
COOL = 2
|
||||
HEAT = 3
|
||||
|
||||
|
||||
def _convert_climate_modes(value):
|
||||
return [ClimateMode(val) for val in value]
|
||||
|
||||
|
||||
@attr.s
|
||||
class ClimateInfo(EntityInfo):
|
||||
supports_current_temperature = attr.ib(type=bool)
|
||||
supports_two_point_target_temperature = attr.ib(type=bool)
|
||||
supported_modes = attr.ib(type=List[ClimateMode], converter=_convert_climate_modes)
|
||||
visual_min_temperature = attr.ib(type=float)
|
||||
visual_max_temperature = attr.ib(type=float)
|
||||
visual_temperature_step = attr.ib(type=float)
|
||||
supports_away = attr.ib(type=bool)
|
||||
|
||||
|
||||
@attr.s
|
||||
class ClimateState(EntityState):
|
||||
mode = attr.ib(type=ClimateMode, converter=ClimateMode)
|
||||
current_temperature = attr.ib(type=float)
|
||||
target_temperature = attr.ib(type=float)
|
||||
target_temperature_low = attr.ib(type=float)
|
||||
target_temperature_high = attr.ib(type=float)
|
||||
away = attr.ib(type=bool)
|
||||
|
||||
|
||||
COMPONENT_TYPE_TO_INFO = {
|
||||
'binary_sensor': BinarySensorInfo,
|
||||
'cover': CoverInfo,
|
||||
'fan': FanInfo,
|
||||
'light': LightInfo,
|
||||
'sensor': SensorInfo,
|
||||
'switch': SwitchInfo,
|
||||
'text_sensor': TextSensorInfo,
|
||||
'camera': CameraInfo,
|
||||
'climate': ClimateInfo,
|
||||
}
|
||||
|
||||
|
||||
# ==================== USER-DEFINED SERVICES ====================
|
||||
@attr.s
|
||||
class ServiceCall:
|
||||
service = attr.ib(type=str)
|
||||
data = attr.ib(type=Dict[str, str], converter=dict)
|
||||
data_template = attr.ib(type=Dict[str, str], converter=dict)
|
||||
variables = attr.ib(type=Dict[str, str], converter=dict)
|
||||
|
||||
|
||||
class UserServiceArgType(enum.IntEnum):
|
||||
BOOL = 0
|
||||
INT = 1
|
||||
FLOAT = 2
|
||||
STRING = 3
|
||||
|
||||
|
||||
def _attr_obj_from_dict(cls, **kwargs):
|
||||
return cls(**{key: kwargs[key] for key in attr.fields_dict(cls)})
|
||||
|
||||
|
||||
@attr.s
|
||||
class UserServiceArg:
|
||||
name = attr.ib(type=str)
|
||||
type_ = attr.ib(type=UserServiceArgType, converter=UserServiceArgType)
|
||||
|
||||
|
||||
@attr.s
|
||||
class UserService:
|
||||
name = attr.ib(type=str)
|
||||
key = attr.ib(type=int)
|
||||
args = attr.ib(type=List[UserServiceArg], converter=list)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(dict_):
|
||||
args = []
|
||||
for arg in dict_.get('args', []):
|
||||
args.append(_attr_obj_from_dict(UserServiceArg, **arg))
|
||||
return UserService(
|
||||
name=dict_.get('name', ''),
|
||||
key=dict_.get('key', 0),
|
||||
args=args
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'key': self.key,
|
||||
'args': [attr.asdict(arg) for arg in self.args],
|
||||
}
|
60
aioesphomeapi/util.py
Normal file
60
aioesphomeapi/util.py
Normal file
@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
import socket
|
||||
from typing import Optional, Tuple, Any
|
||||
|
||||
from aioesphomeapi.core import APIConnectionError
|
||||
|
||||
|
||||
def _varuint_to_bytes(value: int) -> bytes:
|
||||
if value <= 0x7F:
|
||||
return bytes([value])
|
||||
|
||||
ret = bytes()
|
||||
while value:
|
||||
temp = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
ret += bytes([temp | 0x80])
|
||||
else:
|
||||
ret += bytes([temp])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def _bytes_to_varuint(value: bytes) -> Optional[int]:
|
||||
result = 0
|
||||
bitpos = 0
|
||||
for val in value:
|
||||
result |= (val & 0x7F) << bitpos
|
||||
bitpos += 7
|
||||
if (val & 0x80) == 0:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_ip_address_getaddrinfo(eventloop: asyncio.events.AbstractEventLoop,
|
||||
host: str, port: int) -> Tuple[Any, ...]:
|
||||
try:
|
||||
res = await eventloop.getaddrinfo(host, port, family=socket.AF_INET,
|
||||
proto=socket.IPPROTO_TCP)
|
||||
except OSError as err:
|
||||
raise APIConnectionError("Error resolving IP address: {}".format(err))
|
||||
|
||||
if not res:
|
||||
raise APIConnectionError("Error resolving IP address: No matches!")
|
||||
|
||||
_, _, _, _, sockaddr = res[0]
|
||||
|
||||
return sockaddr
|
||||
|
||||
|
||||
async def resolve_ip_address(eventloop: asyncio.events.AbstractEventLoop,
|
||||
host: str, port: int) -> Tuple[Any, ...]:
|
||||
try:
|
||||
return await resolve_ip_address_getaddrinfo(eventloop, host, port)
|
||||
except APIConnectionError as err:
|
||||
if host.endswith('.local'):
|
||||
from aioesphomeapi.host_resolver import resolve_host
|
||||
|
||||
return await eventloop.run_in_executor(None, resolve_host, host), port
|
||||
raise err
|
Loading…
Reference in New Issue
Block a user