From 8312a65187f7540089bfdd127427352e0ecc7f64 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 8 Dec 2019 23:45:24 +0000 Subject: [PATCH 01/23] initial changes --- homeassistant/components/evohome/__init__.py | 160 +++++++++++++++++- homeassistant/components/evohome/const.py | 2 + .../components/evohome/services.yaml | 50 ++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/evohome/services.yaml diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 3d903e86e3064..4a854f2a107f1 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -28,7 +28,16 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util -from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VERSION, TCS +from .const import ( + DOMAIN, + EVO_FOLLOW, + EVO_RESET, + EVO_SYSTEM_MODES, + GWS, + STORAGE_KEY, + STORAGE_VERSION, + TCS, +) _LOGGER = logging.getLogger(__name__) @@ -58,6 +67,46 @@ extra=vol.ALLOW_EXTRA, ) +# TODO: new from here +ATTR_SYSTEM_MODE = "mode" +ATTR_ZONE_ID = "zone_id" +ATTR_ZONE_NAME = "zone_name" +ATTR_ZONE_TEMP = "setpoint" + +ATTR_DURATION_DAYS = "days" +ATTR_DURATION_HOURS = "hours" + +ATTR_UNTIL_MINUTES = "duration" +ATTR_UNTIL_TIME = "until" + +HH_MM_REGEXP = r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$" # 24h format, with leading 0 + +SET_SYSTEM_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_SYSTEM_MODE): vol.In(EVO_SYSTEM_MODES), + vol.Exclusive(ATTR_DURATION_DAYS, "duration"): cv.positive_int, + vol.Exclusive(ATTR_DURATION_HOURS, "duration"): cv.positive_int, + } +) +RESET_SYSTEM_MODE_SCHEMA = vol.Schema({}) +SET_ZONE_MODE_SCHEMA = vol.Schema( + { + vol.Exclusive(ATTR_ZONE_ID, "zone"): cv.string, + vol.Exclusive(ATTR_ZONE_NAME, "zone"): cv.string, + vol.Required(ATTR_ZONE_TEMP): vol.Coerce(float), + vol.Exclusive(ATTR_UNTIL_MINUTES, "until"): cv.positive_int, + vol.Exclusive(ATTR_UNTIL_TIME, "until"): vol.All( + cv.string, vol.Match(HH_MM_REGEXP) + ), + } +) +RESET_ZONE_MODE_SCHEMA = vol.Schema({vol.Required(ATTR_ZONE_NAME): cv.string}) + +SVC_SET_SYSTEM_MODE = "set_system_mode" +SVC_RESET_SYSTEM_MODE = "reset_system_mode" +SVC_SET_ZONE_MODE = "set_zone_setpoint" +SVC_RESET_ZONE_MODE = "reset_zone_setpoint" + def _local_dt_to_aware(dt_naive: datetime) -> datetime: dt_aware = dt_util.now() + (dt_naive - datetime.now()) @@ -221,9 +270,118 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: broker.update, config[DOMAIN][CONF_SCAN_INTERVAL] ) + setup_service_functions(hass, broker) + return True +def setup_service_functions(hass: HomeAssistantType, broker): + """Set up the service functions.""" + + # async def _call_client_api(self, api_function, refresh=True) -> Any: + # try: + # result = await api_function + # except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + # if not _handle_exception(err): + # return + + # if refresh is True: + # self.hass.helpers.event.async_call_later(1, self._evo_broker.update()) + + # return result + + def _clean_dt(dtm): + if dtm is not None: + format_str = "%Y-%m-%d %H:%M:00" # round down to the nearest minute + dtm = datetime.strptime(datetime.strftime(dtm, format_str), format_str) + if dtm < datetime.now(): + dtm += timedelta(days=1) + return dtm + + async def set_system_mode(call): + """Set the system mode.""" + _LOGGER.warn("service = %s, data = %s", call.service, call.data) + + if call.service == SVC_SET_SYSTEM_MODE: + mode = call.data[ATTR_SYSTEM_MODE] + else: + mode = EVO_RESET + + tcs = broker.tcs + + try: + attrs = [m for m in tcs.allowedSystemModes if m["systemMode"] == mode][0] + except IndexError: + raise ValueError(f"'{mode}' mode is not supported by this system") + + until = None + # the following two are mutually exclusive + if ATTR_DURATION_DAYS in call.data: + if attrs.get("timingResolution") != "1.00:00:00": + raise ValueError(f"'{mode}' mode does not support days, only hours") + duration = min(call.data[ATTR_DURATION_DAYS], 99) + if duration: + until = datetime.now() + timedelta(days=duration) + + if ATTR_DURATION_HOURS in call.data: + if attrs.get("timingResolution") != "01:00:00": + raise ValueError(f"'{mode}' mode does not support hours, only days") + duration = min(call.data[ATTR_DURATION_HOURS], 24) + if duration: # can be 0 + until = datetime.now() + timedelta(hours=duration) + + _LOGGER.warn("tcs._set_status(%s, %s)", mode, _clean_dt(until)) + # await broker._call_client_api(tcs._set_status(mode, _clean_dt(until))) + + async def set_zone_setpoint(call): + """Set the system mode.""" + _LOGGER.warn("service = %s, data = %s", call.service, call.data) + + zone_name = call.data[ATTR_ZONE_NAME] + try: + zone = broker.tcs.zones[zone_name] + except KeyError: + raise KeyError(f"'{zone_name}' is not a known zone") + + if call.service == SVC_RESET_ZONE_MODE: + _LOGGER.warn("zone.cancel_temp_override()") + # await broker._call_client_api(zone.cancel_temp_override()) + return + + attrs = zone.setpointCapabilities + resolution = attrs["valueResolution"] + temp = round(call.data[ATTR_ZONE_TEMP] * resolution) / resolution + temp = max(min(temp, attrs["maxHeatSetpoint"]), attrs["minHeatSetpoint"]) + + until = None + # the following two are mutually exclusive + if ATTR_UNTIL_MINUTES in call.data: + duration = min(call.data[ATTR_UNTIL_MINUTES], 24 * 60) + if duration: + until = datetime.now() + timedelta(minutes=duration) + + if ATTR_UNTIL_TIME in call.data: + until_time = call.data[ATTR_UNTIL_TIME] + until_str = datetime.strftime(datetime.now(), "%Y-%m-%d ") + until = datetime.strptime(until_str + until_time, "%Y-%m-%d %H:%M") + + _LOGGER.warn("zone.set_temperature(%s, %s)", temp, _clean_dt(until)) + # broker._call_client_api(zone.set_temperature(temp, _clean_dt(until))) + + hass.services.async_register( + DOMAIN, SVC_SET_SYSTEM_MODE, set_system_mode, schema=SET_SYSTEM_MODE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SVC_RESET_SYSTEM_MODE, set_system_mode, schema=RESET_SYSTEM_MODE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SVC_SET_ZONE_MODE, set_zone_setpoint, schema=SET_ZONE_MODE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SVC_RESET_ZONE_MODE, set_zone_setpoint, schema=RESET_ZONE_MODE_SCHEMA + ) + + class EvoBroker: """Container for evohome client and data.""" diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 444671cf82aa8..e966f16a23ccb 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -13,6 +13,8 @@ EVO_CUSTOM = "Custom" EVO_HEATOFF = "HeatingOff" +EVO_SYSTEM_MODES = [EVO_AUTOECO, EVO_AWAY, EVO_DAYOFF, EVO_HEATOFF, EVO_CUSTOM] + # The Childs' operating mode is one of: EVO_FOLLOW = "FollowSchedule" # the operating mode is 'inherited' from the TCS EVO_TEMPOVER = "TemporaryOverride" diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml new file mode 100644 index 0000000000000..06f50c4f3dd81 --- /dev/null +++ b/homeassistant/components/evohome/services.yaml @@ -0,0 +1,50 @@ +# Describes the format for available services + +set_system_mode: + description: 'Set the system to a mode, either indefinitely, or for a period of time + after which the system will revert to "Auto" mode.' + fields: + mode: + description: 'One of "AutoWithEco", "Away", "DayOff", "HeatingOff", or "Custom".' + example: Away + days: + description: 'The duration in days (today is day 1). Only used for "Away", + "DayOff", or "Custom" modes. If unset (or equal to 0), the mode + remains enabled indefinitely.' + example: 2 + hours: + description: 'The duration in hours. Only used for "AutoWithEco" mode. If unset + (or set to 0), the mode remains enabled indefinitely.' + example: + +reset_system_mode: + description: 'Set the system to "Auto" (i.e. "AutoWithReset") mode and set all the + zones to "FollowSchedule" mode.' + +set_zone_setpoint: + description: 'Set a zone to a setpoint, either indefinitely ("PermanentOverride"), or + for a period of time ("TemporaryOverride") after which the zone will + revert to "FollowSchedule" mode.' + fields: + zone_name: + description: 'The name of the zone.' + example: 'Master Bedroom' + setpoint: + description: 'The temperature to be used instead of the scheduled setpoint.' + example: 5.0 + duration: + description: 'The zone will resume "FollowSchedule" mode after this many + minutes. If unset (or equal to 0), the change is permanent.' + example: 60 + until: + description: 'The zone will resume "FollowSchedule" mode after this time (HH:MM) + If unset, the change is permanent.' + example: + +reset_zone_setpoint: + description: 'Set a zone to "FollowSchedule" mode.' + fields: + zone_name: + description: 'The name of the zone.' + example: 'Master Bedroom' + From f05cf837f072088d7c611fc0e423db99c1467eef Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 9 Dec 2019 23:45:53 +0000 Subject: [PATCH 02/23] initial changes --- homeassistant/components/evohome/__init__.py | 145 +++++++++--------- homeassistant/components/evohome/climate.py | 16 +- homeassistant/components/evohome/const.py | 10 +- .../components/evohome/services.yaml | 59 +++---- .../components/evohome/water_heater.py | 14 +- 5 files changed, 129 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 4a854f2a107f1..3c2a6a9af4ccc 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -2,7 +2,7 @@ Such systems include evohome (multi-zone), and Round Thermostat (single zone). """ -from datetime import datetime, timedelta +from datetime import datetime as dt, timedelta import logging import re from typing import Any, Dict, Optional, Tuple @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, @@ -67,56 +68,57 @@ extra=vol.ALLOW_EXTRA, ) -# TODO: new from here ATTR_SYSTEM_MODE = "mode" -ATTR_ZONE_ID = "zone_id" -ATTR_ZONE_NAME = "zone_name" -ATTR_ZONE_TEMP = "setpoint" - ATTR_DURATION_DAYS = "days" ATTR_DURATION_HOURS = "hours" +ATTR_ZONE_TEMP = "setpoint" ATTR_UNTIL_MINUTES = "duration" ATTR_UNTIL_TIME = "until" +SVC_SET_SYSTEM_MODE = "set_system_mode" +SVC_RESET_SYSTEM = "reset_system" +SVC_SET_ZONE_MODE = "set_zone_override" +SVC_RESET_ZONE_MODE = "clear_zone_override" + HH_MM_REGEXP = r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$" # 24h format, with leading 0 SET_SYSTEM_MODE_SCHEMA = vol.Schema( { vol.Required(ATTR_SYSTEM_MODE): vol.In(EVO_SYSTEM_MODES), - vol.Exclusive(ATTR_DURATION_DAYS, "duration"): cv.positive_int, - vol.Exclusive(ATTR_DURATION_HOURS, "duration"): cv.positive_int, + vol.Exclusive(ATTR_DURATION_DAYS, "duration"): vol.All( + cv.positive_int, vol.Range(min=1, max=99) + ), + vol.Exclusive(ATTR_DURATION_HOURS, "duration"): vol.All( + cv.positive_int, vol.Range(min=1, max=24) + ), } ) -RESET_SYSTEM_MODE_SCHEMA = vol.Schema({}) +RESET_SYSTEM_SCHEMA = vol.Schema({}) SET_ZONE_MODE_SCHEMA = vol.Schema( { - vol.Exclusive(ATTR_ZONE_ID, "zone"): cv.string, - vol.Exclusive(ATTR_ZONE_NAME, "zone"): cv.string, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, vol.Required(ATTR_ZONE_TEMP): vol.Coerce(float), - vol.Exclusive(ATTR_UNTIL_MINUTES, "until"): cv.positive_int, + vol.Exclusive(ATTR_UNTIL_MINUTES, "until"): vol.All( + cv.positive_int, vol.Range(min=0, max=24 * 60) + ), vol.Exclusive(ATTR_UNTIL_TIME, "until"): vol.All( cv.string, vol.Match(HH_MM_REGEXP) ), } ) -RESET_ZONE_MODE_SCHEMA = vol.Schema({vol.Required(ATTR_ZONE_NAME): cv.string}) - -SVC_SET_SYSTEM_MODE = "set_system_mode" -SVC_RESET_SYSTEM_MODE = "reset_system_mode" -SVC_SET_ZONE_MODE = "set_zone_setpoint" -SVC_RESET_ZONE_MODE = "reset_zone_setpoint" +RESET_ZONE_MODE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) -def _local_dt_to_aware(dt_naive: datetime) -> datetime: - dt_aware = dt_util.now() + (dt_naive - datetime.now()) +def _local_dt_to_aware(dt_naive: dt) -> dt: + dt_aware = dt_util.now() + (dt_naive - dt.now()) if dt_aware.microsecond >= 500000: dt_aware += timedelta(seconds=1) return dt_aware.replace(microsecond=0) -def _dt_to_local_naive(dt_aware: datetime) -> datetime: - dt_naive = datetime.now() + (dt_aware - dt_util.now()) +def _dt_to_local_naive(dt_aware: dt) -> dt: + dt_naive = dt.now() + (dt_aware - dt_util.now()) if dt_naive.microsecond >= 500000: dt_naive += timedelta(seconds=1) return dt_naive.replace(microsecond=0) @@ -278,23 +280,11 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: def setup_service_functions(hass: HomeAssistantType, broker): """Set up the service functions.""" - # async def _call_client_api(self, api_function, refresh=True) -> Any: - # try: - # result = await api_function - # except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: - # if not _handle_exception(err): - # return - - # if refresh is True: - # self.hass.helpers.event.async_call_later(1, self._evo_broker.update()) - - # return result - def _clean_dt(dtm): if dtm is not None: format_str = "%Y-%m-%d %H:%M:00" # round down to the nearest minute - dtm = datetime.strptime(datetime.strftime(dtm, format_str), format_str) - if dtm < datetime.now(): + dtm = dt.strptime(dt.strftime(dtm, format_str), format_str) + if dtm < dt.now(): dtm += timedelta(days=1) return dtm @@ -312,40 +302,41 @@ async def set_system_mode(call): try: attrs = [m for m in tcs.allowedSystemModes if m["systemMode"] == mode][0] except IndexError: - raise ValueError(f"'{mode}' mode is not supported by this system") + raise KeyError(f"'{mode}' mode is not supported by this system") until = None - # the following two are mutually exclusive + # the following two attrs are mutually exclusive if ATTR_DURATION_DAYS in call.data: if attrs.get("timingResolution") != "1.00:00:00": - raise ValueError(f"'{mode}' mode does not support days, only hours") - duration = min(call.data[ATTR_DURATION_DAYS], 99) - if duration: - until = datetime.now() + timedelta(days=duration) + raise TypeError(f"'{mode}' mode does not support days, only hours") + until = dt.combine(dt.now().date(), dt.min.time()) + until += timedelta(days=call.data[ATTR_DURATION_DAYS]) if ATTR_DURATION_HOURS in call.data: if attrs.get("timingResolution") != "01:00:00": - raise ValueError(f"'{mode}' mode does not support hours, only days") - duration = min(call.data[ATTR_DURATION_HOURS], 24) - if duration: # can be 0 - until = datetime.now() + timedelta(hours=duration) + raise TypeError(f"'{mode}' mode does not support hours, only days") + until = dt.now() + timedelta(hours=call.data[ATTR_DURATION_HOURS]) - _LOGGER.warn("tcs._set_status(%s, %s)", mode, _clean_dt(until)) - # await broker._call_client_api(tcs._set_status(mode, _clean_dt(until))) + _LOGGER.warn("tcs._set_status('%s', %s)", mode, _clean_dt(until)) + await broker.call_client_api( + tcs._set_status(mode, _clean_dt(until)) + ) # pylint: disable=protected-access async def set_zone_setpoint(call): """Set the system mode.""" _LOGGER.warn("service = %s, data = %s", call.service, call.data) - zone_name = call.data[ATTR_ZONE_NAME] + entity_id = call.data[ATTR_ENTITY_ID] + + zone_id = broker.hass.data["entity_registry"].entities[entity_id].unique_id try: - zone = broker.tcs.zones[zone_name] + zone = broker.tcs.zones_by_id[zone_id] except KeyError: - raise KeyError(f"'{zone_name}' is not a known zone") + raise KeyError(f"'{entity_id}' is not a known evohome zone") if call.service == SVC_RESET_ZONE_MODE: _LOGGER.warn("zone.cancel_temp_override()") - # await broker._call_client_api(zone.cancel_temp_override()) + await broker.call_client_api(zone.cancel_temp_override()) return attrs = zone.setpointCapabilities @@ -354,25 +345,28 @@ async def set_zone_setpoint(call): temp = max(min(temp, attrs["maxHeatSetpoint"]), attrs["minHeatSetpoint"]) until = None - # the following two are mutually exclusive + # the following two attrs are mutually exclusive if ATTR_UNTIL_MINUTES in call.data: - duration = min(call.data[ATTR_UNTIL_MINUTES], 24 * 60) - if duration: - until = datetime.now() + timedelta(minutes=duration) + duration = call.data[ATTR_UNTIL_MINUTES] + if duration == 0: + until = 0 # until next setpoint + else: + until = dt.now() + timedelta(minutes=duration) if ATTR_UNTIL_TIME in call.data: - until_time = call.data[ATTR_UNTIL_TIME] - until_str = datetime.strftime(datetime.now(), "%Y-%m-%d ") - until = datetime.strptime(until_str + until_time, "%Y-%m-%d %H:%M") + until = dt.strptime( + dt.strftime(dt.now(), "%Y-%m-%d ") + call.data[ATTR_UNTIL_TIME], + "%Y-%m-%d %H:%M", + ) - _LOGGER.warn("zone.set_temperature(%s, %s)", temp, _clean_dt(until)) - # broker._call_client_api(zone.set_temperature(temp, _clean_dt(until))) + _LOGGER.warn("zone.set_temperature('%s', %s)", temp, _clean_dt(until)) + broker.call_client_api(zone.set_temperature(temp, _clean_dt(until))) hass.services.async_register( DOMAIN, SVC_SET_SYSTEM_MODE, set_system_mode, schema=SET_SYSTEM_MODE_SCHEMA ) hass.services.async_register( - DOMAIN, SVC_RESET_SYSTEM_MODE, set_system_mode, schema=RESET_SYSTEM_MODE_SCHEMA + DOMAIN, SVC_RESET_SYSTEM, set_system_mode, schema=RESET_SYSTEM_SCHEMA ) hass.services.async_register( DOMAIN, SVC_SET_ZONE_MODE, set_zone_setpoint, schema=SET_ZONE_MODE_SCHEMA @@ -418,6 +412,19 @@ async def save_auth_tokens(self) -> None: await self._store.async_save(app_storage) + async def call_client_api(self, api_function, refresh=True) -> Any: + """Call a client API.""" + try: + result = await api_function + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + if not _handle_exception(err): + return + + if refresh is True: + self.hass.helpers.event.async_call_later(1, self.update()) + + return result + async def _update_v1(self, *args, **kwargs) -> None: """Get the latest high-precision temperatures of the default Location.""" @@ -565,18 +572,6 @@ def temperature_unit(self) -> str: """Return the temperature unit to use in the frontend UI.""" return TEMP_CELSIUS - async def _call_client_api(self, api_function, refresh=True) -> Any: - try: - result = await api_function - except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: - if not _handle_exception(err): - return - - if refresh is True: - self.hass.helpers.event.async_call_later(1, self._evo_broker.update()) - - return result - class EvoChild(EvoDevice): """Base for any evohome child. @@ -660,7 +655,7 @@ async def _update_schedule(self) -> None: if not self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: return # avoid unnecessary I/O - there's nothing to update - self._schedule = await self._call_client_api( + self._schedule = await self._evo_broker.call_client_api( self._evo_device.schedule(), refresh=False ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 3da11bc80874c..f6c93a17bf9b4 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -121,7 +121,7 @@ def __init__(self, evo_broker, evo_device) -> None: async def _set_tcs_mode(self, op_mode: str) -> None: """Set a Controller to any of its native EVO_* operating modes.""" - await self._call_client_api(self._evo_tcs.set_status(op_mode)) + await self._evo_broker.call_client_api(self._evo_tcs.set_status(op_mode)) @property def hvac_modes(self) -> List[str]: @@ -215,7 +215,7 @@ async def async_set_temperature(self, **kwargs) -> None: else: # EVO_PERMOVER until = None - await self._call_client_api( + await self._evo_broker.call_client_api( self._evo_device.set_temperature(temperature, until) ) @@ -237,18 +237,22 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: and 'Away', Zones to (by default) 12C. """ if hvac_mode == HVAC_MODE_OFF: - await self._call_client_api( + await self._evo_broker.call_client_api( self._evo_device.set_temperature(self.min_temp, until=None) ) else: # HVAC_MODE_HEAT - await self._call_client_api(self._evo_device.cancel_temp_override()) + await self._evo_broker.call_client_api( + self._evo_device.cancel_temp_override() + ) async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: """Set the preset mode; if None, then revert to following the schedule.""" evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW) if evo_preset_mode == EVO_FOLLOW: - await self._call_client_api(self._evo_device.cancel_temp_override()) + await self._evo_broker.call_client_api( + self._evo_device.cancel_temp_override() + ) return temperature = self._evo_device.setpointStatus["targetHeatTemperature"] @@ -259,7 +263,7 @@ async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: else: # EVO_PERMOVER until = None - await self._call_client_api( + await self._evo_broker.call_client_api( self._evo_device.set_temperature(temperature, until) ) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index e966f16a23ccb..79af61ff4453b 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -13,7 +13,15 @@ EVO_CUSTOM = "Custom" EVO_HEATOFF = "HeatingOff" -EVO_SYSTEM_MODES = [EVO_AUTOECO, EVO_AWAY, EVO_DAYOFF, EVO_HEATOFF, EVO_CUSTOM] +EVO_SYSTEM_MODES = [ + EVO_RESET, + EVO_AUTO, + EVO_AUTOECO, + EVO_AWAY, + EVO_DAYOFF, + EVO_CUSTOM, + EVO_HEATOFF, +] # The Childs' operating mode is one of: EVO_FOLLOW = "FollowSchedule" # the operating mode is 'inherited' from the TCS diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index 06f50c4f3dd81..d06531d6ab964 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -1,50 +1,53 @@ # Describes the format for available services set_system_mode: - description: 'Set the system to a mode, either indefinitely, or for a period of time - after which the system will revert to "Auto" mode.' + description: >- + Set the system mode, either indefinitely, or for a set period, after which it will + revert to Auto. fields: mode: - description: 'One of "AutoWithEco", "Away", "DayOff", "HeatingOff", or "Custom".' + description: 'One of: Auto, AutoWithEco, Away, DayOff, HeatingOff, or Custom.' example: Away days: - description: 'The duration in days (today is day 1). Only used for "Away", - "DayOff", or "Custom" modes. If unset (or equal to 0), the mode - remains enabled indefinitely.' + description: >- + The duration in days; used with Away, DayOff, or Custom. The system will + revert to Auto at midnight (today is day 1). example: 2 hours: - description: 'The duration in hours. Only used for "AutoWithEco" mode. If unset - (or set to 0), the mode remains enabled indefinitely.' + description: The duration in hours; used only with AutoWithEco. example: -reset_system_mode: - description: 'Set the system to "Auto" (i.e. "AutoWithReset") mode and set all the - zones to "FollowSchedule" mode.' +reset_system: + description: >- + Set the system to Auto mode and reset all the zones to follow their schedules + (i.e. AutoWithReset mode). -set_zone_setpoint: - description: 'Set a zone to a setpoint, either indefinitely ("PermanentOverride"), or - for a period of time ("TemporaryOverride") after which the zone will - revert to "FollowSchedule" mode.' +set_zone_override: + description: >- + Override a zone's setpoint, either indefinitely, or for a set period, after which + it will revert to following its schedule. fields: - zone_name: - description: 'The name of the zone.' - example: 'Master Bedroom' + entity_id: + description: The entity_id of the evohome zone. + example: climate.bathroom setpoint: - description: 'The temperature to be used instead of the scheduled setpoint.' + description: The temperature to be used instead of the scheduled setpoint. example: 5.0 duration: - description: 'The zone will resume "FollowSchedule" mode after this many - minutes. If unset (or equal to 0), the change is permanent.' + description: >- + The zone will revert to its schedule after this many minutes. If 0 the change + is until the next scheduled setpoint. example: 60 until: - description: 'The zone will resume "FollowSchedule" mode after this time (HH:MM) - If unset, the change is permanent.' + description: >- + Used as an alternative to duration; the zone will revert after this time + (HH:MM). example: -reset_zone_setpoint: - description: 'Set a zone to "FollowSchedule" mode.' +clear_zone_override: + description: Set a zone to follow its schedule. fields: - zone_name: - description: 'The name of the zone.' - example: 'Master Bedroom' + entity_id: + description: The entity_id of the zone. + example: climate.bathroom diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index e29dbb49af262..1b0894b789860 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -88,23 +88,27 @@ async def async_set_operation_mode(self, operation_mode: str) -> None: Except for Auto, the mode is only until the next SetPoint. """ if operation_mode == STATE_AUTO: - await self._call_client_api(self._evo_device.set_dhw_auto()) + await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) else: await self._update_schedule() until = parse_datetime(str(self.setpoints.get("next_sp_from"))) if operation_mode == STATE_ON: - await self._call_client_api(self._evo_device.set_dhw_on(until)) + await self._evo_broker.call_client_api( + self._evo_device.set_dhw_on(until) + ) else: # STATE_OFF - await self._call_client_api(self._evo_device.set_dhw_off(until)) + await self._evo_broker.call_client_api( + self._evo_device.set_dhw_off(until) + ) async def async_turn_away_mode_on(self): """Turn away mode on.""" - await self._call_client_api(self._evo_device.set_dhw_off()) + await self._evo_broker.call_client_api(self._evo_device.set_dhw_off()) async def async_turn_away_mode_off(self): """Turn away mode off.""" - await self._call_client_api(self._evo_device.set_dhw_auto()) + await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) async def async_update(self) -> None: """Get the latest state data for a DHW controller.""" From b2efca09d6c72ed91ae7d3bed8ed11cf43dd2bfe Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 9 Dec 2019 23:46:30 +0000 Subject: [PATCH 03/23] initial changes --- homeassistant/components/evohome/services.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index d06531d6ab964..b5466eca41394 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -50,4 +50,3 @@ clear_zone_override: entity_id: description: The entity_id of the zone. example: climate.bathroom - From 5e85b196afa7dc59fdb45721d8a323497b056736 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 10 Dec 2019 12:30:21 +0000 Subject: [PATCH 04/23] ready to submit --- homeassistant/components/evohome/__init__.py | 48 +++++++++++-------- homeassistant/components/evohome/climate.py | 10 +++- .../components/evohome/services.yaml | 4 ++ 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 3c2a6a9af4ccc..c03fcb6491182 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -76,6 +76,7 @@ ATTR_UNTIL_MINUTES = "duration" ATTR_UNTIL_TIME = "until" +SVC_REFRESH_SYSTEM = "force_refresh" SVC_SET_SYSTEM_MODE = "set_system_mode" SVC_RESET_SYSTEM = "reset_system" SVC_SET_ZONE_MODE = "set_zone_override" @@ -94,7 +95,6 @@ ), } ) -RESET_SYSTEM_SCHEMA = vol.Schema({}) SET_ZONE_MODE_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, @@ -288,17 +288,18 @@ def _clean_dt(dtm): dtm += timedelta(days=1) return dtm + async def force_refresh(call): + """Obtain the latest state data via the vendor's RESTful API.""" + await broker.update() + async def set_system_mode(call): """Set the system mode.""" - _LOGGER.warn("service = %s, data = %s", call.service, call.data) - if call.service == SVC_SET_SYSTEM_MODE: mode = call.data[ATTR_SYSTEM_MODE] else: mode = EVO_RESET tcs = broker.tcs - try: attrs = [m for m in tcs.allowedSystemModes if m["systemMode"] == mode][0] except IndexError: @@ -309,6 +310,7 @@ async def set_system_mode(call): if ATTR_DURATION_DAYS in call.data: if attrs.get("timingResolution") != "1.00:00:00": raise TypeError(f"'{mode}' mode does not support days, only hours") + until = dt.combine(dt.now().date(), dt.min.time()) until += timedelta(days=call.data[ATTR_DURATION_DAYS]) @@ -317,29 +319,26 @@ async def set_system_mode(call): raise TypeError(f"'{mode}' mode does not support hours, only days") until = dt.now() + timedelta(hours=call.data[ATTR_DURATION_HOURS]) - _LOGGER.warn("tcs._set_status('%s', %s)", mode, _clean_dt(until)) await broker.call_client_api( - tcs._set_status(mode, _clean_dt(until)) - ) # pylint: disable=protected-access + tcs._set_status(mode, _clean_dt(until)) # pylint: disable=protected-access + ) async def set_zone_setpoint(call): """Set the system mode.""" - _LOGGER.warn("service = %s, data = %s", call.service, call.data) - entity_id = call.data[ATTR_ENTITY_ID] - zone_id = broker.hass.data["entity_registry"].entities[entity_id].unique_id try: - zone = broker.tcs.zones_by_id[zone_id] + unique_id = hass.data["entity_registry"].entities[entity_id].unique_id + zone_entity = broker.entities[unique_id] + zone_device = broker.tcs.zones_by_id[unique_id] except KeyError: raise KeyError(f"'{entity_id}' is not a known evohome zone") if call.service == SVC_RESET_ZONE_MODE: - _LOGGER.warn("zone.cancel_temp_override()") - await broker.call_client_api(zone.cancel_temp_override()) + await broker.call_client_api(zone_device.cancel_temp_override()) return - attrs = zone.setpointCapabilities + attrs = zone_device.setpointCapabilities resolution = attrs["valueResolution"] temp = round(call.data[ATTR_ZONE_TEMP] * resolution) / resolution temp = max(min(temp, attrs["maxHeatSetpoint"]), attrs["minHeatSetpoint"]) @@ -348,8 +347,11 @@ async def set_zone_setpoint(call): # the following two attrs are mutually exclusive if ATTR_UNTIL_MINUTES in call.data: duration = call.data[ATTR_UNTIL_MINUTES] - if duration == 0: - until = 0 # until next setpoint + if duration == 0: # until next setpoint + await zone_entity._update_schedule() # pylint: disable=protected-access + until = dt_util.parse_datetime( + str(zone_entity.setpoints.get("next_sp_from")) + ) else: until = dt.now() + timedelta(minutes=duration) @@ -359,14 +361,18 @@ async def set_zone_setpoint(call): "%Y-%m-%d %H:%M", ) - _LOGGER.warn("zone.set_temperature('%s', %s)", temp, _clean_dt(until)) - broker.call_client_api(zone.set_temperature(temp, _clean_dt(until))) + await broker.call_client_api( + zone_device.set_temperature(temp, _clean_dt(until)) + ) + hass.services.async_register( + DOMAIN, SVC_REFRESH_SYSTEM, force_refresh, schema=vol.Schema({}) + ) hass.services.async_register( DOMAIN, SVC_SET_SYSTEM_MODE, set_system_mode, schema=SET_SYSTEM_MODE_SCHEMA ) hass.services.async_register( - DOMAIN, SVC_RESET_SYSTEM, set_system_mode, schema=RESET_SYSTEM_SCHEMA + DOMAIN, SVC_RESET_SYSTEM, set_system_mode, schema=vol.Schema({}) ) hass.services.async_register( DOMAIN, SVC_SET_ZONE_MODE, set_zone_setpoint, schema=SET_ZONE_MODE_SCHEMA @@ -390,7 +396,9 @@ def __init__(self, hass, client, client_v1, store, params) -> None: loc_idx = params[CONF_LOCATION_IDX] self.config = client.installation_info[loc_idx][GWS][0][TCS][0] self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] - self.temps = None + self.temps = {} + + self._entities = {} async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index f6c93a17bf9b4..69932825e8173 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -90,11 +90,14 @@ async def async_setup_platform( zone.zoneId, zone.name, ) + new_entity = EvoThermostat(broker, zone) + broker.entities = {zone.zoneId: new_entity} - async_add_entities([EvoThermostat(broker, zone)], update_before_add=True) + async_add_entities([new_entity], update_before_add=True) return controller = EvoController(broker, broker.tcs) + broker.entities = {broker.tcs.systemId: controller} zones = [] for zone in broker.tcs.zones.values(): @@ -105,7 +108,10 @@ async def async_setup_platform( zone.zoneId, zone.name, ) - zones.append(EvoZone(broker, zone)) + new_entity = EvoZone(broker, zone) + broker.entities[zone.zoneId] = new_entity + + zones.append(new_entity) async_add_entities([controller] + zones, update_before_add=True) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index b5466eca41394..ecbf0035b06c2 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -22,6 +22,10 @@ reset_system: Set the system to Auto mode and reset all the zones to follow their schedules (i.e. AutoWithReset mode). +force_refresh: + description: >- + Pull the latest data from the vendor's server's. + set_zone_override: description: >- Override a zone's setpoint, either indefinitely, or for a set period, after which From 440c024329d7eeb6871533b86601de128d4cb5bf Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 10 Dec 2019 12:50:53 +0000 Subject: [PATCH 05/23] small tweak --- homeassistant/components/evohome/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index ecbf0035b06c2..a3f0dcd912ab2 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -24,7 +24,7 @@ reset_system: force_refresh: description: >- - Pull the latest data from the vendor's server's. + Pull the latest data from the vendor's servers. set_zone_override: description: >- From aca00b9f9e279bf76d442c926104e28faaf35e0c Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 10 Dec 2019 12:55:10 +0000 Subject: [PATCH 06/23] add HW as a citizen --- homeassistant/components/evohome/water_heater.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 1b0894b789860..fcf506621b73b 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -38,10 +38,10 @@ async def async_setup_platform( broker.tcs.hotwater.zone_type, broker.tcs.hotwater.zoneId, ) + new_entity = EvoDHW(broker, broker.tcs.hotwater) + broker.entities = {broker.tcs.hotwater.zoneId: new_entity} - evo_dhw = EvoDHW(broker, broker.tcs.hotwater) - - async_add_entities([evo_dhw], update_before_add=True) + async_add_entities([new_entity], update_before_add=True) class EvoDHW(EvoChild, WaterHeaterDevice): From 3660cc94db84b966dabec42b666e59d09e05fa5a Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 10 Dec 2019 13:06:31 +0000 Subject: [PATCH 07/23] bugfix services.yaml --- homeassistant/components/evohome/services.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index a3f0dcd912ab2..4b7de74d1a31e 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -12,10 +12,10 @@ set_system_mode: description: >- The duration in days; used with Away, DayOff, or Custom. The system will revert to Auto at midnight (today is day 1). - example: 2 + example: 28 hours: description: The duration in hours; used only with AutoWithEco. - example: + example: 8 reset_system: description: >- @@ -46,7 +46,7 @@ set_zone_override: description: >- Used as an alternative to duration; the zone will revert after this time (HH:MM). - example: + example: '16:30' clear_zone_override: description: Set a zone to follow its schedule. From 9b37c36b679debcbd872d87fba6415f2f7830677 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 14 Dec 2019 15:06:02 +0000 Subject: [PATCH 08/23] refactor into entities --- homeassistant/components/evohome/__init__.py | 133 +++++++----------- homeassistant/components/evohome/climate.py | 96 ++++++++++++- .../components/evohome/water_heater.py | 1 - 3 files changed, 139 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index c03fcb6491182..05c5b69166b6b 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -26,13 +26,13 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util from .const import ( DOMAIN, EVO_FOLLOW, - EVO_RESET, EVO_SYSTEM_MODES, GWS, STORAGE_KEY, @@ -79,8 +79,8 @@ SVC_REFRESH_SYSTEM = "force_refresh" SVC_SET_SYSTEM_MODE = "set_system_mode" SVC_RESET_SYSTEM = "reset_system" -SVC_SET_ZONE_MODE = "set_zone_override" -SVC_RESET_ZONE_MODE = "clear_zone_override" +SVC_SET_ZONE_OVERRIDE = "set_zone_override" +SVC_RESET_ZONE_OVERRIDE = "clear_zone_override" HH_MM_REGEXP = r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$" # 24h format, with leading 0 @@ -95,7 +95,7 @@ ), } ) -SET_ZONE_MODE_SCHEMA = vol.Schema( +SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, vol.Required(ATTR_ZONE_TEMP): vol.Coerce(float), @@ -107,7 +107,7 @@ ), } ) -RESET_ZONE_MODE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) +RESET_ZONE_OVERRIDE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) def _local_dt_to_aware(dt_naive: dt) -> dt: @@ -280,90 +280,40 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: def setup_service_functions(hass: HomeAssistantType, broker): """Set up the service functions.""" - def _clean_dt(dtm): - if dtm is not None: - format_str = "%Y-%m-%d %H:%M:00" # round down to the nearest minute - dtm = dt.strptime(dt.strftime(dtm, format_str), format_str) - if dtm < dt.now(): - dtm += timedelta(days=1) - return dtm - async def force_refresh(call): """Obtain the latest state data via the vendor's RESTful API.""" await broker.update() async def set_system_mode(call): """Set the system mode.""" - if call.service == SVC_SET_SYSTEM_MODE: - mode = call.data[ATTR_SYSTEM_MODE] - else: - mode = EVO_RESET - - tcs = broker.tcs - try: - attrs = [m for m in tcs.allowedSystemModes if m["systemMode"] == mode][0] - except IndexError: - raise KeyError(f"'{mode}' mode is not supported by this system") - - until = None - # the following two attrs are mutually exclusive - if ATTR_DURATION_DAYS in call.data: - if attrs.get("timingResolution") != "1.00:00:00": - raise TypeError(f"'{mode}' mode does not support days, only hours") + payload = { + "unique_id": broker.tcs.systemId, + "service": call.service, + "data": call.data, + } - until = dt.combine(dt.now().date(), dt.min.time()) - until += timedelta(days=call.data[ATTR_DURATION_DAYS]) + hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, payload) - if ATTR_DURATION_HOURS in call.data: - if attrs.get("timingResolution") != "01:00:00": - raise TypeError(f"'{mode}' mode does not support hours, only days") - until = dt.now() + timedelta(hours=call.data[ATTR_DURATION_HOURS]) + async def set_zone_override(call): + """Set the zone override (setpoint).""" + entity_id = call.data[ATTR_ENTITY_ID] - await broker.call_client_api( - tcs._set_status(mode, _clean_dt(until)) # pylint: disable=protected-access - ) + registry = await async_get_registry(hass) + registry_entry = registry.async_get(entity_id) - async def set_zone_setpoint(call): - """Set the system mode.""" - entity_id = call.data[ATTR_ENTITY_ID] + if registry_entry is None or registry_entry.platform != DOMAIN: + raise KeyError(f"'{entity_id}' is not a known evohome entity") - try: - unique_id = hass.data["entity_registry"].entities[entity_id].unique_id - zone_entity = broker.entities[unique_id] - zone_device = broker.tcs.zones_by_id[unique_id] - except KeyError: - raise KeyError(f"'{entity_id}' is not a known evohome zone") - - if call.service == SVC_RESET_ZONE_MODE: - await broker.call_client_api(zone_device.cancel_temp_override()) - return - - attrs = zone_device.setpointCapabilities - resolution = attrs["valueResolution"] - temp = round(call.data[ATTR_ZONE_TEMP] * resolution) / resolution - temp = max(min(temp, attrs["maxHeatSetpoint"]), attrs["minHeatSetpoint"]) - - until = None - # the following two attrs are mutually exclusive - if ATTR_UNTIL_MINUTES in call.data: - duration = call.data[ATTR_UNTIL_MINUTES] - if duration == 0: # until next setpoint - await zone_entity._update_schedule() # pylint: disable=protected-access - until = dt_util.parse_datetime( - str(zone_entity.setpoints.get("next_sp_from")) - ) - else: - until = dt.now() + timedelta(minutes=duration) + if registry_entry.domain != "climate": + raise KeyError(f"'{entity_id}' is not an evohome zone") - if ATTR_UNTIL_TIME in call.data: - until = dt.strptime( - dt.strftime(dt.now(), "%Y-%m-%d ") + call.data[ATTR_UNTIL_TIME], - "%Y-%m-%d %H:%M", - ) + payload = { + "unique_id": registry_entry.unique_id, + "service": call.service, + "data": call.data, + } - await broker.call_client_api( - zone_device.set_temperature(temp, _clean_dt(until)) - ) + hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, payload) hass.services.async_register( DOMAIN, SVC_REFRESH_SYSTEM, force_refresh, schema=vol.Schema({}) @@ -375,10 +325,16 @@ async def set_zone_setpoint(call): DOMAIN, SVC_RESET_SYSTEM, set_system_mode, schema=vol.Schema({}) ) hass.services.async_register( - DOMAIN, SVC_SET_ZONE_MODE, set_zone_setpoint, schema=SET_ZONE_MODE_SCHEMA + DOMAIN, + SVC_SET_ZONE_OVERRIDE, + set_zone_override, + schema=SET_ZONE_OVERRIDE_SCHEMA, ) hass.services.async_register( - DOMAIN, SVC_RESET_ZONE_MODE, set_zone_setpoint, schema=RESET_ZONE_MODE_SCHEMA + DOMAIN, + SVC_RESET_ZONE_OVERRIDE, + set_zone_override, + schema=RESET_ZONE_OVERRIDE_SCHEMA, ) @@ -398,8 +354,6 @@ def __init__(self, hass, client, client_v1, store, params) -> None: self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] self.temps = {} - self._entities = {} - async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" # evohomeasync2 uses naive/local datetimes @@ -428,7 +382,7 @@ async def call_client_api(self, api_function, refresh=True) -> Any: if not _handle_exception(err): return - if refresh is True: + if refresh: self.hass.helpers.event.async_call_later(1, self.update()) return result @@ -525,8 +479,21 @@ def __init__(self, evo_broker, evo_device) -> None: self._device_state_attrs = {} @callback - def _refresh(self) -> None: - self.async_schedule_update_ha_state(force_refresh=True) + def _refresh(self, payload=None) -> None: + if payload is None: + self.async_schedule_update_ha_state(force_refresh=True) + elif payload["unique_id"] == self._unique_id: + if payload["service"] in [SVC_SET_SYSTEM_MODE, SVC_RESET_SYSTEM]: + self.aync_tcs_svc_request(payload["service"], payload["data"]) + self.aync_zone_svc_request(payload["service"], payload["data"]) + + def aync_tcs_svc_request(self, service, data) -> None: + """Process a service request (system mode) for a controller.""" + raise NotImplementedError + + def aync_zone_svc_request(self, service, data) -> None: + """Process a service request (setpoint override) for a zone.""" + raise NotImplementedError @property def should_poll(self) -> bool: diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 69932825e8173..38e378ebb852e 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,4 +1,5 @@ """Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" +from datetime import datetime as dt, timedelta import logging from typing import List, Optional @@ -21,7 +22,19 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.dt import parse_datetime -from . import CONF_LOCATION_IDX, EvoChild, EvoDevice +from . import ( + ATTR_DURATION_DAYS, + ATTR_DURATION_HOURS, + ATTR_SYSTEM_MODE, + ATTR_UNTIL_MINUTES, + ATTR_UNTIL_TIME, + ATTR_ZONE_TEMP, + CONF_LOCATION_IDX, + SVC_RESET_ZONE_OVERRIDE, + SVC_SET_SYSTEM_MODE, + EvoChild, + EvoDevice, +) from .const import ( DOMAIN, EVO_AUTO, @@ -64,6 +77,15 @@ STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] +def _clean_dt(dtm) -> Optional[str]: + if dtm is not None: + format_str = "%Y-%m-%d %H:%M:00" # round down to the nearest minute + dtm = dt.strptime(dt.strftime(dtm, format_str), format_str) + if dtm < dt.now(): + dtm += timedelta(days=1) + return dtm + + async def async_setup_platform( hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None ) -> None: @@ -91,13 +113,11 @@ async def async_setup_platform( zone.name, ) new_entity = EvoThermostat(broker, zone) - broker.entities = {zone.zoneId: new_entity} async_add_entities([new_entity], update_before_add=True) return controller = EvoController(broker, broker.tcs) - broker.entities = {broker.tcs.systemId: controller} zones = [] for zone in broker.tcs.zones.values(): @@ -109,7 +129,6 @@ async def async_setup_platform( zone.name, ) new_entity = EvoZone(broker, zone) - broker.entities[zone.zoneId] = new_entity zones.append(new_entity) @@ -125,7 +144,36 @@ def __init__(self, evo_broker, evo_device) -> None: self._preset_modes = None - async def _set_tcs_mode(self, op_mode: str) -> None: + async def aync_tcs_svc_request(self, service, data) -> None: + """Process a service request (system mode) for a controller.""" + if service == SVC_SET_SYSTEM_MODE: + mode = data[ATTR_SYSTEM_MODE] + else: # otherwise it is SVC_RESET_SYSTEM + mode = EVO_RESET + + allowed_system_modes = self._evo_device.allowedSystemModes + try: + attrs = [m for m in allowed_system_modes if m["systemMode"] == mode][0] + except IndexError: + raise KeyError(f"'{mode}' mode is not supported by this system") + + until = None + # the following two attrs are mutually exclusive + if ATTR_DURATION_DAYS in data: + if attrs.get("timingResolution") != "1.00:00:00": + raise TypeError(f"'{mode}' mode does not support days, only hours") + + until = dt.combine(dt.now().date(), dt.min.time()) + until += timedelta(days=data[ATTR_DURATION_DAYS]) + + if ATTR_DURATION_HOURS in data: + if attrs.get("timingResolution") != "01:00:00": + raise TypeError(f"'{mode}' mode does not support hours, only days") + until = dt.now() + timedelta(hours=data[ATTR_DURATION_HOURS]) + + await self._set_tcs_mode(mode, _clean_dt(until)) + + async def _set_tcs_mode(self, mode: str, until=None) -> None: """Set a Controller to any of its native EVO_* operating modes.""" await self._evo_broker.call_client_api(self._evo_tcs.set_status(op_mode)) @@ -158,6 +206,40 @@ def __init__(self, evo_broker, evo_device) -> None: else: self._precision = self._evo_device.setpointCapabilities["valueResolution"] + async def aync_zone_svc_request(self, service, data) -> None: + """Process a service request (setpoint override) for a zone.""" + if service == SVC_RESET_ZONE_OVERRIDE: + await self._evo_broker.call_client_api( + self._evo_device.cancel_temp_override() + ) + return + + # otherwise it is SVC_SET_ZONE_OVERRIDE + attrs = self._evo_device.setpointCapabilities + resolution = attrs["valueResolution"] + temp = round(data[ATTR_ZONE_TEMP] * resolution) / resolution + temp = max(min(temp, attrs["maxHeatSetpoint"]), attrs["minHeatSetpoint"]) + + until = None + # the following two attrs are mutually exclusive + if ATTR_UNTIL_MINUTES in data: + duration = data[ATTR_UNTIL_MINUTES] + if duration == 0: # then until next setpoint + await self._update_schedule() + until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + else: + until = dt.now() + timedelta(minutes=duration) + + if ATTR_UNTIL_TIME in data: + until = dt.strptime( + dt.strftime(dt.now(), "%Y-%m-%d ") + data[ATTR_UNTIL_TIME], + "%Y-%m-%d %H:%M", + ) + + await self._evo_broker.call_client_api( + self._evo_device.set_temperature(temp, _clean_dt(until)) + ) + @property def hvac_mode(self) -> str: """Return the current operating mode of a Zone.""" @@ -211,7 +293,7 @@ def max_temp(self) -> float: async def async_set_temperature(self, **kwargs) -> None: """Set a new target temperature.""" - temperature = kwargs["temperature"] + temp = kwargs["temperature"] if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: await self._update_schedule() @@ -222,7 +304,7 @@ async def async_set_temperature(self, **kwargs) -> None: until = None await self._evo_broker.call_client_api( - self._evo_device.set_temperature(temperature, until) + self._evo_device.set_temperature(temp, _clean_dt(until)) ) async def async_set_hvac_mode(self, hvac_mode: str) -> None: diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index fcf506621b73b..e24380fe19a28 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -39,7 +39,6 @@ async def async_setup_platform( broker.tcs.hotwater.zoneId, ) new_entity = EvoDHW(broker, broker.tcs.hotwater) - broker.entities = {broker.tcs.hotwater.zoneId: new_entity} async_add_entities([new_entity], update_before_add=True) From a563cdba8a1339bafcae9a38f8b40401b67cd7f3 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 14 Dec 2019 17:30:40 +0000 Subject: [PATCH 09/23] working version --- homeassistant/components/evohome/__init__.py | 13 +++++++++---- homeassistant/components/evohome/climate.py | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 05c5b69166b6b..ac19bf15d0ae6 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -484,14 +484,19 @@ def _refresh(self, payload=None) -> None: self.async_schedule_update_ha_state(force_refresh=True) elif payload["unique_id"] == self._unique_id: if payload["service"] in [SVC_SET_SYSTEM_MODE, SVC_RESET_SYSTEM]: - self.aync_tcs_svc_request(payload["service"], payload["data"]) - self.aync_zone_svc_request(payload["service"], payload["data"]) + self.hass.async_create_task( + self.async_tcs_svc_request(payload["service"], payload["data"]) + ) + else: + self.hass.async_create_task( + self.async_zone_svc_request(payload["service"], payload["data"]) + ) - def aync_tcs_svc_request(self, service, data) -> None: + async def async_tcs_svc_request(self, service, data) -> None: """Process a service request (system mode) for a controller.""" raise NotImplementedError - def aync_zone_svc_request(self, service, data) -> None: + async def async_zone_svc_request(self, service, data) -> None: """Process a service request (setpoint override) for a zone.""" raise NotImplementedError diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 38e378ebb852e..7650d53b5d1b6 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -144,7 +144,7 @@ def __init__(self, evo_broker, evo_device) -> None: self._preset_modes = None - async def aync_tcs_svc_request(self, service, data) -> None: + async def async_tcs_svc_request(self, service, data) -> None: """Process a service request (system mode) for a controller.""" if service == SVC_SET_SYSTEM_MODE: mode = data[ATTR_SYSTEM_MODE] @@ -206,7 +206,7 @@ def __init__(self, evo_broker, evo_device) -> None: else: self._precision = self._evo_device.setpointCapabilities["valueResolution"] - async def aync_zone_svc_request(self, service, data) -> None: + async def async_zone_svc_request(self, service, data) -> None: """Process a service request (setpoint override) for a zone.""" if service == SVC_RESET_ZONE_OVERRIDE: await self._evo_broker.call_client_api( From 49a2c78e3cbf5621c26fe5e3d93362cf22a55ce2 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 14 Dec 2019 17:53:44 +0000 Subject: [PATCH 10/23] refactor set_temp() --- homeassistant/components/evohome/__init__.py | 2 +- homeassistant/components/evohome/climate.py | 36 ++++++++------------ 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index ac19bf15d0ae6..5a1f256d927cc 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -82,7 +82,7 @@ SVC_SET_ZONE_OVERRIDE = "set_zone_override" SVC_RESET_ZONE_OVERRIDE = "clear_zone_override" -HH_MM_REGEXP = r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$" # 24h format, with leading 0 +HH_MM_REGEXP = r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$" # 24h format, leading 0s SET_SYSTEM_MODE_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 7650d53b5d1b6..8875438cb0c94 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -220,21 +220,15 @@ async def async_zone_svc_request(self, service, data) -> None: temp = round(data[ATTR_ZONE_TEMP] * resolution) / resolution temp = max(min(temp, attrs["maxHeatSetpoint"]), attrs["minHeatSetpoint"]) - until = None # the following two attrs are mutually exclusive if ATTR_UNTIL_MINUTES in data: duration = data[ATTR_UNTIL_MINUTES] - if duration == 0: # then until next setpoint - await self._update_schedule() - until = parse_datetime(str(self.setpoints.get("next_sp_from"))) - else: - until = dt.now() + timedelta(minutes=duration) - - if ATTR_UNTIL_TIME in data: - until = dt.strptime( - dt.strftime(dt.now(), "%Y-%m-%d ") + data[ATTR_UNTIL_TIME], - "%Y-%m-%d %H:%M", - ) + until = None if duration == 0 else dt.now() + timedelta(minutes=duration) + elif ATTR_UNTIL_TIME in data: + until_str = dt.strftime(dt.now(), "%Y-%m-%d ") + data[ATTR_UNTIL_TIME] + until = dt.strptime(until_str, "%Y-%m-%d %H:%M",) + else: + until = None await self._evo_broker.call_client_api( self._evo_device.set_temperature(temp, _clean_dt(until)) @@ -293,18 +287,18 @@ def max_temp(self) -> float: async def async_set_temperature(self, **kwargs) -> None: """Set a new target temperature.""" - temp = kwargs["temperature"] + temperature = kwargs["temperature"] + until = kwargs.get("until") - if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: - await self._update_schedule() - until = parse_datetime(str(self.setpoints.get("next_sp_from"))) - elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: - until = parse_datetime(self._evo_device.setpointStatus["until"]) - else: # EVO_PERMOVER - until = None + if until is None: + if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + await self._update_schedule() + until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: + until = parse_datetime(self._evo_device.setpointStatus["until"]) await self._evo_broker.call_client_api( - self._evo_device.set_temperature(temp, _clean_dt(until)) + self._evo_device.set_temperature(temperature, until) ) async def async_set_hvac_mode(self, hvac_mode: str) -> None: From 8a6d58b82370c504d4021012ecf2e820f9a7f6bd Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 14 Dec 2019 18:29:35 +0000 Subject: [PATCH 11/23] refactor set_temp() 2 --- homeassistant/components/evohome/climate.py | 27 +++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 8875438cb0c94..68d97a5e39d64 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -157,21 +157,21 @@ async def async_tcs_svc_request(self, service, data) -> None: except IndexError: raise KeyError(f"'{mode}' mode is not supported by this system") - until = None - # the following two attrs are mutually exclusive if ATTR_DURATION_DAYS in data: if attrs.get("timingResolution") != "1.00:00:00": raise TypeError(f"'{mode}' mode does not support days, only hours") - until = dt.combine(dt.now().date(), dt.min.time()) until += timedelta(days=data[ATTR_DURATION_DAYS]) - if ATTR_DURATION_HOURS in data: + elif ATTR_DURATION_HOURS in data: if attrs.get("timingResolution") != "01:00:00": raise TypeError(f"'{mode}' mode does not support hours, only days") until = dt.now() + timedelta(hours=data[ATTR_DURATION_HOURS]) - await self._set_tcs_mode(mode, _clean_dt(until)) + else: + until = None + + await self._set_tcs_mode(mode, until=_clean_dt(until)) async def _set_tcs_mode(self, mode: str, until=None) -> None: """Set a Controller to any of its native EVO_* operating modes.""" @@ -215,24 +215,21 @@ async def async_zone_svc_request(self, service, data) -> None: return # otherwise it is SVC_SET_ZONE_OVERRIDE - attrs = self._evo_device.setpointCapabilities - resolution = attrs["valueResolution"] - temp = round(data[ATTR_ZONE_TEMP] * resolution) / resolution - temp = max(min(temp, attrs["maxHeatSetpoint"]), attrs["minHeatSetpoint"]) + temp = round(data[ATTR_ZONE_TEMP] * self.precision) / self.precision + temp = max(min(temp, self.max_temp), self.min_temp) - # the following two attrs are mutually exclusive if ATTR_UNTIL_MINUTES in data: duration = data[ATTR_UNTIL_MINUTES] until = None if duration == 0 else dt.now() + timedelta(minutes=duration) + elif ATTR_UNTIL_TIME in data: - until_str = dt.strftime(dt.now(), "%Y-%m-%d ") + data[ATTR_UNTIL_TIME] - until = dt.strptime(until_str, "%Y-%m-%d %H:%M",) + until = dt.strftime(dt.now(), "%Y-%m-%d ") + data[ATTR_UNTIL_TIME] + until = dt.strptime(until, "%Y-%m-%d %H:%M") + else: until = None - await self._evo_broker.call_client_api( - self._evo_device.set_temperature(temp, _clean_dt(until)) - ) + await self.async_set_temperature(temperature=temp, until=_clean_dt(until)) @property def hvac_mode(self) -> str: From a7d4479b1cd338fba9305338b3d02506a6b4835e Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 20 Dec 2019 15:58:55 +0000 Subject: [PATCH 12/23] latest changes --- homeassistant/components/evohome/__init__.py | 115 +++++++++++------- homeassistant/components/evohome/climate.py | 36 +++--- .../components/evohome/services.yaml | 42 +++---- 3 files changed, 106 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 5a1f256d927cc..ea71ef16c188a 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -30,15 +30,7 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util -from .const import ( - DOMAIN, - EVO_FOLLOW, - EVO_SYSTEM_MODES, - GWS, - STORAGE_KEY, - STORAGE_VERSION, - TCS, -) +from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VERSION, TCS _LOGGER = logging.getLogger(__name__) @@ -69,12 +61,11 @@ ) ATTR_SYSTEM_MODE = "mode" -ATTR_DURATION_DAYS = "days" -ATTR_DURATION_HOURS = "hours" +ATTR_DURATION_DAYS = "period" +ATTR_DURATION_HOURS = "duration" ATTR_ZONE_TEMP = "setpoint" -ATTR_UNTIL_MINUTES = "duration" -ATTR_UNTIL_TIME = "until" +ATTR_DURATION_UNTIL = "duration" SVC_REFRESH_SYSTEM = "force_refresh" SVC_SET_SYSTEM_MODE = "set_system_mode" @@ -82,32 +73,20 @@ SVC_SET_ZONE_OVERRIDE = "set_zone_override" SVC_RESET_ZONE_OVERRIDE = "clear_zone_override" -HH_MM_REGEXP = r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$" # 24h format, leading 0s - -SET_SYSTEM_MODE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_SYSTEM_MODE): vol.In(EVO_SYSTEM_MODES), - vol.Exclusive(ATTR_DURATION_DAYS, "duration"): vol.All( - cv.positive_int, vol.Range(min=1, max=99) - ), - vol.Exclusive(ATTR_DURATION_HOURS, "duration"): vol.All( - cv.positive_int, vol.Range(min=1, max=24) - ), - } -) +RESET_ZONE_OVERRIDE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_ZONE_TEMP): vol.Coerce(float), - vol.Exclusive(ATTR_UNTIL_MINUTES, "until"): vol.All( - cv.positive_int, vol.Range(min=0, max=24 * 60) + vol.Required(ATTR_ZONE_TEMP): vol.All( + vol.Coerce(float), vol.Range(min=4.0, max=35.0), ), - vol.Exclusive(ATTR_UNTIL_TIME, "until"): vol.All( - cv.string, vol.Match(HH_MM_REGEXP) + vol.Optional(ATTR_DURATION_UNTIL): vol.All( + cv.time_period, + vol.Range(min=timedelta(minutes=0), max=timedelta(minutes=24 * 60)), ), } ) -RESET_ZONE_OVERRIDE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) +# system mode schemas are built dynamically, below def _local_dt_to_aware(dt_naive: dt) -> dt: @@ -278,7 +257,10 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: def setup_service_functions(hass: HomeAssistantType, broker): - """Set up the service functions.""" + """Set up the service functions. + + Not all evohome-compatible systems support all system modes. + """ async def force_refresh(call): """Obtain the latest state data via the vendor's RESTful API.""" @@ -291,7 +273,6 @@ async def set_system_mode(call): "service": call.service, "data": call.data, } - hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, payload) async def set_zone_override(call): @@ -315,15 +296,6 @@ async def set_zone_override(call): hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, payload) - hass.services.async_register( - DOMAIN, SVC_REFRESH_SYSTEM, force_refresh, schema=vol.Schema({}) - ) - hass.services.async_register( - DOMAIN, SVC_SET_SYSTEM_MODE, set_system_mode, schema=SET_SYSTEM_MODE_SCHEMA - ) - hass.services.async_register( - DOMAIN, SVC_RESET_SYSTEM, set_system_mode, schema=vol.Schema({}) - ) hass.services.async_register( DOMAIN, SVC_SET_ZONE_OVERRIDE, @@ -337,6 +309,57 @@ async def set_zone_override(call): schema=RESET_ZONE_OVERRIDE_SCHEMA, ) + modes = broker.config["allowedSystemModes"] + + if [m["systemMode"] for m in modes if m["systemMode"] == "AutoWithReset"]: + hass.services.async_register( + DOMAIN, SVC_RESET_SYSTEM, set_system_mode, schema=vol.Schema({}) + ) + + system_mode_schemas = [] + modes = [m for m in modes if m["systemMode"] != "AutoWithReset"] + + perm_modes = [m["systemMode"] for m in modes if not m["canBeTemporary"]] + if perm_modes: # any of: "Auto", "HeatingOff", permanent only + schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) + system_mode_schemas.append(schema) + + modes = [m for m in modes if m["canBeTemporary"]] + + temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Duration"] + if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours + schema = vol.Schema( + { + vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), + vol.Optional(ATTR_DURATION_HOURS): vol.All( + cv.time_period, # lambda td: (td.total_seconds() // 3600) + vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), + ), + } + ) + system_mode_schemas.append(schema) + + temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Period"] + if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days + schema = vol.Schema( + { + vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), + vol.Optional(ATTR_DURATION_DAYS): vol.All( + cv.time_period, + vol.Range(min=timedelta(days=1), max=timedelta(days=99)), + ), + } + ) + system_mode_schemas.append(schema) + + if system_mode_schemas: + hass.services.async_register( + DOMAIN, + SVC_SET_SYSTEM_MODE, + set_system_mode, + schema=vol.Any(*system_mode_schemas), + ) + class EvoBroker: """Container for evohome client and data.""" @@ -483,13 +506,13 @@ def _refresh(self, payload=None) -> None: if payload is None: self.async_schedule_update_ha_state(force_refresh=True) elif payload["unique_id"] == self._unique_id: - if payload["service"] in [SVC_SET_SYSTEM_MODE, SVC_RESET_SYSTEM]: + if payload["service"] in [SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE]: self.hass.async_create_task( - self.async_tcs_svc_request(payload["service"], payload["data"]) + self.async_zone_svc_request(payload["service"], payload["data"]) ) else: self.hass.async_create_task( - self.async_zone_svc_request(payload["service"], payload["data"]) + self.async_tcs_svc_request(payload["service"], payload["data"]) ) async def async_tcs_svc_request(self, service, data) -> None: diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 68d97a5e39d64..ef80a13ba1248 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -25,9 +25,8 @@ from . import ( ATTR_DURATION_DAYS, ATTR_DURATION_HOURS, + ATTR_DURATION_UNTIL, ATTR_SYSTEM_MODE, - ATTR_UNTIL_MINUTES, - ATTR_UNTIL_TIME, ATTR_ZONE_TEMP, CONF_LOCATION_IDX, SVC_RESET_ZONE_OVERRIDE, @@ -77,7 +76,7 @@ STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] -def _clean_dt(dtm) -> Optional[str]: +def _clean_dt(dtm) -> Optional[dt]: if dtm is not None: format_str = "%Y-%m-%d %H:%M:00" # round down to the nearest minute dtm = dt.strptime(dt.strftime(dtm, format_str), format_str) @@ -144,7 +143,7 @@ def __init__(self, evo_broker, evo_device) -> None: self._preset_modes = None - async def async_tcs_svc_request(self, service, data) -> None: + async def async_tcs_svc_request(self, service: str, data) -> None: """Process a service request (system mode) for a controller.""" if service == SVC_SET_SYSTEM_MODE: mode = data[ATTR_SYSTEM_MODE] @@ -161,19 +160,19 @@ async def async_tcs_svc_request(self, service, data) -> None: if attrs.get("timingResolution") != "1.00:00:00": raise TypeError(f"'{mode}' mode does not support days, only hours") until = dt.combine(dt.now().date(), dt.min.time()) - until += timedelta(days=data[ATTR_DURATION_DAYS]) + until += data[ATTR_DURATION_DAYS] elif ATTR_DURATION_HOURS in data: if attrs.get("timingResolution") != "01:00:00": raise TypeError(f"'{mode}' mode does not support hours, only days") - until = dt.now() + timedelta(hours=data[ATTR_DURATION_HOURS]) + until = dt.now() + data[ATTR_DURATION_HOURS] else: until = None - await self._set_tcs_mode(mode, until=_clean_dt(until)) + await self._set_tcs_mode(mode, until=until) - async def _set_tcs_mode(self, mode: str, until=None) -> None: + async def _set_tcs_mode(self, mode: str, until: Optional[dt] = None) -> None: """Set a Controller to any of its native EVO_* operating modes.""" await self._evo_broker.call_client_api(self._evo_tcs.set_status(op_mode)) @@ -218,18 +217,19 @@ async def async_zone_svc_request(self, service, data) -> None: temp = round(data[ATTR_ZONE_TEMP] * self.precision) / self.precision temp = max(min(temp, self.max_temp), self.min_temp) - if ATTR_UNTIL_MINUTES in data: - duration = data[ATTR_UNTIL_MINUTES] - until = None if duration == 0 else dt.now() + timedelta(minutes=duration) - - elif ATTR_UNTIL_TIME in data: - until = dt.strftime(dt.now(), "%Y-%m-%d ") + data[ATTR_UNTIL_TIME] - until = dt.strptime(until, "%Y-%m-%d %H:%M") - + if ATTR_DURATION_UNTIL in data: + duration = data[ATTR_DURATION_UNTIL] + if duration == 0: + await self._update_schedule() + until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + else: + until = dt.now() + data[ATTR_DURATION_UNTIL] else: - until = None + until = None # indefinitely - await self.async_set_temperature(temperature=temp, until=_clean_dt(until)) + await self._evo_broker.call_client_api( + self._evo_device.set_temperature(temperature=temp, until=until) + ) @property def hvac_mode(self) -> str: diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index 4b7de74d1a31e..316adfdae6553 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -2,51 +2,47 @@ set_system_mode: description: >- - Set the system mode, either indefinitely, or for a set period, after which it will - revert to Auto. + Set the system mode, either indefinitely, or for a specified period of time, after + which it will revert to Auto. fields: mode: description: 'One of: Auto, AutoWithEco, Away, DayOff, HeatingOff, or Custom.' example: Away - days: + period: description: >- - The duration in days; used with Away, DayOff, or Custom. The system will - revert to Auto at midnight (today is day 1). - example: 28 - hours: - description: The duration in hours; used only with AutoWithEco. - example: 8 + A period of time in days; used with Away, DayOff, or Custom. The system will + revert to Auto at midnight (up to 99 days, today is day 1). + example: '{"days": 28}' + duration: + description: The duration in hours; used only with AutoWithEco (up to 24 hours). + example: '{"hours": 18}' reset_system: description: >- - Set the system to Auto mode and reset all the zones to follow their schedules - (i.e. AutoWithReset mode). + Set the system to Auto mode and reset all the zones to follow their schedules. + Not all Evohome systems support this feature (i.e. AutoWithReset mode). force_refresh: description: >- - Pull the latest data from the vendor's servers. + Pull the latest data from the vendor's servers now, rather than waiting for the + next scheduled update. set_zone_override: description: >- - Override a zone's setpoint, either indefinitely, or for a set period, after which - it will revert to following its schedule. + Override a zone's setpoint, either indefinitely, or for a specified period of + time, after which it will revert to following its schedule. fields: entity_id: - description: The entity_id of the evohome zone. + description: The entity_id of the Evohome zone. example: climate.bathroom setpoint: description: The temperature to be used instead of the scheduled setpoint. example: 5.0 duration: description: >- - The zone will revert to its schedule after this many minutes. If 0 the change - is until the next scheduled setpoint. - example: 60 - until: - description: >- - Used as an alternative to duration; the zone will revert after this time - (HH:MM). - example: '16:30' + The zone will revert to its schedule after this time. If 0 the change is until + the next scheduled setpoint. + example: '{"minutes": 135}' clear_zone_override: description: Set a zone to follow its schedule. From 4665ab56b9c0a5c7a26de7291356ac2601ba9385 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 20 Dec 2019 17:44:27 +0000 Subject: [PATCH 13/23] small fixes --- homeassistant/components/evohome/climate.py | 11 +---------- homeassistant/components/evohome/const.py | 10 ---------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index ef80a13ba1248..b286cc0410d8a 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,5 +1,5 @@ """Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" -from datetime import datetime as dt, timedelta +from datetime import datetime as dt import logging from typing import List, Optional @@ -76,15 +76,6 @@ STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] -def _clean_dt(dtm) -> Optional[dt]: - if dtm is not None: - format_str = "%Y-%m-%d %H:%M:00" # round down to the nearest minute - dtm = dt.strptime(dt.strftime(dtm, format_str), format_str) - if dtm < dt.now(): - dtm += timedelta(days=1) - return dtm - - async def async_setup_platform( hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None ) -> None: diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 79af61ff4453b..444671cf82aa8 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -13,16 +13,6 @@ EVO_CUSTOM = "Custom" EVO_HEATOFF = "HeatingOff" -EVO_SYSTEM_MODES = [ - EVO_RESET, - EVO_AUTO, - EVO_AUTOECO, - EVO_AWAY, - EVO_DAYOFF, - EVO_CUSTOM, - EVO_HEATOFF, -] - # The Childs' operating mode is one of: EVO_FOLLOW = "FollowSchedule" # the operating mode is 'inherited' from the TCS EVO_TEMPOVER = "TemporaryOverride" From d0269530fdebcc9b6e3f7d0b6fb58851b2f99e06 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 16 Jan 2020 15:01:15 +0000 Subject: [PATCH 14/23] review tweaks 2 --- homeassistant/components/evohome/__init__.py | 50 ++++++++++++-------- homeassistant/components/evohome/climate.py | 13 +++-- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index ea71ef16c188a..60bedec92c572 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -27,6 +27,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util @@ -239,7 +240,7 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: ) await broker.save_auth_tokens() - await broker.update() # get initial state + await broker.async_update() # get initial state hass.async_create_task(async_load_platform(hass, "climate", DOMAIN, {}, config)) if broker.tcs.hotwater: @@ -248,7 +249,7 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: ) hass.helpers.event.async_track_time_interval( - broker.update, config[DOMAIN][CONF_SCAN_INTERVAL] + broker.async_update, config[DOMAIN][CONF_SCAN_INTERVAL] ) setup_service_functions(hass, broker) @@ -256,16 +257,23 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: return True +@callback def setup_service_functions(hass: HomeAssistantType, broker): - """Set up the service functions. + """Set up the service handlers for the system/zone operating modes. - Not all evohome-compatible systems support all system modes. + Not all TCC-compatible systems support all operating modes. In addition, each + mode will require any of four distinct service schemas. This has to be enumerated + before registering the approperiate handlers. + + It appears that all TCC-compatible systems support the same three zones modes. """ + @verify_domain_control async def force_refresh(call): """Obtain the latest state data via the vendor's RESTful API.""" - await broker.update() + await broker.async_update() + @verify_domain_control async def set_system_mode(call): """Set the system mode.""" payload = { @@ -275,6 +283,7 @@ async def set_system_mode(call): } hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, payload) + @verify_domain_control async def set_zone_override(call): """Set the zone override (setpoint).""" entity_id = call.data[ATTR_ENTITY_ID] @@ -283,10 +292,10 @@ async def set_zone_override(call): registry_entry = registry.async_get(entity_id) if registry_entry is None or registry_entry.platform != DOMAIN: - raise KeyError(f"'{entity_id}' is not a known evohome entity") + raise ValueError(f"'{entity_id}' is not a known evohome entity") if registry_entry.domain != "climate": - raise KeyError(f"'{entity_id}' is not an evohome zone") + raise ValueError(f"'{entity_id}' is not an evohome zone") payload = { "unique_id": registry_entry.unique_id, @@ -296,6 +305,7 @@ async def set_zone_override(call): hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, payload) + # The zone modes are consistent across all systems and use the same schema hass.services.async_register( DOMAIN, SVC_SET_ZONE_OVERRIDE, @@ -309,8 +319,10 @@ async def set_zone_override(call): schema=RESET_ZONE_OVERRIDE_SCHEMA, ) + # Enumerate what operating modes this system supports modes = broker.config["allowedSystemModes"] + # Not all systems support "AutoWithReset": register this handler only if required if [m["systemMode"] for m in modes if m["systemMode"] == "AutoWithReset"]: hass.services.async_register( DOMAIN, SVC_RESET_SYSTEM, set_system_mode, schema=vol.Schema({}) @@ -319,13 +331,15 @@ async def set_zone_override(call): system_mode_schemas = [] modes = [m for m in modes if m["systemMode"] != "AutoWithReset"] + # Permanent-only modes will use this schema perm_modes = [m["systemMode"] for m in modes if not m["canBeTemporary"]] - if perm_modes: # any of: "Auto", "HeatingOff", permanent only + if perm_modes: # any of: "Auto", "HeatingOff": permanent only schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) system_mode_schemas.append(schema) modes = [m for m in modes if m["canBeTemporary"]] + # These modes are set for a number of hours (or indefinitely): use this schema temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Duration"] if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( @@ -339,6 +353,7 @@ async def set_zone_override(call): ) system_mode_schemas.append(schema) + # These modes are set for a number of days (or indefinitely): use this schema temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Period"] if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( @@ -406,7 +421,7 @@ async def call_client_api(self, api_function, refresh=True) -> Any: return if refresh: - self.hass.helpers.event.async_call_later(1, self.update()) + self.hass.helpers.event.async_call_later(1, self.async_update()) return result @@ -468,7 +483,7 @@ async def _update_v2(self, *args, **kwargs) -> None: if access_token != self.client.access_token: await self.save_auth_tokens() - async def update(self, *args, **kwargs) -> None: + async def async_update(self, *args, **kwargs) -> None: """Get the latest state data of an entire evohome Location. This includes state data for a Controller and all its child devices, such as the @@ -502,18 +517,15 @@ def __init__(self, evo_broker, evo_device) -> None: self._device_state_attrs = {} @callback - def _refresh(self, payload=None) -> None: + async def _refresh(self, payload=None) -> None: if payload is None: self.async_schedule_update_ha_state(force_refresh=True) - elif payload["unique_id"] == self._unique_id: + return + if payload["unique_id"] == self._unique_id: if payload["service"] in [SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE]: - self.hass.async_create_task( - self.async_zone_svc_request(payload["service"], payload["data"]) - ) - else: - self.hass.async_create_task( - self.async_tcs_svc_request(payload["service"], payload["data"]) - ) + await self.async_zone_svc_request(payload["service"], payload["data"]) + return + await self.async_tcs_svc_request(payload["service"], payload["data"]) async def async_tcs_svc_request(self, service, data) -> None: """Process a service request (system mode) for a controller.""" diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index b286cc0410d8a..b679e2c6dfdf4 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -142,19 +142,18 @@ async def async_tcs_svc_request(self, service: str, data) -> None: mode = EVO_RESET allowed_system_modes = self._evo_device.allowedSystemModes - try: - attrs = [m for m in allowed_system_modes if m["systemMode"] == mode][0] - except IndexError: - raise KeyError(f"'{mode}' mode is not supported by this system") + attrs = [m for m in allowed_system_modes if m["systemMode"] == mode] + if not attrs: # TODO: do I need this if teh service schema is set correctly? + raise ValueError(f"'{mode}' mode is not supported by this system") if ATTR_DURATION_DAYS in data: - if attrs.get("timingResolution") != "1.00:00:00": + if attrs[0].get("timingResolution") != "1.00:00:00": raise TypeError(f"'{mode}' mode does not support days, only hours") until = dt.combine(dt.now().date(), dt.min.time()) until += data[ATTR_DURATION_DAYS] elif ATTR_DURATION_HOURS in data: - if attrs.get("timingResolution") != "01:00:00": + if attrs[0].get("timingResolution") != "01:00:00": raise TypeError(f"'{mode}' mode does not support hours, only days") until = dt.now() + data[ATTR_DURATION_HOURS] @@ -165,7 +164,7 @@ async def async_tcs_svc_request(self, service: str, data) -> None: async def _set_tcs_mode(self, mode: str, until: Optional[dt] = None) -> None: """Set a Controller to any of its native EVO_* operating modes.""" - await self._evo_broker.call_client_api(self._evo_tcs.set_status(op_mode)) + await self._evo_broker.call_client_api(self._evo_tcs.set_status(mode)) @property def hvac_modes(self) -> List[str]: From 45884c648536d10333f4aae359d039f88becd5d5 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 10:10:56 +0000 Subject: [PATCH 15/23] final tweaks --- homeassistant/components/evohome/__init__.py | 73 ++++++++++--------- homeassistant/components/evohome/climate.py | 2 +- .../components/evohome/services.yaml | 4 +- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 60bedec92c572..ef9624368fcac 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -25,6 +25,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.service import verify_domain_control @@ -79,7 +83,7 @@ { vol.Required(ATTR_ENTITY_ID): cv.entity_id, vol.Required(ATTR_ZONE_TEMP): vol.All( - vol.Coerce(float), vol.Range(min=4.0, max=35.0), + vol.Coerce(float), vol.Range(min=4.0, max=35.0) ), vol.Optional(ATTR_DURATION_UNTIL): vol.All( cv.time_period, @@ -268,12 +272,12 @@ def setup_service_functions(hass: HomeAssistantType, broker): It appears that all TCC-compatible systems support the same three zones modes. """ - @verify_domain_control + @verify_domain_control(hass, DOMAIN) async def force_refresh(call): """Obtain the latest state data via the vendor's RESTful API.""" await broker.async_update() - @verify_domain_control + @verify_domain_control(hass, DOMAIN) async def set_system_mode(call): """Set the system mode.""" payload = { @@ -281,9 +285,9 @@ async def set_system_mode(call): "service": call.service, "data": call.data, } - hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, payload) + async_dispatcher_send(hass, DOMAIN, payload) - @verify_domain_control + @verify_domain_control(hass, DOMAIN) async def set_zone_override(call): """Set the zone override (setpoint).""" entity_id = call.data[ATTR_ENTITY_ID] @@ -303,30 +307,16 @@ async def set_zone_override(call): "data": call.data, } - hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, payload) + async_dispatcher_send(hass, DOMAIN, payload) - # The zone modes are consistent across all systems and use the same schema - hass.services.async_register( - DOMAIN, - SVC_SET_ZONE_OVERRIDE, - set_zone_override, - schema=SET_ZONE_OVERRIDE_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SVC_RESET_ZONE_OVERRIDE, - set_zone_override, - schema=RESET_ZONE_OVERRIDE_SCHEMA, - ) + hass.services.async_register(DOMAIN, SVC_REFRESH_SYSTEM, force_refresh) - # Enumerate what operating modes this system supports + # Enumerate which operating modes are supported by this system modes = broker.config["allowedSystemModes"] # Not all systems support "AutoWithReset": register this handler only if required if [m["systemMode"] for m in modes if m["systemMode"] == "AutoWithReset"]: - hass.services.async_register( - DOMAIN, SVC_RESET_SYSTEM, set_system_mode, schema=vol.Schema({}) - ) + hass.services.async_register(DOMAIN, SVC_RESET_SYSTEM, set_system_mode) system_mode_schemas = [] modes = [m for m in modes if m["systemMode"] != "AutoWithReset"] @@ -375,6 +365,20 @@ async def set_zone_override(call): schema=vol.Any(*system_mode_schemas), ) + # The zone modes are consistent across all systems and use the same schema + hass.services.async_register( + DOMAIN, + SVC_SET_ZONE_OVERRIDE, + set_zone_override, + schema=SET_ZONE_OVERRIDE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SVC_RESET_ZONE_OVERRIDE, + set_zone_override, + schema=RESET_ZONE_OVERRIDE_SCHEMA, + ) + class EvoBroker: """Container for evohome client and data.""" @@ -476,7 +480,7 @@ async def _update_v2(self, *args, **kwargs) -> None: except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: _handle_exception(err) else: - self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN) + async_dispatcher_send(self.hass, DOMAIN) _LOGGER.debug("Status = %s", status[GWS][0][TCS][0]) @@ -496,7 +500,7 @@ async def async_update(self, *args, **kwargs) -> None: await self._update_v1() # inform the evohome devices that state data has been updated - self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN) + async_dispatcher_send(self.hass, DOMAIN) class EvoDevice(Entity): @@ -516,16 +520,17 @@ def __init__(self, evo_broker, evo_device) -> None: self._supported_features = None self._device_state_attrs = {} - @callback - async def _refresh(self, payload=None) -> None: + async def async_refresh(self, payload=None) -> None: + """Process any signals.""" if payload is None: self.async_schedule_update_ha_state(force_refresh=True) return - if payload["unique_id"] == self._unique_id: - if payload["service"] in [SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE]: - await self.async_zone_svc_request(payload["service"], payload["data"]) - return - await self.async_tcs_svc_request(payload["service"], payload["data"]) + if payload["unique_id"] != self._unique_id: + return + if payload["service"] in [SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE]: + await self.async_zone_svc_request(payload["service"], payload["data"]) + return + await self.async_tcs_svc_request(payload["service"], payload["data"]) async def async_tcs_svc_request(self, service, data) -> None: """Process a service request (system mode) for a controller.""" @@ -575,7 +580,7 @@ def supported_features(self) -> int: async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - self.hass.helpers.dispatcher.async_dispatcher_connect(DOMAIN, self._refresh) + async_dispatcher_connect(self.hass, DOMAIN, self.async_refresh) @property def precision(self) -> float: @@ -665,7 +670,7 @@ def setpoints(self) -> Dict[str, Any]: return self._setpoints async def _update_schedule(self) -> None: - """Get the latest schedule.""" + """Get the latest schedule, if any.""" if "DailySchedules" in self._schedule and not self._schedule["DailySchedules"]: if not self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: return # avoid unnecessary I/O - there's nothing to update diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index b679e2c6dfdf4..cb0c6edbb5f42 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -143,7 +143,7 @@ async def async_tcs_svc_request(self, service: str, data) -> None: allowed_system_modes = self._evo_device.allowedSystemModes attrs = [m for m in allowed_system_modes if m["systemMode"] == mode] - if not attrs: # TODO: do I need this if teh service schema is set correctly? + if not attrs: # TODO: do I need this if the service schema is set correctly? raise ValueError(f"'{mode}' mode is not supported by this system") if ATTR_DURATION_DAYS in data: diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index 316adfdae6553..a1cc9f5392222 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -10,8 +10,8 @@ set_system_mode: example: Away period: description: >- - A period of time in days; used with Away, DayOff, or Custom. The system will - revert to Auto at midnight (up to 99 days, today is day 1). + A period of time in days; used only with Away, DayOff, or Custom. The system + will revert to Auto at midnight (up to 99 days, today is day 1). example: '{"days": 28}' duration: description: The duration in hours; used only with AutoWithEco (up to 24 hours). From c03fcff8ee82b81a13347cb04b2e2e5aa086496c Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 10:27:27 +0000 Subject: [PATCH 16/23] clean doctext --- homeassistant/components/evohome/__init__.py | 20 +++++++++---------- homeassistant/components/evohome/climate.py | 14 ++++++------- .../components/evohome/water_heater.py | 4 ++-- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index ef9624368fcac..d5ea619392659 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -178,7 +178,7 @@ def _handle_exception(err) -> bool: async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: - """Create a (EMEA/EU-based) Honeywell evohome system.""" + """Create a (EMEA/EU-based) Honeywell TCC system.""" async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: app_storage = await store.async_load() @@ -265,9 +265,9 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: def setup_service_functions(hass: HomeAssistantType, broker): """Set up the service handlers for the system/zone operating modes. - Not all TCC-compatible systems support all operating modes. In addition, each - mode will require any of four distinct service schemas. This has to be enumerated - before registering the approperiate handlers. + Not all Honeywell TCC-compatible systems support all operating modes. In addition, + each mode will require any of four distinct service schemas. This has to be + enumerated before registering the approperiate handlers. It appears that all TCC-compatible systems support the same three zones modes. """ @@ -488,7 +488,7 @@ async def _update_v2(self, *args, **kwargs) -> None: await self.save_auth_tokens() async def async_update(self, *args, **kwargs) -> None: - """Get the latest state data of an entire evohome Location. + """Get the latest state data of an entire Honeywell TCC Location. This includes state data for a Controller and all its child devices, such as the operating mode of the Controller and the current temp of its children (e.g. @@ -520,7 +520,7 @@ def __init__(self, evo_broker, evo_device) -> None: self._supported_features = None self._device_state_attrs = {} - async def async_refresh(self, payload=None) -> None: + async def async_refresh(self, payload: Optional[dict] = None) -> None: """Process any signals.""" if payload is None: self.async_schedule_update_ha_state(force_refresh=True) @@ -532,11 +532,11 @@ async def async_refresh(self, payload=None) -> None: return await self.async_tcs_svc_request(payload["service"], payload["data"]) - async def async_tcs_svc_request(self, service, data) -> None: + async def async_tcs_svc_request(self, service: dict, data: dict) -> None: """Process a service request (system mode) for a controller.""" raise NotImplementedError - async def async_zone_svc_request(self, service, data) -> None: + async def async_zone_svc_request(self, service: dict, data: dict) -> None: """Process a service request (setpoint override) for a zone.""" raise NotImplementedError @@ -552,12 +552,12 @@ def unique_id(self) -> Optional[str]: @property def name(self) -> str: - """Return the name of the Evohome entity.""" + """Return the name of the evohome entity.""" return self._name @property def device_state_attributes(self) -> Dict[str, Any]: - """Return the Evohome-specific state attributes.""" + """Return the evohome-specific state attributes.""" status = self._device_state_attrs if "systemModeStatus" in status: convert_until(status["systemModeStatus"], "timeUntil") diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index cb0c6edbb5f42..9a9d10e880c13 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -126,7 +126,7 @@ async def async_setup_platform( class EvoClimateDevice(EvoDevice, ClimateDevice): - """Base for a Honeywell evohome Climate device.""" + """Base for an evohome Climate device.""" def __init__(self, evo_broker, evo_device) -> None: """Initialize a Climate device.""" @@ -134,7 +134,7 @@ def __init__(self, evo_broker, evo_device) -> None: self._preset_modes = None - async def async_tcs_svc_request(self, service: str, data) -> None: + async def async_tcs_svc_request(self, service: dict, data: dict) -> None: """Process a service request (system mode) for a controller.""" if service == SVC_SET_SYSTEM_MODE: mode = data[ATTR_SYSTEM_MODE] @@ -178,7 +178,7 @@ def preset_modes(self) -> Optional[List[str]]: class EvoZone(EvoChild, EvoClimateDevice): - """Base for a Honeywell evohome Zone.""" + """Base for a Honeywell TCC Zone.""" def __init__(self, evo_broker, evo_device) -> None: """Initialize a Zone.""" @@ -195,7 +195,7 @@ def __init__(self, evo_broker, evo_device) -> None: else: self._precision = self._evo_device.setpointCapabilities["valueResolution"] - async def async_zone_svc_request(self, service, data) -> None: + async def async_zone_svc_request(self, service: dict, data: dict) -> None: """Process a service request (setpoint override) for a zone.""" if service == SVC_RESET_ZONE_OVERRIDE: await self._evo_broker.call_client_api( @@ -345,14 +345,14 @@ async def async_update(self) -> None: class EvoController(EvoClimateDevice): - """Base for a Honeywell evohome Controller (hub). + """Base for a Honeywell TCC Controller (hub). The Controller (aka TCS, temperature control system) is the parent of all the child (CH/DHW) devices. It is also a Climate device. """ def __init__(self, evo_broker, evo_device) -> None: - """Initialize a evohome Controller (hub).""" + """Initialize an evohome Controller (hub).""" super().__init__(evo_broker, evo_device) self._unique_id = evo_device.systemId @@ -422,7 +422,7 @@ async def async_update(self) -> None: class EvoThermostat(EvoZone): - """Base for a Honeywell Round Thermostat. + """Base for a Honeywell TCC Round Thermostat. These are implemented as a combined Controller/Zone. """ diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index e24380fe19a28..cd4fb2aadce8a 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -44,10 +44,10 @@ async def async_setup_platform( class EvoDHW(EvoChild, WaterHeaterDevice): - """Base for a Honeywell evohome DHW controller (aka boiler).""" + """Base for a Honeywell TCC DHW controller (aka boiler).""" def __init__(self, evo_broker, evo_device) -> None: - """Initialize a evohome DHW controller.""" + """Initialize an evohome DHW controller.""" super().__init__(evo_broker, evo_device) self._unique_id = evo_device.dhwId From 18f42e960c586fe84e666eee7ff378420c7d38c5 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 10:28:29 +0000 Subject: [PATCH 17/23] clean doctext 2 --- homeassistant/components/evohome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 444671cf82aa8..eaa7048e53bca 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -13,7 +13,7 @@ EVO_CUSTOM = "Custom" EVO_HEATOFF = "HeatingOff" -# The Childs' operating mode is one of: +# The Children's operating mode is one of: EVO_FOLLOW = "FollowSchedule" # the operating mode is 'inherited' from the TCS EVO_TEMPOVER = "TemporaryOverride" EVO_PERMOVER = "PermanentOverride" From 82c96761d423d4a16017e760051cc58778b3dea1 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 10:32:29 +0000 Subject: [PATCH 18/23] clean doctext 3 --- homeassistant/components/evohome/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index d5ea619392659..18a59e6f86686 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -149,7 +149,7 @@ def _handle_exception(err) -> bool: return False except aiohttp.ClientConnectionError: - # this appears to be common with Honeywell's servers + # this appears to be a common occurance with the vendor's servers _LOGGER.warning( "Unable to connect with the vendor's server. " "Check your network and the vendor's service status page. " From 4e8b416045f358be27767133e96b951d9ac7eb69 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 11:51:46 +0000 Subject: [PATCH 19/23] final changes --- homeassistant/components/evohome/__init__.py | 13 +++++++------ homeassistant/components/evohome/climate.py | 14 ++++---------- homeassistant/components/evohome/services.yaml | 4 ++-- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 18a59e6f86686..f048b27dbd213 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -72,12 +72,13 @@ ATTR_ZONE_TEMP = "setpoint" ATTR_DURATION_UNTIL = "duration" -SVC_REFRESH_SYSTEM = "force_refresh" +SVC_REFRESH_SYSTEM = "refresh_system" SVC_SET_SYSTEM_MODE = "set_system_mode" SVC_RESET_SYSTEM = "reset_system" SVC_SET_ZONE_OVERRIDE = "set_zone_override" SVC_RESET_ZONE_OVERRIDE = "clear_zone_override" + RESET_ZONE_OVERRIDE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( { @@ -299,7 +300,7 @@ async def set_zone_override(call): raise ValueError(f"'{entity_id}' is not a known evohome entity") if registry_entry.domain != "climate": - raise ValueError(f"'{entity_id}' is not an evohome zone") + raise ValueError(f"'{entity_id}' is not an evohome controller/zone") payload = { "unique_id": registry_entry.unique_id, @@ -368,15 +369,15 @@ async def set_zone_override(call): # The zone modes are consistent across all systems and use the same schema hass.services.async_register( DOMAIN, - SVC_SET_ZONE_OVERRIDE, + SVC_RESET_ZONE_OVERRIDE, set_zone_override, - schema=SET_ZONE_OVERRIDE_SCHEMA, + schema=RESET_ZONE_OVERRIDE_SCHEMA, ) hass.services.async_register( DOMAIN, - SVC_RESET_ZONE_OVERRIDE, + SVC_SET_ZONE_OVERRIDE, set_zone_override, - schema=RESET_ZONE_OVERRIDE_SCHEMA, + schema=SET_ZONE_OVERRIDE_SCHEMA, ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 9a9d10e880c13..50155e6dd2183 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -135,26 +135,20 @@ def __init__(self, evo_broker, evo_device) -> None: self._preset_modes = None async def async_tcs_svc_request(self, service: dict, data: dict) -> None: - """Process a service request (system mode) for a controller.""" + """Process a service request (system mode) for a controller. + + Data validation is not required, it will have been done upstream. + """ if service == SVC_SET_SYSTEM_MODE: mode = data[ATTR_SYSTEM_MODE] else: # otherwise it is SVC_RESET_SYSTEM mode = EVO_RESET - allowed_system_modes = self._evo_device.allowedSystemModes - attrs = [m for m in allowed_system_modes if m["systemMode"] == mode] - if not attrs: # TODO: do I need this if the service schema is set correctly? - raise ValueError(f"'{mode}' mode is not supported by this system") - if ATTR_DURATION_DAYS in data: - if attrs[0].get("timingResolution") != "1.00:00:00": - raise TypeError(f"'{mode}' mode does not support days, only hours") until = dt.combine(dt.now().date(), dt.min.time()) until += data[ATTR_DURATION_DAYS] elif ATTR_DURATION_HOURS in data: - if attrs[0].get("timingResolution") != "01:00:00": - raise TypeError(f"'{mode}' mode does not support hours, only days") until = dt.now() + data[ATTR_DURATION_HOURS] else: diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index a1cc9f5392222..1e14d58a95089 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -3,7 +3,7 @@ set_system_mode: description: >- Set the system mode, either indefinitely, or for a specified period of time, after - which it will revert to Auto. + which it will revert to Auto. Not all systems support all modes. fields: mode: description: 'One of: Auto, AutoWithEco, Away, DayOff, HeatingOff, or Custom.' @@ -22,7 +22,7 @@ reset_system: Set the system to Auto mode and reset all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode). -force_refresh: +refresh_system: description: >- Pull the latest data from the vendor's servers now, rather than waiting for the next scheduled update. From e1cc62f4db972176a85131a645239a84020207f6 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 14:10:33 +0000 Subject: [PATCH 20/23] last tweak --- homeassistant/components/evohome/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index 1e14d58a95089..ebc859ed9e3c3 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -1,3 +1,4 @@ +# Support for (EMEA/EU-based) Honeywell TCC climate systems. # Describes the format for available services set_system_mode: From 822d7499372cb04c84f1041818b35e9d14e91de9 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 15:08:55 +0000 Subject: [PATCH 21/23] remove unwanted code --- homeassistant/components/evohome/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index f048b27dbd213..ac60f97e3f550 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -337,7 +337,7 @@ async def set_zone_override(call): { vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), vol.Optional(ATTR_DURATION_HOURS): vol.All( - cv.time_period, # lambda td: (td.total_seconds() // 3600) + cv.time_period, vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), ), } From db4f5f62f0c671f42046d814f2895bc3560a7e7e Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 17:57:23 +0000 Subject: [PATCH 22/23] tweaks --- homeassistant/components/evohome/__init__.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index ac60f97e3f550..a7b299e6d0cf0 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -1,6 +1,6 @@ """Support for (EMEA/EU-based) Honeywell TCC climate systems. -Such systems include evohome (multi-zone), and Round Thermostat (single zone). +Such systems include evohome, Round Thermostat, and others. """ from datetime import datetime as dt, timedelta import logging @@ -30,7 +30,6 @@ async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util @@ -274,12 +273,12 @@ def setup_service_functions(hass: HomeAssistantType, broker): """ @verify_domain_control(hass, DOMAIN) - async def force_refresh(call): + async def force_refresh(call) -> None: """Obtain the latest state data via the vendor's RESTful API.""" await broker.async_update() @verify_domain_control(hass, DOMAIN) - async def set_system_mode(call): + async def set_system_mode(call) -> None: """Set the system mode.""" payload = { "unique_id": broker.tcs.systemId, @@ -289,18 +288,18 @@ async def set_system_mode(call): async_dispatcher_send(hass, DOMAIN, payload) @verify_domain_control(hass, DOMAIN) - async def set_zone_override(call): + async def set_zone_override(call) -> None: """Set the zone override (setpoint).""" entity_id = call.data[ATTR_ENTITY_ID] - registry = await async_get_registry(hass) + registry = await hass.helpers.entity_registry.async_get_registry() registry_entry = registry.async_get(entity_id) if registry_entry is None or registry_entry.platform != DOMAIN: - raise ValueError(f"'{entity_id}' is not a known evohome entity") + raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity") if registry_entry.domain != "climate": - raise ValueError(f"'{entity_id}' is not an evohome controller/zone") + raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone") payload = { "unique_id": registry_entry.unique_id, From 23be84f77e437a0742d7886450bb26e5d7532b7b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 17 Jan 2020 19:20:24 +0000 Subject: [PATCH 23/23] small tweak --- homeassistant/components/evohome/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index a7b299e6d0cf0..b9d3f35964aca 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -86,8 +86,7 @@ vol.Coerce(float), vol.Range(min=4.0, max=35.0) ), vol.Optional(ATTR_DURATION_UNTIL): vol.All( - cv.time_period, - vol.Range(min=timedelta(minutes=0), max=timedelta(minutes=24 * 60)), + cv.time_period, vol.Range(min=timedelta(days=0), max=timedelta(days=1)), ), } )