1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-14 23:28:42 +00:00

Fix Z-Wave climate set preset (#162728)

This commit is contained in:
Martin Hjelmare
2026-02-14 12:45:36 +01:00
committed by GitHub
parent f246c90073
commit 225ecedc95
4 changed files with 888 additions and 7 deletions

View File

@@ -283,7 +283,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
return UnitOfTemperature.CELSIUS
@property
def hvac_mode(self) -> HVACMode:
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation ie. heat, cool mode."""
if self._current_mode is None:
# Thermostat(valve) with no support for setting
@@ -292,7 +292,10 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
if self._current_mode.value is None:
# guard missing value
return HVACMode.HEAT
return ZW_HVAC_MODE_MAP.get(int(self._current_mode.value), HVACMode.HEAT_COOL)
mode = ZW_HVAC_MODE_MAP.get(int(self._current_mode.value))
if mode is not None and mode not in self._hvac_modes:
return None
return mode
@property
def hvac_modes(self) -> list[HVACMode]:
@@ -548,12 +551,17 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
"""Set new target preset mode."""
assert self._current_mode is not None
if preset_mode == PRESET_NONE:
# try to restore to the (translated) main hvac mode
await self.async_set_hvac_mode(self.hvac_mode)
# Try to restore to the (translated) main hvac mode.
if (hvac_mode := self.hvac_mode) is None:
# Current preset mode doesn't map to a supported HVAC mode.
# Pick the first supported non-off mode.
hvac_mode = next(
mode for mode in self._hvac_modes if mode != HVACMode.OFF
)
await self.async_set_hvac_mode(hvac_mode)
return
preset_mode_value = self._hvac_presets.get(preset_mode)
if preset_mode_value is None:
raise ValueError(f"Received an invalid preset mode: {preset_mode}")
preset_mode_value = self._hvac_presets[preset_mode]
await self._async_set_value(self._current_mode, preset_mode_value)

View File

@@ -176,6 +176,12 @@ def climate_eurotronic_spirit_z_state_fixture() -> dict[str, Any]:
return load_json_object_fixture("climate_eurotronic_spirit_z_state.json", DOMAIN)
@pytest.fixture(name="climate_eurotronic_comet_z_state", scope="package")
def climate_eurotronic_comet_z_state_fixture() -> dict[str, Any]:
"""Load the climate Eurotronic Comet Z thermostat node state fixture data."""
return load_json_object_fixture("climate_eurotronic_comet_z_state.json", DOMAIN)
@pytest.fixture(name="climate_heatit_z_trm6_state", scope="package")
def climate_heatit_z_trm6_state_fixture() -> dict[str, Any]:
"""Load the climate HEATIT Z-TRM6 thermostat node state fixture data."""
@@ -851,6 +857,16 @@ def climate_eurotronic_spirit_z_fixture(
return node
@pytest.fixture(name="climate_eurotronic_comet_z")
def climate_eurotronic_comet_z_fixture(
client: MagicMock, climate_eurotronic_comet_z_state: dict[str, Any]
) -> Node:
"""Mock a climate Eurotronic Comet Z node."""
node = Node(client, copy.deepcopy(climate_eurotronic_comet_z_state))
client.driver.controller.nodes[node.node_id] = node
return node
@pytest.fixture(name="climate_heatit_z_trm6")
def climate_heatit_z_trm6_fixture(client, climate_heatit_z_trm6_state) -> Node:
"""Mock a climate radio HEATIT Z-TRM6 node."""

View File

@@ -0,0 +1,528 @@
{
"nodeId": 2,
"index": 0,
"installerIcon": 4608,
"userIcon": 4608,
"status": 4,
"ready": true,
"isListening": false,
"isRouting": true,
"isSecure": true,
"manufacturerId": 328,
"productId": 3,
"productType": 4,
"firmwareVersion": "14.1.4",
"zwavePlusVersion": 2,
"deviceConfig": {
"filename": "/data/db/devices/0x0148/cometz_700.json",
"isEmbedded": true,
"manufacturer": "Eurotronics",
"manufacturerId": 328,
"label": "Comet Z",
"description": "Radiator Thermostat",
"devices": [
{
"productType": 4,
"productId": 3
}
],
"firmwareVersion": {
"min": "0.0",
"max": "255.255"
},
"preferred": false,
"associations": {},
"paramInformation": {
"_map": {}
}
},
"label": "Comet Z",
"interviewAttempts": 0,
"isFrequentListening": "1000ms",
"maxDataRate": 100000,
"supportedDataRates": [40000, 100000],
"protocolVersion": 3,
"supportsBeaming": true,
"supportsSecurity": false,
"nodeType": 1,
"zwavePlusNodeType": 0,
"zwavePlusRoleType": 7,
"deviceClass": {
"basic": {
"key": 4,
"label": "Routing End Node"
},
"generic": {
"key": 8,
"label": "Thermostat"
},
"specific": {
"key": 6,
"label": "General Thermostat V2"
}
},
"interviewStage": "Complete",
"deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0148:0x0004:0x0003:14.1.4",
"statistics": {
"commandsTX": 23,
"commandsRX": 21,
"commandsDroppedRX": 0,
"commandsDroppedTX": 0,
"timeoutResponse": 3,
"rtt": 877.4,
"rssi": -79,
"lwr": {
"protocolDataRate": 2,
"repeaters": [],
"rssi": -78,
"repeaterRSSI": []
}
},
"highestSecurityClass": 0,
"isControllerNode": false,
"keepAwake": false,
"protocol": 0,
"sdkVersion": "7.15.4",
"endpoints": [
{
"nodeId": 2,
"index": 0,
"installerIcon": 4608,
"userIcon": 4608,
"deviceClass": {
"basic": {
"key": 4,
"label": "Routing End Node"
},
"generic": {
"key": 8,
"label": "Thermostat"
},
"specific": {
"key": 6,
"label": "General Thermostat V2"
}
},
"commandClasses": [
{
"id": 49,
"name": "Multilevel Sensor",
"version": 11,
"isSecure": true
},
{
"id": 38,
"name": "Multilevel Switch",
"version": 1,
"isSecure": true
},
{
"id": 64,
"name": "Thermostat Mode",
"version": 3,
"isSecure": true
},
{
"id": 67,
"name": "Thermostat Setpoint",
"version": 3,
"isSecure": true
},
{
"id": 128,
"name": "Battery",
"version": 1,
"isSecure": true
},
{
"id": 112,
"name": "Configuration",
"version": 1,
"isSecure": true
},
{
"id": 114,
"name": "Manufacturer Specific",
"version": 2,
"isSecure": true
},
{
"id": 134,
"name": "Version",
"version": 3,
"isSecure": true
}
]
}
],
"values": [
{
"endpoint": 0,
"commandClass": 38,
"commandClassName": "Multilevel Switch",
"property": "currentValue",
"propertyName": "currentValue",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Current value",
"min": 0,
"max": 99,
"stateful": true,
"secret": false
},
"value": 0
},
{
"endpoint": 0,
"commandClass": 38,
"commandClassName": "Multilevel Switch",
"property": "targetValue",
"propertyName": "targetValue",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"label": "Target value",
"valueChangeOptions": ["transitionDuration"],
"min": 0,
"max": 99,
"stateful": true,
"secret": false
},
"value": 0
},
{
"endpoint": 0,
"commandClass": 38,
"commandClassName": "Multilevel Switch",
"property": "Up",
"propertyName": "Up",
"ccVersion": 1,
"metadata": {
"type": "boolean",
"readable": false,
"writeable": true,
"label": "Perform a level change (Up)",
"ccSpecific": {
"switchType": 2
},
"valueChangeOptions": ["transitionDuration"],
"states": {
"true": "Start",
"false": "Stop"
},
"stateful": true,
"secret": false
}
},
{
"endpoint": 0,
"commandClass": 38,
"commandClassName": "Multilevel Switch",
"property": "Down",
"propertyName": "Down",
"ccVersion": 1,
"metadata": {
"type": "boolean",
"readable": false,
"writeable": true,
"label": "Perform a level change (Down)",
"ccSpecific": {
"switchType": 2
},
"valueChangeOptions": ["transitionDuration"],
"states": {
"true": "Start",
"false": "Stop"
},
"stateful": true,
"secret": false
}
},
{
"endpoint": 0,
"commandClass": 38,
"commandClassName": "Multilevel Switch",
"property": "duration",
"propertyName": "duration",
"ccVersion": 1,
"metadata": {
"type": "duration",
"readable": true,
"writeable": false,
"label": "Remaining duration",
"stateful": true,
"secret": false
}
},
{
"endpoint": 0,
"commandClass": 38,
"commandClassName": "Multilevel Switch",
"property": "restorePrevious",
"propertyName": "restorePrevious",
"ccVersion": 1,
"metadata": {
"type": "boolean",
"readable": false,
"writeable": true,
"label": "Restore previous value",
"states": {
"true": "Restore"
},
"stateful": true,
"secret": false
}
},
{
"endpoint": 0,
"commandClass": 49,
"commandClassName": "Multilevel Sensor",
"property": "Air temperature",
"propertyName": "Air temperature",
"ccVersion": 11,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Air temperature",
"ccSpecific": {
"sensorType": 1,
"scale": 0
},
"unit": "\u00b0C",
"stateful": true,
"secret": false
},
"value": 18.5
},
{
"endpoint": 0,
"commandClass": 64,
"commandClassName": "Thermostat Mode",
"property": "mode",
"propertyName": "mode",
"ccVersion": 3,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"label": "Thermostat mode",
"min": 0,
"max": 255,
"states": {
"0": "Off",
"1": "Heat",
"11": "Energy heat",
"15": "Full power",
"31": "Manufacturer specific"
},
"stateful": true,
"secret": false
},
"value": 1
},
{
"endpoint": 0,
"commandClass": 64,
"commandClassName": "Thermostat Mode",
"property": "manufacturerData",
"propertyName": "manufacturerData",
"ccVersion": 3,
"metadata": {
"type": "buffer",
"readable": true,
"writeable": false,
"label": "Manufacturer data",
"stateful": true,
"secret": false
},
"value": {
"type": "Buffer",
"data": []
}
},
{
"endpoint": 0,
"commandClass": 67,
"commandClassName": "Thermostat Setpoint",
"property": "setpoint",
"propertyKey": 1,
"propertyName": "setpoint",
"propertyKeyName": "Heating",
"ccVersion": 3,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"label": "Setpoint (Heating)",
"ccSpecific": {
"setpointType": 1
},
"min": 8,
"max": 28,
"unit": "\u00b0C",
"stateful": true,
"secret": false
},
"value": 21
},
{
"endpoint": 0,
"commandClass": 67,
"commandClassName": "Thermostat Setpoint",
"property": "setpoint",
"propertyKey": 11,
"propertyName": "setpoint",
"propertyKeyName": "Energy Save Heating",
"ccVersion": 3,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"label": "Setpoint (Energy Save Heating)",
"ccSpecific": {
"setpointType": 11
},
"min": 8,
"max": 28,
"unit": "\u00b0C",
"stateful": true,
"secret": false
},
"value": 16
},
{
"endpoint": 0,
"commandClass": 114,
"commandClassName": "Manufacturer Specific",
"property": "manufacturerId",
"propertyName": "manufacturerId",
"ccVersion": 2,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Manufacturer ID",
"min": 0,
"max": 65535,
"stateful": true,
"secret": false
},
"value": 328
},
{
"endpoint": 0,
"commandClass": 114,
"commandClassName": "Manufacturer Specific",
"property": "productType",
"propertyName": "productType",
"ccVersion": 2,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Product type",
"min": 0,
"max": 65535,
"stateful": true,
"secret": false
},
"value": 4
},
{
"endpoint": 0,
"commandClass": 114,
"commandClassName": "Manufacturer Specific",
"property": "productId",
"propertyName": "productId",
"ccVersion": 2,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Product ID",
"min": 0,
"max": 65535,
"stateful": true,
"secret": false
},
"value": 3
},
{
"endpoint": 0,
"commandClass": 128,
"commandClassName": "Battery",
"property": "level",
"propertyName": "level",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Battery level",
"min": 0,
"max": 100,
"unit": "%",
"stateful": true,
"secret": false
},
"value": 45
},
{
"endpoint": 0,
"commandClass": 134,
"commandClassName": "Version",
"property": "hardwareVersion",
"propertyName": "hardwareVersion",
"ccVersion": 3,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Z-Wave chip hardware version",
"stateful": true,
"secret": false
},
"value": 1
},
{
"endpoint": 0,
"commandClass": 134,
"commandClassName": "Version",
"property": "firmwareVersions",
"propertyName": "firmwareVersions",
"ccVersion": 3,
"metadata": {
"type": "string[]",
"readable": true,
"writeable": false,
"label": "Z-Wave chip firmware versions",
"stateful": true,
"secret": false
},
"value": ["14.1", "1.6"]
},
{
"endpoint": 0,
"commandClass": 134,
"commandClassName": "Version",
"property": "protocolVersion",
"propertyName": "protocolVersion",
"ccVersion": 3,
"metadata": {
"type": "string",
"readable": true,
"writeable": false,
"label": "Z-Wave protocol version",
"stateful": true,
"secret": false
},
"value": "7.15"
}
]
}

View File

@@ -1,6 +1,7 @@
"""Test the Z-Wave JS climate platform."""
import copy
from unittest.mock import MagicMock
import pytest
from zwave_js_server.const import CommandClass
@@ -56,6 +57,8 @@ from .common import (
replace_value_of_zwave_value,
)
from tests.common import MockConfigEntry
@pytest.fixture
def platforms() -> list[str]:
@@ -1001,3 +1004,329 @@ async def test_thermostat_unknown_values(
state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY)
assert ATTR_HVAC_ACTION not in state.attributes
async def test_set_preset_mode_manufacturer_specific(
hass: HomeAssistant,
client: MagicMock,
climate_eurotronic_comet_z: Node,
integration: MockConfigEntry,
) -> None:
"""Test setting preset mode to manufacturer specific.
This tests the Eurotronic Comet Z thermostat which has a
"Manufacturer specific" thermostat mode (value 31) that is
exposed as a preset mode.
"""
node = climate_eurotronic_comet_z
entity_id = "climate.radiator_thermostat"
state = hass.states.get(entity_id)
assert state
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_TEMPERATURE] == 21
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
# Test setting preset mode to "Manufacturer specific"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_PRESET_MODE: "Manufacturer specific",
},
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"] == 2
assert args["valueId"] == {
"commandClass": 64,
"endpoint": 0,
"property": "mode",
}
assert args["value"] == 31
client.async_send_command.reset_mock()
# Simulate the device updating to manufacturer specific mode
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": 2,
"args": {
"commandClassName": "Thermostat Mode",
"commandClass": 64,
"endpoint": 0,
"property": "mode",
"propertyName": "mode",
"newValue": 31,
"prevValue": 1,
},
},
)
node.receive_event(event)
state = hass.states.get(entity_id)
assert state
# Mode 31 is not in ZW_HVAC_MODE_MAP, so hvac_mode is unknown.
assert state.state == "unknown"
assert state.attributes[ATTR_PRESET_MODE] == "Manufacturer specific"
# Test restoring hvac mode by setting preset to none.
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_PRESET_MODE: PRESET_NONE,
},
blocking=True,
)
assert client.async_send_command.call_count == 1
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == 2
assert args["valueId"] == {
"commandClass": 64,
"endpoint": 0,
"property": "mode",
}
assert args["value"] == 1
client.async_send_command.reset_mock()
async def test_preset_mode_mapped_to_unsupported_hvac_mode(
hass: HomeAssistant,
client: MagicMock,
climate_eurotronic_comet_z: Node,
integration: MockConfigEntry,
) -> None:
"""Test preset mapping to an HVAC mode the entity doesn't support.
The Away mode (13) maps to HVACMode.HEAT_COOL in ZW_HVAC_MODE_MAP,
but the Comet Z only supports OFF and HEAT. The hvac_mode property
should return None for this unsupported mapping.
"""
node = climate_eurotronic_comet_z
entity_id = "climate.radiator_thermostat"
# Simulate the device being set to Away mode (13).
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": 2,
"args": {
"commandClassName": "Thermostat Mode",
"commandClass": 64,
"endpoint": 0,
"property": "mode",
"propertyName": "mode",
"newValue": 13,
"prevValue": 1,
},
},
)
node.receive_event(event)
state = hass.states.get(entity_id)
assert state
# Away maps to HEAT_COOL which the device doesn't support,
# so hvac_mode returns None.
assert state.state == "unknown"
async def test_set_preset_mode_mapped_preset(
hass: HomeAssistant,
client: MagicMock,
climate_eurotronic_comet_z: Node,
integration: MockConfigEntry,
) -> None:
"""Test that a preset mapping to a supported HVAC mode shows that mode.
The Eurotronic Comet Z has "Energy heat" (mode 11 = HEATING_ECON) which
maps to HVACMode.HEAT in ZW_HVAC_MODE_MAP. Since the device supports
heat, hvac_mode should return heat while in this preset.
"""
node = climate_eurotronic_comet_z
entity_id = "climate.radiator_thermostat"
state = hass.states.get(entity_id)
assert state
assert state.state == HVACMode.HEAT
# Set preset mode to "Energy heat"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_PRESET_MODE: "Energy heat",
},
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["value"] == 11
client.async_send_command.reset_mock()
# Simulate the device updating to energy heat mode
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": 2,
"args": {
"commandClassName": "Thermostat Mode",
"commandClass": 64,
"endpoint": 0,
"property": "mode",
"propertyName": "mode",
"newValue": 11,
"prevValue": 1,
},
},
)
node.receive_event(event)
state = hass.states.get(entity_id)
assert state
# Energy heat (HEATING_ECON) maps to HVACMode.HEAT which the device
# supports, so hvac_mode returns heat.
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_PRESET_MODE] == "Energy heat"
# Clear preset - should restore to heat (the mapped mode).
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_PRESET_MODE: PRESET_NONE,
},
blocking=True,
)
assert client.async_send_command.call_count == 1
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["value"] == 1
client.async_send_command.reset_mock()
async def test_set_preset_mode_none_while_in_hvac_mode(
hass: HomeAssistant,
client: MagicMock,
climate_eurotronic_comet_z: Node,
integration: MockConfigEntry,
) -> None:
"""Test setting preset mode to none while already in an HVAC mode."""
entity_id = "climate.radiator_thermostat"
state = hass.states.get(entity_id)
assert state
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
# Setting preset to none while already in an HVAC mode restores
# the current hvac mode.
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_PRESET_MODE: PRESET_NONE,
},
blocking=True,
)
assert client.async_send_command.call_count == 1
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == 2
assert args["valueId"] == {
"commandClass": 64,
"endpoint": 0,
"property": "mode",
}
assert args["value"] == 1
async def test_set_preset_mode_none_unmapped_preset(
hass: HomeAssistant,
client: MagicMock,
climate_eurotronic_comet_z: Node,
integration: MockConfigEntry,
) -> None:
"""Test clearing an unmapped preset falls back to first supported HVAC mode.
When the device is in a preset mode that has no mapping in ZW_HVAC_MODE_MAP
(e.g. "Manufacturer specific"), hvac_mode returns None. Setting preset to
none should fall back to the first supported non-off HVAC mode.
"""
node = climate_eurotronic_comet_z
entity_id = "climate.radiator_thermostat"
# Simulate the device being externally changed to "Manufacturer specific"
# mode without HA having set a preset first.
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": 2,
"args": {
"commandClassName": "Thermostat Mode",
"commandClass": 64,
"endpoint": 0,
"property": "mode",
"propertyName": "mode",
"newValue": 31,
"prevValue": 1,
},
},
)
node.receive_event(event)
state = hass.states.get(entity_id)
assert state
assert state.state == "unknown"
assert state.attributes[ATTR_PRESET_MODE] == "Manufacturer specific"
client.async_send_command.reset_mock()
# Setting preset to none should default to heat since there is no
# stored previous HVAC mode.
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_PRESET_MODE: PRESET_NONE,
},
blocking=True,
)
assert client.async_send_command.call_count == 1
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == 2
assert args["valueId"] == {
"commandClass": 64,
"endpoint": 0,
"property": "mode",
}
assert args["value"] == 1