diff --git a/homeassistant/components/zwave_js/icons.json b/homeassistant/components/zwave_js/icons.json index 0a4b941aa250..e03dac535341 100644 --- a/homeassistant/components/zwave_js/icons.json +++ b/homeassistant/components/zwave_js/icons.json @@ -120,5 +120,13 @@ "set_value": { "service": "mdi:form-textbox" } + }, + "triggers": { + "event": { + "trigger": "mdi:z-wave" + }, + "value_updated": { + "trigger": "mdi:update" + } } } diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 0f6c78f2ae49..19e1dd754a88 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -426,6 +426,137 @@ } }, "selector": { + "command_class": { + "options": { + "0": "No Operation (0)", + "32": "Basic (32)", + "33": "Controller Replication (33)", + "34": "Application Status (34)", + "35": "Z/IP (35)", + "37": "Binary Switch (37)", + "38": "Multilevel Switch (38)", + "39": "All Switch (39)", + "40": "Binary Toggle Switch (40)", + "41": "Multilevel Toggle Switch (41)", + "43": "Scene Activation (43)", + "44": "Scene Actuator Configuration (44)", + "45": "Scene Controller Configuration (45)", + "48": "Binary Sensor (48)", + "49": "Multilevel Sensor (49)", + "50": "Meter (50)", + "51": "Color Switch (51)", + "52": "Network Management Inclusion (52)", + "53": "Pulse Meter (53)", + "54": "Basic Tariff Information (54)", + "55": "HRV Status (55)", + "57": "HRV Control (57)", + "58": "Demand Control Plan Configuration (58)", + "59": "Demand Control Plan Monitor (59)", + "60": "Meter Table Configuration (60)", + "61": "Meter Table Monitor (61)", + "62": "Meter Table Push Configuration (62)", + "63": "Prepayment (63)", + "64": "Thermostat Mode (64)", + "65": "Prepayment Encapsulation (65)", + "66": "Thermostat Operating State (66)", + "67": "Thermostat Setpoint (67)", + "68": "Thermostat Fan Mode (68)", + "69": "Thermostat Fan State (69)", + "70": "Climate Control Schedule (70)", + "71": "Thermostat Setback (71)", + "72": "Rate Table Configuration (72)", + "73": "Rate Table Monitor (73)", + "74": "Tariff Table Configuration (74)", + "75": "Tariff Table Monitor (75)", + "76": "Door Lock Logging (76)", + "77": "Network Management Basic Node (77)", + "78": "Schedule Entry Lock (78)", + "79": "Z/IP 6LoWPAN (79)", + "80": "Basic Window Covering (80)", + "81": "Move To Position Window Covering (81)", + "82": "Network Management Proxy (82)", + "83": "Schedule (83)", + "84": "Network Management Primary (84)", + "85": "Transport Service (85)", + "86": "CRC-16 Encapsulation (86)", + "87": "Application Capability (87)", + "88": "Z/IP ND (88)", + "89": "Association Group Information (89)", + "90": "Device Reset Locally (90)", + "91": "Central Scene (91)", + "92": "IP Association (92)", + "93": "Anti-Theft (93)", + "94": "Z-Wave Plus Info (94)", + "95": "Z/IP Gateway (95)", + "96": "Multi Channel (96)", + "97": "Z/IP Portal (97)", + "98": "Door Lock (98)", + "99": "User Code (99)", + "100": "Humidity Control Setpoint (100)", + "102": "Barrier Operator (102)", + "103": "Network Management Installation and Maintenance (103)", + "104": "Z/IP Naming and Location (104)", + "105": "Mailbox (105)", + "106": "Window Covering (106)", + "107": "Irrigation (107)", + "108": "Supervision (108)", + "109": "Humidity Control Mode (109)", + "110": "Humidity Control Operating State (110)", + "111": "Entry Control (111)", + "112": "Configuration (112)", + "113": "Notification (113)", + "114": "Manufacturer Specific (114)", + "115": "Powerlevel (115)", + "116": "Inclusion Controller (116)", + "117": "Protection (117)", + "118": "Lock (118)", + "119": "Node Naming and Location (119)", + "120": "Node Provisioning (120)", + "121": "Sound Switch (121)", + "122": "Firmware Update Meta Data (122)", + "123": "Grouping Name (123)", + "124": "Remote Association Activation (124)", + "125": "Remote Association Configuration (125)", + "126": "Anti-Theft Unlock (126)", + "128": "Battery (128)", + "129": "Clock (129)", + "130": "Hail (130)", + "132": "Wake Up (132)", + "133": "Association (133)", + "134": "Version (134)", + "135": "Indicator (135)", + "136": "Proprietary (136)", + "137": "Language (137)", + "138": "Time (138)", + "139": "Time Parameters (139)", + "140": "Geographic Location (140)", + "142": "Multi Channel Association (142)", + "143": "Multi Command (143)", + "144": "Energy Production (144)", + "145": "Manufacturer Proprietary (145)", + "146": "Screen Meta Data (146)", + "147": "Screen Attributes (147)", + "148": "Simple AV Control (148)", + "152": "Security (152)", + "154": "IP Configuration (154)", + "155": "Association Command Configuration (155)", + "156": "Alarm Sensor (156)", + "157": "Alarm Silence (157)", + "158": "Sensor Configuration (158)", + "159": "Security 2 (159)", + "160": "IR Repeater (160)", + "161": "Authentication (161)", + "162": "Authentication Media Write (162)", + "163": "Generic Schedule (163)" + } + }, + "event_source": { + "options": { + "controller": "Controller", + "driver": "Driver", + "node": "Node" + } + }, "network_type": { "options": { "existing": "It already exists", @@ -890,5 +1021,79 @@ }, "name": "Set a value" } + }, + "triggers": { + "event": { + "description": "Triggers when a Z-Wave JS controller, driver, or node emits an event.", + "fields": { + "config_entry_id": { + "description": "The Z-Wave JS config entry to listen to. Required for controller and driver events.", + "name": "Config entry" + }, + "device_id": { + "description": "Devices whose node events to listen to. Node events need at least one device or entity, and controller or driver events must not have any.", + "name": "Devices" + }, + "entity_id": { + "description": "Entities whose node events to listen to. Node events need at least one device or entity, and controller or driver events must not have any.", + "name": "Entities" + }, + "event": { + "description": "Name of the Z-Wave JS event, for example `value notification`.", + "name": "Event" + }, + "event_data": { + "description": "Key-value pairs the event data must contain for the trigger to fire.", + "name": "Event data" + }, + "event_source": { + "description": "Whether the event is emitted by the controller, the driver, or a node.", + "name": "Event source" + }, + "partial_dict_match": { + "description": "Match nested dictionaries in the event data on the given keys only, instead of requiring an exact match.", + "name": "Partial dictionary match" + } + }, + "name": "Z-Wave JS event received" + }, + "value_updated": { + "description": "Triggers when a Z-Wave value on one or more nodes changes.", + "fields": { + "command_class": { + "description": "Command class of the value.", + "name": "Command class" + }, + "device_id": { + "description": "Devices whose values to watch. At least one device or entity is required.", + "name": "Devices" + }, + "endpoint": { + "description": "Endpoint of the value.", + "name": "Endpoint" + }, + "entity_id": { + "description": "Entities whose values to watch. At least one device or entity is required.", + "name": "Entities" + }, + "from": { + "description": "Only trigger when the value changes from this value, or from any value in this list.", + "name": "From" + }, + "property": { + "description": "Property of the value.", + "name": "Property" + }, + "property_key": { + "description": "Property key of the value.", + "name": "Property key" + }, + "to": { + "description": "Only trigger when the value changes to this value, or to any value in this list.", + "name": "To" + } + }, + "name": "Z-Wave JS value updated" + } } } diff --git a/homeassistant/components/zwave_js/triggers.yaml b/homeassistant/components/zwave_js/triggers.yaml new file mode 100644 index 000000000000..820e684e1eb0 --- /dev/null +++ b/homeassistant/components/zwave_js/triggers.yaml @@ -0,0 +1,209 @@ +# Describes the format for available Z-Wave JS triggers + +.device_id: &device_id + required: false + example: 8f4219cfa57e23f6f669c4616c2205e2 + selector: + device: + filter: + - integration: zwave_js + multiple: true + +.entity_id: &entity_id + required: false + example: sensor.living_room_temperature + selector: + entity: + filter: + - integration: zwave_js + multiple: true + +event: + fields: + event_source: + required: true + selector: + select: + translation_key: event_source + options: + - controller + - driver + - node + config_entry_id: + required: false + selector: + config_entry: + integration: zwave_js + device_id: *device_id + entity_id: *entity_id + event: + required: true + example: value notification + selector: + text: + event_data: + required: false + selector: + object: + partial_dict_match: + required: false + default: false + selector: + boolean: + +value_updated: + fields: + device_id: *device_id + entity_id: *entity_id + command_class: + required: true + selector: + select: + translation_key: command_class + sort: true + options: + - "0" + - "32" + - "33" + - "34" + - "35" + - "37" + - "38" + - "39" + - "40" + - "41" + - "43" + - "44" + - "45" + - "48" + - "49" + - "50" + - "51" + - "52" + - "53" + - "54" + - "55" + - "57" + - "58" + - "59" + - "60" + - "61" + - "62" + - "63" + - "64" + - "65" + - "66" + - "67" + - "68" + - "69" + - "70" + - "71" + - "72" + - "73" + - "74" + - "75" + - "76" + - "77" + - "78" + - "79" + - "80" + - "81" + - "82" + - "83" + - "84" + - "85" + - "86" + - "87" + - "88" + - "89" + - "90" + - "91" + - "92" + - "93" + - "94" + - "95" + - "96" + - "97" + - "98" + - "99" + - "100" + - "102" + - "103" + - "104" + - "105" + - "106" + - "107" + - "108" + - "109" + - "110" + - "111" + - "112" + - "113" + - "114" + - "115" + - "116" + - "117" + - "118" + - "119" + - "120" + - "121" + - "122" + - "123" + - "124" + - "125" + - "126" + - "128" + - "129" + - "130" + - "132" + - "133" + - "134" + - "135" + - "136" + - "137" + - "138" + - "139" + - "140" + - "142" + - "143" + - "144" + - "145" + - "146" + - "147" + - "148" + - "152" + - "154" + - "155" + - "156" + - "157" + - "158" + - "159" + - "160" + - "161" + - "162" + - "163" + property: + required: true + example: currentValue + selector: + text: + endpoint: + required: false + example: 1 + selector: + number: + min: 0 + mode: box + property_key: + required: false + example: 1 + selector: + text: + from: + required: false + selector: + object: + to: + required: false + selector: + object: diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 1e29e8dd338b..8d67e29410ea 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -51,11 +51,25 @@ RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" -def validate_non_node_event_source(obj: dict) -> dict: - """Validate that a trigger for a non node event source has a config entry.""" - if obj[ATTR_EVENT_SOURCE] != "node" and ATTR_CONFIG_ENTRY_ID in obj: +def validate_event_source_targets(obj: dict) -> dict: + """Validate that the targets match the event source.""" + if obj[ATTR_EVENT_SOURCE] == "node": + if ATTR_DEVICE_ID not in obj and ATTR_ENTITY_ID not in obj: + raise vol.Invalid( + f"Node event triggers must contain {ATTR_DEVICE_ID} or " + f"{ATTR_ENTITY_ID}." + ) return obj - raise vol.Invalid(f"Non node event triggers must contain {ATTR_CONFIG_ENTRY_ID}.") + if ATTR_CONFIG_ENTRY_ID not in obj: + raise vol.Invalid( + f"Non node event triggers must contain {ATTR_CONFIG_ENTRY_ID}." + ) + if ATTR_DEVICE_ID in obj or ATTR_ENTITY_ID in obj: + raise vol.Invalid( + f"Non node event triggers must not contain {ATTR_DEVICE_ID} or " + f"{ATTR_ENTITY_ID}." + ) + return obj def validate_event_name(obj: dict) -> dict: @@ -112,10 +126,7 @@ _CONFIG_SCHEMA = vol.Schema( _OPTIONS_SCHEMA_DICT, validate_event_name, validate_event_data, - vol.Any( - validate_non_node_event_source, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), - ), + validate_event_source_targets, ) } ) diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index 54de60f45d59..9720a27c4c7d 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -158,7 +158,6 @@ NON_MIGRATED_INTEGRATIONS = { "webhook", "webostv", "zone", - "zwave_js", } diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 9d866a2267c0..fbe8cb7dbe50 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -3,6 +3,7 @@ from copy import deepcopy from typing import Any +from zwave_js_server.const import CommandClass from zwave_js_server.model.node.data_model import NodeDataType from homeassistant.components.zwave_js.helpers import ( @@ -10,6 +11,9 @@ from homeassistant.components.zwave_js.helpers import ( value_matches_matcher, ) +# NIF markers listed in SDS13548 as "not an actual Command Class" +COMMAND_CLASS_MARKERS = {CommandClass.MARK, CommandClass.SECURITY_SCHEME0_MARK} + AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" BATTERY_SENSOR = "sensor.multisensor_6_battery_level" TAMPER_SENSOR = "binary_sensor.multisensor_6_tampering_product_cover_removed" diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index a4a2a3f9cd92..9f9aef4e00ce 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -1,7 +1,8 @@ """The tests for Z-Wave JS automation triggers.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise import copy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import voluptuous as vol @@ -13,17 +14,24 @@ from homeassistant.components import automation from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.components.zwave_js.trigger import TRIGGERS +from homeassistant.components.zwave_js.triggers.event import ( + _OPTIONS_SCHEMA_DICT as EVENT_OPTIONS_SCHEMA_DICT, +) from homeassistant.components.zwave_js.triggers.trigger_helpers import ( async_bypass_dynamic_config_validation, ) +from homeassistant.components.zwave_js.triggers.value_updated import ( + _OPTIONS_SCHEMA_DICT as VALUE_UPDATED_OPTIONS_SCHEMA_DICT, +) from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, trigger +from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component -from .common import SCHLAGE_BE469_LOCK_ENTITY +from .common import COMMAND_CLASS_MARKERS, SCHLAGE_BE469_LOCK_ENTITY -from tests.common import async_capture_events +from tests.common import MockConfigEntry, async_capture_events async def test_zwave_js_value_updated( @@ -1042,6 +1050,84 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + ("event_source", "event", "option_keys", "expectation"), + [ + pytest.param( + "controller", + "inclusion started", + ["config_entry_id", "device_id"], + pytest.raises(vol.Invalid, match="must not contain"), + id="controller_with_device_id", + ), + pytest.param( + "driver", + "logging", + ["config_entry_id", "entity_id"], + pytest.raises(vol.Invalid, match="must not contain"), + id="driver_with_entity_id", + ), + pytest.param( + "node", + "interview stage completed", + [], + pytest.raises(vol.Invalid, match="must contain"), + id="node_without_targets", + ), + pytest.param( + "controller", + "inclusion started", + ["config_entry_id"], + does_not_raise(), + id="controller_without_targets", + ), + pytest.param( + "controller", + "inclusion started", + [], + pytest.raises(vol.Invalid, match="must contain config_entry_id"), + id="controller_without_config_entry", + ), + ], +) +async def test_zwave_js_event_source_target_validation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + event_source: str, + event: str, + option_keys: list[str], + expectation: AbstractContextManager, +) -> None: + """Test that zwave_js.event targets are validated against the event source.""" + device = device_registry.async_get_device_by_identifier( + get_device_id(client.driver, lock_schlage_be469), integration.entry_id + ) + assert device + options = { + "config_entry_id": integration.entry_id, + "device_id": device.id, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + } + + with expectation: + await trigger.async_validate_trigger_config( + hass, + [ + { + "platform": f"{DOMAIN}.event", + "options": { + "event_source": event_source, + "event": event, + **{key: options[key] for key in option_keys}, + }, + } + ], + ) + + async def test_zwave_js_trigger_config_entry_unloaded( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1495,3 +1581,46 @@ async def test_zwave_js_old_syntax( node.receive_event(event) await hass.async_block_till_done() assert len(zwavejs_value_updated) == 1 + + +@pytest.mark.usefixtures("integration") +async def test_value_updated_command_class_options(hass: HomeAssistant) -> None: + """Test the command class options and translations match the CommandClass enum.""" + expected = {str(cc.value) for cc in CommandClass if cc not in COMMAND_CLASS_MARKERS} + + descriptions = await trigger.async_get_all_descriptions(hass) + options = descriptions[f"{DOMAIN}.value_updated"]["fields"]["command_class"][ + "selector" + ]["select"]["options"] + assert len(options) == len(expected) + assert set(options) == expected + + translations = await async_get_translations(hass, "en", "selector", {DOMAIN}) + prefix = f"component.{DOMAIN}.selector.command_class.options." + assert { + key.removeprefix(prefix) for key in translations if key.startswith(prefix) + } == expected + + +@pytest.mark.parametrize( + ("trigger_type", "options_schema"), + [ + pytest.param(f"{DOMAIN}.event", EVENT_OPTIONS_SCHEMA_DICT, id="event"), + pytest.param( + f"{DOMAIN}.value_updated", + VALUE_UPDATED_OPTIONS_SCHEMA_DICT, + id="value_updated", + ), + ], +) +@pytest.mark.usefixtures("integration") +async def test_trigger_description_fields_match_schema( + hass: HomeAssistant, trigger_type: str, options_schema: dict[vol.Marker, object] +) -> None: + """Test the described fields match the trigger's options schema.""" + descriptions = await trigger.async_get_all_descriptions(hass) + fields = descriptions[trigger_type]["fields"] + assert set(fields) == {str(key) for key in options_schema} + assert {name for name, field in fields.items() if field["required"]} == { + str(key) for key in options_schema if isinstance(key, vol.Required) + }