diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py new file mode 100644 index 00000000000..97c45e3c961 --- /dev/null +++ b/homeassistant/components/zwave_js/climate.py @@ -0,0 +1,300 @@ +"""Representation of Z-Wave thermostats.""" +import logging +from typing import Any, Callable, Dict, List, Optional + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import ( + THERMOSTAT_CURRENT_TEMP_PROPERTY, + THERMOSTAT_MODE_SETPOINT_MAP, + THERMOSTAT_MODES, + THERMOSTAT_OPERATING_STATE_PROPERTY, + THERMOSTAT_SETPOINT_PROPERTY, + CommandClass, + ThermostatMode, + ThermostatOperatingState, + ThermostatSetpointType, +) +from zwave_js_server.model.value import Value as ZwaveValue + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + DOMAIN as CLIMATE_DOMAIN, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + +_LOGGER = logging.getLogger(__name__) + +# Map Z-Wave HVAC Mode to Home Assistant value +# Note: We treat "auto" as "heat_cool" as most Z-Wave devices +# report auto_changeover as auto without schedule support. +ZW_HVAC_MODE_MAP: Dict[int, str] = { + ThermostatMode.OFF: HVAC_MODE_OFF, + ThermostatMode.HEAT: HVAC_MODE_HEAT, + ThermostatMode.COOL: HVAC_MODE_COOL, + # Z-Wave auto mode is actually heat/cool in the hass world + ThermostatMode.AUTO: HVAC_MODE_HEAT_COOL, + ThermostatMode.AUXILIARY: HVAC_MODE_HEAT, + ThermostatMode.FAN: HVAC_MODE_FAN_ONLY, + ThermostatMode.FURNANCE: HVAC_MODE_HEAT, + ThermostatMode.DRY: HVAC_MODE_DRY, + ThermostatMode.AUTO_CHANGE_OVER: HVAC_MODE_HEAT_COOL, + ThermostatMode.HEATING_ECON: HVAC_MODE_HEAT, + ThermostatMode.COOLING_ECON: HVAC_MODE_COOL, + ThermostatMode.AWAY: HVAC_MODE_HEAT_COOL, + ThermostatMode.FULL_POWER: HVAC_MODE_HEAT, +} + +HVAC_CURRENT_MAP: Dict[int, str] = { + ThermostatOperatingState.IDLE: CURRENT_HVAC_IDLE, + ThermostatOperatingState.PENDING_HEAT: CURRENT_HVAC_IDLE, + ThermostatOperatingState.HEATING: CURRENT_HVAC_HEAT, + ThermostatOperatingState.PENDING_COOL: CURRENT_HVAC_IDLE, + ThermostatOperatingState.COOLING: CURRENT_HVAC_COOL, + ThermostatOperatingState.FAN_ONLY: CURRENT_HVAC_FAN, + ThermostatOperatingState.VENT_ECONOMIZER: CURRENT_HVAC_FAN, + ThermostatOperatingState.AUX_HEATING: CURRENT_HVAC_HEAT, + ThermostatOperatingState.SECOND_STAGE_HEATING: CURRENT_HVAC_HEAT, + ThermostatOperatingState.SECOND_STAGE_COOLING: CURRENT_HVAC_COOL, + ThermostatOperatingState.SECOND_STAGE_AUX_HEAT: CURRENT_HVAC_HEAT, + ThermostatOperatingState.THIRD_STAGE_AUX_HEAT: CURRENT_HVAC_HEAT, +} + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Set up Z-Wave climate from config entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_climate(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave Climate.""" + entities: List[ZWaveBaseEntity] = [] + entities.append(ZWaveClimate(client, info)) + + async_add_entities(entities) + + hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + async_dispatcher_connect( + hass, f"{DOMAIN}_add_{CLIMATE_DOMAIN}", async_add_climate + ) + ) + + +class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): + """Representation of a Z-Wave climate.""" + + def __init__(self, client: ZwaveClient, info: ZwaveDiscoveryInfo) -> None: + """Initialize lock.""" + super().__init__(client, info) + self._hvac_modes: Dict[str, Optional[int]] = {} + self._hvac_presets: Dict[str, Optional[int]] = {} + + self._current_mode = self.info.primary_value + self._setpoint_values: Dict[ThermostatSetpointType, ZwaveValue] = {} + for enum in ThermostatSetpointType: + self._setpoint_values[enum] = self.get_zwave_value( + THERMOSTAT_SETPOINT_PROPERTY, + command_class=CommandClass.THERMOSTAT_SETPOINT, + value_property_key_name=enum.value, + add_to_watched_value_ids=True, + ) + self._operating_state = self.get_zwave_value( + THERMOSTAT_OPERATING_STATE_PROPERTY, + command_class=CommandClass.THERMOSTAT_OPERATING_STATE, + add_to_watched_value_ids=True, + ) + self._current_temp = self.get_zwave_value( + THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + add_to_watched_value_ids=True, + ) + self._set_modes_and_presets() + + def _setpoint_value(self, setpoint_type: ThermostatSetpointType) -> ZwaveValue: + """Optionally return a ZwaveValue for a setpoint.""" + val = self._setpoint_values[setpoint_type] + if val is None: + raise ValueError("Value requested is not available") + + return val + + def _set_modes_and_presets(self) -> None: + """Convert Z-Wave Thermostat modes into Home Assistant modes and presets.""" + all_modes: Dict[str, Optional[int]] = {} + all_presets: Dict[str, Optional[int]] = {PRESET_NONE: None} + + # Z-Wave uses one list for both modes and presets. + # Iterate over all Z-Wave ThermostatModes and extract the hvac modes and presets. + current_mode = self._current_mode + if not current_mode: + return + for mode_id, mode_name in current_mode.metadata.states.items(): + mode_id = int(mode_id) + if mode_id in THERMOSTAT_MODES: + # treat value as hvac mode + hass_mode = ZW_HVAC_MODE_MAP.get(mode_id) + if hass_mode: + all_modes[hass_mode] = mode_id + else: + # treat value as hvac preset + all_presets[mode_name] = mode_id + self._hvac_modes = all_modes + self._hvac_presets = all_presets + + @property + def _current_mode_setpoint_enums(self) -> List[Optional[ThermostatSetpointType]]: + """Return the list of enums that are relevant to the current thermostat mode.""" + return THERMOSTAT_MODE_SETPOINT_MAP.get(int(self._current_mode.value), []) # type: ignore + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + temp: Optional[ZwaveValue] = None + if self._current_mode_setpoint_enums: + temp = self._setpoint_value(self._current_mode_setpoint_enums[0]) + if temp is not None and temp.metadata.unit == "°F": + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if self._current_mode is None: + # Thermostat(valve) with no support for setting a mode is considered heating-only + return HVAC_MODE_HEAT + return ZW_HVAC_MODE_MAP.get(int(self._current_mode.value), HVAC_MODE_HEAT_COOL) + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return list(self._hvac_modes) + + @property + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported.""" + if not self._operating_state: + return None + return HVAC_CURRENT_MAP.get(int(self._operating_state.value)) + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._current_temp.value if self._current_temp else None + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + temp = self._setpoint_value(self._current_mode_setpoint_enums[0]) + return temp.value if temp else None + + @property + def target_temperature_high(self) -> Optional[float]: + """Return the highbound target temperature we try to reach.""" + temp = self._setpoint_value(self._current_mode_setpoint_enums[1]) + return temp.value if temp else None + + @property + def target_temperature_low(self) -> Optional[float]: + """Return the lowbound target temperature we try to reach.""" + return self.target_temperature + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + if self._current_mode and int(self._current_mode.value) not in THERMOSTAT_MODES: + return_val: str = self._current_mode.metadata.states.get( + self._current_mode.value + ) + return return_val + return PRESET_NONE + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return list(self._hvac_presets) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + support = SUPPORT_PRESET_MODE + if len(self._current_mode_setpoint_enums) == 1: + support |= SUPPORT_TARGET_TEMPERATURE + if len(self._current_mode_setpoint_enums) > 1: + support |= SUPPORT_TARGET_TEMPERATURE_RANGE + return support + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + assert self.hass + hvac_mode: Optional[str] = kwargs.get(ATTR_HVAC_MODE) + + if hvac_mode is not None: + await self.async_set_hvac_mode(hvac_mode) + if len(self._current_mode_setpoint_enums) == 1: + setpoint: ZwaveValue = self._setpoint_value( + self._current_mode_setpoint_enums[0] + ) + target_temp: Optional[float] = kwargs.get(ATTR_TEMPERATURE) + if target_temp is not None: + await self.info.node.async_set_value(setpoint, target_temp) + elif len(self._current_mode_setpoint_enums) == 2: + setpoint_low: ZwaveValue = self._setpoint_value( + self._current_mode_setpoint_enums[0] + ) + setpoint_high: ZwaveValue = self._setpoint_value( + self._current_mode_setpoint_enums[1] + ) + target_temp_low: Optional[float] = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high: Optional[float] = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if target_temp_low is not None: + await self.info.node.async_set_value(setpoint_low, target_temp_low) + if target_temp_high is not None: + await self.info.node.async_set_value(setpoint_high, target_temp_high) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + if not self._current_mode: + # Thermostat(valve) with no support for setting a mode + raise ValueError( + f"Thermostat {self.entity_id} does not support setting a mode" + ) + hvac_mode_value = self._hvac_modes.get(hvac_mode) + if hvac_mode_value is None: + raise ValueError(f"Received an invalid hvac mode: {hvac_mode}") + await self.info.node.async_set_value(self._current_mode, hvac_mode_value) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + if preset_mode == PRESET_NONE: + # try to restore to the (translated) main hvac mode + await self.async_set_hvac_mode(self.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}") + await self.info.node.async_set_value(self._current_mode, preset_mode_value) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 18a22a9f8d8..f9391dc1b05 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -3,7 +3,7 @@ DOMAIN = "zwave_js" NAME = "Z-Wave JS" -PLATFORMS = ["binary_sensor", "light", "lock", "sensor", "switch"] +PLATFORMS = ["binary_sensor", "climate", "light", "lock", "sensor", "switch"] DATA_CLIENT = "client" DATA_UNSUBSCRIBE = "unsubs" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c0d298fec37..590ae0f0d42 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -74,6 +74,19 @@ DISCOVERY_SCHEMAS = [ property={"currentMode", "locked"}, type={"number", "boolean"}, ), + # climate + ZWaveDiscoverySchema( + platform="climate", + device_class_generic={"Thermostat"}, + device_class_specific={ + "Setback Thermostat", + "Thermostat General", + "Thermostat General V2", + }, + command_class={CommandClass.THERMOSTAT_MODE}, + property={"mode"}, + type={"number"}, + ), # lights # primary value is the currentValue (brightness) ZWaveDiscoverySchema( diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 183f1785585..5c82d70152e 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -67,6 +67,14 @@ def lock_schlage_be469_state_fixture(): return json.loads(load_fixture("zwave_js/lock_schlage_be469_state.json")) +@pytest.fixture(name="climate_radio_thermostat_ct100_plus_state", scope="session") +def climate_radio_thermostat_ct100_plus_state_fixture(): + """Load the climate radio thermostat ct100 plus node state fixture data.""" + return json.loads( + load_fixture("zwave_js/climate_radio_thermostat_ct100_plus_state.json") + ) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state): """Mock a client.""" @@ -122,6 +130,16 @@ def lock_schlage_be469_fixture(client, lock_schlage_be469_state): return node +@pytest.fixture(name="climate_radio_thermostat_ct100_plus") +def climate_radio_thermostat_ct100_plus_fixture( + client, climate_radio_thermostat_ct100_plus_state +): + """Mock a climate radio thermostat ct100 plus node.""" + node = Node(client, climate_radio_thermostat_ct100_plus_state) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="integration") async def integration_fixture(hass, client): """Set up the zwave_js integration.""" diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py new file mode 100644 index 00000000000..aca1022f8b0 --- /dev/null +++ b/tests/components/zwave_js/test_climate.py @@ -0,0 +1,326 @@ +"""Test the Z-Wave JS climate platform.""" +import pytest +from zwave_js_server.event import Event + +from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_IDLE, + DOMAIN as CLIMATE_DOMAIN, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE + +CLIMATE_RADIO_THERMOSTAT_ENTITY = "climate.z_wave_thermostat_thermostat_mode" + + +async def test_thermostat_v2( + hass, client, climate_radio_thermostat_ct100_plus, integration +): + """Test a thermostat v2 command class entity.""" + node = climate_radio_thermostat_ct100_plus + state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) + + assert state + assert state.state == HVAC_MODE_HEAT + assert state.attributes[ATTR_HVAC_MODES] == [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + ] + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.2 + assert state.attributes[ATTR_TEMPERATURE] == 22.2 + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + + # Test setting preset mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_PRESET_MODE: PRESET_NONE, + }, + 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"] == { + "commandClassName": "Thermostat Mode", + "commandClass": 64, + "endpoint": 1, + "property": "mode", + "propertyName": "mode", + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "min": 0, + "max": 31, + "label": "Thermostat mode", + "states": {"0": "Off", "1": "Heat", "2": "Cool", "3": "Auto"}, + }, + "value": 1, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + # Test setting hvac mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_HVAC_MODE: HVAC_MODE_COOL, + }, + 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"] == { + "commandClassName": "Thermostat Mode", + "commandClass": 64, + "endpoint": 1, + "property": "mode", + "propertyName": "mode", + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "min": 0, + "max": 31, + "label": "Thermostat mode", + "states": {"0": "Off", "1": "Heat", "2": "Cool", "3": "Auto"}, + }, + "value": 1, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + + # Test setting temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_HVAC_MODE: HVAC_MODE_COOL, + ATTR_TEMPERATURE: 25, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 13 + assert args["valueId"] == { + "commandClassName": "Thermostat Mode", + "commandClass": 64, + "endpoint": 1, + "property": "mode", + "propertyName": "mode", + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "min": 0, + "max": 31, + "label": "Thermostat mode", + "states": {"0": "Off", "1": "Heat", "2": "Cool", "3": "Auto"}, + }, + "value": 1, + } + assert args["value"] == 2 + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 13 + assert args["valueId"] == { + "commandClassName": "Thermostat Setpoint", + "commandClass": 67, + "endpoint": 1, + "property": "setpoint", + "propertyKey": 1, + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "unit": "°F", + "ccSpecific": {"setpointType": 1}, + }, + "value": 72, + } + assert args["value"] == 77 + + client.async_send_command.reset_mock() + + # Test cool mode update from value updated event + 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": 2, + "prevValue": 1, + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) + assert state.state == HVAC_MODE_COOL + assert state.attributes[ATTR_TEMPERATURE] == 22.8 + + # Test heat_cool mode update from value updated event + 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": 3, + "prevValue": 1, + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) + assert state.state == HVAC_MODE_HEAT_COOL + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 22.8 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 + + client.async_send_command.reset_mock() + + # Test setting temperature with heat_cool + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 25, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 13 + assert args["valueId"] == { + "commandClassName": "Thermostat Setpoint", + "commandClass": 67, + "endpoint": 1, + "property": "setpoint", + "propertyKey": 1, + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "unit": "°F", + "ccSpecific": {"setpointType": 1}, + }, + "value": 72, + } + assert args["value"] == 77 + + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 13 + assert args["valueId"] == { + "commandClassName": "Thermostat Setpoint", + "commandClass": 67, + "endpoint": 1, + "property": "setpoint", + "propertyKey": 2, + "propertyName": "setpoint", + "propertyKeyName": "Cooling", + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "unit": "°F", + "ccSpecific": {"setpointType": 2}, + }, + "value": 73, + } + assert args["value"] == 86 + + client.async_send_command.reset_mock() + + with pytest.raises(ValueError): + # Test setting unknown preset mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_PRESET_MODE: "unknown_preset", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 + + # Test setting invalid hvac mode + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_HVAC_MODE: HVAC_MODE_DRY, + }, + blocking=True, + ) + + # Test setting invalid preset mode + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + ATTR_PRESET_MODE: "invalid_mode", + }, + blocking=True, + ) diff --git a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json b/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json new file mode 100644 index 00000000000..77a68aafde1 --- /dev/null +++ b/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json @@ -0,0 +1,693 @@ +{ + "nodeId": 13, + "index": 0, + "installerIcon": 4608, + "userIcon": 4608, + "status": 4, + "ready": true, + "deviceClass": { + "basic": "Static Controller", + "generic": "Thermostat", + "specific": "Thermostat General V2", + "mandatorySupportedCCs": [ + "Basic", + "Manufacturer Specific", + "Thermostat Mode", + "Thermostat Setpoint", + "Version" + ], + "mandatoryControlCCs": [] + }, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 4, + "isBeaming": true, + "manufacturerId": 152, + "productId": 256, + "productType": 25602, + "firmwareVersion": "10.7", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 5, + "deviceConfig": { + "manufacturerId": 152, + "manufacturer": "Radio Thermostat Company of America (RTC)", + "label": "CT100 Plus", + "description": "Z-Wave Thermostat", + "devices": [{ "productType": "0x6402", "productId": "0x0100" }], + "firmwareVersion": { "min": "0.0", "max": "255.255" }, + "paramInformation": { "_map": {} } + }, + "label": "CT100 Plus", + "neighbors": [1, 2, 3, 4, 20], + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 2, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 13, + "index": 0, + "installerIcon": 4608, + "userIcon": 4608 + }, + { + "nodeId": 13, + "index": 1, + "installerIcon": 4608, + "userIcon": 4608 + }, + { "nodeId": 13, "index": 2 } + ], + "values": [ + { + "commandClassName": "Manufacturer Specific", + "commandClass": 114, + "endpoint": 0, + "property": "manufacturerId", + "propertyName": "manufacturerId", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 152 + }, + { + "commandClassName": "Manufacturer Specific", + "commandClass": 114, + "endpoint": 0, + "property": "productType", + "propertyName": "productType", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 25602 + }, + { + "commandClassName": "Manufacturer Specific", + "commandClass": 114, + "endpoint": 0, + "property": "productId", + "propertyName": "productId", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 256 + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "libraryType", + "propertyName": "libraryType", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "protocolVersion", + "propertyName": "protocolVersion", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.24" + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["10.7"] + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + }, + { + "commandClassName": "Indicator", + "commandClass": 135, + "endpoint": 0, + "property": "value", + "propertyName": "value", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "label": "Indicator value", + "ccSpecific": { "indicatorId": 0 } + }, + "value": 0 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 1, + "propertyName": "Temperature Reporting Threshold", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 4, + "default": 2, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Disabled", + "1": "0.5° F", + "2": "1.0° F", + "3": "1.5° F", + "4": "2.0° F" + }, + "label": "Temperature Reporting Threshold", + "description": "Reporting threshold for changes in the ambient temperature", + "isFromConfig": true + }, + "value": 1 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 2, + "propertyName": "HVAC Settings", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "valueSize": 4, + "min": 0, + "max": 0, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "HVAC Settings", + "description": "Configured HVAC settings", + "isFromConfig": true + }, + "value": 17891329 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 4, + "propertyName": "Power Status", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "valueSize": 1, + "min": 0, + "max": 0, + "default": 0, + "format": 0, + "allowManualEntry": true, + "label": "Power Status", + "description": "C-Wire / Battery Status", + "isFromConfig": true + }, + "value": 1 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 7, + "propertyName": "Thermostat Swing Temperature", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 8, + "default": 2, + "format": 0, + "allowManualEntry": false, + "states": { + "1": "0.5° F", + "2": "1.0° F", + "3": "1.5° F", + "4": "2.0° F", + "5": "2.5° F", + "6": "3.0° F", + "7": "3.5° F", + "8": "4.0° F" + }, + "label": "Thermostat Swing Temperature", + "description": "Variance allowed from setpoint to engage HVAC", + "isFromConfig": true + }, + "value": 1 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 8, + "propertyName": "Thermostat Diff Temperature", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 4, + "max": 12, + "default": 4, + "format": 0, + "allowManualEntry": false, + "states": { "4": "2.0° F", "8": "4.0° F", "12": "6.0° F" }, + "label": "Thermostat Diff Temperature", + "description": "Configures additional stages", + "isFromConfig": true + }, + "value": 1028 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 9, + "propertyName": "Thermostat Recovery Mode", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 2, + "default": 2, + "format": 0, + "allowManualEntry": false, + "states": { + "1": "Fast recovery mode", + "2": "Economy recovery mode" + }, + "label": "Thermostat Recovery Mode", + "description": "Fast or Economy recovery mode", + "isFromConfig": true + }, + "value": 2 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 10, + "propertyName": "Temperature Reporting Filter", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 4, + "min": 0, + "max": 124, + "default": 124, + "format": 0, + "allowManualEntry": true, + "label": "Temperature Reporting Filter", + "description": "Upper/Lower bounds for thermostat temperature reporting", + "isFromConfig": true + }, + "value": 0 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 11, + "propertyName": "Simple UI Mode", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Normal mode enabled", + "1": "Simple mode enabled" + }, + "label": "Simple UI Mode", + "description": "Simple mode enable/disable", + "isFromConfig": true + }, + "value": 1 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 12, + "propertyName": "Multicast", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 1, + "default": 0, + "format": 0, + "allowManualEntry": false, + "states": { + "0": "Multicast disabled", + "1": "Multicast enabled" + }, + "label": "Multicast", + "description": "Enable or disables Multicast", + "isFromConfig": true + }, + "value": 0 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 3, + "propertyName": "Utility Lock Enable/Disable", + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 255, + "default": 0, + "format": 1, + "allowManualEntry": false, + "states": { + "0": "Utility lock disabled", + "1": "Utility lock enabled" + }, + "label": "Utility Lock Enable/Disable", + "description": "Prevents setpoint changes at thermostat", + "isFromConfig": true + } + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 5, + "propertyName": "Humidity Reporting Threshold", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 255, + "default": 0, + "format": 1, + "allowManualEntry": false, + "states": { + "0": "Disabled", + "1": "3% RH", + "2": "5% RH", + "3": "10% RH" + }, + "label": "Humidity Reporting Threshold", + "description": "Reporting threshold for changes in the relative humidity", + "isFromConfig": true + } + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 6, + "propertyName": "Auxiliary/Emergency", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 255, + "default": 0, + "format": 1, + "allowManualEntry": false, + "states": { + "0": "Auxiliary/Emergency heat disabled", + "1": "Auxiliary/Emergency heat enabled" + }, + "label": "Auxiliary/Emergency", + "description": "Enables or disables auxiliary / emergency heating", + "isFromConfig": true + } + }, + { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 0, + "property": "level", + "propertyName": "level", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 100, + "unit": "%", + "label": "Battery level" + }, + "value": 100 + }, + { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 0, + "property": "isLow", + "propertyName": "isLow", + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": false + }, + { + "commandClassName": "Indicator", + "commandClass": 135, + "endpoint": 1, + "property": "value", + "propertyName": "value", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "label": "Indicator value", + "ccSpecific": { "indicatorId": 0 } + }, + "value": 0 + }, + { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 1, + "property": "Air temperature", + "propertyName": "Air temperature", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "unit": "°F", + "label": "Air temperature", + "ccSpecific": { "sensorType": 1, "scale": 1 } + }, + "value": 72 + }, + { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 1, + "property": "Humidity", + "propertyName": "Humidity", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "unit": "%", + "label": "Humidity", + "ccSpecific": { "sensorType": 5, "scale": 0 } + }, + "value": 30 + }, + { + "commandClassName": "Thermostat Mode", + "commandClass": 64, + "endpoint": 1, + "property": "mode", + "propertyName": "mode", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 31, + "label": "Thermostat mode", + "states": { "0": "Off", "1": "Heat", "2": "Cool", "3": "Auto" } + }, + "value": 1 + }, + { + "commandClassName": "Thermostat Mode", + "commandClass": 64, + "endpoint": 1, + "property": "manufacturerData", + "propertyName": "manufacturerData", + "metadata": { "type": "any", "readable": true, "writeable": true } + }, + { + "commandClassName": "Thermostat Operating State", + "commandClass": 66, + "endpoint": 1, + "property": "state", + "propertyName": "state", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Operating state", + "states": { + "0": "Idle", + "1": "Heating", + "2": "Cooling", + "3": "Fan Only", + "4": "Pending Heat", + "5": "Pending Cool", + "6": "Vent/Economizer", + "7": "Aux Heating", + "8": "2nd Stage Heating", + "9": "2nd Stage Cooling", + "10": "2nd Stage Aux Heat", + "11": "3rd Stage Aux Heat" + } + }, + "value": 0 + }, + { + "commandClassName": "Thermostat Setpoint", + "commandClass": 67, + "endpoint": 1, + "property": "setpoint", + "propertyKey": 1, + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "unit": "°F", + "ccSpecific": { "setpointType": 1 } + }, + "value": 72 + }, + { + "commandClassName": "Thermostat Setpoint", + "commandClass": 67, + "endpoint": 1, + "property": "setpoint", + "propertyKey": 2, + "propertyName": "setpoint", + "propertyKeyName": "Cooling", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "unit": "°F", + "ccSpecific": { "setpointType": 2 } + }, + "value": 73 + }, + { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 1, + "property": "level", + "propertyName": "level", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 100, + "unit": "%", + "label": "Battery level" + }, + "value": 100 + }, + { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 1, + "property": "isLow", + "propertyName": "isLow", + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": false + } + ] +}