diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 530a8022233042..dbb7c6cc114359 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -43,7 +43,8 @@ ZWAVE_JS_EVENT, ) from .discovery import async_discover_values -from .entity import get_device_id +from .helpers import get_device_id +from .services import ZWaveServices LOGGER = logging.getLogger(__package__) CONNECT_TIMEOUT = 10 @@ -192,6 +193,9 @@ def async_on_notification(notification: Notification) -> None: DATA_UNSUBSCRIBE: unsubscribe_callbacks, } + services = ZWaveServices(hass) + services.async_register() + # Set up websocket API async_register_api(hass) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 5eb537c0d4ee19..1031a51719ac86 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -33,4 +33,11 @@ ATTR_PROPERTY_KEY_NAME = "property_key_name" ATTR_PARAMETERS = "parameters" +# service constants +SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" + +ATTR_CONFIG_PARAMETER = "parameter" +ATTR_CONFIG_PARAMETER_BITMASK = "bitmask" +ATTR_CONFIG_VALUE = "value" + ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 3e81bfaeadf878..3141dd0caea4c2 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,30 +1,23 @@ """Generic Z-Wave Entity Class.""" import logging -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Union from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue, get_value_id from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo +from .helpers import get_device_id LOGGER = logging.getLogger(__name__) EVENT_VALUE_UPDATED = "value updated" -@callback -def get_device_id(client: ZwaveClient, node: ZwaveNode) -> Tuple[str, str]: - """Get device registry identifier for Z-Wave node.""" - return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}") - - class ZWaveBaseEntity(Entity): """Generic Entity Class for a Z-Wave Device.""" diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py new file mode 100644 index 00000000000000..cc00c39b747ac0 --- /dev/null +++ b/homeassistant/components/zwave_js/helpers.py @@ -0,0 +1,100 @@ +"""Helper functions for Z-Wave JS integration.""" +from typing import List, Tuple, cast + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.model.node import Node as ZwaveNode + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import async_get as async_get_dev_reg +from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg + +from .const import DATA_CLIENT, DOMAIN + + +@callback +def get_device_id(client: ZwaveClient, node: ZwaveNode) -> Tuple[str, str]: + """Get device registry identifier for Z-Wave node.""" + return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}") + + +@callback +def get_home_and_node_id_from_device_id(device_id: Tuple[str, str]) -> List[str]: + """ + Get home ID and node ID for Z-Wave device registry entry. + + Returns [home_id, node_id] + """ + return device_id[1].split("-") + + +@callback +def async_get_node_from_device_id(hass: HomeAssistant, device_id: str) -> ZwaveNode: + """ + Get node from a device ID. + + Raises ValueError if device is invalid or node can't be found. + """ + device_entry = async_get_dev_reg(hass).async_get(device_id) + + if not device_entry: + raise ValueError("Device ID is not valid") + + # Use device config entry ID's to validate that this is a valid zwave_js device + # and to get the client + config_entry_ids = device_entry.config_entries + config_entry_id = next( + ( + config_entry_id + for config_entry_id in config_entry_ids + if cast( + ConfigEntry, + hass.config_entries.async_get_entry(config_entry_id), + ).domain + == DOMAIN + ), + None, + ) + if config_entry_id is None or config_entry_id not in hass.data[DOMAIN]: + raise ValueError("Device is not from an existing zwave_js config entry") + + client = hass.data[DOMAIN][config_entry_id][DATA_CLIENT] + + # Get node ID from device identifier, perform some validation, and then get the + # node + identifier = next( + ( + get_home_and_node_id_from_device_id(identifier) + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + + node_id = int(identifier[1]) if identifier is not None else None + + if node_id is None or node_id not in client.driver.controller.nodes: + raise ValueError("Device node can't be found") + + return client.driver.controller.nodes[node_id] + + +@callback +def async_get_node_from_entity_id(hass: HomeAssistant, entity_id: str) -> ZwaveNode: + """ + Get node from an entity ID. + + Raises ValueError if entity is invalid. + """ + entity_entry = async_get_ent_reg(hass).async_get(entity_id) + + if not entity_entry: + raise ValueError("Entity ID is not valid") + + if entity_entry.platform != DOMAIN: + raise ValueError("Entity is not from zwave_js integration") + + # Assert for mypy, safe because we know that zwave_js entities are always + # tied to a device + assert entity_entry.device_id + return async_get_node_from_device_id(hass, entity_entry.device_id) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py new file mode 100644 index 00000000000000..da60ddab6662dd --- /dev/null +++ b/homeassistant/components/zwave_js/services.py @@ -0,0 +1,110 @@ +"""Methods and classes related to executing Z-Wave commands and publishing these to hass.""" + +import logging +from typing import Dict, Set, Union + +import voluptuous as vol +from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.util.node import async_set_config_parameter + +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +import homeassistant.helpers.config_validation as cv + +from . import const +from .helpers import async_get_node_from_device_id, async_get_node_from_entity_id + +_LOGGER = logging.getLogger(__name__) + + +def parameter_name_does_not_need_bitmask( + val: Dict[str, Union[int, str]] +) -> Dict[str, Union[int, str]]: + """Validate that if a parameter name is provided, bitmask is not as well.""" + if isinstance(val[const.ATTR_CONFIG_PARAMETER], str) and ( + val.get(const.ATTR_CONFIG_PARAMETER_BITMASK) + ): + raise vol.Invalid( + "Don't include a bitmask when a parameter name is specified", + path=[const.ATTR_CONFIG_PARAMETER, const.ATTR_CONFIG_PARAMETER_BITMASK], + ) + return val + + +# Validates that a bitmask is provided in hex form and converts it to decimal +# int equivalent since that's what the library uses +BITMASK_SCHEMA = vol.All( + cv.string, vol.Lower, vol.Match(r"^(0x)?[0-9a-f]+$"), lambda value: int(value, 16) +) + + +class ZWaveServices: + """Class that holds our services (Zwave Commands) that should be published to hass.""" + + def __init__(self, hass: HomeAssistant): + """Initialize with hass object.""" + self._hass = hass + + @callback + def async_register(self) -> None: + """Register all our services.""" + self._hass.services.async_register( + const.DOMAIN, + const.SERVICE_SET_CONFIG_PARAMETER, + self.async_set_config_parameter, + schema=vol.All( + { + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Any( + vol.Coerce(int), cv.string + ), + vol.Optional(const.ATTR_CONFIG_PARAMETER_BITMASK): vol.Any( + vol.Coerce(int), BITMASK_SCHEMA + ), + vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( + vol.Coerce(int), cv.string + ), + }, + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + parameter_name_does_not_need_bitmask, + ), + ) + + async def async_set_config_parameter(self, service: ServiceCall) -> None: + """Set a config 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] + } + 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] + + for node in nodes: + zwave_value = await async_set_config_parameter( + node, + new_value, + property_or_property_name, + property_key=property_key, + ) + + if zwave_value: + _LOGGER.info( + "Set configuration parameter %s on Node %s with value %s", + zwave_value, + node, + new_value, + ) + else: + raise ValueError( + f"Unable to set configuration parameter on Node {node} with " + f"value {new_value}" + ) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index d2f1c75b64e1af..a5e9efd721635f 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -38,3 +38,31 @@ set_lock_usercode: example: 1234 selector: text: + +set_config_parameter: + name: Set a Z-Wave device configuration parameter + description: Allow for changing configuration parameters of your Z-Wave devices. + target: + entity: + integration: zwave_js + fields: + parameter: + name: Parameter + description: The (name or id of the) configuration parameter you want to configure. + example: Minimum brightness level + required: true + selector: + text: + value: + name: Value + description: The new value to set for this configuration parameter. + example: 5 + required: true + selector: + object: + bitmask: + name: Bitmask + description: Target a specific bitmask (see the documentation for more information). + advanced: true + selector: + object: diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 1aad07400ad87d..4f55a26d690faa 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -7,7 +7,7 @@ from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.zwave_js.const import DOMAIN -from homeassistant.components.zwave_js.entity import get_device_id +from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ( CONN_CLASS_LOCAL_PUSH, ENTRY_STATE_LOADED, diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py new file mode 100644 index 00000000000000..b085d9e32fb641 --- /dev/null +++ b/tests/components/zwave_js/test_services.py @@ -0,0 +1,295 @@ +"""Test the Z-Wave JS services.""" +import pytest +import voluptuous as vol + +from homeassistant.components.zwave_js.const import ( + ATTR_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER_BITMASK, + ATTR_CONFIG_VALUE, + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.helpers.device_registry import async_get as async_get_dev_reg +from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg + +from .common import AIR_TEMPERATURE_SENSOR + +from tests.common import MockConfigEntry + + +async def test_set_config_parameter(hass, client, multisensor_6, integration): + """Test the set_config_parameter service.""" + dev_reg = async_get_dev_reg(hass) + ent_reg = async_get_ent_reg(hass) + entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) + + # Test setting config parameter by property and property_key + 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: 1, + }, + 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"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + # Test setting parameter by property name + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: "Group 2: Send battery reports", + ATTR_CONFIG_VALUE: 1, + }, + 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"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + # Test setting parameter by property name and state label + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: entity_entry.device_id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + 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"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 41, + "propertyName": "Temperature Threshold (Unit)", + "propertyKey": 15, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 3, + "min": 1, + "max": 2, + "default": 1, + "format": 0, + "allowManualEntry": False, + "states": {"1": "Celsius", "2": "Fahrenheit"}, + "label": "Temperature Threshold (Unit)", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + + # Test setting parameter by property and bitmask + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: "0x01", + ATTR_CONFIG_VALUE: 1, + }, + 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"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + # Test that an invalid entity ID raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: "sensor.fake_entity", + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + # Test that an invalid device ID raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: "fake_device_id", + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + # Test that we can't include a bitmask value if parameter is a string + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: entity_entry.device_id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + non_zwave_js_config_entry = MockConfigEntry(entry_id="fake_entry_id") + non_zwave_js_config_entry.add_to_hass(hass) + non_zwave_js_device = dev_reg.async_get_or_create( + config_entry_id=non_zwave_js_config_entry.entry_id, + identifiers={("test", "test")}, + ) + + # Test that a non Z-Wave JS device raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: non_zwave_js_device.id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + zwave_js_device_with_invalid_node_id = dev_reg.async_get_or_create( + config_entry_id=integration.entry_id, identifiers={(DOMAIN, "500-500")} + ) + + # Test that a Z-Wave JS device with an invalid node ID raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_DEVICE_ID: zwave_js_device_with_invalid_node_id.id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + non_zwave_js_entity = ent_reg.async_get_or_create( + "test", + "sensor", + "test_sensor", + suggested_object_id="test_sensor", + config_entry=non_zwave_js_config_entry, + ) + + # Test that a non Z-Wave JS entity raises a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: non_zwave_js_entity.entity_id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + )