From 2be50eb5b46c0eb8425c828fac3f58d606ed56a9 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 21 Aug 2021 00:09:52 -0400 Subject: [PATCH] Add zwave_js device triggers for any zwave value (#54958) * Add zwave_js device triggers for any zwave value * translations * Validate value --- .../components/zwave_js/device_trigger.py | 184 +++++++-- .../components/zwave_js/strings.json | 2 + .../components/zwave_js/translations/en.json | 7 +- .../zwave_js/test_device_trigger.py | 351 ++++++++++++++++++ 4 files changed, 520 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 6d1b611d14f..d2f7c18ca78 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -2,7 +2,7 @@ from __future__ import annotations import voluptuous as vol -from zwave_js_server.const import CommandClass +from zwave_js_server.const import CommandClass, ConfigurationValueType from homeassistant.components.automation import AutomationActionType from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -23,6 +23,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType +from . import trigger from .const import ( ATTR_COMMAND_CLASS, ATTR_DATA_TYPE, @@ -45,6 +46,7 @@ from .helpers import ( async_get_node_status_sensor_entity_id, get_zwave_value_from_config, ) +from .triggers.value_updated import ATTR_FROM, ATTR_TO CONF_SUBTYPE = "subtype" CONF_VALUE_ID = "value_id" @@ -55,8 +57,18 @@ NOTIFICATION_NOTIFICATION = "event.notification.notification" BASIC_VALUE_NOTIFICATION = "event.value_notification.basic" CENTRAL_SCENE_VALUE_NOTIFICATION = "event.value_notification.central_scene" SCENE_ACTIVATION_VALUE_NOTIFICATION = "event.value_notification.scene_activation" +CONFIG_PARAMETER_VALUE_UPDATED = f"{DOMAIN}.value_updated.config_parameter" +VALUE_VALUE_UPDATED = f"{DOMAIN}.value_updated.value" NODE_STATUS = "state.node_status" +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + cv.boolean, + cv.string, +) + NOTIFICATION_EVENT_CC_MAPPINGS = ( (ENTRY_CONTROL_NOTIFICATION, CommandClass.ENTRY_CONTROL), (NOTIFICATION_NOTIFICATION, CommandClass.NOTIFICATION), @@ -135,12 +147,39 @@ NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend( } ) +# zwave_js.value_updated based trigger schemas +BASE_VALUE_UPDATED_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + 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(None, vol.Coerce(int), str), + vol.Optional(ATTR_ENDPOINT): vol.Any(None, vol.Coerce(int)), + vol.Optional(ATTR_FROM): VALUE_SCHEMA, + vol.Optional(ATTR_TO): VALUE_SCHEMA, + } +) + +CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA = BASE_VALUE_UPDATED_SCHEMA.extend( + { + vol.Required(CONF_TYPE): CONFIG_PARAMETER_VALUE_UPDATED, + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +VALUE_VALUE_UPDATED_SCHEMA = BASE_VALUE_UPDATED_SCHEMA.extend( + { + vol.Required(CONF_TYPE): VALUE_VALUE_UPDATED, + } +) + TRIGGER_SCHEMA = vol.Any( ENTRY_CONTROL_NOTIFICATION_SCHEMA, NOTIFICATION_NOTIFICATION_SCHEMA, BASIC_VALUE_NOTIFICATION_SCHEMA, CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA, SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA, + CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA, + VALUE_VALUE_UPDATED_SCHEMA, NODE_STATUS_SCHEMA, ) @@ -233,6 +272,25 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: ] ) + # Generic value update event trigger + triggers.append({**base_trigger, CONF_TYPE: VALUE_VALUE_UPDATED}) + + # Config parameter value update event triggers + triggers.extend( + [ + { + **base_trigger, + CONF_TYPE: CONFIG_PARAMETER_VALUE_UPDATED, + ATTR_PROPERTY: config_value.property_, + ATTR_PROPERTY_KEY: config_value.property_key, + ATTR_ENDPOINT: config_value.endpoint, + ATTR_COMMAND_CLASS: config_value.command_class, + CONF_SUBTYPE: f"{config_value.value_id} ({config_value.property_name})", + } + for config_value in node.get_configuration_values().values() + ] + ) + return triggers @@ -253,20 +311,25 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] - trigger_platform = trigger_type.split(".")[0] - - event_data = {CONF_DEVICE_ID: config[CONF_DEVICE_ID]} - event_config = { - event.CONF_PLATFORM: "event", - event.CONF_EVENT_DATA: event_data, - } - - if ATTR_COMMAND_CLASS in config: - event_data[ATTR_COMMAND_CLASS] = config[ATTR_COMMAND_CLASS] + trigger_split = trigger_type.split(".") + # Our convention for trigger types is to have the trigger type at the beginning + # delimited by a `.`. For zwave_js triggers, there is a `.` in the name + trigger_platform = trigger_split[0] + if trigger_platform == DOMAIN: + trigger_platform = ".".join(trigger_split[:2]) # Take input data from automation trigger UI and add it to the trigger we are # attaching to if trigger_platform == "event": + event_data = {CONF_DEVICE_ID: config[CONF_DEVICE_ID]} + event_config = { + event.CONF_PLATFORM: "event", + event.CONF_EVENT_DATA: event_data, + } + + if ATTR_COMMAND_CLASS in config: + event_data[ATTR_COMMAND_CLASS] = config[ATTR_COMMAND_CLASS] + if trigger_type == ENTRY_CONTROL_NOTIFICATION: event_config[event.CONF_EVENT_TYPE] = ZWAVE_JS_NOTIFICATION_EVENT copy_available_params(config, event_data, [ATTR_EVENT_TYPE, ATTR_DATA_TYPE]) @@ -296,19 +359,53 @@ async def async_attach_trigger( hass, event_config, action, automation_info, platform_type="device" ) - state_config = {state.CONF_PLATFORM: "state"} + if trigger_platform == "state": + if trigger_type == NODE_STATUS: + state_config = {state.CONF_PLATFORM: "state"} - if trigger_platform == "state" and trigger_type == NODE_STATUS: - state_config[state.CONF_ENTITY_ID] = config[CONF_ENTITY_ID] - copy_available_params( - config, state_config, [state.CONF_FOR, state.CONF_FROM, state.CONF_TO] - ) + state_config[state.CONF_ENTITY_ID] = config[CONF_ENTITY_ID] + copy_available_params( + config, state_config, [state.CONF_FOR, state.CONF_FROM, state.CONF_TO] + ) + else: + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") state_config = state.TRIGGER_SCHEMA(state_config) return await state.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) + if trigger_platform == f"{DOMAIN}.value_updated": + # Try to get the value to make sure the value ID is valid + try: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + except (ValueError, vol.Invalid) as err: + raise HomeAssistantError("Invalid value specified") from err + + zwave_js_config = { + state.CONF_PLATFORM: trigger_platform, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + copy_available_params( + config, + zwave_js_config, + [ + ATTR_COMMAND_CLASS, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_ENDPOINT, + ATTR_FROM, + ATTR_TO, + ], + ) + zwave_js_config = await trigger.async_validate_trigger_config( + hass, zwave_js_config + ) + return await trigger.async_attach_trigger( + hass, zwave_js_config, action, automation_info + ) + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") @@ -316,12 +413,14 @@ async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List trigger capabilities.""" + trigger_type = config[CONF_TYPE] + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) value = ( get_zwave_value_from_config(node, config) if ATTR_PROPERTY in config else None ) # Add additional fields to the automation trigger UI - if config[CONF_TYPE] == NOTIFICATION_NOTIFICATION: + if trigger_type == NOTIFICATION_NOTIFICATION: return { "extra_fields": vol.Schema( { @@ -333,7 +432,7 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] == ENTRY_CONTROL_NOTIFICATION: + if trigger_type == ENTRY_CONTROL_NOTIFICATION: return { "extra_fields": vol.Schema( { @@ -343,7 +442,7 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] == NODE_STATUS: + if trigger_type == NODE_STATUS: return { "extra_fields": vol.Schema( { @@ -354,7 +453,7 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] in ( + if trigger_type in ( BASIC_VALUE_NOTIFICATION, CENTRAL_SCENE_VALUE_NOTIFICATION, SCENE_ACTIVATION_VALUE_NOTIFICATION, @@ -369,4 +468,47 @@ async def async_get_trigger_capabilities( return {"extra_fields": vol.Schema({vol.Optional(ATTR_VALUE): value_schema})} + if trigger_type == CONFIG_PARAMETER_VALUE_UPDATED: + # We can be more deliberate about the config parameter schema here because + # there are a limited number of types + if value.configuration_value_type == ConfigurationValueType.UNDEFINED: + return {} + if value.configuration_value_type == ConfigurationValueType.ENUMERATED: + value_schema = vol.In({int(k): v for k, v in value.metadata.states.items()}) + else: + value_schema = vol.All( + vol.Coerce(int), + vol.Range(min=value.metadata.min, max=value.metadata.max), + ) + return { + "extra_fields": vol.Schema( + { + vol.Optional(state.CONF_FROM): value_schema, + vol.Optional(state.CONF_TO): value_schema, + } + ) + } + + if trigger_type == VALUE_VALUE_UPDATED: + # Only show command classes on this node and exclude Configuration CC since it + # is already covered + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_COMMAND_CLASS): vol.In( + { + CommandClass(cc.id).value: CommandClass(cc.id).name + for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] + if cc.id != CommandClass.CONFIGURATION + } + ), + vol.Required(ATTR_PROPERTY): cv.string, + vol.Optional(ATTR_PROPERTY_KEY): cv.string, + vol.Optional(ATTR_ENDPOINT): cv.string, + vol.Optional(state.CONF_FROM): cv.string, + vol.Optional(state.CONF_TO): cv.string, + } + ) + } + return {} diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 628451a6215..cc5e241c09e 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -104,6 +104,8 @@ "event.value_notification.basic": "Basic CC event on {subtype}", "event.value_notification.central_scene": "Central Scene action on {subtype}", "event.value_notification.scene_activation": "Scene Activation on {subtype}", + "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", + "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value", "state.node_status": "Node status changed" }, "condition_type": { diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index b742a011d19..7e366724f40 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -63,7 +63,9 @@ "event.value_notification.basic": "Basic CC event on {subtype}", "event.value_notification.central_scene": "Central Scene action on {subtype}", "event.value_notification.scene_activation": "Scene Activation on {subtype}", - "state.node_status": "Node status changed" + "state.node_status": "Node status changed", + "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", + "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value" } }, "options": { @@ -115,6 +117,5 @@ "title": "The Z-Wave JS add-on is starting." } } - }, - "title": "Z-Wave JS" + } } \ No newline at end of file diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 86e053a5882..f593bb406e8 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -951,6 +951,333 @@ async def test_get_trigger_capabilities_scene_activation_value_notification( ] +async def test_get_value_updated_value_triggers( + hass, client, lock_schlage_be469, integration +): + """Test we get the zwave_js.value_updated.value trigger from a zwave_js device.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "zwave_js.value_updated.value", + "device_id": device.id, + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_value_updated_value_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for zwave_js.value_updated.value trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "property_key": None, + "endpoint": None, + "from": "open", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "zwave_js.value_updated.value - " + "{{ trigger.platform}} - " + "{{ trigger.previous_value }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake value update that shouldn't trigger + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "insideHandlesCanOpenDoor", + "newValue": [True, False, False, False], + "prevValue": [False, False, False, False], + "propertyName": "insideHandlesCanOpenDoor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Publish fake value update that should trigger + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "closed", + "prevValue": "open", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "zwave_js.value_updated.value - zwave_js.value_updated - open" + ) + + +async def test_get_trigger_capabilities_value_updated_value( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a zwave_js.value_updated.value trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "property_key": None, + "endpoint": None, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "command_class", + "required": True, + "type": "select", + "options": [ + (133, "ASSOCIATION"), + (128, "BATTERY"), + (98, "DOOR_LOCK"), + (122, "FIRMWARE_UPDATE_MD"), + (114, "MANUFACTURER_SPECIFIC"), + (113, "NOTIFICATION"), + (152, "SECURITY"), + (99, "USER_CODE"), + (134, "VERSION"), + ], + }, + {"name": "property", "required": True, "type": "string"}, + {"name": "property_key", "optional": True, "type": "string"}, + {"name": "endpoint", "optional": True, "type": "string"}, + {"name": "from", "optional": True, "type": "string"}, + {"name": "to", "optional": True, "type": "string"}, + ] + + +async def test_get_value_updated_config_parameter_triggers( + hass, client, lock_schlage_be469, integration +): + """Test we get the zwave_js.value_updated.config_parameter trigger from a zwave_js device.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "zwave_js.value_updated.config_parameter", + "device_id": device.id, + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_value_updated_config_parameter_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for zwave_js.value_updated.config_parameter trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + "from": 255, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "zwave_js.value_updated.config_parameter - " + "{{ trigger.platform}} - " + "{{ trigger.previous_value_raw }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake value update + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 3, + "newValue": 0, + "prevValue": 255, + "propertyName": "Beeper", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "zwave_js.value_updated.config_parameter - zwave_js.value_updated - 255" + ) + + +async def test_get_trigger_capabilities_value_updated_config_parameter_range( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a range zwave_js.value_updated.config_parameter trigger.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 6, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-6 (User Slot Status)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "valueMin": 0, + "valueMax": 255, + "type": "integer", + }, + { + "name": "to", + "optional": True, + "valueMin": 0, + "valueMax": 255, + "type": "integer", + }, + ] + + +async def test_get_trigger_capabilities_value_updated_config_parameter_enumerated( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from an enumerated zwave_js.value_updated.config_parameter trigger.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "options": [(0, "Disable Beeper"), (255, "Enable Beeper")], + "type": "select", + }, + { + "name": "to", + "optional": True, + "options": [(0, "Disable Beeper"), (255, "Enable Beeper")], + "type": "select", + }, + ] + + async def test_failure_scenarios(hass, client, hank_binary_switch, integration): """Test failure scenarios.""" with pytest.raises(HomeAssistantError): @@ -982,6 +1309,30 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): {}, ) + with pytest.raises(HomeAssistantError): + await async_attach_trigger( + hass, + {"type": "state.failed_type", "device_id": device.id}, + None, + {}, + ) + + with pytest.raises(HomeAssistantError): + await async_attach_trigger( + hass, + { + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": -1234, + "property_key": None, + "endpoint": None, + "from": "open", + }, + None, + {}, + ) + with patch( "homeassistant.components.zwave_js.device_trigger.async_get_node_from_device_id", return_value=None,