Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit c1d61b9

Browse files
raman325emontnemery
andauthored
Improve climate turn_on/turn_off services for zwave_js (home-assistant#109187)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
1 parent b60f931 commit c1d61b9

File tree

2 files changed

+273
-5
lines changed

2 files changed

+273
-5
lines changed

‎homeassistant/components/zwave_js/climate.py‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,19 @@ def __init__(
139139
self._hvac_modes: dict[HVACMode, int | None] = {}
140140
self._hvac_presets: dict[str, int | None] = {}
141141
self._unit_value: ZwaveValue | None = None
142+
self._last_hvac_mode_id_before_off: int | None = None
142143

143144
self._current_mode = self.get_zwave_value(
144145
THERMOSTAT_MODE_PROPERTY, command_class=CommandClass.THERMOSTAT_MODE
145146
)
147+
self._supports_resume: bool = bool(
148+
self._current_mode
149+
and (
150+
str(ThermostatMode.RESUME_ON.value)
151+
in self._current_mode.metadata.states
152+
)
153+
)
154+
146155
self._setpoint_values: dict[ThermostatSetpointType, ZwaveValue | None] = {}
147156
for enum in ThermostatSetpointType:
148157
self._setpoint_values[enum] = self.get_zwave_value(
@@ -196,13 +205,9 @@ def __init__(
196205
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
197206
if HVACMode.OFF in self._hvac_modes:
198207
self._attr_supported_features |= ClimateEntityFeature.TURN_OFF
199-
200208
# We can only support turn on if we are able to turn the device off,
201209
# otherwise the device can be considered always on
202-
if len(self._hvac_modes) == 2 or any(
203-
mode in self._hvac_modes
204-
for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL)
205-
):
210+
if len(self._hvac_modes) > 1:
206211
self._attr_supported_features |= ClimateEntityFeature.TURN_ON
207212
# If any setpoint value exists, we can assume temperature
208213
# can be set
@@ -496,8 +501,54 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
496501
# Thermostat(valve) has no support for setting a mode, so we make it a no-op
497502
return
498503

504+
# When turning the HVAC off from an on state, store the last HVAC mode ID so we
505+
# can set it again when turning the device back on.
506+
if hvac_mode == HVACMode.OFF and self._current_mode.value != ThermostatMode.OFF:
507+
self._last_hvac_mode_id_before_off = self._current_mode.value
499508
await self._async_set_value(self._current_mode, hvac_mode_id)
500509

510+
async def async_turn_off(self) -> None:
511+
"""Turn the entity off."""
512+
await self.async_set_hvac_mode(HVACMode.OFF)
513+
514+
async def async_turn_on(self) -> None:
515+
"""Turn the entity on."""
516+
# If current mode is not off, do nothing
517+
if self.hvac_mode != HVACMode.OFF:
518+
return
519+
520+
# We can safely assert here because this function can only be called if the
521+
# device can be turned off and on which would require the device to have the
522+
# current mode Z-Wave Value
523+
assert self._current_mode
524+
525+
# If the device supports resume, use resume to get to the right mode
526+
if self._supports_resume:
527+
await self._async_set_value(self._current_mode, ThermostatMode.RESUME_ON)
528+
return
529+
530+
# If we have an HVAC mode ID from before the device was turned off, set it to
531+
# that mode
532+
if self._last_hvac_mode_id_before_off is not None:
533+
await self._async_set_value(
534+
self._current_mode, self._last_hvac_mode_id_before_off
535+
)
536+
self._last_hvac_mode_id_before_off = None
537+
return
538+
539+
# Attempt to set the device to the first available mode among heat_cool, heat,
540+
# and cool to mirror previous behavior. If none of those are available, set it
541+
# to the first available mode that is not off.
542+
try:
543+
hvac_mode = next(
544+
mode
545+
for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL)
546+
if mode in self._hvac_modes
547+
)
548+
except StopIteration:
549+
hvac_mode = next(mode for mode in self._hvac_modes if mode != HVACMode.OFF)
550+
await self.async_set_hvac_mode(hvac_mode)
551+
501552
async def async_set_preset_mode(self, preset_mode: str) -> None:
502553
"""Set new target preset mode."""
503554
assert self._current_mode is not None

‎tests/components/zwave_js/test_climate.py‎

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"""Test the Z-Wave JS climate platform."""
2+
import copy
3+
24
import pytest
35
from zwave_js_server.const import CommandClass
46
from zwave_js_server.const.command_class.thermostat import (
@@ -37,6 +39,8 @@
3739
ATTR_ENTITY_ID,
3840
ATTR_SUPPORTED_FEATURES,
3941
ATTR_TEMPERATURE,
42+
SERVICE_TURN_OFF,
43+
SERVICE_TURN_ON,
4044
)
4145
from homeassistant.core import HomeAssistant
4246
from homeassistant.exceptions import ServiceValidationError
@@ -89,6 +93,18 @@ async def test_thermostat_v2(
8993

9094
client.async_send_command.reset_mock()
9195

96+
# Check that turning the device on is a no-op because it is already on
97+
await hass.services.async_call(
98+
CLIMATE_DOMAIN,
99+
SERVICE_TURN_ON,
100+
{ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY},
101+
blocking=True,
102+
)
103+
104+
assert len(client.async_send_command.call_args_list) == 0
105+
106+
client.async_send_command.reset_mock()
107+
92108
# Test setting hvac mode
93109
await hass.services.async_call(
94110
CLIMATE_DOMAIN,
@@ -277,6 +293,68 @@ async def test_thermostat_v2(
277293

278294
client.async_send_command.reset_mock()
279295

296+
# Test turning device off then on to see if the previous state is retained
297+
await hass.services.async_call(
298+
CLIMATE_DOMAIN,
299+
SERVICE_TURN_OFF,
300+
{ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY},
301+
blocking=True,
302+
)
303+
304+
assert len(client.async_send_command.call_args_list) == 1
305+
args = client.async_send_command.call_args[0][0]
306+
assert args["command"] == "node.set_value"
307+
assert args["nodeId"] == 13
308+
assert args["valueId"] == {
309+
"endpoint": 1,
310+
"commandClass": 64,
311+
"property": "mode",
312+
}
313+
assert args["value"] == 0
314+
315+
# Update state to off
316+
event = Event(
317+
type="value updated",
318+
data={
319+
"source": "node",
320+
"event": "value updated",
321+
"nodeId": 13,
322+
"args": {
323+
"commandClassName": "Thermostat Mode",
324+
"commandClass": 64,
325+
"endpoint": 1,
326+
"property": "mode",
327+
"propertyName": "mode",
328+
"newValue": 0,
329+
"prevValue": 3,
330+
},
331+
},
332+
)
333+
node.receive_event(event)
334+
335+
client.async_send_command.reset_mock()
336+
337+
# Test turning device on
338+
await hass.services.async_call(
339+
CLIMATE_DOMAIN,
340+
SERVICE_TURN_ON,
341+
{ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY},
342+
blocking=True,
343+
)
344+
345+
assert len(client.async_send_command.call_args_list) == 1
346+
args = client.async_send_command.call_args[0][0]
347+
assert args["command"] == "node.set_value"
348+
assert args["nodeId"] == 13
349+
assert args["valueId"] == {
350+
"endpoint": 1,
351+
"commandClass": 64,
352+
"property": "mode",
353+
}
354+
assert args["value"] == 3
355+
356+
client.async_send_command.reset_mock()
357+
280358
# Test setting invalid fan mode
281359
with pytest.raises(ServiceValidationError):
282360
await hass.services.async_call(
@@ -304,6 +382,145 @@ async def test_thermostat_v2(
304382
assert "Error while refreshing value" in caplog.text
305383

306384

385+
async def test_thermostat_v2_turn_on_after_off(
386+
hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration
387+
) -> None:
388+
"""Test thermostat v2 command class entity that is turned on after starting off."""
389+
node = climate_radio_thermostat_ct100_plus
390+
391+
# Turn device off so we can test turning it back on to see if the turn on service
392+
# attempts to find a value to set
393+
event = Event(
394+
type="value updated",
395+
data={
396+
"source": "node",
397+
"event": "value updated",
398+
"nodeId": 13,
399+
"args": {
400+
"commandClassName": "Thermostat Mode",
401+
"commandClass": 64,
402+
"endpoint": 1,
403+
"property": "mode",
404+
"propertyName": "mode",
405+
"newValue": 0,
406+
"prevValue": 1,
407+
},
408+
},
409+
)
410+
node.receive_event(event)
411+
412+
client.async_send_command.reset_mock()
413+
414+
# Test turning device on
415+
await hass.services.async_call(
416+
CLIMATE_DOMAIN,
417+
SERVICE_TURN_ON,
418+
{ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY},
419+
blocking=True,
420+
)
421+
422+
assert len(client.async_send_command.call_args_list) == 1
423+
args = client.async_send_command.call_args[0][0]
424+
assert args["command"] == "node.set_value"
425+
assert args["nodeId"] == 13
426+
assert args["valueId"] == {
427+
"endpoint": 1,
428+
"commandClass": 64,
429+
"property": "mode",
430+
}
431+
assert args["value"] == 3
432+
433+
client.async_send_command.reset_mock()
434+
435+
436+
async def test_thermostat_turn_on_after_off_no_heat_cool_auto(
437+
hass: HomeAssistant, client, aeotec_radiator_thermostat_state, integration
438+
) -> None:
439+
"""Test thermostat that is turned on after starting off w/o heat, cool, or auto."""
440+
node_state = copy.deepcopy(aeotec_radiator_thermostat_state)
441+
# Only allow off and dry modes so we can test fallback logic when turning HVAC on
442+
# without a last mode stored.
443+
value = next(
444+
value
445+
for value in node_state["values"]
446+
if value["commandClass"] == 64 and value["property"] == "mode"
447+
)
448+
value["metadata"]["states"] = {"0": "Off", "6": "Fan", "8": "Dry"}
449+
value["value"] = 0
450+
node = Node(client, node_state)
451+
client.driver.controller.emit("node added", {"node": node})
452+
await hass.async_block_till_done()
453+
entity_id = "climate.thermostat_hvac"
454+
assert hass.states.get(entity_id).state == HVACMode.OFF
455+
456+
client.async_send_command.reset_mock()
457+
458+
# Test turning device on sets it to first available mode (Energy heat)
459+
await hass.services.async_call(
460+
CLIMATE_DOMAIN,
461+
SERVICE_TURN_ON,
462+
{ATTR_ENTITY_ID: entity_id},
463+
blocking=True,
464+
)
465+
466+
assert len(client.async_send_command.call_args_list) == 1
467+
args = client.async_send_command.call_args[0][0]
468+
assert args["command"] == "node.set_value"
469+
assert args["nodeId"] == 4
470+
assert args["valueId"] == {
471+
"endpoint": 0,
472+
"commandClass": 64,
473+
"property": "mode",
474+
}
475+
assert args["value"] == 6
476+
477+
478+
async def test_thermostat_turn_on_after_off_with_resume(
479+
hass: HomeAssistant, client, aeotec_radiator_thermostat_state, integration
480+
) -> None:
481+
"""Test thermostat that is turned on after starting off with resume support."""
482+
node_state = copy.deepcopy(aeotec_radiator_thermostat_state)
483+
# Add resume thermostat mode so we can test that it prefers the resume mode
484+
value = next(
485+
value
486+
for value in node_state["values"]
487+
if value["commandClass"] == 64 and value["property"] == "mode"
488+
)
489+
value["metadata"]["states"] = {
490+
"0": "Off",
491+
"5": "Resume (on)",
492+
"6": "Fan",
493+
"8": "Dry",
494+
}
495+
value["value"] = 0
496+
node = Node(client, node_state)
497+
client.driver.controller.emit("node added", {"node": node})
498+
await hass.async_block_till_done()
499+
entity_id = "climate.thermostat_hvac"
500+
assert hass.states.get(entity_id).state == HVACMode.OFF
501+
502+
client.async_send_command.reset_mock()
503+
504+
# Test turning device on sends resume command
505+
await hass.services.async_call(
506+
CLIMATE_DOMAIN,
507+
SERVICE_TURN_ON,
508+
{ATTR_ENTITY_ID: entity_id},
509+
blocking=True,
510+
)
511+
512+
assert len(client.async_send_command.call_args_list) == 1
513+
args = client.async_send_command.call_args[0][0]
514+
assert args["command"] == "node.set_value"
515+
assert args["nodeId"] == 4
516+
assert args["valueId"] == {
517+
"endpoint": 0,
518+
"commandClass": 64,
519+
"property": "mode",
520+
}
521+
assert args["value"] == 5
522+
523+
307524
async def test_thermostat_different_endpoints(
308525
hass: HomeAssistant,
309526
client,

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /