Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve climate turn_on/turn_off services for zwave_js #109187

Merged
merged 14 commits into from
Feb 13, 2024
61 changes: 56 additions & 5 deletions homeassistant/components/zwave_js/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
217 changes: 217 additions & 0 deletions tests/components/zwave_js/test_climate.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading