Skip to content

Commit

Permalink
Improve climate turn_on/turn_off services for zwave_js (home-assistan…
Browse files Browse the repository at this point in the history
…t#109187)

Co-authored-by: Erik Montnemery <erik@montnemery.com>
  • Loading branch information
2 people authored and astrandb committed Feb 13, 2024
1 parent 636917b commit 34da76c
Show file tree
Hide file tree
Showing 2 changed files with 273 additions and 5 deletions.
61 changes: 56 additions & 5 deletions homeassistant/components/zwave_js/climate.py
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
@@ -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

0 comments on commit 34da76c

Please sign in to comment.