"""Support for Z-Wave climate devices.""" from enum import IntEnum import logging from typing import Optional, Tuple from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, ClimateEntity from homeassistant.components.climate.const import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_COOL, CURRENT_HVAC_FAN, CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, HVAC_MODE_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_NONE, SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DATA_UNSUBSCRIBE, DOMAIN from .entity import ZWaveDeviceEntity VALUE_LIST = "List" VALUE_ID = "Value" VALUE_LABEL = "Label" VALUE_SELECTED_ID = "Selected_id" VALUE_SELECTED_LABEL = "Selected" ATTR_FAN_ACTION = "fan_action" ATTR_VALVE_POSITION = "valve_position" _LOGGER = logging.getLogger(__name__) class ThermostatMode(IntEnum): """Enum with all (known/used) Z-Wave ThermostatModes.""" # https://github.com/OpenZWave/open-zwave/blob/master/cpp/src/command_classes/ThermostatMode.cpp OFF = 0 HEAT = 1 COOL = 2 AUTO = 3 AUXILIARY = 4 RESUME_ON = 5 FAN = 6 FURNANCE = 7 DRY = 8 MOIST = 9 AUTO_CHANGE_OVER = 10 HEATING_ECON = 11 COOLING_ECON = 12 AWAY = 13 FULL_POWER = 15 MANUFACTURER_SPECIFIC = 31 # In Z-Wave the modes and presets are both in ThermostatMode. # This list contains thermostatmodes we should consider a mode only MODES_LIST = [ ThermostatMode.OFF, ThermostatMode.HEAT, ThermostatMode.COOL, ThermostatMode.AUTO, ThermostatMode.AUTO_CHANGE_OVER, ] MODE_SETPOINT_MAPPINGS = { ThermostatMode.OFF: (), ThermostatMode.HEAT: ("setpoint_heating",), ThermostatMode.COOL: ("setpoint_cooling",), ThermostatMode.AUTO: ("setpoint_heating", "setpoint_cooling"), ThermostatMode.AUXILIARY: ("setpoint_heating",), ThermostatMode.FURNANCE: ("setpoint_furnace",), ThermostatMode.DRY: ("setpoint_dry_air",), ThermostatMode.MOIST: ("setpoint_moist_air",), ThermostatMode.AUTO_CHANGE_OVER: ("setpoint_auto_changeover",), ThermostatMode.HEATING_ECON: ("setpoint_eco_heating",), ThermostatMode.COOLING_ECON: ("setpoint_eco_cooling",), ThermostatMode.AWAY: ("setpoint_away_heating", "setpoint_away_cooling"), ThermostatMode.FULL_POWER: ("setpoint_full_power",), } # strings, OZW and/or qt-ozw does not send numeric values # https://github.com/OpenZWave/open-zwave/blob/master/cpp/src/command_classes/ThermostatOperatingState.cpp HVAC_CURRENT_MAPPINGS = { "idle": CURRENT_HVAC_IDLE, "heat": CURRENT_HVAC_HEAT, "pending heat": CURRENT_HVAC_IDLE, "heating": CURRENT_HVAC_HEAT, "cool": CURRENT_HVAC_COOL, "pending cool": CURRENT_HVAC_IDLE, "cooling": CURRENT_HVAC_COOL, "fan only": CURRENT_HVAC_FAN, "vent / economiser": CURRENT_HVAC_FAN, "off": CURRENT_HVAC_OFF, } # Map Z-Wave HVAC Mode to Home Assistant value # Note: We treat "auto" as "heat_cool" as most Z-Wave devices # report auto_changeover as auto without schedule support. ZW_HVAC_MODE_MAPPINGS = { ThermostatMode.OFF: HVAC_MODE_OFF, ThermostatMode.HEAT: HVAC_MODE_HEAT, ThermostatMode.COOL: HVAC_MODE_COOL, # Z-Wave auto mode is actually heat/cool in the hass world ThermostatMode.AUTO: HVAC_MODE_HEAT_COOL, ThermostatMode.AUXILIARY: HVAC_MODE_HEAT, ThermostatMode.FAN: HVAC_MODE_FAN_ONLY, ThermostatMode.FURNANCE: HVAC_MODE_HEAT, ThermostatMode.DRY: HVAC_MODE_DRY, ThermostatMode.AUTO_CHANGE_OVER: HVAC_MODE_HEAT_COOL, ThermostatMode.HEATING_ECON: HVAC_MODE_HEAT, ThermostatMode.COOLING_ECON: HVAC_MODE_COOL, ThermostatMode.AWAY: HVAC_MODE_HEAT_COOL, ThermostatMode.FULL_POWER: HVAC_MODE_HEAT, } # Map Home Assistant HVAC Mode to Z-Wave value HVAC_MODE_ZW_MAPPINGS = { HVAC_MODE_OFF: ThermostatMode.OFF, HVAC_MODE_HEAT: ThermostatMode.HEAT, HVAC_MODE_COOL: ThermostatMode.COOL, HVAC_MODE_FAN_ONLY: ThermostatMode.FAN, HVAC_MODE_DRY: ThermostatMode.DRY, HVAC_MODE_HEAT_COOL: ThermostatMode.AUTO_CHANGE_OVER, } async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Z-Wave Climate from Config Entry.""" @callback def async_add_climate(values): """Add Z-Wave Climate.""" async_add_entities([ZWaveClimateEntity(values)]) hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( async_dispatcher_connect( hass, f"{DOMAIN}_new_{CLIMATE_DOMAIN}", async_add_climate ) ) class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): """Representation of a Z-Wave Climate device.""" def __init__(self, values): """Initialize the entity.""" super().__init__(values) self._hvac_modes = {} self._hvac_presets = {} self.on_value_update() @callback def on_value_update(self): """Call when the underlying values object changes.""" self._current_mode_setpoint_values = self._get_current_mode_setpoint_values() if not self._hvac_modes: self._set_modes_and_presets() @property def hvac_mode(self): """Return hvac operation ie. heat, cool mode.""" if not self.values.mode: # Thermostat(valve) with no support for setting a mode is considered heating-only return HVAC_MODE_HEAT return ZW_HVAC_MODE_MAPPINGS.get( self.values.mode.value[VALUE_SELECTED_ID], HVAC_MODE_HEAT_COOL ) @property def hvac_modes(self): """Return the list of available hvac operation modes.""" return list(self._hvac_modes) @property def fan_mode(self): """Return the fan speed set.""" return self.values.fan_mode.value[VALUE_SELECTED_LABEL] @property def fan_modes(self): """Return a list of available fan modes.""" return [entry[VALUE_LABEL] for entry in self.values.fan_mode.value[VALUE_LIST]] @property def temperature_unit(self): """Return the unit of measurement.""" if self.values.temperature is not None and self.values.temperature.units == "F": return TEMP_FAHRENHEIT return TEMP_CELSIUS @property def current_temperature(self): """Return the current temperature.""" if not self.values.temperature: return None return self.values.temperature.value @property def hvac_action(self): """Return the current running hvac operation if supported.""" if not self.values.operating_state: return None cur_state = self.values.operating_state.value.lower() return HVAC_CURRENT_MAPPINGS.get(cur_state) @property def preset_mode(self): """Return preset operation ie. eco, away.""" # A Zwave mode that can not be translated to a hass mode is considered a preset if not self.values.mode: return None if self.values.mode.value[VALUE_SELECTED_ID] not in MODES_LIST: return self.values.mode.value[VALUE_SELECTED_LABEL] return PRESET_NONE @property def preset_modes(self): """Return the list of available preset operation modes.""" return list(self._hvac_presets) @property def target_temperature(self): """Return the temperature we try to reach.""" return self._current_mode_setpoint_values[0].value @property def target_temperature_low(self) -> Optional[float]: """Return the lowbound target temperature we try to reach.""" return self._current_mode_setpoint_values[0].value @property def target_temperature_high(self) -> Optional[float]: """Return the highbound target temperature we try to reach.""" return self._current_mode_setpoint_values[1].value async def async_set_temperature(self, **kwargs): """Set new target temperature. Must know if single or double setpoint. """ hvac_mode = kwargs.get(ATTR_HVAC_MODE) if hvac_mode is not None: await self.async_set_hvac_mode(hvac_mode) if len(self._current_mode_setpoint_values) == 1: setpoint = self._current_mode_setpoint_values[0] target_temp = kwargs.get(ATTR_TEMPERATURE) if setpoint is not None and target_temp is not None: setpoint.send_value(target_temp) elif len(self._current_mode_setpoint_values) == 2: (setpoint_low, setpoint_high) = self._current_mode_setpoint_values target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) if setpoint_low is not None and target_temp_low is not None: setpoint_low.send_value(target_temp_low) if setpoint_high is not None and target_temp_high is not None: setpoint_high.send_value(target_temp_high) async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" # get id for this fan_mode fan_mode_value = _get_list_id(self.values.fan_mode.value[VALUE_LIST], fan_mode) if fan_mode_value is None: _LOGGER.warning("Received an invalid fan mode: %s", fan_mode) return self.values.fan_mode.send_value(fan_mode_value) async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" if not self.values.mode: # Thermostat(valve) with no support for setting a mode _LOGGER.warning( "Thermostat %s does not support setting a mode", self.entity_id ) return hvac_mode_value = self._hvac_modes.get(hvac_mode) if hvac_mode_value is None: _LOGGER.warning("Received an invalid hvac mode: %s", hvac_mode) return self.values.mode.send_value(hvac_mode_value) async def async_set_preset_mode(self, preset_mode): """Set new target preset mode.""" if preset_mode == PRESET_NONE: # try to restore to the (translated) main hvac mode await self.async_set_hvac_mode(self.hvac_mode) return preset_mode_value = self._hvac_presets.get(preset_mode) if preset_mode_value is None: _LOGGER.warning("Received an invalid preset mode: %s", preset_mode) return self.values.mode.send_value(preset_mode_value) @property def device_state_attributes(self): """Return the optional state attributes.""" data = super().device_state_attributes if self.values.fan_action: data[ATTR_FAN_ACTION] = self.values.fan_action.value if self.values.valve_position: data[ ATTR_VALVE_POSITION ] = f"{self.values.valve_position.value} {self.values.valve_position.units}" return data @property def supported_features(self): """Return the list of supported features.""" support = 0 if len(self._current_mode_setpoint_values) == 1: support |= SUPPORT_TARGET_TEMPERATURE if len(self._current_mode_setpoint_values) > 1: support |= SUPPORT_TARGET_TEMPERATURE_RANGE if self.values.fan_mode: support |= SUPPORT_FAN_MODE if self.values.mode: support |= SUPPORT_PRESET_MODE return support def _get_current_mode_setpoint_values(self) -> Tuple: """Return a tuple of current setpoint Z-Wave value(s).""" if not self.values.mode: setpoint_names = ("setpoint_heating",) else: current_mode = self.values.mode.value[VALUE_SELECTED_ID] setpoint_names = MODE_SETPOINT_MAPPINGS.get(current_mode, ()) # we do not want None values in our tuple so check if the value exists return tuple( getattr(self.values, value_name) for value_name in setpoint_names if getattr(self.values, value_name, None) ) def _set_modes_and_presets(self): """Convert Z-Wave Thermostat modes into Home Assistant modes and presets.""" all_modes = {} all_presets = {PRESET_NONE: None} if self.values.mode: # Z-Wave uses one list for both modes and presets. # Iterate over all Z-Wave ThermostatModes and extract the hvac modes and presets. for val in self.values.mode.value[VALUE_LIST]: if val[VALUE_ID] in MODES_LIST: # treat value as hvac mode hass_mode = ZW_HVAC_MODE_MAPPINGS.get(val[VALUE_ID]) all_modes[hass_mode] = val[VALUE_ID] else: # treat value as hvac preset all_presets[val[VALUE_LABEL]] = val[VALUE_ID] else: all_modes[HVAC_MODE_HEAT] = None self._hvac_modes = all_modes self._hvac_presets = all_presets def _get_list_id(value_lst, value_lbl): """Return the id for the value in the list.""" return next( (val[VALUE_ID] for val in value_lst if val[VALUE_LABEL] == value_lbl), None )