From 4d23ffacd1a2b1e1841500715ada00a872b68961 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 19 Feb 2021 18:20:10 -0500 Subject: [PATCH] Add zwave_js thermostat fan mode and fan state support (#46793) * add thermostat fan mode and fan state support * return when fan mode is not supported * use get just in case * validate state key is in states so we dont have to use get * pylint --- homeassistant/components/zwave_js/climate.py | 69 ++++++++++++++++++- tests/components/zwave_js/test_climate.py | 56 +++++++++++++++ ...ate_radio_thermostat_ct100_plus_state.json | 46 +++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 689187f0b34..341b8f99fd6 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -1,5 +1,5 @@ """Representation of Z-Wave thermostats.""" -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( @@ -33,6 +33,7 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_NONE, + SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, @@ -81,6 +82,8 @@ HVAC_CURRENT_MAP: Dict[int, str] = { ThermostatOperatingState.THIRD_STAGE_AUX_HEAT: CURRENT_HVAC_HEAT, } +ATTR_FAN_STATE = "fan_state" + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable @@ -148,6 +151,16 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): add_to_watched_value_ids=True, check_all_endpoints=True, ) + self._fan_mode = self.get_zwave_value( + THERMOSTAT_MODE_PROPERTY, + CommandClass.THERMOSTAT_FAN_MODE, + add_to_watched_value_ids=True, + ) + self._fan_state = self.get_zwave_value( + THERMOSTAT_OPERATING_STATE_PROPERTY, + CommandClass.THERMOSTAT_FAN_STATE, + add_to_watched_value_ids=True, + ) self._set_modes_and_presets() def _setpoint_value(self, setpoint_type: ThermostatSetpointType) -> ZwaveValue: @@ -275,6 +288,40 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): """Return a list of available preset modes.""" return list(self._hvac_presets) + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting.""" + if ( + self._fan_mode + and self._fan_mode.value is not None + and str(self._fan_mode.value) in self._fan_mode.metadata.states + ): + return cast(str, self._fan_mode.metadata.states[str(self._fan_mode.value)]) + return None + + @property + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes.""" + if self._fan_mode and self._fan_mode.metadata.states: + return list(self._fan_mode.metadata.states.values()) + return None + + @property + def device_state_attributes(self) -> Optional[Dict[str, str]]: + """Return the optional state attributes.""" + if ( + self._fan_state + and self._fan_state.value is not None + and str(self._fan_state.value) in self._fan_state.metadata.states + ): + return { + ATTR_FAN_STATE: self._fan_state.metadata.states[ + str(self._fan_state.value) + ] + } + + return None + @property def supported_features(self) -> int: """Return the list of supported features.""" @@ -283,8 +330,28 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): support |= SUPPORT_TARGET_TEMPERATURE if len(self._current_mode_setpoint_enums) > 1: support |= SUPPORT_TARGET_TEMPERATURE_RANGE + if self._fan_mode: + support |= SUPPORT_FAN_MODE return support + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + if not self._fan_mode: + return + + try: + new_state = int( + next( + state + for state, label in self._fan_mode.metadata.states.items() + if label == fan_mode + ) + ) + except StopIteration: + raise ValueError(f"Received an invalid fan mode: {fan_mode}") from None + + await self.info.node.async_set_value(self._fan_mode, new_state) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" assert self.hass diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index e11d3b75c47..7336acd82eb 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -5,6 +5,7 @@ from zwave_js_server.event import Event from homeassistant.components.climate.const import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, @@ -19,10 +20,12 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_NONE, + SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, ) +from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE CLIMATE_RADIO_THERMOSTAT_ENTITY = "climate.z_wave_thermostat" @@ -50,6 +53,8 @@ async def test_thermostat_v2( assert state.attributes[ATTR_TEMPERATURE] == 22.2 assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert state.attributes[ATTR_FAN_MODE] == "Auto low" + assert state.attributes[ATTR_FAN_STATE] == "Idle / off" # Test setting preset mode await hass.services.async_call( @@ -329,6 +334,57 @@ async def test_thermostat_v2( blocking=True, ) + client.async_send_command.reset_mock() + + # Test setting fan mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_FAN_MODE: "Low", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 13 + assert args["valueId"] == { + "endpoint": 1, + "commandClass": 68, + "commandClassName": "Thermostat Fan Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "min": 0, + "max": 255, + "states": {"0": "Auto low", "1": "Low"}, + "label": "Thermostat fan mode", + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + # Test setting invalid fan mode + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_FAN_MODE: "fake value", + }, + blocking=True, + ) + async def test_thermostat_different_endpoints( hass, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration diff --git a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json b/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json index 77a68aafde1..caad22aac36 100644 --- a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json +++ b/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json @@ -594,6 +594,52 @@ "propertyName": "manufacturerData", "metadata": { "type": "any", "readable": true, "writeable": true } }, + { + "endpoint": 1, + "commandClass": 68, + "commandClassName": "Thermostat Fan Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "states": { "0": "Auto low", "1": "Low" }, + "label": "Thermostat fan mode" + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 69, + "commandClassName": "Thermostat Fan State", + "property": "state", + "propertyName": "state", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "states": { + "0": "Idle / off", + "1": "Running / running low", + "2": "Running high", + "3": "Running medium", + "4": "Circulation mode", + "5": "Humidity circulation mode", + "6": "Right - left circulation mode", + "7": "Up - down circulation mode", + "8": "Quiet circulation mode" + }, + "label": "Thermostat fan state" + }, + "value": 0 + }, { "commandClassName": "Thermostat Operating State", "commandClass": 66,