Expand zwave_js.set_config_parameter with additional parameters (#102092)

This commit is contained in:
Raman Gupta 2023-11-08 17:05:31 -05:00 committed by GitHub
parent 1a51d863cf
commit f511a8a26a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 289 additions and 19 deletions

View File

@ -106,6 +106,8 @@ ATTR_NODES = "nodes"
ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_PARAMETER = "parameter"
ATTR_CONFIG_PARAMETER_BITMASK = "bitmask" ATTR_CONFIG_PARAMETER_BITMASK = "bitmask"
ATTR_CONFIG_VALUE = "value" ATTR_CONFIG_VALUE = "value"
ATTR_VALUE_SIZE = "value_size"
ATTR_VALUE_FORMAT = "value_format"
# refresh value # refresh value
ATTR_REFRESH_ALL_VALUES = "refresh_all_values" ATTR_REFRESH_ALL_VALUES = "refresh_all_values"
# multicast # multicast

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Generator, Sequence from collections.abc import Generator, Sequence
import logging import logging
import math
from typing import Any, TypeVar from typing import Any, TypeVar
import voluptuous as vol 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.exceptions import FailedZWaveCommand, SetValueFailed
from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.endpoint import Endpoint
from zwave_js_server.model.node import Node as ZwaveNode 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.multicast import async_multicast_set_value
from zwave_js_server.util.node import ( from zwave_js_server.util.node import (
async_bulk_set_partial_config_parameters, async_bulk_set_partial_config_parameters,
@ -58,6 +63,13 @@ def parameter_name_does_not_need_bitmask(
return val 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]: def broadcast_command(val: dict[str, Any]) -> dict[str, Any]:
"""Validate that the service call is for a broadcast command.""" """Validate that the service call is for a broadcast command."""
if val.get(const.ATTR_BROADCAST): if val.get(const.ATTR_BROADCAST):
@ -78,10 +90,10 @@ def get_valid_responses_from_results(
def raise_exceptions_from_results( def raise_exceptions_from_results(
zwave_objects: Sequence[ZwaveNode | Endpoint], zwave_objects: Sequence[T], results: Sequence[Any]
results: Sequence[Any],
) -> None: ) -> None:
"""Raise list of exceptions from a list of results.""" """Raise list of exceptions from a list of results."""
errors: Sequence[tuple[T, Any]]
if errors := [ if errors := [
tup for tup in zip(zwave_objects, results) if isinstance(tup[1], Exception) 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.Required(const.ATTR_CONFIG_VALUE): vol.Any(
vol.Coerce(int), BITMASK_SCHEMA, cv.string 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( cv.has_at_least_one_key(
ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID 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, parameter_name_does_not_need_bitmask,
get_nodes_from_service_data, get_nodes_from_service_data,
has_at_least_one_node, has_at_least_one_node,
@ -487,7 +508,33 @@ class ZWaveServices:
property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER]
property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK)
new_value = service.data[const.ATTR_CONFIG_VALUE] 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( results = await asyncio.gather(
*( *(
async_set_config_parameter( async_set_config_parameter(
@ -497,23 +544,42 @@ class ZWaveServices:
property_key=property_key, property_key=property_key,
endpoint=endpoint, 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 for node in nodes
), ),
return_exceptions=True, return_exceptions=True,
) )
nodes_list = list(nodes)
for node, result in get_valid_responses_from_results(nodes_list, results): def process_results(
zwave_value = result[0] nodes_or_endpoints_list: list[T], _results: list[Any]
cmd_status = result[1] ) -> None:
if cmd_status == CommandStatus.ACCEPTED: """Process results for given nodes or endpoints."""
msg = "Set configuration parameter %s on Node %s with value %s" for node_or_endpoint, result in get_valid_responses_from_results(
else: nodes_or_endpoints_list, _results
msg = ( ):
"Added command to queue to set configuration parameter %s on Node " zwave_value = result[0]
"%s with value %s. Parameter will be set when the device wakes up" cmd_status = result[1]
) if cmd_status.status == CommandStatus.ACCEPTED:
_LOGGER.info(msg, zwave_value, node, new_value) msg = "Set configuration parameter %s on Node %s with value %s"
raise_exceptions_from_results(nodes_list, results) 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( async def async_bulk_set_partial_config_parameters(
self, service: ServiceCall self, service: ServiceCall
@ -605,7 +671,7 @@ class ZWaveServices:
results = await asyncio.gather(*coros, return_exceptions=True) results = await asyncio.gather(*coros, return_exceptions=True)
nodes_list = list(nodes) nodes_list = list(nodes)
# multiple set_values my fail so we will track the entire list # 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] = [] set_value_failed_error_list: list[SetValueFailed] = []
for node_, result in get_valid_responses_from_results(nodes_list, results): for node_, result in get_valid_responses_from_results(nodes_list, results):
if result and result.status not in SET_VALUE_SUCCESS: if result and result.status not in SET_VALUE_SUCCESS:

View File

@ -54,6 +54,18 @@ set_config_parameter:
required: true required: true
selector: selector:
text: 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: bulk_set_partial_config_parameters:
target: target:

View File

@ -216,11 +216,19 @@
}, },
"bitmask": { "bitmask": {
"name": "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": { "value": {
"name": "Value", "name": "Value",
"description": "The new value to set for this configuration parameter." "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."
} }
} }
}, },

View File

@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
import voluptuous as vol import voluptuous as vol
from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.exceptions import FailedZWaveCommand
from zwave_js_server.model.value import SetConfigParameterResult
from homeassistant.components.group import Group from homeassistant.components.group import Group
from homeassistant.components.zwave_js.const import ( from homeassistant.components.zwave_js.const import (
@ -22,6 +23,8 @@ from homeassistant.components.zwave_js.const import (
ATTR_PROPERTY_KEY, ATTR_PROPERTY_KEY,
ATTR_REFRESH_ALL_VALUES, ATTR_REFRESH_ALL_VALUES,
ATTR_VALUE, ATTR_VALUE,
ATTR_VALUE_FORMAT,
ATTR_VALUE_SIZE,
ATTR_WAIT_FOR_RESULT, ATTR_WAIT_FOR_RESULT,
DOMAIN, DOMAIN,
SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS,
@ -56,7 +59,12 @@ from tests.common import MockConfigEntry
async def test_set_config_parameter( 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: ) -> None:
"""Test the set_config_parameter service.""" """Test the set_config_parameter service."""
dev_reg = async_get_dev_reg(hass) 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() 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 # Test groups get expanded
assert await async_setup_component(hass, "group", {}) assert await async_setup_component(hass, "group", {})
await Group.async_create_group( await Group.async_create_group(
@ -296,6 +361,54 @@ async def test_set_config_parameter(
config_entry=non_zwave_js_config_entry, 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, # 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. # non Z-Wave JS device, invalid device_id, and invalid node_id gets filtered out.
await hass.services.async_call( await hass.services.async_call(
@ -376,6 +489,75 @@ async def test_set_config_parameter(
blocking=True, 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( async def test_set_config_parameter_gather(
hass: HomeAssistant, hass: HomeAssistant,