Merge branch 'master' into camera

# Conflicts:
#	aioesphomeapi/api_pb2.py
#	aioesphomeapi/client.py
This commit is contained in:
Otto Winter 2019-03-08 13:50:48 +01:00
commit 97c0f496a8
No known key found for this signature in database
GPG Key ID: DB66C0BE6013F97E
6 changed files with 528 additions and 205 deletions

View File

@ -92,7 +92,7 @@ message DeviceInfoResponse {
// A string describing the date of compilation, this is generated by the compiler
// and therefore may not be in the same format all the time.
// If the user isn't using esphomeyaml, this will also not be set.
// If the user isn't using ESPHome, this will also not be set.
string compilation_time = 5;
// The model of the board. For example NodeMCU
@ -339,3 +339,32 @@ message GetTimeResponse {
fixed32 epoch_seconds = 1;
}
message ListEntitiesServicesArgument {
string name = 1;
enum Type {
BOOL = 0;
INT = 1;
FLOAT = 2;
STRING = 3;
}
Type type = 2;
}
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;
}
message ExecuteServiceRequest {
fixed32 key = 1;
repeated ExecuteServiceArgument args = 2;
}

File diff suppressed because one or more lines are too long

View File

@ -57,8 +57,10 @@ MESSAGE_TYPE_TO_PROTO = {
38: pb.SubscribeHomeAssistantStatesRequest,
39: pb.SubscribeHomeAssistantStateResponse,
40: pb.HomeAssistantStateResponse,
41: pb.ListEntitiesCameraResponse,
42: pb.CameraImageResponse,
41: pb.ListEntitiesServicesResponse,
42: pb.ExecuteServiceRequest,
43: pb.ListEntitiesCameraResponse,
44: pb.CameraImageResponse,
}
@ -89,8 +91,8 @@ def _bytes_to_varuint(value: bytes) -> Optional[int]:
return None
async def resolve_ip_address(eventloop: asyncio.events.AbstractEventLoop,
host: str, port: int) -> Tuple[Any, ...]:
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)
@ -105,6 +107,18 @@ async def resolve_ip_address(eventloop: asyncio.events.AbstractEventLoop,
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:
@ -268,16 +282,49 @@ class ServiceCall:
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 State:
running = attr.ib(type=bool)
stopped = attr.ib(type=bool)
socket = attr.ib(type=Optional[socket.socket])
socket_reader = attr.ib(type=Optional[asyncio.StreamReader])
socket_writer = attr.ib(type=Optional[asyncio.StreamWriter])
socket_open = attr.ib(type=bool)
connected = attr.ib(type=bool)
authenticated = attr.ib(type=bool)
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
@ -365,7 +412,7 @@ class APIConnection:
try:
coro = resolve_ip_address(self._params.eventloop, self._params.address,
self._params.port)
sockaddr = await asyncio.wait_for(coro, 15.0)
sockaddr = await asyncio.wait_for(coro, 30.0)
except APIConnectionError as err:
await self._on_error()
raise err
@ -381,7 +428,7 @@ class APIConnection:
self._params.address, self._params.port, sockaddr)
try:
coro = self._params.eventloop.sock_connect(self._socket, sockaddr)
await asyncio.wait_for(coro, 15.0)
await asyncio.wait_for(coro, 30.0)
except OSError as err:
await self._on_error()
raise APIConnectionError("Error connecting to {}: {}".format(sockaddr, err))
@ -470,7 +517,7 @@ class APIConnection:
async def send_message_await_response_complex(self, send_msg: message.Message,
do_append: Callable[[Any], bool],
do_stop: Callable[[Any], bool],
timeout: float = 1.0) -> List[Any]:
timeout: float = 5.0) -> List[Any]:
fut = self._params.eventloop.create_future()
responses = []
@ -501,7 +548,7 @@ class APIConnection:
async def send_message_await_response(self,
send_msg: message.Message,
response_type: Any, timeout: float = 1.0) -> Any:
response_type: Any, timeout: float = 5.0) -> Any:
def is_response(msg):
return isinstance(msg, response_type)
@ -543,7 +590,10 @@ class APIConnection:
return
msg = MESSAGE_TYPE_TO_PROTO[msg_type]()
msg.ParseFromString(raw_msg)
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)
@ -558,6 +608,11 @@ class APIConnection:
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):
@ -605,10 +660,14 @@ class APIClient:
raise APIConnectionError("Already connected!")
connected = False
stopped = False
async def _on_stop():
if self._connection is None:
nonlocal stopped
if stopped:
return
stopped = True
self._connection = None
if connected and on_stop is not None:
await on_stop()
@ -658,7 +717,7 @@ class APIClient:
has_deep_sleep=resp.has_deep_sleep,
)
async def list_entities(self) -> List[Any]:
async def list_entities_services(self) -> Tuple[List[Any], List[UserService]]:
self._check_authenticated()
response_types = {
pb.ListEntitiesBinarySensorResponse: BinarySensorInfo,
@ -668,6 +727,7 @@ class APIClient:
pb.ListEntitiesSensorResponse: SensorInfo,
pb.ListEntitiesSwitchResponse: SwitchInfo,
pb.ListEntitiesTextSensorResponse: TextSensorInfo,
pb.ListEntitiesServicesResponse: None,
pb.ListEntitiesCameraResponse: CameraInfo,
}
@ -680,7 +740,21 @@ class APIClient:
resp = await self._connection.send_message_await_response_complex(
pb.ListEntitiesRequest(), do_append, do_stop, timeout=5)
entities = []
services = []
for msg in resp:
if isinstance(msg, pb.ListEntitiesServicesResponse):
args = []
for arg in msg.args:
args.append(UserServiceArg(
name=arg.name,
type_=arg.type,
))
services.append(UserService(
name=msg.name,
key=msg.key,
args=args,
))
continue
cls = None
for resp_type, cls in response_types.items():
if isinstance(msg, resp_type):
@ -689,7 +763,7 @@ class APIClient:
for key, _ in attr.fields_dict(cls).items():
kwargs[key] = getattr(msg, key)
entities.append(cls(**kwargs))
return entities
return entities, services
async def subscribe_states(self, on_state: Callable[[Any], None]) -> None:
self._check_authenticated()
@ -711,10 +785,7 @@ class APIClient:
data = image_stream.pop(msg.key, bytes()) + msg.data
if msg.done:
on_state(CameraState(key=msg.key, image=data))
hash_ = 0
for x in data:
hash_ ^= x
_LOGGER.warning("Got image hash=%s len=%s", hex(hash_), len(data))
_LOGGER.warning("Got image len=%s", len(data))
else:
image_stream[msg.key] = data
return
@ -847,10 +918,10 @@ class APIClient:
req.color_temperature = color_temperature
if transition_length is not None:
req.has_transition_length = True
req.transition_length = int(round(transition_length / 1000))
req.transition_length = int(round(transition_length * 1000))
if flash_length is not None:
req.has_flash_length = True
req.flash_length = int(round(flash_length / 1000))
req.flash_length = int(round(flash_length * 1000))
if effect is not None:
req.has_effect = True
req.effect = effect
@ -866,3 +937,23 @@ class APIClient:
req.key = key
req.state = state
await self._connection.send_message(req)
async def execute_service(self, service: UserService, data: dict):
self._check_authenticated()
req = pb.ExecuteServiceRequest()
req.key = service.key
args = []
for arg_desc in service.args:
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_',
}[arg_desc.type_]
setattr(arg, attr_, val)
args.append(arg)
req.args.extend(args)
await self._connection.send_message(req)

View File

@ -0,0 +1,74 @@
import socket
import time
import zeroconf
class HostResolver(zeroconf.RecordUpdateListener):
def __init__(self, name):
self.name = name
self.address = None
def update_record(self, zc, now, record):
if record is None:
return
if record.type == zeroconf._TYPE_A:
assert isinstance(record, zeroconf.DNSAddress)
if record.name == self.name:
self.address = record.address
def request(self, zc, timeout):
now = time.time()
delay = 0.2
next_ = now + delay
last = now + timeout
try:
zc.add_listener(self, zeroconf.DNSQuestion(self.name, zeroconf._TYPE_ANY,
zeroconf._CLASS_IN))
while self.address is None:
if last <= now:
# Timeout
return False
if next_ <= now:
out = zeroconf.DNSOutgoing(zeroconf._FLAGS_QR_QUERY)
out.add_question(
zeroconf.DNSQuestion(self.name, zeroconf._TYPE_A, zeroconf._CLASS_IN))
out.add_answer_at_time(
zc.cache.get_by_details(self.name, zeroconf._TYPE_A,
zeroconf._CLASS_IN), now)
zc.send(out)
next_ = now + delay
delay *= 2
zc.wait(min(next_, last) - now)
now = time.time()
finally:
zc.remove_listener(self)
return True
def resolve_host(host, timeout=3.0):
from aioesphomeapi import APIConnectionError
try:
zc = zeroconf.Zeroconf()
except Exception:
raise APIConnectionError("Cannot start mDNS sockets, is this a docker container without "
"host network mode?")
try:
info = HostResolver(host + '.')
address = None
if info.request(zc, timeout):
address = socket.inet_ntoa(info.address)
except Exception as err:
raise APIConnectionError("Error resolving mDNS hostname: {}".format(err))
finally:
zc.close()
if address is None:
raise APIConnectionError("Error resolving address with mDNS: Did not respond. "
"Maybe the device is offline.")
return address

View File

@ -1,2 +1,3 @@
protobuf
attrs
zeroconf>=0.21.3

View File

@ -2,16 +2,16 @@
"""aioesphomeapi setup script."""
from setuptools import find_packages, setup
VERSION = '1.4.1'
VERSION = '1.6.0'
PROJECT_NAME = 'aioesphomeapi'
PROJECT_PACKAGE_NAME = 'aioesphomeapi'
PROJECT_LICENSE = 'MIT'
PROJECT_AUTHOR = 'Otto Winter'
PROJECT_COPYRIGHT = ' 2018, Otto Winter'
PROJECT_URL = 'https://esphomelib.com/esphomeyaml/'
PROJECT_COPYRIGHT = ' 2019, Otto Winter'
PROJECT_URL = 'https://esphome.io/'
PROJECT_EMAIL = 'contact@otto-winter.com'
PROJECT_GITHUB_USERNAME = 'OttoWinter'
PROJECT_GITHUB_USERNAME = 'esphome'
PROJECT_GITHUB_REPOSITORY = 'aioesphomeapi'
PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME)
@ -22,7 +22,8 @@ DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, VERSION)
REQUIRES = [
'attrs',
'protobuf',
'protobuf>=3.6',
'zeroconf>=0.21.3',
]
setup(