Add ESPHome User-Defined Services (#21409)

* Add ESPHome User-Defined Services

* Update requirements_all.txt
This commit is contained in:
Otto Winter 2019-02-25 19:34:06 +01:00 committed by Paulus Schoutsen
parent d3f1ee4a89
commit db4c06c8fe
2 changed files with 78 additions and 11 deletions

View File

@ -1,7 +1,7 @@
"""Support for esphome devices.""" """Support for esphome devices."""
import asyncio import asyncio
import logging import logging
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Callable from typing import Any, Dict, List, Optional, TYPE_CHECKING, Callable, Tuple
import attr import attr
import voluptuous as vol import voluptuous as vol
@ -13,6 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, \
from homeassistant.core import callback, Event, State from homeassistant.core import callback, Event, State
import homeassistant.helpers.device_registry as dr import homeassistant.helpers.device_registry as dr
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import template from homeassistant.helpers import template
from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ from homeassistant.helpers.dispatcher import async_dispatcher_connect, \
async_dispatcher_send async_dispatcher_send
@ -28,9 +29,10 @@ from .config_flow import EsphomeFlowHandler # noqa
if TYPE_CHECKING: if TYPE_CHECKING:
from aioesphomeapi import APIClient, EntityInfo, EntityState, DeviceInfo, \ from aioesphomeapi import APIClient, EntityInfo, EntityState, DeviceInfo, \
ServiceCall ServiceCall, UserService
REQUIREMENTS = ['aioesphomeapi==1.5.0'] DOMAIN = 'esphome'
REQUIREMENTS = ['aioesphomeapi==1.6.0']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -69,6 +71,7 @@ class RuntimeEntryData:
reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None) reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None)
state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict)
info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict)
services = attr.ib(type=Dict[int, 'UserService'], factory=dict)
available = attr.ib(type=bool, default=False) available = attr.ib(type=bool, default=False)
device_info = attr.ib(type='DeviceInfo', default=None) device_info = attr.ib(type='DeviceInfo', default=None)
cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list)
@ -105,14 +108,16 @@ class RuntimeEntryData:
signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id)
async_dispatcher_send(hass, signal) async_dispatcher_send(hass, signal)
async def async_load_from_store(self) -> List['EntityInfo']: async def async_load_from_store(self) -> Tuple[List['EntityInfo'],
List['UserService']]:
"""Load the retained data from store and return de-serialized data.""" """Load the retained data from store and return de-serialized data."""
# pylint: disable= redefined-outer-name # pylint: disable= redefined-outer-name
from aioesphomeapi import COMPONENT_TYPE_TO_INFO, DeviceInfo from aioesphomeapi import COMPONENT_TYPE_TO_INFO, DeviceInfo, \
UserService
restored = await self.store.async_load() restored = await self.store.async_load()
if restored is None: if restored is None:
return [] return [], []
self.device_info = _attr_obj_from_dict(DeviceInfo, self.device_info = _attr_obj_from_dict(DeviceInfo,
**restored.pop('device_info')) **restored.pop('device_info'))
@ -123,17 +128,23 @@ class RuntimeEntryData:
for info in restored_infos: for info in restored_infos:
cls = COMPONENT_TYPE_TO_INFO[comp_type] cls = COMPONENT_TYPE_TO_INFO[comp_type]
infos.append(_attr_obj_from_dict(cls, **info)) infos.append(_attr_obj_from_dict(cls, **info))
return infos services = []
for service in restored.get('services', []):
services.append(UserService.from_dict(service))
return infos, services
async def async_save_to_store(self) -> None: async def async_save_to_store(self) -> None:
"""Generate dynamic data to store and save it to the filesystem.""" """Generate dynamic data to store and save it to the filesystem."""
store_data = { store_data = {
'device_info': attr.asdict(self.device_info) 'device_info': attr.asdict(self.device_info),
'services': []
} }
for comp_type, infos in self.info.items(): for comp_type, infos in self.info.items():
store_data[comp_type] = [attr.asdict(info) store_data[comp_type] = [attr.asdict(info)
for info in infos.values()] for info in infos.values()]
for service in self.services.values():
store_data['services'].append(service.to_dict())
await self.store.async_save(store_data) await self.store.async_save(store_data)
@ -233,8 +244,9 @@ async def async_setup_entry(hass: HomeAssistantType,
entry_data.device_info) entry_data.device_info)
entry_data.async_update_device_state(hass) entry_data.async_update_device_state(hass)
entity_infos = await cli.list_entities() entity_infos, services = await cli.list_entities_services()
entry_data.async_update_static_infos(hass, entity_infos) entry_data.async_update_static_infos(hass, entity_infos)
await _setup_services(hass, entry_data, services)
await cli.subscribe_states(async_on_state) await cli.subscribe_states(async_on_state)
await cli.subscribe_service_calls(async_on_service_call) await cli.subscribe_service_calls(async_on_service_call)
await cli.subscribe_home_assistant_states( await cli.subscribe_home_assistant_states(
@ -277,8 +289,9 @@ async def async_setup_entry(hass: HomeAssistantType,
entry, component)) entry, component))
await asyncio.wait(tasks) await asyncio.wait(tasks)
infos = await entry_data.async_load_from_store() infos, services = await entry_data.async_load_from_store()
entry_data.async_update_static_infos(hass, infos) entry_data.async_update_static_infos(hass, infos)
await _setup_services(hass, entry_data, services)
# If first connect fails, the next re-connect will be scheduled # If first connect fails, the next re-connect will be scheduled
# outside of _pending_task, in order not to delay HA startup # outside of _pending_task, in order not to delay HA startup
@ -366,6 +379,60 @@ async def _async_setup_device_registry(hass: HomeAssistantType,
) )
async def _register_service(hass: HomeAssistantType,
entry_data: RuntimeEntryData,
service: 'UserService'):
from aioesphomeapi import USER_SERVICE_ARG_BOOL, USER_SERVICE_ARG_INT, \
USER_SERVICE_ARG_FLOAT, USER_SERVICE_ARG_STRING
service_name = '{}_{}'.format(entry_data.device_info.name, service.name)
schema = {}
for arg in service.args:
schema[vol.Required(arg.name)] = {
USER_SERVICE_ARG_BOOL: cv.boolean,
USER_SERVICE_ARG_INT: vol.Coerce(int),
USER_SERVICE_ARG_FLOAT: vol.Coerce(float),
USER_SERVICE_ARG_STRING: cv.string,
}[arg.type_]
async def execute_service(call):
await entry_data.client.execute_service(service, call.data)
hass.services.async_register(DOMAIN, service_name, execute_service,
vol.Schema(schema))
async def _setup_services(hass: HomeAssistantType,
entry_data: RuntimeEntryData,
services: List['UserService']):
old_services = entry_data.services.copy()
to_unregister = []
to_register = []
for service in services:
if service.key in old_services:
# Already exists
matching = old_services.pop(service.key)
if matching != service:
# Need to re-register
to_unregister.append(matching)
to_register.append(service)
else:
# New service
to_register.append(service)
for service in old_services.values():
to_unregister.append(service)
entry_data.services = {serv.key: serv for serv in services}
for service in to_unregister:
service_name = '{}_{}'.format(entry_data.device_info.name,
service.name)
hass.services.async_remove(DOMAIN, service_name)
for service in to_register:
await _register_service(hass, entry_data, service)
async def _cleanup_instance(hass: HomeAssistantType, async def _cleanup_instance(hass: HomeAssistantType,
entry: ConfigEntry) -> None: entry: ConfigEntry) -> None:
"""Cleanup the esphome client if it exists.""" """Cleanup the esphome client if it exists."""

View File

@ -102,7 +102,7 @@ aioautomatic==0.6.5
aiodns==1.1.1 aiodns==1.1.1
# homeassistant.components.esphome # homeassistant.components.esphome
aioesphomeapi==1.5.0 aioesphomeapi==1.6.0
# homeassistant.components.freebox # homeassistant.components.freebox
aiofreepybox==0.0.6 aiofreepybox==0.0.6