From 34da76cc0825695713001b0027073b39582d3664 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:06:23 -0500 Subject: [PATCH] Improve climate turn_on/turn_off services for zwave_js (#109187) Co-authored-by: Erik Montnemery --- homeassistant/components/zwave_js/climate.py | 61 +++++- tests/components/zwave_js/test_climate.py | 217 +++++++++++++++++++ 2 files changed, 273 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index f5ad8ce36cd7bc..2f84b52b7daeb1 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -139,10 +139,19 @@ def __init__( self._hvac_modes: dict[HVACMode, int | None] = {} self._hvac_presets: dict[str, int | None] = {} self._unit_value: ZwaveValue | None = None + self._last_hvac_mode_id_before_off: int | None = None self._current_mode = self.get_zwave_value( THERMOSTAT_MODE_PROPERTY, command_class=CommandClass.THERMOSTAT_MODE ) + self._supports_resume: bool = bool( + self._current_mode + and ( + str(ThermostatMode.RESUME_ON.value) + in self._current_mode.metadata.states + ) + ) + self._setpoint_values: dict[ThermostatSetpointType, ZwaveValue | None] = {} for enum in ThermostatSetpointType: self._setpoint_values[enum] = self.get_zwave_value( @@ -196,13 +205,9 @@ def __init__( self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if HVACMode.OFF in self._hvac_modes: self._attr_supported_features |= ClimateEntityFeature.TURN_OFF - # We can only support turn on if we are able to turn the device off, # otherwise the device can be considered always on - if len(self._hvac_modes) == 2 or any( - mode in self._hvac_modes - for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL) - ): + if len(self._hvac_modes) > 1: self._attr_supported_features |= ClimateEntityFeature.TURN_ON # If any setpoint value exists, we can assume temperature # can be set @@ -496,8 +501,54 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Thermostat(valve) has no support for setting a mode, so we make it a no-op return + # When turning the HVAC off from an on state, store the last HVAC mode ID so we + # can set it again when turning the device back on. + if hvac_mode == HVACMode.OFF and self._current_mode.value != ThermostatMode.OFF: + self._last_hvac_mode_id_before_off = self._current_mode.value await self._async_set_value(self._current_mode, hvac_mode_id) + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + # If current mode is not off, do nothing + if self.hvac_mode != HVACMode.OFF: + return + + # We can safely assert here because this function can only be called if the + # device can be turned off and on which would require the device to have the + # current mode Z-Wave Value + assert self._current_mode + + # If the device supports resume, use resume to get to the right mode + if self._supports_resume: + await self._async_set_value(self._current_mode, ThermostatMode.RESUME_ON) + return + + # If we have an HVAC mode ID from before the device was turned off, set it to + # that mode + if self._last_hvac_mode_id_before_off is not None: + await self._async_set_value( + self._current_mode, self._last_hvac_mode_id_before_off + ) + self._last_hvac_mode_id_before_off = None + return + + # Attempt to set the device to the first available mode among heat_cool, heat, + # and cool to mirror previous behavior. If none of those are available, set it + # to the first available mode that is not off. + try: + hvac_mode = next( + mode + for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL) + if mode in self._hvac_modes + ) + except StopIteration: + hvac_mode = next(mode for mode in self._hvac_modes if mode != HVACMode.OFF) + await self.async_set_hvac_mode(hvac_mode) + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" assert self._current_mode is not None diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index fdbb2ef7f4c762..c96f1c48797227 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -1,4 +1,6 @@ """Test the Z-Wave JS climate platform.""" +import copy + import pytest from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.thermostat import ( @@ -37,6 +39,8 @@ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -89,6 +93,18 @@ async def test_thermostat_v2( client.async_send_command.reset_mock() + # Check that turning the device on is a no-op because it is already on + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 + + client.async_send_command.reset_mock() + # Test setting hvac mode await hass.services.async_call( CLIMATE_DOMAIN, @@ -277,6 +293,68 @@ async def test_thermostat_v2( client.async_send_command.reset_mock() + # Test turning device off then on to see if the previous state is retained + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY}, + 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": 64, + "property": "mode", + } + assert args["value"] == 0 + + # Update state to off + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 13, + "args": { + "commandClassName": "Thermostat Mode", + "commandClass": 64, + "endpoint": 1, + "property": "mode", + "propertyName": "mode", + "newValue": 0, + "prevValue": 3, + }, + }, + ) + node.receive_event(event) + + client.async_send_command.reset_mock() + + # Test turning device on + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY}, + 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": 64, + "property": "mode", + } + assert args["value"] == 3 + + client.async_send_command.reset_mock() + # Test setting invalid fan mode with pytest.raises(ServiceValidationError): await hass.services.async_call( @@ -304,6 +382,145 @@ async def test_thermostat_v2( assert "Error while refreshing value" in caplog.text +async def test_thermostat_v2_turn_on_after_off( + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration +) -> None: + """Test thermostat v2 command class entity that is turned on after starting off.""" + node = climate_radio_thermostat_ct100_plus + + # Turn device off so we can test turning it back on to see if the turn on service + # attempts to find a value to set + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 13, + "args": { + "commandClassName": "Thermostat Mode", + "commandClass": 64, + "endpoint": 1, + "property": "mode", + "propertyName": "mode", + "newValue": 0, + "prevValue": 1, + }, + }, + ) + node.receive_event(event) + + client.async_send_command.reset_mock() + + # Test turning device on + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY}, + 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": 64, + "property": "mode", + } + assert args["value"] == 3 + + client.async_send_command.reset_mock() + + +async def test_thermostat_turn_on_after_off_no_heat_cool_auto( + hass: HomeAssistant, client, aeotec_radiator_thermostat_state, integration +) -> None: + """Test thermostat that is turned on after starting off w/o heat, cool, or auto.""" + node_state = copy.deepcopy(aeotec_radiator_thermostat_state) + # Only allow off and dry modes so we can test fallback logic when turning HVAC on + # without a last mode stored. + value = next( + value + for value in node_state["values"] + if value["commandClass"] == 64 and value["property"] == "mode" + ) + value["metadata"]["states"] = {"0": "Off", "6": "Fan", "8": "Dry"} + value["value"] = 0 + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + entity_id = "climate.thermostat_hvac" + assert hass.states.get(entity_id).state == HVACMode.OFF + + client.async_send_command.reset_mock() + + # Test turning device on sets it to first available mode (Energy heat) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + 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"] == 4 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 64, + "property": "mode", + } + assert args["value"] == 6 + + +async def test_thermostat_turn_on_after_off_with_resume( + hass: HomeAssistant, client, aeotec_radiator_thermostat_state, integration +) -> None: + """Test thermostat that is turned on after starting off with resume support.""" + node_state = copy.deepcopy(aeotec_radiator_thermostat_state) + # Add resume thermostat mode so we can test that it prefers the resume mode + value = next( + value + for value in node_state["values"] + if value["commandClass"] == 64 and value["property"] == "mode" + ) + value["metadata"]["states"] = { + "0": "Off", + "5": "Resume (on)", + "6": "Fan", + "8": "Dry", + } + value["value"] = 0 + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + entity_id = "climate.thermostat_hvac" + assert hass.states.get(entity_id).state == HVACMode.OFF + + client.async_send_command.reset_mock() + + # Test turning device on sends resume command + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + 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"] == 4 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 64, + "property": "mode", + } + assert args["value"] == 5 + + async def test_thermostat_different_endpoints( hass: HomeAssistant, client,