"""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 homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF, PRESET_AWAY, PRESET_ECO, PRESET_HOME, PRESET_NONE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE, ) from homeassistant.const import PRECISION_TENTHS from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.dt import parse_datetime from . import CONF_LOCATION_IDX, EvoDevice from .const import ( DOMAIN, EVO_RESET, EVO_AUTO, EVO_AUTOECO, EVO_AWAY, EVO_CUSTOM, EVO_DAYOFF, EVO_HEATOFF, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER, ) _LOGGER = logging.getLogger(__name__) PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW PRESET_CUSTOM = "Custom" HA_HVAC_TO_TCS = {HVAC_MODE_OFF: EVO_HEATOFF, HVAC_MODE_HEAT: EVO_AUTO} TCS_PRESET_TO_HA = { EVO_AWAY: PRESET_AWAY, EVO_CUSTOM: PRESET_CUSTOM, EVO_AUTOECO: PRESET_ECO, EVO_DAYOFF: PRESET_HOME, EVO_RESET: PRESET_RESET, } # EVO_AUTO: None, HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()} EVO_PRESET_TO_HA = { EVO_FOLLOW: PRESET_NONE, EVO_TEMPOVER: "temporary", EVO_PERMOVER: "permanent", } HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} async def async_setup_platform( hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None ) -> None: """Create the evohome Controller, and its Zones, if any.""" if discovery_info is None: return broker = hass.data[DOMAIN]["broker"] loc_idx = broker.params[CONF_LOCATION_IDX] _LOGGER.debug( "Found Location/Controller, id=%s [%s], name=%s (location_idx=%s)", broker.tcs.systemId, broker.tcs.modelType, broker.tcs.location.name, loc_idx, ) # special case of RoundThermostat (is single zone) if broker.config["zones"][0]["modelType"] == "RoundModulation": zone = list(broker.tcs.zones.values())[0] _LOGGER.debug( "Found %s, id=%s [%s], name=%s", zone.zoneType, zone.zoneId, zone.modelType, zone.name, ) async_add_entities([EvoThermostat(broker, zone)], update_before_add=True) return controller = EvoController(broker, broker.tcs) zones = [] for zone in broker.tcs.zones.values(): _LOGGER.debug( "Found %s, id=%s [%s], name=%s", zone.zoneType, zone.zoneId, zone.modelType, zone.name, ) zones.append(EvoZone(broker, zone)) async_add_entities([controller] + zones, update_before_add=True) class EvoClimateDevice(EvoDevice, ClimateDevice): """Base for a Honeywell evohome Climate device.""" def __init__(self, evo_broker, evo_device) -> None: """Initialize the evohome 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.""" 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 list(HA_HVAC_TO_TCS) @property def preset_modes(self) -> Optional[List[str]]: """Return a list of available preset modes.""" return self._preset_modes class EvoZone(EvoClimateDevice): """Base for a Honeywell evohome Zone.""" def __init__(self, evo_broker, evo_device) -> None: """Initialize the evohome Zone.""" super().__init__(evo_broker, evo_device) 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.""" if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: return HVAC_MODE_AUTO is_off = self.target_temperature <= self.min_temp return HVAC_MODE_OFF if is_off else HVAC_MODE_HEAT @property def hvac_action(self) -> Optional[str]: """Return the current running hvac operation if supported.""" if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF: return CURRENT_HVAC_OFF if self.target_temperature <= self.min_temp: return CURRENT_HVAC_OFF 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 self._evo_device.setpointStatus["targetHeatTemperature"] @property def preset_mode(self) -> Optional[str]: """Return the current preset mode, e.g., home, away, temp.""" if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) return EVO_PRESET_TO_HA.get( self._evo_device.setpointStatus["setpointMode"], "follow" ) @property def min_temp(self) -> float: """Return the minimum target temperature of a evohome Zone. The default is 5, but is user-configurable within 5-35 (in Celsius). """ return self._evo_device.setpointCapabilities["minHeatSetpoint"] @property def max_temp(self) -> float: """Return the maximum target temperature of a evohome Zone. The default is 35, but is user-configurable within 5-35 (in Celsius). """ return self._evo_device.setpointCapabilities["maxHeatSetpoint"] async def async_set_temperature(self, **kwargs) -> None: """Set a new target temperature.""" until = kwargs.get("until") if until: until = parse_datetime(until) await self._set_temperature(kwargs["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) else: # HVAC_MODE_HEAT await self._set_zone_mode(EVO_FOLLOW) 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. """ await self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)) class EvoController(EvoClimateDevice): """Base for a Honeywell evohome 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 the evohome Controller (hub).""" super().__init__(evo_broker, evo_device) 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.""" tcs_mode = self._evo_tcs.systemModeStatus["mode"] return HVAC_MODE_OFF if tcs_mode == EVO_HEATOFF else HVAC_MODE_HEAT @property def current_temperature(self) -> Optional[float]: """Return the average current temperature of the heating Zones. Controllers do not have a current temp, but one is expected by HA. """ temps = [ z.temperatureStatus["temperature"] for z in self._evo_tcs.zones.values() if z.temperatureStatus["isAvailable"] ] return round(sum(temps) / len(temps), 1) if temps else None @property 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. The evohome Controller doesn't have a target temperature. """ return async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set an operating mode for the 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. """ 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 class EvoThermostat(EvoZone): """Base for a Honeywell Round Thermostat. Implemented as a combined Controller/Zone. """ def __init__(self, evo_broker, evo_device) -> None: """Initialize the Round 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.""" if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF: return HVAC_MODE_OFF return super().hvac_mode @property def preset_mode(self) -> Optional[str]: """Return the current preset mode, e.g., home, away, temp.""" if ( self._evo_tcs.systemModeStatus["mode"] == EVO_AUTOECO and self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW ): return PRESET_ECO return super().preset_mode async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set an operating mode.""" 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. """ 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))