diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index da8794e8048..adbdb10cf89 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -47,13 +47,13 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( + BITMASK_SCHEMA, CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, ) from .helpers import async_enable_statistics, update_data_collection_preference -from .services import BITMASK_SCHEMA DATA_UNSUBSCRIBE = "unsubs" diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index e4486a681e1..21a7941f097 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,6 +1,10 @@ """Constants for the Z-Wave JS integration.""" import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + CONF_ADDON_DEVICE = "device" CONF_ADDON_EMULATE_HARDWARE = "emulate_hardware" CONF_ADDON_LOG_LEVEL = "log_level" @@ -56,6 +60,8 @@ ATTR_CURRENT_VALUE_RAW = "current_value_raw" ATTR_DESCRIPTION = "description" # service constants +SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" +SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" SERVICE_SET_VALUE = "set_value" SERVICE_RESET_METER = "reset_meter" SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" @@ -98,3 +104,25 @@ ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" ENTITY_DESC_KEY_TIMESTAMP = "timestamp" ENTITY_DESC_KEY_MEASUREMENT = "measurement" ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" + +# Schema Constants + +# 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]+$", + msg="Must provide an integer (e.g. 255) or a bitmask in hex form (e.g. 0xff)", + ), + lambda value: int(value, 16), +) + +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + BITMASK_SCHEMA, + cv.string, +) diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py new file mode 100644 index 00000000000..14d64f87eb7 --- /dev/null +++ b/homeassistant/components/zwave_js/device_action.py @@ -0,0 +1,309 @@ +"""Provides device actions for Z-Wave JS.""" +from __future__ import annotations + +from collections import defaultdict +import re +from typing import Any + +import voluptuous as vol +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const.command_class.meter import CC_SPECIFIC_METER_TYPE +from zwave_js_server.model.value import get_value_id +from zwave_js_server.util.command_class.meter import get_meter_type + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER_BITMASK, + ATTR_ENDPOINT, + ATTR_METER_TYPE, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_REFRESH_ALL_VALUES, + ATTR_VALUE, + ATTR_WAIT_FOR_RESULT, + DOMAIN, + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_PING, + SERVICE_REFRESH_VALUE, + SERVICE_RESET_METER, + SERVICE_SET_CONFIG_PARAMETER, + SERVICE_SET_LOCK_USERCODE, + SERVICE_SET_VALUE, + VALUE_SCHEMA, +) +from .device_automation_helpers import ( + CONF_SUBTYPE, + VALUE_ID_REGEX, + get_config_parameter_value_schema, +) +from .helpers import async_get_node_from_device_id + +ACTION_TYPES = { + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_PING, + SERVICE_REFRESH_VALUE, + SERVICE_RESET_METER, + SERVICE_SET_CONFIG_PARAMETER, + SERVICE_SET_LOCK_USERCODE, + SERVICE_SET_VALUE, +} + +CLEAR_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_CLEAR_LOCK_USERCODE, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), + } +) + +PING_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_PING, + } +) + +REFRESH_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_REFRESH_VALUE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(ATTR_REFRESH_ALL_VALUES, default=False): cv.boolean, + } +) + +RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_RESET_METER, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(SENSOR_DOMAIN), + vol.Optional(ATTR_METER_TYPE): vol.Coerce(int), + vol.Optional(ATTR_VALUE): vol.Coerce(int), + } +) + +SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_CONFIG_PARAMETER, + vol.Required(ATTR_CONFIG_PARAMETER): vol.Any(int, str), + vol.Required(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(None, int, str), + vol.Required(ATTR_VALUE): vol.Coerce(int), + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +SET_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_LOCK_USERCODE, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), + vol.Required(ATTR_USERCODE): cv.string, + } +) + +SET_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_VALUE, + vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_PROPERTY): vol.Any(int, str), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(ATTR_VALUE): VALUE_SCHEMA, + vol.Optional(ATTR_WAIT_FOR_RESULT, default=False): cv.boolean, + } +) + +ACTION_SCHEMA = vol.Any( + CLEAR_LOCK_USERCODE_SCHEMA, + PING_SCHEMA, + REFRESH_VALUE_SCHEMA, + RESET_METER_SCHEMA, + SET_CONFIG_PARAMETER_SCHEMA, + SET_LOCK_USERCODE_SCHEMA, + SET_VALUE_SCHEMA, +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device actions for Z-Wave JS devices.""" + registry = entity_registry.async_get(hass) + actions = [] + + node = async_get_node_from_device_id(hass, device_id) + + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + + actions.extend( + [ + {**base_action, CONF_TYPE: SERVICE_SET_VALUE}, + {**base_action, CONF_TYPE: SERVICE_PING}, + ] + ) + actions.extend( + [ + { + **base_action, + CONF_TYPE: SERVICE_SET_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER: config_value.property_, + ATTR_CONFIG_PARAMETER_BITMASK: config_value.property_key, + CONF_SUBTYPE: f"{config_value.value_id} ({config_value.property_name})", + } + for config_value in node.get_configuration_values().values() + ] + ) + + meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) + + for entry in entity_registry.async_entries_for_device(registry, device_id): + entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} + actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) + if entry.domain == LOCK_DOMAIN: + actions.extend( + [ + {**entity_action, CONF_TYPE: SERVICE_SET_LOCK_USERCODE}, + {**entity_action, CONF_TYPE: SERVICE_CLEAR_LOCK_USERCODE}, + ] + ) + + if entry.domain == SENSOR_DOMAIN: + value_id = entry.unique_id.split(".")[1] + # If this unique ID doesn't have a value ID, we know it is the node status + # sensor which doesn't have any relevant actions + if re.match(VALUE_ID_REGEX, value_id): + value = node.values[value_id] + else: + continue + # If the value has the meterType CC specific value, we can add a reset_meter + # action for it + if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: + meter_endpoints[value.endpoint].setdefault( + CONF_ENTITY_ID, entry.entity_id + ) + meter_endpoints[value.endpoint].setdefault(ATTR_METER_TYPE, set()).add( + get_meter_type(value) + ) + + if not meter_endpoints: + return actions + + for endpoint, endpoint_data in meter_endpoints.items(): + base_action[CONF_ENTITY_ID] = endpoint_data[CONF_ENTITY_ID] + actions.append( + { + **base_action, + CONF_TYPE: SERVICE_RESET_METER, + CONF_SUBTYPE: f"Endpoint {endpoint} (All)", + } + ) + for meter_type in endpoint_data[ATTR_METER_TYPE]: + actions.append( + { + **base_action, + CONF_TYPE: SERVICE_RESET_METER, + ATTR_METER_TYPE: meter_type, + CONF_SUBTYPE: f"Endpoint {endpoint} ({meter_type.name})", + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Context | None +) -> None: + """Execute a device action.""" + action_type = service = config.pop(CONF_TYPE) + if action_type not in ACTION_TYPES: + raise HomeAssistantError(f"Unhandled action type {action_type}") + + service_data = {k: v for k, v in config.items() if v not in (None, "")} + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) + + +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List action capabilities.""" + action_type = config[CONF_TYPE] + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + + # Add additional fields to the automation action UI + if action_type == SERVICE_CLEAR_LOCK_USERCODE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_CODE_SLOT): cv.string, + } + ) + } + + if action_type == SERVICE_SET_LOCK_USERCODE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_CODE_SLOT): cv.string, + vol.Required(ATTR_USERCODE): cv.string, + } + ) + } + + if action_type == SERVICE_RESET_METER: + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_VALUE): cv.string, + } + ) + } + + if action_type == SERVICE_REFRESH_VALUE: + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_REFRESH_ALL_VALUES): cv.boolean, + } + ) + } + + if action_type == SERVICE_SET_VALUE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_COMMAND_CLASS): vol.In( + {cc.value: cc.name for cc in CommandClass} + ), + vol.Required(ATTR_PROPERTY): cv.string, + vol.Optional(ATTR_PROPERTY_KEY): cv.string, + vol.Optional(ATTR_ENDPOINT): cv.string, + vol.Required(ATTR_VALUE): cv.string, + vol.Optional(ATTR_WAIT_FOR_RESULT): cv.boolean, + } + ) + } + + if action_type == SERVICE_SET_CONFIG_PARAMETER: + value_id = get_value_id( + node, + CommandClass.CONFIGURATION, + config[ATTR_CONFIG_PARAMETER], + property_key=config[ATTR_CONFIG_PARAMETER_BITMASK], + ) + value_schema = get_config_parameter_value_schema(node, value_id) + if value_schema is None: + return {} + return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} + + return {} diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py new file mode 100644 index 00000000000..cfdb65a4b02 --- /dev/null +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -0,0 +1,34 @@ +"""Provides helpers for Z-Wave JS device automations.""" +from __future__ import annotations + +from typing import cast + +import voluptuous as vol +from zwave_js_server.const import ConfigurationValueType +from zwave_js_server.model.node import Node +from zwave_js_server.model.value import ConfigurationValue + +NODE_STATUSES = ["asleep", "awake", "dead", "alive"] + +CONF_SUBTYPE = "subtype" +CONF_VALUE_ID = "value_id" + +VALUE_ID_REGEX = r"([0-9]+-[0-9]+-[0-9]+-).+" + + +def get_config_parameter_value_schema(node: Node, value_id: str) -> vol.Schema | None: + """Get the extra fields schema for a config parameter value.""" + config_value = cast(ConfigurationValue, node.values[value_id]) + min_ = config_value.metadata.min + max_ = config_value.metadata.max + + if config_value.configuration_value_type in ( + ConfigurationValueType.RANGE, + ConfigurationValueType.MANUAL_ENTRY, + ): + return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_)) + + if config_value.configuration_value_type == ConfigurationValueType.ENUMERATED: + return vol.In({int(k): v for k, v in config_value.metadata.states.items()}) + + return None diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index f17654f184a..6694d88a135 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -14,7 +14,6 @@ from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN @@ -24,34 +23,37 @@ from .const import ( ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_VALUE, + VALUE_SCHEMA, +) +from .device_automation_helpers import ( + CONF_SUBTYPE, + CONF_VALUE_ID, + NODE_STATUSES, + get_config_parameter_value_schema, ) from .helpers import ( async_get_node_from_device_id, async_is_device_config_entry_not_loaded, check_type_schema_map, - get_value_state_schema, get_zwave_value_from_config, remove_keys_with_empty_values, ) -CONF_SUBTYPE = "subtype" -CONF_VALUE_ID = "value_id" CONF_STATUS = "status" NODE_STATUS_TYPE = "node_status" -NODE_STATUS_TYPES = ["asleep", "awake", "dead", "alive"] CONFIG_PARAMETER_TYPE = "config_parameter" VALUE_TYPE = "value" CONDITION_TYPES = {NODE_STATUS_TYPE, CONFIG_PARAMETER_TYPE, VALUE_TYPE} -NODE_STATUS_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +NODE_STATUS_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): NODE_STATUS_TYPE, - vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES), + vol.Required(CONF_STATUS): vol.In(NODE_STATUSES), } ) -CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +CONFIG_PARAMETER_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): CONFIG_PARAMETER_TYPE, vol.Required(CONF_VALUE_ID): cv.string, @@ -60,20 +62,14 @@ CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( } ) -VALUE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +VALUE_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): VALUE_TYPE, vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), - vol.Required(ATTR_VALUE): vol.Any( - bool, - vol.Coerce(int), - vol.Coerce(float), - cv.boolean, - cv.string, - ), + vol.Required(ATTR_VALUE): VALUE_SCHEMA, } ) @@ -204,10 +200,9 @@ async def async_get_condition_capabilities( # Add additional fields to the automation trigger UI if config[CONF_TYPE] == CONFIG_PARAMETER_TYPE: value_id = config[CONF_VALUE_ID] - value_schema = get_value_state_schema(node.values[value_id]) - if not value_schema: + value_schema = get_config_parameter_value_schema(node, value_id) + if value_schema is None: return {} - return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} if config[CONF_TYPE] == VALUE_TYPE: @@ -234,7 +229,7 @@ async def async_get_condition_capabilities( if config[CONF_TYPE] == NODE_STATUS_TYPE: return { "extra_fields": vol.Schema( - {vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES)} + {vol.Required(CONF_STATUS): vol.In(NODE_STATUSES)} ) } diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index d3deba0979a..11236697198 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -49,6 +49,7 @@ from .const import ( ZWAVE_JS_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ) +from .device_automation_helpers import CONF_SUBTYPE, NODE_STATUSES from .helpers import ( async_get_node_from_device_id, async_get_node_status_sensor_entity_id, @@ -65,8 +66,6 @@ from .triggers.value_updated import ( PLATFORM_TYPE as VALUE_UPDATED_PLATFORM_TYPE, ) -CONF_SUBTYPE = "subtype" - # Trigger types ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control" NOTIFICATION_NOTIFICATION = "event.notification.notification" @@ -153,8 +152,6 @@ BASE_STATE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( } ) -NODE_STATUSES = ["asleep", "awake", "dead", "alive"] - NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend( { vol.Required(CONF_TYPE): NODE_STATUS, diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 0f2a0862d7f..d70b6ef2009 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -25,7 +25,12 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import ( + DATA_CLIENT, + DOMAIN, + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_USERCODE, +) from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -42,9 +47,6 @@ STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { }, } -SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" -SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 9b165aada18..08841465321 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -59,27 +59,6 @@ def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: ) -# 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]+$", - msg="Must provide an integer (e.g. 255) or a bitmask in hex form (e.g. 0xff)", - ), - lambda value: int(value, 16), -) - -VALUE_SCHEMA = vol.Any( - bool, - vol.Coerce(int), - vol.Coerce(float), - BITMASK_SCHEMA, - cv.string, -) - - class ZWaveServices: """Class that holds our services (Zwave Commands) that should be published to hass.""" @@ -198,10 +177,10 @@ class ZWaveServices: vol.Coerce(int), cv.string ), vol.Optional(const.ATTR_CONFIG_PARAMETER_BITMASK): vol.Any( - vol.Coerce(int), BITMASK_SCHEMA + vol.Coerce(int), const.BITMASK_SCHEMA ), vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( - vol.Coerce(int), BITMASK_SCHEMA, cv.string + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string ), }, cv.has_at_least_one_key( @@ -232,8 +211,10 @@ class ZWaveServices: vol.Coerce(int), { vol.Any( - vol.Coerce(int), BITMASK_SCHEMA, cv.string - ): vol.Any(vol.Coerce(int), BITMASK_SCHEMA, cv.string) + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string + ): vol.Any( + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string + ) }, ), }, @@ -284,9 +265,11 @@ class ZWaveServices: vol.Coerce(int), str ), vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), - vol.Required(const.ATTR_VALUE): VALUE_SCHEMA, + vol.Required(const.ATTR_VALUE): const.VALUE_SCHEMA, vol.Optional(const.ATTR_WAIT_FOR_RESULT): cv.boolean, - vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, + vol.Optional(const.ATTR_OPTIONS): { + cv.string: const.VALUE_SCHEMA + }, }, cv.has_at_least_one_key( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID @@ -319,8 +302,10 @@ class ZWaveServices: vol.Coerce(int), str ), vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), - vol.Required(const.ATTR_VALUE): VALUE_SCHEMA, - vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, + vol.Required(const.ATTR_VALUE): const.VALUE_SCHEMA, + vol.Optional(const.ATTR_OPTIONS): { + cv.string: const.VALUE_SCHEMA + }, }, vol.Any( cv.has_at_least_one_key( diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index d0bdec1a80c..75c1ea76e9d 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -67,7 +67,9 @@ "on_supervisor": { "title": "Select connection method", "description": "Do you want to use the Z-Wave JS Supervisor add-on?", - "data": { "use_addon": "Use the Z-Wave JS Supervisor add-on" } + "data": { + "use_addon": "Use the Z-Wave JS Supervisor add-on" + } }, "install_addon": { "title": "The Z-Wave JS add-on installation has started" @@ -81,7 +83,9 @@ "emulate_hardware": "Emulate Hardware" } }, - "start_addon": { "title": "The Z-Wave JS add-on is starting." } + "start_addon": { + "title": "The Z-Wave JS add-on is starting." + } }, "error": { "invalid_ws_url": "Invalid websocket URL", @@ -118,6 +122,15 @@ "node_status": "Node status", "config_parameter": "Config parameter {subtype} value", "value": "Current value of a Z-Wave Value" + }, + "action_type": { + "clear_lock_usercode": "Clear usercode on {entity_name}", + "set_lock_usercode": "Set a usercode on {entity_name}", + "set_config_parameter": "Set value of config parameter {subtype}", + "set_value": "Set value of a Z-Wave Value", + "refresh_value": "Refresh the value(s) for {entity_name}", + "ping": "Ping device", + "reset_meter": "Reset meters on {subtype}" } } } diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 8ba33702d1d..abe37c4da04 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -58,6 +58,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Clear usercode on {entity_name}", + "ping": "Ping device", + "refresh_value": "Refresh the value(s) for {entity_name}", + "reset_meter": "Reset meters on {subtype}", + "set_config_parameter": "Set value of config parameter {subtype}", + "set_lock_usercode": "Set a usercode on {entity_name}", + "set_value": "Set value of a Z-Wave Value" + }, "condition_type": { "config_parameter": "Config parameter {subtype} value", "node_status": "Node status", diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py new file mode 100644 index 00000000000..65bc8e4bddb --- /dev/null +++ b/tests/components/zwave_js/test_device_action.py @@ -0,0 +1,457 @@ +"""The tests for Z-Wave JS device actions.""" +import pytest +import voluptuous_serialize +from zwave_js_server.client import Client +from zwave_js_server.const import CommandClass +from zwave_js_server.model.node import Node + +from homeassistant.components import automation +from homeassistant.components.zwave_js import DOMAIN, device_action +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.setup import async_setup_component + +from tests.common import async_get_device_automations, async_mock_service + + +async def test_get_actions( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected actions from a zwave_js node.""" + node = lock_schlage_be469 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + expected_actions = [ + { + "domain": DOMAIN, + "type": "clear_lock_usercode", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "set_lock_usercode", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "refresh_value", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "set_value", + "device_id": device.id, + }, + { + "domain": DOMAIN, + "type": "ping", + "device_id": device.id, + }, + { + "domain": DOMAIN, + "type": "set_config_parameter", + "device_id": device.id, + "parameter": 3, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + }, + ] + actions = await async_get_device_automations(hass, "action", device.id) + for action in expected_actions: + assert action in actions + + +async def test_get_actions_meter( + hass: HomeAssistant, + client: Client, + aeon_smart_switch_6: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected meter actions from a zwave_js node.""" + node = aeon_smart_switch_6 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + actions = await async_get_device_automations(hass, "action", device.id) + filtered_actions = [action for action in actions if action["type"] == "reset_meter"] + assert len(filtered_actions) > 0 + + +async def test_action(hass: HomeAssistant) -> None: + """Test for turn_on and turn_off actions.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_clear_lock_usercode", + }, + "action": { + "domain": DOMAIN, + "type": "clear_lock_usercode", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + "code_slot": 1, + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_lock_usercode", + }, + "action": { + "domain": DOMAIN, + "type": "set_lock_usercode", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + "code_slot": 1, + "usercode": "1234", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_refresh_value", + }, + "action": { + "domain": DOMAIN, + "type": "refresh_value", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_ping", + }, + "action": { + "domain": DOMAIN, + "type": "ping", + "device_id": "fake", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_value", + }, + "action": { + "domain": DOMAIN, + "type": "set_value", + "device_id": "fake", + "command_class": 112, + "property": "test", + "value": 1, + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_config_parameter", + }, + "action": { + "domain": DOMAIN, + "type": "set_config_parameter", + "device_id": "fake", + "parameter": 3, + "bitmask": None, + "subtype": "2-112-0-3 (Beeper)", + "value": 255, + }, + }, + ] + }, + ) + + clear_lock_usercode = async_mock_service(hass, "zwave_js", "clear_lock_usercode") + hass.bus.async_fire("test_event_clear_lock_usercode") + await hass.async_block_till_done() + assert len(clear_lock_usercode) == 1 + + set_lock_usercode = async_mock_service(hass, "zwave_js", "set_lock_usercode") + hass.bus.async_fire("test_event_set_lock_usercode") + await hass.async_block_till_done() + assert len(set_lock_usercode) == 1 + + refresh_value = async_mock_service(hass, "zwave_js", "refresh_value") + hass.bus.async_fire("test_event_refresh_value") + await hass.async_block_till_done() + assert len(refresh_value) == 1 + + ping = async_mock_service(hass, "zwave_js", "ping") + hass.bus.async_fire("test_event_ping") + await hass.async_block_till_done() + assert len(ping) == 1 + + set_value = async_mock_service(hass, "zwave_js", "set_value") + hass.bus.async_fire("test_event_set_value") + await hass.async_block_till_done() + assert len(set_value) == 1 + + set_config_parameter = async_mock_service(hass, "zwave_js", "set_config_parameter") + hass.bus.async_fire("test_event_set_config_parameter") + await hass.async_block_till_done() + assert len(set_config_parameter) == 1 + + +async def test_get_action_capabilities( + hass: HomeAssistant, + client: Client, + climate_radio_thermostat_ct100_plus: Node, + integration: ConfigEntry, +): + """Test we get the expected action capabilities.""" + node = climate_radio_thermostat_ct100_plus + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + # Test refresh_value + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "refresh_value", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "boolean", "name": "refresh_all_values", "optional": True}] + + # Test ping + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "ping", + }, + ) + assert not capabilities + + # Test set_value + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_value", + }, + ) + assert capabilities and "extra_fields" in capabilities + + cc_options = [(cc.value, cc.name) for cc in CommandClass] + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "command_class", + "required": True, + "options": cc_options, + "type": "select", + }, + {"name": "property", "required": True, "type": "string"}, + {"name": "property_key", "optional": True, "type": "string"}, + {"name": "endpoint", "optional": True, "type": "string"}, + {"name": "value", "required": True, "type": "string"}, + {"type": "boolean", "name": "wait_for_result", "optional": True}, + ] + + # Test enumerated type param + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 1, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-1 (Temperature Reporting Threshold)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "required": True, + "options": [ + (0, "Disabled"), + (1, "0.5° F"), + (2, "1.0° F"), + (3, "1.5° F"), + (4, "2.0° F"), + ], + "type": "select", + } + ] + + # Test range type param + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 10, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-10 (Temperature Reporting Filter)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "required": True, + "type": "integer", + "valueMin": 0, + "valueMax": 124, + } + ] + + # Test undefined type param + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 2, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-2 (HVAC Settings)", + }, + ) + assert not capabilities + + +async def test_get_action_capabilities_lock_triggers( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +): + """Test we get the expected action capabilities for lock triggers.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + # Test clear_lock_usercode + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + "type": "clear_lock_usercode", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "string", "name": "code_slot", "required": True}] + + # Test set_lock_usercode + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + "type": "set_lock_usercode", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + {"type": "string", "name": "code_slot", "required": True}, + {"type": "string", "name": "usercode", "required": True}, + ] + + +async def test_get_action_capabilities_meter_triggers( + hass: HomeAssistant, + client: Client, + aeon_smart_switch_6: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected action capabilities for meter triggers.""" + node = aeon_smart_switch_6 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "sensor.meter", + "type": "reset_meter", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "string", "name": "value", "optional": True}] + + +async def test_failure_scenarios( + hass: HomeAssistant, + client: Client, + hank_binary_switch: Node, + integration: ConfigEntry, +): + """Test failure scenarios.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + with pytest.raises(HomeAssistantError): + await device_action.async_call_action_from_config( + hass, {"type": "failed.test", "device_id": device.id}, {}, None + ) + + assert ( + await device_action.async_get_action_capabilities( + hass, {"type": "failed.test", "device_id": device.id} + ) + == {} + )