diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 004162341b1..51f565a0980 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,7 +1,7 @@ """Support for esphome devices.""" import asyncio 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 voluptuous as vol @@ -13,6 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, \ from homeassistant.core import callback, Event, State import homeassistant.helpers.device_registry as dr from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ async_dispatcher_send @@ -28,9 +29,10 @@ from .config_flow import EsphomeFlowHandler # noqa if TYPE_CHECKING: 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__) @@ -69,6 +71,7 @@ class RuntimeEntryData: reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None) state = 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) device_info = attr.ib(type='DeviceInfo', default=None) 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) 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.""" # 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() if restored is None: - return [] + return [], [] self.device_info = _attr_obj_from_dict(DeviceInfo, **restored.pop('device_info')) @@ -123,17 +128,23 @@ class RuntimeEntryData: for info in restored_infos: cls = COMPONENT_TYPE_TO_INFO[comp_type] 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: """Generate dynamic data to store and save it to the filesystem.""" 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(): store_data[comp_type] = [attr.asdict(info) 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) @@ -233,8 +244,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry_data.device_info) 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) + await _setup_services(hass, entry_data, services) await cli.subscribe_states(async_on_state) await cli.subscribe_service_calls(async_on_service_call) await cli.subscribe_home_assistant_states( @@ -277,8 +289,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry, component)) 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) + await _setup_services(hass, entry_data, services) # If first connect fails, the next re-connect will be scheduled # 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, entry: ConfigEntry) -> None: """Cleanup the esphome client if it exists.""" diff --git a/requirements_all.txt b/requirements_all.txt index 1e9595418ca..48a2e6990c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -102,7 +102,7 @@ aioautomatic==0.6.5 aiodns==1.1.1 # homeassistant.components.esphome -aioesphomeapi==1.5.0 +aioesphomeapi==1.6.0 # homeassistant.components.freebox aiofreepybox==0.0.6