Describe Z-Wave JS triggers for the automation editor (#181129)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Raman Gupta
2026-09-07 19:49:39 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 3f4cebe868
commit aa6fc90ba4
7 changed files with 578 additions and 13 deletions
@@ -120,5 +120,13 @@
"set_value": {
"service": "mdi:form-textbox"
}
},
"triggers": {
"event": {
"trigger": "mdi:z-wave"
},
"value_updated": {
"trigger": "mdi:update"
}
}
}
@@ -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"
}
}
}
@@ -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:
@@ -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,
)
}
)
-1
View File
@@ -158,7 +158,6 @@ NON_MIGRATED_INTEGRATIONS = {
"webhook",
"webostv",
"zone",
"zwave_js",
}
+4
View File
@@ -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"
+133 -4
View File
@@ -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)
}