diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 21662611905..1c9f78b1751 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -49,6 +49,7 @@ ATTR_EVENT_LABEL = "event_label" ATTR_EVENT_TYPE = "event_type" ATTR_EVENT_DATA = "event_data" ATTR_DATA_TYPE = "data_type" +ATTR_WAIT_FOR_RESULT = "wait_for_result" # service constants SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" @@ -62,4 +63,6 @@ SERVICE_REFRESH_VALUE = "refresh_value" ATTR_REFRESH_ALL_VALUES = "refresh_all_values" +SERVICE_SET_VALUE = "set_value" + ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 66b0b09db17..513abd97318 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -5,7 +5,9 @@ import logging import voluptuous as vol from zwave_js_server.const import CommandStatus +from zwave_js_server.exceptions import SetValueFailed from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.model.value import get_value_id from zwave_js_server.util.node import ( async_bulk_set_partial_config_parameters, async_set_config_parameter, @@ -120,6 +122,29 @@ class ZWaveServices: ), ) + self._hass.services.async_register( + const.DOMAIN, + const.SERVICE_SET_VALUE, + self.async_set_value, + schema=vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(const.ATTR_COMMAND_CLASS): vol.Coerce(int), + vol.Required(const.ATTR_PROPERTY): vol.Any(vol.Coerce(int), str), + vol.Optional(const.ATTR_PROPERTY_KEY): vol.Any( + vol.Coerce(int), str + ), + vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(const.ATTR_VALUE): vol.Any( + bool, vol.Coerce(int), vol.Coerce(float), cv.string + ), + vol.Optional(const.ATTR_WAIT_FOR_RESULT): vol.Coerce(bool), + }, + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + ), + ) + async def async_set_config_parameter(self, service: ServiceCall) -> None: """Set a config value on a node.""" nodes: set[ZwaveNode] = set() @@ -203,3 +228,43 @@ class ZWaveServices: f"{const.DOMAIN}_{entry.unique_id}_poll_value", service.data[const.ATTR_REFRESH_ALL_VALUES], ) + + async def async_set_value(self, service: ServiceCall) -> None: + """Set a value on a node.""" + nodes: set[ZwaveNode] = set() + if ATTR_ENTITY_ID in service.data: + nodes |= { + async_get_node_from_entity_id(self._hass, entity_id) + for entity_id in service.data[ATTR_ENTITY_ID] + } + if ATTR_DEVICE_ID in service.data: + nodes |= { + async_get_node_from_device_id(self._hass, device_id) + for device_id in service.data[ATTR_DEVICE_ID] + } + command_class = service.data[const.ATTR_COMMAND_CLASS] + property_ = service.data[const.ATTR_PROPERTY] + property_key = service.data.get(const.ATTR_PROPERTY_KEY) + endpoint = service.data.get(const.ATTR_ENDPOINT) + new_value = service.data[const.ATTR_VALUE] + wait_for_result = service.data.get(const.ATTR_WAIT_FOR_RESULT) + + for node in nodes: + success = await node.async_set_value( + get_value_id( + node, + command_class, + property_, + endpoint=endpoint, + property_key=property_key, + ), + new_value, + wait_for_result=wait_for_result, + ) + + if success is False: + raise SetValueFailed( + "Unable to set value, refer to " + "https://zwave-js.github.io/node-zwave-js/#/api/node?id=setvalue " + "for possible reasons" + ) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index c9a1483fd2e..f9d90f94779 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -113,3 +113,53 @@ refresh_value: default: false selector: boolean: + +set_value: + name: Set a value on a Z-Wave device (Advanced) + description: Allow for changing any value that Z-Wave JS recognizes on a Z-Wave device. This service has minimal validation so only use this service if you know what you are doing. + target: + entity: + integration: zwave_js + fields: + command_class: + name: Command Class + description: The ID of the command class for the value. + example: 117 + required: true + selector: + text: + endpoint: + name: Endpoint + description: The endpoint for the value. + example: 1 + required: false + selector: + text: + property: + name: Property + description: The ID of the property for the value. + example: currentValue + required: true + selector: + text: + property_key: + name: Property Key + description: The ID of the property key for the value + example: 1 + required: false + selector: + text: + value: + name: Value + description: The new value to set. + example: "ffbb99" + required: true + selector: + object: + wait_for_result: + name: Wait for result? + description: Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the service call can take a while if setting a value on an asleep battery device. + example: false + required: false + selector: + boolean: diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index c6bd69abadb..b5a5f8f48f0 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -1,16 +1,22 @@ """Test the Z-Wave JS services.""" import pytest import voluptuous as vol +from zwave_js_server.exceptions import SetValueFailed from homeassistant.components.zwave_js.const import ( + ATTR_COMMAND_CLASS, ATTR_CONFIG_PARAMETER, ATTR_CONFIG_PARAMETER_BITMASK, ATTR_CONFIG_VALUE, + ATTR_PROPERTY, ATTR_REFRESH_ALL_VALUES, + ATTR_VALUE, + ATTR_WAIT_FOR_RESULT, DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, SERVICE_REFRESH_VALUE, SERVICE_SET_CONFIG_PARAMETER, + SERVICE_SET_VALUE, ) from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.helpers.device_registry import ( @@ -19,7 +25,11 @@ from homeassistant.helpers.device_registry import ( ) from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg -from .common import AIR_TEMPERATURE_SENSOR, CLIMATE_RADIO_THERMOSTAT_ENTITY +from .common import ( + AIR_TEMPERATURE_SENSOR, + CLIMATE_DANFOSS_LC13_ENTITY, + CLIMATE_RADIO_THERMOSTAT_ENTITY, +) from tests.common import MockConfigEntry @@ -531,3 +541,84 @@ async def test_poll_value( {ATTR_ENTITY_ID: "sensor.fake_entity_id"}, blocking=True, ) + + +async def test_set_value(hass, client, climate_danfoss_lc_13, integration): + """Test set_value service.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: CLIMATE_DANFOSS_LC13_ENTITY, + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 2, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 5 + assert args["valueId"] == { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": {"0": "Unprotected", "2": "NoOperationPossible"}, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command_no_wait.reset_mock() + + # Test that when a command fails we raise an exception + client.async_send_command.return_value = {"success": False} + + with pytest.raises(SetValueFailed): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_DEVICE_ID: device.id, + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 2, + ATTR_WAIT_FOR_RESULT: True, + }, + 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"] == 5 + assert args["valueId"] == { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": {"0": "Unprotected", "2": "NoOperationPossible"}, + }, + "value": 0, + } + assert args["value"] == 2