From f511a8a26a4d866fd4020b9370b60f08c4bce7ba Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 8 Nov 2023 17:05:31 -0500 Subject: [PATCH] Expand `zwave_js.set_config_parameter` with additional parameters (#102092) --- homeassistant/components/zwave_js/const.py | 2 + homeassistant/components/zwave_js/services.py | 100 ++++++++-- .../components/zwave_js/services.yaml | 12 ++ .../components/zwave_js/strings.json | 10 +- tests/components/zwave_js/test_services.py | 184 +++++++++++++++++- 5 files changed, 289 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 5d4a8c574bf..acc1da4e51a 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -106,6 +106,8 @@ ATTR_NODES = "nodes" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_PARAMETER_BITMASK = "bitmask" ATTR_CONFIG_VALUE = "value" +ATTR_VALUE_SIZE = "value_size" +ATTR_VALUE_FORMAT = "value_format" # refresh value ATTR_REFRESH_ALL_VALUES = "refresh_all_values" # multicast diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 20485d8a922..12c1ed242af 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Generator, Sequence import logging +import math from typing import Any, TypeVar import voluptuous as vol @@ -13,7 +14,11 @@ from zwave_js_server.const.command_class.notification import NotificationType from zwave_js_server.exceptions import FailedZWaveCommand, SetValueFailed from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import ValueDataType, get_value_id_str +from zwave_js_server.model.value import ( + ConfigurationValueFormat, + ValueDataType, + get_value_id_str, +) from zwave_js_server.util.multicast import async_multicast_set_value from zwave_js_server.util.node import ( async_bulk_set_partial_config_parameters, @@ -58,6 +63,13 @@ def parameter_name_does_not_need_bitmask( return val +def check_base_2(val: int) -> int: + """Check if value is a power of 2.""" + if not math.log2(val).is_integer(): + raise vol.Invalid("Value must be a power of 2.") + return val + + def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: """Validate that the service call is for a broadcast command.""" if val.get(const.ATTR_BROADCAST): @@ -78,10 +90,10 @@ def get_valid_responses_from_results( def raise_exceptions_from_results( - zwave_objects: Sequence[ZwaveNode | Endpoint], - results: Sequence[Any], + zwave_objects: Sequence[T], results: Sequence[Any] ) -> None: """Raise list of exceptions from a list of results.""" + errors: Sequence[tuple[T, Any]] if errors := [ tup for tup in zip(zwave_objects, results) if isinstance(tup[1], Exception) ]: @@ -263,10 +275,19 @@ class ZWaveServices: vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( vol.Coerce(int), BITMASK_SCHEMA, cv.string ), + vol.Inclusive(const.ATTR_VALUE_SIZE, "raw"): vol.All( + vol.Coerce(int), vol.Range(min=1, max=4), check_base_2 + ), + vol.Inclusive(const.ATTR_VALUE_FORMAT, "raw"): vol.Coerce( + ConfigurationValueFormat + ), }, cv.has_at_least_one_key( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID ), + cv.has_at_most_one_key( + const.ATTR_CONFIG_PARAMETER_BITMASK, const.ATTR_VALUE_SIZE + ), parameter_name_does_not_need_bitmask, get_nodes_from_service_data, has_at_least_one_node, @@ -487,7 +508,33 @@ class ZWaveServices: property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) new_value = service.data[const.ATTR_CONFIG_VALUE] + value_size = service.data.get(const.ATTR_VALUE_SIZE) + value_format = service.data.get(const.ATTR_VALUE_FORMAT) + nodes_without_endpoints: set[ZwaveNode] = set() + # Remove nodes that don't have the specified endpoint + for node in nodes: + if endpoint not in node.endpoints: + nodes_without_endpoints.add(node) + nodes = nodes.difference(nodes_without_endpoints) + if not nodes: + raise HomeAssistantError( + "None of the specified nodes have the specified endpoint" + ) + if nodes_without_endpoints and _LOGGER.isEnabledFor(logging.WARNING): + _LOGGER.warning( + ( + "The following nodes do not have endpoint %x and will be " + "skipped: %s" + ), + endpoint, + nodes_without_endpoints, + ) + + # If value_size isn't provided, we will use the utility function which includes + # additional checks and protections. If it is provided, we will use the + # node.async_set_raw_config_parameter_value method which calls the + # Configuration CC set API. results = await asyncio.gather( *( async_set_config_parameter( @@ -497,23 +544,42 @@ class ZWaveServices: property_key=property_key, endpoint=endpoint, ) + if value_size is None + else node.endpoints[endpoint].async_set_raw_config_parameter_value( + new_value, + property_or_property_name, + property_key=property_key, + value_size=value_size, + value_format=value_format, + ) for node in nodes ), return_exceptions=True, ) - nodes_list = list(nodes) - for node, result in get_valid_responses_from_results(nodes_list, results): - zwave_value = result[0] - cmd_status = result[1] - if cmd_status == CommandStatus.ACCEPTED: - msg = "Set configuration parameter %s on Node %s with value %s" - else: - msg = ( - "Added command to queue to set configuration parameter %s on Node " - "%s with value %s. Parameter will be set when the device wakes up" - ) - _LOGGER.info(msg, zwave_value, node, new_value) - raise_exceptions_from_results(nodes_list, results) + + def process_results( + nodes_or_endpoints_list: list[T], _results: list[Any] + ) -> None: + """Process results for given nodes or endpoints.""" + for node_or_endpoint, result in get_valid_responses_from_results( + nodes_or_endpoints_list, _results + ): + zwave_value = result[0] + cmd_status = result[1] + if cmd_status.status == CommandStatus.ACCEPTED: + msg = "Set configuration parameter %s on Node %s with value %s" + else: + msg = ( + "Added command to queue to set configuration parameter %s on %s " + "with value %s. Parameter will be set when the device wakes up" + ) + _LOGGER.info(msg, zwave_value, node_or_endpoint, new_value) + raise_exceptions_from_results(nodes_or_endpoints_list, _results) + + if value_size is None: + process_results(list(nodes), results) + else: + process_results([node.endpoints[endpoint] for node in nodes], results) async def async_bulk_set_partial_config_parameters( self, service: ServiceCall @@ -605,7 +671,7 @@ class ZWaveServices: results = await asyncio.gather(*coros, return_exceptions=True) nodes_list = list(nodes) # multiple set_values my fail so we will track the entire list - set_value_failed_nodes_list: list[ZwaveNode | Endpoint] = [] + set_value_failed_nodes_list: list[ZwaveNode] = [] set_value_failed_error_list: list[SetValueFailed] = [] for node_, result in get_valid_responses_from_results(nodes_list, results): if result and result.status not in SET_VALUE_SUCCESS: diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index e21103aa22e..cb8e726bf32 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -54,6 +54,18 @@ set_config_parameter: required: true selector: text: + value_size: + example: 1 + selector: + number: + min: 1 + max: 4 + value_format: + example: 1 + selector: + number: + min: 0 + max: 3 bulk_set_partial_config_parameters: target: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 59cec0ed541..71c6b93e2bd 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -216,11 +216,19 @@ }, "bitmask": { "name": "Bitmask", - "description": "Target a specific bitmask (see the documentation for more information)." + "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format." }, "value": { "name": "Value", "description": "The new value to set for this configuration parameter." + }, + "value_size": { + "name": "Value size", + "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." + }, + "value_format": { + "name": "Value format", + "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." } } }, diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index f5b7809d8cc..8697dad2e7b 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest import voluptuous as vol from zwave_js_server.exceptions import FailedZWaveCommand +from zwave_js_server.model.value import SetConfigParameterResult from homeassistant.components.group import Group from homeassistant.components.zwave_js.const import ( @@ -22,6 +23,8 @@ from homeassistant.components.zwave_js.const import ( ATTR_PROPERTY_KEY, ATTR_REFRESH_ALL_VALUES, ATTR_VALUE, + ATTR_VALUE_FORMAT, + ATTR_VALUE_SIZE, ATTR_WAIT_FOR_RESULT, DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -56,7 +59,12 @@ from tests.common import MockConfigEntry async def test_set_config_parameter( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + client, + multisensor_6, + aeotec_zw164_siren, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the set_config_parameter service.""" dev_reg = async_get_dev_reg(hass) @@ -225,6 +233,63 @@ async def test_set_config_parameter( client.async_send_command_no_wait.reset_mock() + # Test setting parameter by value_size + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 2, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + 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"] == "endpoint.set_raw_config_parameter_value" + assert args["nodeId"] == 52 + assert args["endpoint"] == 0 + options = args["options"] + assert options["parameter"] == 2 + assert options["value"] == 1 + assert options["valueSize"] == 2 + assert options["valueFormat"] == 1 + + client.async_send_command_no_wait.reset_mock() + + # Test setting parameter when one node has endpoint and other doesn't + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: [AIR_TEMPERATURE_SENSOR, "siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 1, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.set_raw_config_parameter_value" + assert args["nodeId"] == 2 + assert args["endpoint"] == 1 + options = args["options"] + assert options["parameter"] == 32 + assert options["value"] == 1 + assert options["valueSize"] == 2 + assert options["valueFormat"] == 1 + + client.async_send_command_no_wait.reset_mock() + client.async_send_command.reset_mock() + # Test groups get expanded assert await async_setup_component(hass, "group", {}) await Group.async_create_group( @@ -296,6 +361,54 @@ async def test_set_config_parameter( config_entry=non_zwave_js_config_entry, ) + # Test unknown endpoint throws error when None are remaining + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_ENDPOINT: 5, + ATTR_CONFIG_PARAMETER: 2, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + # Test that we can't include bitmask and value size and value format + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: "Fahrenheit", + ATTR_VALUE_FORMAT: 1, + ATTR_VALUE_SIZE: 2, + }, + blocking=True, + ) + + # Test that value size must be 1, 2, or 4 (not 3) + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: "Fahrenheit", + ATTR_VALUE_FORMAT: 1, + ATTR_VALUE_SIZE: 3, + }, + blocking=True, + ) + # Test that a Z-Wave JS device with an invalid node ID, non Z-Wave JS entity, # non Z-Wave JS device, invalid device_id, and invalid node_id gets filtered out. await hass.services.async_call( @@ -376,6 +489,75 @@ async def test_set_config_parameter( blocking=True, ) + client.async_send_command_no_wait.reset_mock() + client.async_send_command.reset_mock() + + caplog.clear() + + config_value = aeotec_zw164_siren.values["2-112-0-32"] + cmd_result = SetConfigParameterResult("accepted", {"status": 255}) + + # Test accepted return + with patch( + "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", + return_value=(config_value, cmd_result), + ) as mock_set_raw_config_parameter_value: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: ["siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 0, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + assert len(mock_set_raw_config_parameter_value.call_args_list) == 1 + assert mock_set_raw_config_parameter_value.call_args[0][0] == 1 + assert mock_set_raw_config_parameter_value.call_args[0][1] == 32 + assert mock_set_raw_config_parameter_value.call_args[1] == { + "property_key": None, + "value_size": 2, + "value_format": 1, + } + + assert "Set configuration parameter" in caplog.text + caplog.clear() + + # Test queued return + cmd_result.status = "queued" + with patch( + "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", + return_value=(config_value, cmd_result), + ) as mock_set_raw_config_parameter_value: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: ["siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 0, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + assert len(mock_set_raw_config_parameter_value.call_args_list) == 1 + assert mock_set_raw_config_parameter_value.call_args[0][0] == 1 + assert mock_set_raw_config_parameter_value.call_args[0][1] == 32 + assert mock_set_raw_config_parameter_value.call_args[1] == { + "property_key": None, + "value_size": 2, + "value_format": 1, + } + + assert "Added command to queue" in caplog.text + caplog.clear() + async def test_set_config_parameter_gather( hass: HomeAssistant,