From 63f6a3b46b0fba9d2365749c6d715050cb277e07 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Aug 2021 15:21:55 -0400 Subject: [PATCH] Add zwave_js.value_updated automation trigger (#54897) * Add zwave_js automation trigger * Rename to align with zwave-js api * Improve test coverage * Add additional template variables * Support states values in addition to keys when present * remove entity ID from trigger payload * comments and order * Add init and dynamically define platform_type * reduce mypy ignores * pylint * pylint * review * use module map --- homeassistant/components/zwave_js/const.py | 7 + homeassistant/components/zwave_js/trigger.py | 54 ++++ .../components/zwave_js/triggers/__init__.py | 1 + .../zwave_js/triggers/value_updated.py | 193 ++++++++++++ tests/components/zwave_js/test_trigger.py | 276 ++++++++++++++++++ 5 files changed, 531 insertions(+) create mode 100644 homeassistant/components/zwave_js/trigger.py create mode 100644 homeassistant/components/zwave_js/triggers/__init__.py create mode 100644 homeassistant/components/zwave_js/triggers/value_updated.py create mode 100644 tests/components/zwave_js/test_trigger.py diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 7848af146b5..4a311012690 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -48,6 +48,13 @@ ATTR_OPTIONS = "options" ATTR_NODE = "node" ATTR_ZWAVE_VALUE = "zwave_value" +# automation trigger attributes +ATTR_PREVIOUS_VALUE = "previous_value" +ATTR_PREVIOUS_VALUE_RAW = "previous_value_raw" +ATTR_CURRENT_VALUE = "current_value" +ATTR_CURRENT_VALUE_RAW = "current_value_raw" +ATTR_DESCRIPTION = "description" + # service constants SERVICE_SET_VALUE = "set_value" SERVICE_RESET_METER = "reset_meter" diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py new file mode 100644 index 00000000000..69e770e3817 --- /dev/null +++ b/homeassistant/components/zwave_js/trigger.py @@ -0,0 +1,54 @@ +"""Z-Wave JS trigger dispatcher.""" +from __future__ import annotations + +from types import ModuleType +from typing import Any, Callable, cast + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .triggers import value_updated + +TRIGGERS = { + "value_updated": value_updated, +} + + +def _get_trigger_platform(config: ConfigType) -> ModuleType: + """Return trigger platform.""" + platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) + if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: + raise ValueError(f"Unknown Z-Wave JS trigger platform {config[CONF_PLATFORM]}") + return TRIGGERS[platform_split[1]] + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + if hasattr(platform, "async_validate_trigger_config"): + return cast( + ConfigType, + await getattr(platform, "async_validate_trigger_config")(hass, config), + ) + assert hasattr(platform, "TRIGGER_SCHEMA") + return cast(ConfigType, getattr(platform, "TRIGGER_SCHEMA")(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: Callable, + automation_info: dict[str, Any], +) -> Callable: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + assert hasattr(platform, "async_attach_trigger") + return cast( + Callable, + await getattr(platform, "async_attach_trigger")( + hass, config, action, automation_info + ), + ) diff --git a/homeassistant/components/zwave_js/triggers/__init__.py b/homeassistant/components/zwave_js/triggers/__init__.py new file mode 100644 index 00000000000..7c4f867d465 --- /dev/null +++ b/homeassistant/components/zwave_js/triggers/__init__.py @@ -0,0 +1 @@ +"""Z-Wave JS triggers.""" diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py new file mode 100644 index 00000000000..a2dbb84cf3b --- /dev/null +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -0,0 +1,193 @@ +"""Offer Z-Wave JS value updated listening automation rules.""" +from __future__ import annotations + +import functools +import logging +from typing import Any, Callable + +import voluptuous as vol +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node +from zwave_js_server.model.value import Value, get_value_id + +from homeassistant.components.zwave_js.const import ( + ATTR_COMMAND_CLASS, + ATTR_COMMAND_CLASS_NAME, + ATTR_CURRENT_VALUE, + ATTR_CURRENT_VALUE_RAW, + ATTR_ENDPOINT, + ATTR_NODE_ID, + ATTR_PREVIOUS_VALUE, + ATTR_PREVIOUS_VALUE_RAW, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_PROPERTY_KEY_NAME, + ATTR_PROPERTY_NAME, + DOMAIN, +) +from homeassistant.components.zwave_js.helpers import ( + async_get_node_from_device_id, + async_get_node_from_entity_id, + get_device_id, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM, MATCH_ALL +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +# Platform type should be . +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +ATTR_FROM = "from" +ATTR_TO = "to" + +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + cv.boolean, + cv.string, +) + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_COMMAND_CLASS): vol.In( + {cc.value: cc.name for cc in CommandClass} + ), + vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_FROM, default=MATCH_ALL): vol.Any( + VALUE_SCHEMA, [VALUE_SCHEMA] + ), + vol.Optional(ATTR_TO, default=MATCH_ALL): vol.Any( + VALUE_SCHEMA, [VALUE_SCHEMA] + ), + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: Callable, + automation_info: dict[str, Any], + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + nodes: set[Node] = set() + if ATTR_DEVICE_ID in config: + nodes.update( + { + async_get_node_from_device_id(hass, device_id) + for device_id in config.get(ATTR_DEVICE_ID, []) + } + ) + if ATTR_ENTITY_ID in config: + nodes.update( + { + async_get_node_from_entity_id(hass, entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + from_value = config[ATTR_FROM] + to_value = config[ATTR_TO] + command_class = config[ATTR_COMMAND_CLASS] + property_ = config[ATTR_PROPERTY] + endpoint = config.get(ATTR_ENDPOINT) + property_key = config.get(ATTR_PROPERTY_KEY) + unsubs = [] + job = HassJob(action) + + trigger_data: dict = {} + if automation_info: + trigger_data = automation_info.get("trigger_data", {}) + + @callback + def async_on_value_updated( + value: Value, device: dr.DeviceEntry, event: Event + ) -> None: + """Handle value update.""" + event_value: Value = event["value"] + if event_value != value: + return + + # Get previous value and its state value if it exists + prev_value_raw = event["args"]["prevValue"] + prev_value = value.metadata.states.get(str(prev_value_raw), prev_value_raw) + # Get current value and its state value if it exists + curr_value_raw = event["args"]["newValue"] + curr_value = value.metadata.states.get(str(curr_value_raw), curr_value_raw) + # Check from and to values against previous and current values respectively + for value_to_eval, raw_value_to_eval, match in ( + (prev_value, prev_value_raw, from_value), + (curr_value, curr_value_raw, to_value), + ): + if ( + match != MATCH_ALL + and value_to_eval != match + and not ( + isinstance(match, list) + and (value_to_eval in match or raw_value_to_eval in match) + ) + and raw_value_to_eval != match + ): + return + + device_name = device.name_by_user or device.name + + payload = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device.id, + ATTR_NODE_ID: value.node.node_id, + ATTR_COMMAND_CLASS: value.command_class, + ATTR_COMMAND_CLASS_NAME: value.command_class_name, + ATTR_PROPERTY: value.property_, + ATTR_PROPERTY_NAME: value.property_name, + ATTR_ENDPOINT: endpoint, + ATTR_PROPERTY_KEY: value.property_key, + ATTR_PROPERTY_KEY_NAME: value.property_key_name, + ATTR_PREVIOUS_VALUE: prev_value, + ATTR_PREVIOUS_VALUE_RAW: prev_value_raw, + ATTR_CURRENT_VALUE: curr_value, + ATTR_CURRENT_VALUE_RAW: curr_value_raw, + "description": f"Z-Wave value {value_id} updated on {device_name}", + } + + hass.async_run_hass_job(job, {"trigger": payload}) + + dev_reg = dr.async_get(hass) + for node in nodes: + device_identifier = get_device_id(node.client, node) + device = dev_reg.async_get_device({device_identifier}) + assert device + value_id = get_value_id(node, command_class, property_, endpoint, property_key) + value = node.values[value_id] + # We need to store the current value and device for the callback + unsubs.append( + node.on( + "value updated", + functools.partial(async_on_value_updated, value, device), + ) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py new file mode 100644 index 00000000000..33f6205c7b9 --- /dev/null +++ b/tests/components/zwave_js/test_trigger.py @@ -0,0 +1,276 @@ +"""The tests for Z-Wave JS automation triggers.""" +from unittest.mock import AsyncMock, patch + +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node + +from homeassistant.components import automation +from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.trigger import async_validate_trigger_config +from homeassistant.const import SERVICE_RELOAD +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get as async_get_dev_reg, +) +from homeassistant.setup import async_setup_component + +from .common import SCHLAGE_BE469_LOCK_ENTITY + +from tests.common import async_capture_events + + +async def test_zwave_js_value_updated(hass, client, lock_schlage_be469, integration): + """Test for zwave_js.value_updated automation trigger.""" + trigger_type = f"{DOMAIN}.value_updated" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + no_value_filter = async_capture_events(hass, "no_value_filter") + single_from_value_filter = async_capture_events(hass, "single_from_value_filter") + multiple_from_value_filters = async_capture_events( + hass, "multiple_from_value_filters" + ) + from_and_to_value_filters = async_capture_events(hass, "from_and_to_value_filters") + different_value = async_capture_events(hass, "different_value") + + def clear_events(): + """Clear all events in the event list.""" + no_value_filter.clear() + single_from_value_filter.clear() + multiple_from_value_filters.clear() + from_and_to_value_filters.clear() + different_value.clear() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # no value filter + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "no_value_filter", + }, + }, + # single from value filter + { + "trigger": { + "platform": trigger_type, + "device_id": device.id, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": "ajar", + }, + "action": { + "event": "single_from_value_filter", + }, + }, + # multiple from value filters + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + }, + "action": { + "event": "multiple_from_value_filters", + }, + }, + # from and to value filters + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + "to": ["opened"], + }, + "action": { + "event": "from_and_to_value_filters", + }, + }, + # different value + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "boltStatus", + }, + "action": { + "event": "different_value", + }, + }, + ] + }, + ) + + # Test that no value filter is triggered + 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": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that a single_from_value_filter is triggered + 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": "boo", + "prevValue": "ajar", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 1 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that multiple_from_value_filters are triggered + 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": "boo", + "prevValue": "closed", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 1 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that from_and_to_value_filters is triggered + 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": "opened", + "prevValue": "closed", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 1 + assert len(from_and_to_value_filters) == 1 + assert len(different_value) == 0 + + clear_events() + + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "boltStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "boltStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 0 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 1 + + clear_events() + + with patch("homeassistant.config.load_yaml", return_value={}): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + +async def test_async_validate_trigger_config(hass): + """Test async_validate_trigger_config.""" + mock_platform = AsyncMock() + with patch( + "homeassistant.components.zwave_js.trigger._get_trigger_platform", + return_value=mock_platform, + ): + mock_platform.async_validate_trigger_config.return_value = {} + await async_validate_trigger_config(hass, {}) + mock_platform.async_validate_trigger_config.assert_awaited()