diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index ba7a72024ed16b..14bf12239538b2 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -4,6 +4,7 @@ """ from datetime import datetime, timedelta import logging +import re from typing import Any, Dict, Optional, Tuple import aiohttp.client_exceptions @@ -25,9 +26,9 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from homeassistant.util.dt import parse_datetime, utcnow +import homeassistant.util.dt as dt_util -from .const import DOMAIN, STORAGE_VERSION, STORAGE_KEY, GWS, TCS +from .const import DOMAIN, EVO_FOLLOW, STORAGE_VERSION, STORAGE_KEY, GWS, TCS _LOGGER = logging.getLogger(__name__) @@ -55,20 +56,45 @@ ) -def _local_dt_to_utc(dt_naive: datetime) -> datetime: - dt_aware = utcnow() + (dt_naive - datetime.now()) +def _local_dt_to_aware(dt_naive: datetime) -> datetime: + dt_aware = dt_util.now() + (dt_naive - datetime.now()) if dt_aware.microsecond >= 500000: dt_aware += timedelta(seconds=1) return dt_aware.replace(microsecond=0) -def _utc_to_local_dt(dt_aware: datetime) -> datetime: - dt_naive = datetime.now() + (dt_aware - utcnow()) +def _dt_to_local_naive(dt_aware: datetime) -> datetime: + dt_naive = datetime.now() + (dt_aware - dt_util.now()) if dt_naive.microsecond >= 500000: dt_naive += timedelta(seconds=1) return dt_naive.replace(microsecond=0) +def convert_until(status_dict, until_key) -> str: + """Convert datetime string from "%Y-%m-%dT%H:%M:%SZ" to local/aware/isoformat.""" + if until_key in status_dict: # only present for certain modes + dt_utc_naive = dt_util.parse_datetime(status_dict[until_key]) + status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() + + +def convert_dict(dictionary: Dict[str, Any]) -> Dict[str, Any]: + """Recursively convert a dict's keys to snake_case.""" + + def convert_key(key: str) -> str: + """Convert a string to snake_case.""" + string = re.sub(r"[\-\.\s]", "_", str(key)) + return (string[0]).lower() + re.sub( + r"[A-Z]", lambda matched: "_" + matched.group(0).lower(), string[1:] + ) + + return { + (convert_key(k) if isinstance(k, str) else k): ( + convert_dict(v) if isinstance(v, dict) else v + ) + for k, v in dictionary.items() + } + + def _handle_exception(err) -> bool: try: raise err @@ -135,7 +161,7 @@ class EvoBroker: """Container for evohome client and data.""" def __init__(self, hass, params) -> None: - """Initialize the evohome client and data structure.""" + """Initialize the evohome client and its data structure.""" self.hass = hass self.params = params self.config = {} @@ -157,7 +183,7 @@ async def init_client(self) -> bool: # evohomeasync2 uses naive/local datetimes if access_token_expires is not None: - access_token_expires = _utc_to_local_dt(access_token_expires) + access_token_expires = _dt_to_local_naive(access_token_expires) client = self.client = evohomeasync2.EvohomeClient( self.params[CONF_USERNAME], @@ -220,7 +246,7 @@ async def _load_auth_tokens( access_token = app_storage.get(CONF_ACCESS_TOKEN) at_expires_str = app_storage.get(CONF_ACCESS_TOKEN_EXPIRES) if at_expires_str: - at_expires_dt = parse_datetime(at_expires_str) + at_expires_dt = dt_util.parse_datetime(at_expires_str) else: at_expires_dt = None @@ -230,7 +256,7 @@ async def _load_auth_tokens( async def _save_auth_tokens(self, *args) -> None: # evohomeasync2 uses naive/local datetimes - access_token_expires = _local_dt_to_utc(self.client.access_token_expires) + access_token_expires = _local_dt_to_aware(self.client.access_token_expires) self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME] self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token @@ -246,11 +272,11 @@ async def _save_auth_tokens(self, *args) -> None: ) async def update(self, *args, **kwargs) -> None: - """Get the latest state data of the entire evohome Location. + """Get the latest state data of an entire evohome Location. - This includes state data for the Controller and all its child devices, - such as the operating mode of the Controller and the current temp of - its children (e.g. Zones, DHW controller). + 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. + Zones, DHW controller). """ loc_idx = self.params[CONF_LOCATION_IDX] @@ -260,9 +286,7 @@ async def update(self, *args, **kwargs) -> None: _handle_exception(err) else: # inform the evohome devices that state data has been updated - self.hass.helpers.dispatcher.async_dispatcher_send( - DOMAIN, {"signal": "refresh"} - ) + self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN) _LOGGER.debug("Status = %s", status[GWS][0][TCS][0]) @@ -270,8 +294,8 @@ async def update(self, *args, **kwargs) -> None: class EvoDevice(Entity): """Base for any evohome device. - This includes the Controller, (up to 12) Heating Zones and - (optionally) a DHW controller. + This includes the Controller, (up to 12) Heating Zones and (optionally) a + DHW controller. """ def __init__(self, evo_broker, evo_device) -> None: @@ -280,72 +304,26 @@ def __init__(self, evo_broker, evo_device) -> None: self._evo_broker = evo_broker self._evo_tcs = evo_broker.tcs - self._name = self._icon = self._precision = None - self._state_attributes = [] + self._unique_id = self._name = self._icon = self._precision = None + self._device_state_attrs = {} + self._state_attributes = [] self._supported_features = None - self._schedule = {} @callback - def _refresh(self, packet): - if packet["signal"] == "refresh": - self.async_schedule_update_ha_state(force_refresh=True) - - @property - def setpoints(self) -> Dict[str, Any]: - """Return the current/next setpoints from the schedule. - - Only Zones & DHW controllers (but not the TCS) can have schedules. - """ - if not self._schedule["DailySchedules"]: - return {} - - switchpoints = {} - - day_time = datetime.now() - day_of_week = int(day_time.strftime("%w")) # 0 is Sunday - - # Iterate today's switchpoints until past the current time of day... - day = self._schedule["DailySchedules"][day_of_week] - sp_idx = -1 # last switchpoint of the day before - for i, tmp in enumerate(day["Switchpoints"]): - if day_time.strftime("%H:%M:%S") > tmp["TimeOfDay"]: - sp_idx = i # current setpoint - else: - break - - # Did the current SP start yesterday? Does the next start SP tomorrow? - current_sp_day = -1 if sp_idx == -1 else 0 - next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 - - for key, offset, idx in [ - ("current", current_sp_day, sp_idx), - ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), - ]: - - spt = switchpoints[key] = {} - - sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") - day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] - switchpoint = day["Switchpoints"][idx] - - dt_naive = datetime.strptime( - f"{sp_date}T{switchpoint['TimeOfDay']}", "%Y-%m-%dT%H:%M:%S" - ) - - spt["from"] = _local_dt_to_utc(dt_naive).isoformat() - try: - spt["temperature"] = switchpoint["heatSetpoint"] - except KeyError: - spt["state"] = switchpoint["DhwState"] - - return switchpoints + def _refresh(self) -> None: + self.async_schedule_update_ha_state(force_refresh=True) @property def should_poll(self) -> bool: """Evohome entities should not be polled.""" return False + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._unique_id + @property def name(self) -> str: """Return the name of the Evohome entity.""" @@ -354,15 +332,15 @@ def name(self) -> str: @property def device_state_attributes(self) -> Dict[str, Any]: """Return the Evohome-specific state attributes.""" - status = {} - for attr in self._state_attributes: - if attr != "setpoints": - status[attr] = getattr(self._evo_device, attr) - - if "setpoints" in self._state_attributes: - status["setpoints"] = self.setpoints + status = self._device_state_attrs + if "systemModeStatus" in status: + convert_until(status["systemModeStatus"], "timeUntil") + if "setpointStatus" in status: + convert_until(status["setpointStatus"], "until") + if "stateStatus" in status: + convert_until(status["stateStatus"], "until") - return {"status": status} + return {"status": convert_dict(status)} @property def icon(self) -> str: @@ -388,27 +366,98 @@ 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) -> None: + async def _call_client_api(self, api_function, refresh=True) -> Any: try: - await api_function + result = await api_function except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: - _handle_exception(err) + if not _handle_exception(err): + return - self.hass.helpers.event.async_call_later( - 2, self._evo_broker.update() - ) # call update() in 2 seconds + if refresh is True: + self.hass.helpers.event.async_call_later(1, self._evo_broker.update()) - async def _update_schedule(self) -> None: - """Get the latest state data.""" - if ( - not self._schedule.get("DailySchedules") - or parse_datetime(self.setpoints["next"]["from"]) < utcnow() - ): + return result + + +class EvoChild(EvoDevice): + """Base for any evohome child. + + This includes (up to 12) Heating Zones and (optionally) a DHW controller. + """ + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a evohome Controller (hub).""" + super().__init__(evo_broker, evo_device) + self._schedule = {} + self._setpoints = {} + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature of a Zone.""" + if self._evo_device.temperatureStatus["isAvailable"]: + return self._evo_device.temperatureStatus["temperature"] + return None + + @property + def setpoints(self) -> Dict[str, Any]: + """Return the current/next setpoints from the schedule. + + Only Zones & DHW controllers (but not the TCS) can have schedules. + """ + if not self._schedule["DailySchedules"]: + return {} # no schedule {'DailySchedules': []}, so no scheduled setpoints + + day_time = dt_util.now() + day_of_week = int(day_time.strftime("%w")) # 0 is Sunday + time_of_day = day_time.strftime("%H:%M:%S") + + # Iterate today's switchpoints until past the current time of day... + day = self._schedule["DailySchedules"][day_of_week] + sp_idx = -1 # last switchpoint of the day before + for i, tmp in enumerate(day["Switchpoints"]): + if time_of_day > tmp["TimeOfDay"]: + sp_idx = i # current setpoint + else: + break + + # Did the current SP start yesterday? Does the next start SP tomorrow? + this_sp_day = -1 if sp_idx == -1 else 0 + next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 + + for key, offset, idx in [ + ("this", this_sp_day, sp_idx), + ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), + ]: + sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") + day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] + switchpoint = day["Switchpoints"][idx] + + dt_local_aware = _local_dt_to_aware( + dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}") + ) + + self._setpoints[f"{key}_sp_from"] = dt_local_aware.isoformat() try: - self._schedule = await self._evo_device.schedule() - except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: - _handle_exception(err) + self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"] + except KeyError: + self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] + + return self._setpoints + + async def _update_schedule(self) -> None: + """Get the latest schedule.""" + 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 + + self._schedule = await self._call_client_api( + self._evo_device.schedule(), refresh=False + ) async def async_update(self) -> None: """Get the latest state data.""" - await self._update_schedule() + next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00") + if dt_util.now() >= dt_util.parse_datetime(next_sp_from): + await self._update_schedule() # no schedule, or it's out-of-date + + self._device_state_attrs = {"setpoints": self.setpoints} diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 0264f76f38f5db..e5c8c6af14bde1 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,7 +1,6 @@ """Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" -from datetime import datetime import logging -from typing import Any, Dict, Optional, List +from typing import Optional, List from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( @@ -22,7 +21,7 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.dt import parse_datetime -from . import CONF_LOCATION_IDX, EvoDevice +from . import CONF_LOCATION_IDX, EvoDevice, EvoChild from .const import ( DOMAIN, EVO_RESET, @@ -61,6 +60,9 @@ } HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} +STATE_ATTRS_TCS = ["systemId", "activeFaults", "systemModeStatus"] +STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] + async def async_setup_platform( hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None @@ -114,63 +116,20 @@ class EvoClimateDevice(EvoDevice, ClimateDevice): """Base for a Honeywell evohome Climate device.""" def __init__(self, evo_broker, evo_device) -> None: - """Initialize the evohome Climate device.""" + """Initialize a Climate device.""" super().__init__(evo_broker, evo_device) self._preset_modes = None - async def _set_temperature( - self, temperature: float, until: Optional[datetime] = None - ) -> None: - """Set a new target temperature for the Zone. - - until == None means indefinitely (i.e. PermanentOverride) - """ - await self._call_client_api( - self._evo_device.set_temperature(temperature, until) - ) - - async def _set_zone_mode(self, op_mode: str) -> None: - """Set a Zone to one of its native EVO_* operating modes. - - Zones inherit their _effective_ operating mode from the Controller. - - Usually, Zones are in 'FollowSchedule' mode, where their setpoints are - a function of their own schedule and the Controller's operating mode, - e.g. 'AutoWithEco' mode means their setpoint is (by default) 3C less - than scheduled. - - However, Zones can _override_ these setpoints, either indefinitely, - 'PermanentOverride' mode, or for a period of time, 'TemporaryOverride', - after which they will revert back to 'FollowSchedule'. - - Finally, some of the Controller's operating modes are _forced_ upon the - Zones, regardless of any override mode, e.g. 'HeatingOff', Zones to - (by default) 5C, and 'Away', Zones to (by default) 12C. - """ - if op_mode == EVO_FOLLOW: - await self._call_client_api(self._evo_device.cancel_temp_override()) - return - - temperature = self._evo_device.setpointStatus["targetHeatTemperature"] - until = None # EVO_PERMOVER - - if op_mode == EVO_TEMPOVER and self._schedule["DailySchedules"]: - await self._update_schedule() - if self._schedule["DailySchedules"]: - until = parse_datetime(self.setpoints["next"]["from"]) - - await self._set_temperature(temperature, until=until) - async def _set_tcs_mode(self, op_mode: str) -> None: - """Set the Controller to any of its native EVO_* operating modes.""" + """Set a Controller to any of its native EVO_* operating modes.""" await self._call_client_api( self._evo_tcs._set_status(op_mode) # pylint: disable=protected-access ) @property def hvac_modes(self) -> List[str]: - """Return the list of available hvac operation modes.""" + """Return a list of available hvac operation modes.""" return list(HA_HVAC_TO_TCS) @property @@ -179,36 +138,24 @@ def preset_modes(self) -> Optional[List[str]]: return self._preset_modes -class EvoZone(EvoClimateDevice): +class EvoZone(EvoChild, EvoClimateDevice): """Base for a Honeywell evohome Zone.""" def __init__(self, evo_broker, evo_device) -> None: - """Initialize the evohome Zone.""" + """Initialize a Zone.""" super().__init__(evo_broker, evo_device) + self._unique_id = evo_device.zoneId self._name = evo_device.name self._icon = "mdi:radiator" self._precision = self._evo_device.setpointCapabilities["valueResolution"] - self._state_attributes = [ - "zoneId", - "activeFaults", - "setpointStatus", - "temperatureStatus", - "setpoints", - ] - self._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE self._preset_modes = list(HA_PRESET_TO_EVO) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._evo_device.temperatureStatus["isAvailable"] - @property def hvac_mode(self) -> str: - """Return the current operating mode of the evohome Zone.""" + """Return the current operating mode of a Zone.""" if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: return HVAC_MODE_AUTO is_off = self.target_temperature <= self.min_temp @@ -221,24 +168,15 @@ def hvac_action(self) -> Optional[str]: return CURRENT_HVAC_OFF if self.target_temperature <= self.min_temp: return CURRENT_HVAC_OFF - if self.target_temperature < self.current_temperature: + if not self._evo_device.temperatureStatus["isAvailable"]: + return None + if self.target_temperature <= self.current_temperature: return CURRENT_HVAC_IDLE return CURRENT_HVAC_HEAT - @property - def current_temperature(self) -> Optional[float]: - """Return the current temperature of the evohome Zone.""" - return ( - self._evo_device.temperatureStatus["temperature"] - if self._evo_device.temperatureStatus["isAvailable"] - else None - ) - @property def target_temperature(self) -> float: - """Return the target temperature of the evohome Zone.""" - if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF: - return self._evo_device.setpointCapabilities["minHeatSetpoint"] + """Return the target temperature of a Zone.""" return self._evo_device.setpointStatus["targetHeatTemperature"] @property @@ -252,7 +190,7 @@ def preset_mode(self) -> Optional[str]: @property def min_temp(self) -> float: - """Return the minimum target temperature of a evohome Zone. + """Return the minimum target temperature of a Zone. The default is 5, but is user-configurable within 5-35 (in Celsius). """ @@ -260,7 +198,7 @@ def min_temp(self) -> float: @property def max_temp(self) -> float: - """Return the maximum target temperature of a evohome Zone. + """Return the maximum target temperature of a Zone. The default is 35, but is user-configurable within 5-35 (in Celsius). """ @@ -268,26 +206,70 @@ def max_temp(self) -> float: async def async_set_temperature(self, **kwargs) -> None: """Set a new target temperature.""" - until = kwargs.get("until") - if until: - until = parse_datetime(until) + temperature = kwargs["temperature"] - await self._set_temperature(kwargs["temperature"], 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 + + await self._call_client_api( + self._evo_device.set_temperature(temperature, until) + ) async def async_set_hvac_mode(self, hvac_mode: str) -> None: - """Set an operating mode for the Zone.""" - if hvac_mode == HVAC_MODE_OFF: - await self._set_temperature(self.min_temp, until=None) + """Set a Zone to one of its native EVO_* operating modes. + + Zones inherit their _effective_ operating mode from their Controller. + Usually, Zones are in 'FollowSchedule' mode, where their setpoints are a + function of their own schedule and the Controller's operating mode, e.g. + 'AutoWithEco' mode means their setpoint is (by default) 3C less than scheduled. + + However, Zones can _override_ these setpoints, either indefinitely, + 'PermanentOverride' mode, or for a set period of time, 'TemporaryOverride' mode + (after which they will revert back to 'FollowSchedule' mode). + + Finally, some of the Controller's operating modes are _forced_ upon the Zones, + regardless of any override mode, e.g. 'HeatingOff', Zones to (by default) 5C, + and 'Away', Zones to (by default) 12C. + """ + if hvac_mode == HVAC_MODE_OFF: + await self._call_client_api( + self._evo_device.set_temperature(self.min_temp, until=None) + ) else: # HVAC_MODE_HEAT - await self._set_zone_mode(EVO_FOLLOW) + await self._call_client_api(self._evo_device.cancel_temp_override()) async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: - """Set a new preset mode. + """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 preset_mode is None, then revert to following the schedule. - """ - await self._set_zone_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()) + return + + temperature = self._evo_device.setpointStatus["targetHeatTemperature"] + + if evo_preset_mode == EVO_TEMPOVER: + await self._update_schedule() + until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + else: # EVO_PERMOVER + until = None + + await self._call_client_api( + self._evo_device.set_temperature(temperature, until) + ) + + async def async_update(self) -> None: + """Get the latest state data for a Zone.""" + await super().async_update() + + for attr in STATE_ATTRS_ZONES: + self._device_state_attrs[attr] = getattr(self._evo_device, attr) class EvoController(EvoClimateDevice): @@ -298,21 +280,20 @@ class EvoController(EvoClimateDevice): """ def __init__(self, evo_broker, evo_device) -> None: - """Initialize the evohome Controller (hub).""" + """Initialize a evohome Controller (hub).""" super().__init__(evo_broker, evo_device) + self._unique_id = evo_device.systemId self._name = evo_device.location.name self._icon = "mdi:thermostat" self._precision = PRECISION_TENTHS - self._state_attributes = ["systemId", "activeFaults", "systemModeStatus"] - self._supported_features = SUPPORT_PRESET_MODE self._preset_modes = list(HA_PRESET_TO_TCS) @property def hvac_mode(self) -> str: - """Return the current operating mode of the evohome Controller.""" + """Return the current operating mode of a Controller.""" tcs_mode = self._evo_tcs.systemModeStatus["mode"] return HVAC_MODE_OFF if tcs_mode == EVO_HEATOFF else HVAC_MODE_HEAT @@ -334,52 +315,53 @@ def preset_mode(self) -> Optional[str]: """Return the current preset mode, e.g., home, away, temp.""" return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) - async def async_set_temperature(self, **kwargs) -> None: - """Do nothing. + @property + def min_temp(self) -> float: + """Return None as Controllers don't have a target temperature.""" + return None - The evohome Controller doesn't have a target temperature. - """ - return + @property + def max_temp(self) -> float: + """Return None as Controllers don't have a target temperature.""" + return None + + async def async_set_temperature(self, **kwargs) -> None: + """Raise exception as Controllers don't have a target temperature.""" + raise NotImplementedError("Evohome Controllers don't have target temperatures.") async def async_set_hvac_mode(self, hvac_mode: str) -> None: - """Set an operating mode for the Controller.""" + """Set an operating mode for a Controller.""" await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: - """Set a new preset mode. - - If preset_mode is None, then revert to 'Auto' mode. - """ + """Set the preset mode; if None, then revert to 'Auto' mode.""" await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) async def async_update(self) -> None: - """Get the latest state data.""" - return + """Get the latest state data for a Controller.""" + self._device_state_attrs = {} + + attrs = self._device_state_attrs + for attr in STATE_ATTRS_TCS: + if attr == "activeFaults": + attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) + else: + attrs[attr] = getattr(self._evo_tcs, attr) class EvoThermostat(EvoZone): """Base for a Honeywell Round Thermostat. - Implemented as a combined Controller/Zone. + These are implemented as a combined Controller/Zone. """ def __init__(self, evo_broker, evo_device) -> None: - """Initialize the Round Thermostat.""" + """Initialize the Thermostat.""" super().__init__(evo_broker, evo_device) self._name = evo_broker.tcs.location.name self._preset_modes = [PRESET_AWAY, PRESET_ECO] - @property - def device_state_attributes(self) -> Dict[str, Any]: - """Return the device-specific state attributes.""" - status = super().device_state_attributes["status"] - - status["systemModeStatus"] = self._evo_tcs.systemModeStatus - status["activeFaults"] += self._evo_tcs.activeFaults - - return {"status": status} - @property def hvac_mode(self) -> str: """Return the current operating mode.""" @@ -404,11 +386,19 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: - """Set a new preset mode. - - If preset_mode is None, then revert to following the schedule. - """ + """Set the preset mode; if None, then revert to following the schedule.""" if preset_mode in list(HA_PRESET_TO_TCS): await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode)) else: - await self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)) + await super().async_set_hvac_mode(preset_mode) + + async def async_update(self) -> None: + """Get the latest state data for the Thermostat.""" + await super().async_update() + + attrs = self._device_state_attrs + for attr in STATE_ATTRS_TCS: + if attr == "activeFaults": # self._evo_device also has "activeFaults" + attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) + else: + attrs[attr] = getattr(self._evo_tcs, attr) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index a0480d62a10aa2..444671cf82aa8f 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -21,5 +21,3 @@ # These are used only to help prevent E501 (line too long) violations GWS = "gateways" TCS = "temperatureControlSystems" - -EVO_STRFTIME = "%Y-%m-%dT%H:%M:%SZ" diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 1b37bc3b2b58c2..b65665eb2c9ad2 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -3,27 +3,31 @@ from typing import List from homeassistant.components.water_heater import ( + SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE, WaterHeaterDevice, ) from homeassistant.const import PRECISION_WHOLE, STATE_OFF, STATE_ON +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.dt import parse_datetime -from . import EvoDevice -from .const import DOMAIN, EVO_STRFTIME, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER +from . import EvoChild +from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER _LOGGER = logging.getLogger(__name__) -HA_STATE_TO_EVO = {STATE_ON: "On", STATE_OFF: "Off"} -EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items()} +STATE_AUTO = "auto" -HA_OPMODE_TO_DHW = {STATE_ON: EVO_FOLLOW, STATE_OFF: EVO_PERMOVER} +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: "On", STATE_OFF: "Off"} +EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} + +STATE_ATTRS_DHW = ["dhwId", "activeFaults", "stateStatus", "temperatureStatus"] async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None ) -> None: - """Create the DHW controller.""" + """Create a DHW controller.""" if discovery_info is None: return @@ -38,63 +42,71 @@ async def async_setup_platform( async_add_entities([evo_dhw], update_before_add=True) -class EvoDHW(EvoDevice, WaterHeaterDevice): +class EvoDHW(EvoChild, WaterHeaterDevice): """Base for a Honeywell evohome DHW controller (aka boiler).""" def __init__(self, evo_broker, evo_device) -> None: - """Initialize the evohome DHW controller.""" + """Initialize a evohome DHW controller.""" super().__init__(evo_broker, evo_device) + self._unique_id = evo_device.dhwId self._name = "DHW controller" self._icon = "mdi:thermometer-lines" self._precision = PRECISION_WHOLE - self._state_attributes = [ - "dhwId", - "activeFaults", - "stateStatus", - "temperatureStatus", - "setpoints", - ] - - self._supported_features = SUPPORT_OPERATION_MODE - self._operation_list = list(HA_OPMODE_TO_DHW) + self._supported_features = SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._evo_device.temperatureStatus.get("isAvailable", False) + def state(self): + """Return the current state.""" + return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] @property def current_operation(self) -> str: - """Return the current operating mode (On, or Off).""" + """Return the current operating mode (Auto, On, or Off).""" + if self._evo_device.stateStatus["mode"] == EVO_FOLLOW: + return STATE_AUTO return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] @property def operation_list(self) -> List[str]: """Return the list of available operations.""" - return self._operation_list + return list(HA_STATE_TO_EVO) @property - def current_temperature(self) -> float: - """Return the current temperature.""" - return self._evo_device.temperatureStatus["temperature"] + def is_away_mode_on(self): + """Return True if away mode is on.""" + is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF + is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER + return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: - """Set new operation mode for a DHW controller.""" - op_mode = HA_OPMODE_TO_DHW[operation_mode] - - state = "" if op_mode == EVO_FOLLOW else HA_STATE_TO_EVO[STATE_OFF] - until = None # EVO_FOLLOW, EVO_PERMOVER + """Set new operation mode for a DHW controller. - if op_mode == EVO_TEMPOVER and self._schedule["DailySchedules"]: + 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()) + else: await self._update_schedule() - if self._schedule["DailySchedules"]: - until = parse_datetime(self.setpoints["next"]["from"]) - until = until.strftime(EVO_STRFTIME) + 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)) + else: # STATE_OFF + await self._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()) + + async def async_turn_away_mode_off(self): + """Turn away mode off.""" + await self._call_client_api(self._evo_device.set_dhw_auto()) - data = {"Mode": op_mode, "State": state, "UntilTime": until} + async def async_update(self) -> None: + """Get the latest state data for a DHW controller.""" + await super().async_update() - await self._call_client_api( - self._evo_device._set_dhw(data) # pylint: disable=protected-access - ) + for attr in STATE_ATTRS_DHW: + self._device_state_attrs[attr] = getattr(self._evo_device, attr)