Big Refactor

This commit is contained in:
Otto Winter 2019-04-07 19:03:26 +02:00
parent 6789949b99
commit e74118923e
No known key found for this signature in database
GPG Key ID: DB66C0BE6013F97E
8 changed files with 1969 additions and 1695 deletions

View File

@ -1 +1,5 @@
from .client import *
from .connection import *
from .core import *
from .model import *
from .util import *

View File

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

View File

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