Expand `zwave_js.set_config_parameter` with additional parameters (#102092)

pull/103691/head
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_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

View File

@ -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:

View File

@ -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:

View File

@ -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."
}
}
},

View File

@ -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,