diff --git a/test/model/test_value.py b/test/model/test_value.py index 0a8ef627d..5c921aa5b 100644 --- a/test/model/test_value.py +++ b/test/model/test_value.py @@ -6,6 +6,13 @@ from zwave_js_server.model.value import get_value_id +def test_value_size(lock_schlage_be469): + """Test the value size property for a value.""" + node = lock_schlage_be469 + zwave_value = node.values["20-112-0-3"] + assert zwave_value.metadata.value_size == 1 + + def test_buffer_dict(client, idl_101_lock_state): """Test that we handle buffer dictionary correctly.""" node_data = deepcopy(idl_101_lock_state) diff --git a/test/util/test_node.py b/test/util/test_node.py index c25e815b8..f03eec4a9 100644 --- a/test/util/test_node.py +++ b/test/util/test_node.py @@ -1,10 +1,14 @@ """Test node utility functions.""" import pytest -from zwave_js_server.exceptions import InvalidNewValue, NotFoundError +from zwave_js_server.const import CommandClass +from zwave_js_server.exceptions import InvalidNewValue, NotFoundError, ValueTypeError from zwave_js_server.model.node import Node from zwave_js_server.model.value import ConfigurationValue -from zwave_js_server.util.node import async_set_config_parameter +from zwave_js_server.util.node import ( + async_bulk_set_partial_config_parameters, + async_set_config_parameter, +) async def test_configuration_parameter_values( @@ -101,3 +105,69 @@ async def test_configuration_parameter_values( "value": 4, "messageId": uuid4, } + + +async def test_bulk_set_partial_config_parameters(multisensor_6, uuid4, mock_command): + """Test bulk setting partial config parameters.""" + node: Node = multisensor_6 + ack_commands = mock_command( + {"command": "node.set_value", "nodeId": node.node_id}, + {"success": True}, + ) + await async_bulk_set_partial_config_parameters(node, 101, 241) + assert len(ack_commands) == 1 + assert ack_commands[0] == { + "command": "node.set_value", + "nodeId": node.node_id, + "valueId": { + "commandClass": CommandClass.CONFIGURATION.value, + "property": 101, + }, + "value": 241, + "messageId": uuid4, + } + + await async_bulk_set_partial_config_parameters( + node, 101, {128: 1, 64: 1, 32: 1, 16: 1, 1: 1} + ) + assert len(ack_commands) == 2 + assert ack_commands[1] == { + "command": "node.set_value", + "nodeId": node.node_id, + "valueId": { + "commandClass": CommandClass.CONFIGURATION.value, + "property": 101, + }, + "value": 241, + "messageId": uuid4, + } + + # Only set some values so we use cached values for the rest + await async_bulk_set_partial_config_parameters( + node, 101, {64: 1, 32: 1, 16: 1, 1: 1} + ) + assert len(ack_commands) == 3 + assert ack_commands[2] == { + "command": "node.set_value", + "nodeId": node.node_id, + "valueId": { + "commandClass": CommandClass.CONFIGURATION.value, + "property": 101, + }, + "value": 241, + "messageId": uuid4, + } + + # Use an invalid property + with pytest.raises(NotFoundError): + await async_bulk_set_partial_config_parameters(node, 999, 99) + + # use an invalid bitmask + with pytest.raises(NotFoundError): + await async_bulk_set_partial_config_parameters( + node, 101, {128: 1, 64: 1, 32: 1, 16: 1, 2: 1} + ) + + # Try to bulkset a property that isn't broken into partials + with pytest.raises(ValueTypeError): + await async_bulk_set_partial_config_parameters(node, 252, 1) diff --git a/zwave_js_server/exceptions.py b/zwave_js_server/exceptions.py index 0ef18c92d..6df86b9e2 100644 --- a/zwave_js_server/exceptions.py +++ b/zwave_js_server/exceptions.py @@ -82,6 +82,10 @@ class InvalidNewValue(BaseZwaveJSServerError): """Exception raised when target new value is invalid based on Value metadata.""" +class ValueTypeError(BaseZwaveJSServerError): + """Exception raised when target Zwave value is the wrong type.""" + + class SetValueFailed(BaseZwaveJSServerError): """ Exception raise when setting a value fails. diff --git a/zwave_js_server/model/node.py b/zwave_js_server/model/node.py index 1c2a2cae5..4fe10330b 100644 --- a/zwave_js_server/model/node.py +++ b/zwave_js_server/model/node.py @@ -325,7 +325,7 @@ def receive_event(self, event: Event) -> None: self.emit(event.type, event.data) - async def _async_send_command( + async def async_send_command( self, cmd: str, require_schema: Optional[int] = None, @@ -367,7 +367,7 @@ async def async_set_value( raise UnwriteableValue # the value object needs to be send to the server - result = await self._async_send_command( + result = await self.async_send_command( "set_value", valueId=val.data, value=new_value, @@ -381,11 +381,11 @@ async def async_set_value( async def async_refresh_info(self) -> None: """Send refreshInfo command to Node.""" - await self._async_send_command("refresh_info", wait_for_result=False) + await self.async_send_command("refresh_info", wait_for_result=False) async def async_get_defined_value_ids(self) -> List[Value]: """Send getDefinedValueIDs command to Node.""" - data = await self._async_send_command( + data = await self.async_send_command( "get_defined_value_ids", wait_for_result=True ) @@ -403,21 +403,21 @@ async def async_get_value_metadata(self, val: Union[Value, str]) -> ValueMetadat if not isinstance(val, Value): val = self.values[val] # the value object needs to be send to the server - data = await self._async_send_command( + data = await self.async_send_command( "get_value_metadata", valueId=val.data, wait_for_result=True ) return ValueMetadata(cast(MetaDataType, data)) async def async_abort_firmware_update(self) -> None: """Send abortFirmwareUpdate command to Node.""" - await self._async_send_command("abort_firmware_update", wait_for_result=False) + await self.async_send_command("abort_firmware_update", wait_for_result=False) async def async_poll_value(self, val: Union[Value, str]) -> None: """Send pollValue command to Node for given value (or value_id).""" # a value may be specified as value_id or the value itself if not isinstance(val, Value): val = self.values[val] - await self._async_send_command("poll_value", valueId=val.data, require_schema=1) + await self.async_send_command("poll_value", valueId=val.data, require_schema=1) def handle_wake_up(self, event: Event) -> None: """Process a node wake up event.""" diff --git a/zwave_js_server/model/value.py b/zwave_js_server/model/value.py index 38d7cb914..63d547987 100644 --- a/zwave_js_server/model/value.py +++ b/zwave_js_server/model/value.py @@ -23,6 +23,7 @@ class MetaDataType(TypedDict, total=False): states: Dict[int, str] ccSpecific: Dict[str, Any] allowManualEntry: bool + valueSize: int class ValueDataType(TypedDict, total=False): @@ -141,6 +142,11 @@ def allow_manual_entry(self) -> Optional[bool]: """Return allowManualEntry.""" return self.data.get("allowManualEntry") + @property + def value_size(self) -> Optional[int]: + """Return valueSize.""" + return self.data.get("valueSize") + def update(self, data: MetaDataType) -> None: """Update data.""" self.data.update(data) diff --git a/zwave_js_server/util/lock.py b/zwave_js_server/util/lock.py index 5025385b2..958c99724 100644 --- a/zwave_js_server/util/lock.py +++ b/zwave_js_server/util/lock.py @@ -13,7 +13,7 @@ ) from ..exceptions import NotFoundError from ..model.node import Node -from ..model.value import get_value_id, Value +from ..model.value import Value, get_value_id def get_code_slot_value(node: Node, code_slot: int, property_name: str) -> Value: diff --git a/zwave_js_server/util/node.py b/zwave_js_server/util/node.py index f84ea2348..1ad7cddec 100644 --- a/zwave_js_server/util/node.py +++ b/zwave_js_server/util/node.py @@ -1,58 +1,17 @@ """Utility functions for Z-Wave JS nodes.""" import json -from typing import Optional, Union +from typing import Dict, Optional, Union, cast from ..const import CommandClass, ConfigurationValueType -from ..exceptions import InvalidNewValue, NotFoundError, SetValueFailed +from ..exceptions import InvalidNewValue, NotFoundError, SetValueFailed, ValueTypeError from ..model.node import Node from ..model.value import ConfigurationValue, get_value_id -async def async_set_config_parameter( - node: Node, - new_value: Union[int, str], - property_or_property_name: Union[int, str], - property_key: Optional[Union[int, str]] = None, -) -> ConfigurationValue: - """ - Set a value for a config parameter on this node. - - new_value and property_ can be provided as labels, so we need to resolve them to - the appropriate key - """ - config_values = node.get_configuration_values() - - # If a property name is provided, we have to search for the correct value since - # we can't use value ID - if isinstance(property_or_property_name, str): - try: - zwave_value = next( - config_value - for config_value in config_values.values() - if config_value.property_name == property_or_property_name - ) - except StopIteration: - raise NotFoundError( - "Configuration parameter with parameter name " - f"{property_or_property_name} could not be found" - ) from None - else: - value_id = get_value_id( - node, - CommandClass.CONFIGURATION, - property_or_property_name, - endpoint=0, - property_key=property_key, - ) - - try: - zwave_value = config_values[value_id] - except KeyError: - raise NotFoundError( - f"Configuration parameter with value ID {value_id} could not be " - "found" - ) from None - +def _validate_and_transform_new_value( + zwave_value: ConfigurationValue, new_value: Union[int, str] +) -> int: + """Validate a new value and return the integer value to set.""" # Validate that new value for enumerated configuration parameter is a valid state # key or label if ( @@ -64,8 +23,8 @@ async def async_set_config_parameter( ] ): raise InvalidNewValue( - "Must provide a value that represents a valid state key or label from " - f"{json.dumps(zwave_value.metadata.states)}" + f"Must provide a value for {zwave_value.value_id} that represents a valid " + f"state key or label from {json.dumps(zwave_value.metadata.states)}" ) # Validate that new value for manual entry configuration parameter is a valid state @@ -76,8 +35,8 @@ async def async_set_config_parameter( and str(new_value) not in zwave_value.metadata.states.values() ): raise InvalidNewValue( - "Must provide a value that represents a valid state from " - f"{list(zwave_value.metadata.states.values())}" + f"Must provide a value for {zwave_value.value_id} that represents a valid " + f"state from {list(zwave_value.metadata.states.values())}" ) # If needed, convert a state label to its key. We know the state exists because @@ -115,6 +74,147 @@ async def async_set_config_parameter( f"Must provide a value within the target range ({', '.join(bounds)})" ) + return new_value + + +def partial_param_bit_shift(property_key: int) -> int: + """Get the number of bits to shift the value for a given property key.""" + # We can get the binary representation of the property key, reverse it, + # and find the first 1 + return bin(property_key)[::-1].index("1") + + +async def async_bulk_set_partial_config_parameters( + node: Node, + property_: int, + new_value: Union[int, Dict[int, Union[int, str]]], +) -> None: + """Bulk set partial configuration values on this node.""" + config_values = node.get_configuration_values() + property_values = [ + value for value in config_values.values() if value.property_ == property_ + ] + + # If we can't find any values with this property, the property is wrong + if not property_values: + raise NotFoundError( + f"Configuration parameter {property_} for node {node.node_id} not found" + ) + + # If we only find one value with this property_, we know this value isn't split + # into partial params + if len(property_values) == 1: + raise ValueTypeError( + f"Configuration parameter {property_} for node {node.node_id} does not " + "have partials" + ) + + # If new_value is a dictionary, we need to calculate the full value to send + if isinstance(new_value, dict): + temp_value = 0 + # For each property key provided, we bit shift the partial value using the + # property_key + for property_key, partial_value in new_value.items(): + value_id = get_value_id( + node, CommandClass.CONFIGURATION, property_, property_key=property_key + ) + if value_id not in node.values: + raise NotFoundError( + f"Bitmask {property_key} ({hex(property_key)}) not found for " + f"parameter {property_}" + ) + zwave_value = cast(ConfigurationValue, node.values[value_id]) + partial_value = _validate_and_transform_new_value( + zwave_value, partial_value + ) + temp_value += partial_value << partial_param_bit_shift(property_key) + + # To set partial parameters in bulk, we also have to include cached values for + # property keys that haven't been specified + for property_value in property_values: + if property_value.property_key not in new_value: + temp_value += cast( + int, property_value.value + ) << partial_param_bit_shift(cast(int, property_value.property_key)) + + new_value = temp_value + else: + remaining_value = new_value + + # Break down the bulk value into partial values and validate them against + # each partial parameter's metadata by looping through the property values + # starting with the highest property key + for zwave_value in sorted( + property_values, key=lambda val: cast(int, val.property_key), reverse=True + ): + property_key = cast(int, zwave_value.property_key) + multiplication_factor = 2 ** partial_param_bit_shift(property_key) + partial_value = int(remaining_value / multiplication_factor) + remaining_value = remaining_value % multiplication_factor + _validate_and_transform_new_value(zwave_value, partial_value) + + response = await node.async_send_command( + "set_value", + valueId={ + "commandClass": CommandClass.CONFIGURATION.value, + "property": property_, + }, + value=new_value, + ) + if response and not cast(bool, response["success"]): + raise SetValueFailed( + "Unable to set value, refer to " + "https://zwave-js.github.io/node-zwave-js/#/api/node?id=setvalue for " + "possible reasons" + ) + + +async def async_set_config_parameter( + node: Node, + new_value: Union[int, str], + property_or_property_name: Union[int, str], + property_key: Optional[Union[int, str]] = None, +) -> ConfigurationValue: + """ + Set a value for a config parameter on this node. + + new_value and property_ can be provided as labels, so we need to resolve them to + the appropriate key + """ + config_values = node.get_configuration_values() + + # If a property name is provided, we have to search for the correct value since + # we can't use value ID + if isinstance(property_or_property_name, str): + try: + zwave_value = next( + config_value + for config_value in config_values.values() + if config_value.property_name == property_or_property_name + ) + except StopIteration: + raise NotFoundError( + "Configuration parameter with parameter name " + f"{property_or_property_name} could not be found" + ) from None + else: + value_id = get_value_id( + node, + CommandClass.CONFIGURATION, + property_or_property_name, + endpoint=0, + property_key=property_key, + ) + + if value_id not in config_values: + raise NotFoundError( + f"Configuration parameter with value ID {value_id} could not be " + "found" + ) from None + zwave_value = config_values[value_id] + + new_value = _validate_and_transform_new_value(zwave_value, new_value) + # Finally attempt to set the value and return the Value object if successful if await node.async_set_value(zwave_value, new_value) is False: raise SetValueFailed(