Add zwave_js device triggers (#51968)
* Initial support for zwave_js device triggers * lint * Add node status changed trigger * comments * create helper function and simplify trigger logic * simplify code * fix exception * remove unused type ignore * switch to append to make future changes easier * make exception consistent * Add state config schema validation * comment * remove 0 from falsy check * increase test coverage * typos * Add central scene and scene activation value notification triggers * reorder things for readability and enumerate node statuses * Add support for Basic CC value notifications * fix schemas since additional fields on triggers aren't very flexible * pylint * remove extra logger statement * fix comment * dont use get when we know key will be available in dict * tweak text * use better schema for required extra fields that are ints * rename trigger types to make them easier to parse * fix strings * missed renaming of one trigger type * typo * Fix strings * reduce complexity * Use Al's suggestion for strings * add additional failure test cases * remove errant logging statement * make CC required * raise vol.Invalid when value ID isn't legit to prepare for next PR * Use helper function * fix tests * black
This commit is contained in:
parent
4d711898c7
commit
dd908caeba
8 changed files with 1970 additions and 1 deletions
371
homeassistant/components/zwave_js/device_trigger.py
Normal file
371
homeassistant/components/zwave_js/device_trigger.py
Normal file
|
@ -0,0 +1,371 @@
|
|||
"""Provides device triggers for Z-Wave JS."""
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
from zwave_js_server.const import CommandClass
|
||||
|
||||
from homeassistant.components.automation import AutomationActionType
|
||||
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
|
||||
from homeassistant.components.homeassistant.triggers import event, state
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
entity_registry,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ATTR_COMMAND_CLASS,
|
||||
ATTR_DATA_TYPE,
|
||||
ATTR_ENDPOINT,
|
||||
ATTR_EVENT,
|
||||
ATTR_EVENT_LABEL,
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_LABEL,
|
||||
ATTR_PROPERTY,
|
||||
ATTR_PROPERTY_KEY,
|
||||
ATTR_TYPE,
|
||||
ATTR_VALUE,
|
||||
ATTR_VALUE_RAW,
|
||||
DOMAIN,
|
||||
ZWAVE_JS_NOTIFICATION_EVENT,
|
||||
ZWAVE_JS_VALUE_NOTIFICATION_EVENT,
|
||||
)
|
||||
from .helpers import (
|
||||
async_get_node_from_device_id,
|
||||
async_get_node_status_sensor_entity_id,
|
||||
get_zwave_value_from_config,
|
||||
)
|
||||
|
||||
CONF_SUBTYPE = "subtype"
|
||||
CONF_VALUE_ID = "value_id"
|
||||
|
||||
# Trigger types
|
||||
ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control"
|
||||
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"
|
||||
NODE_STATUS = "state.node_status"
|
||||
|
||||
NOTIFICATION_EVENT_CC_MAPPINGS = (
|
||||
(ENTRY_CONTROL_NOTIFICATION, CommandClass.ENTRY_CONTROL),
|
||||
(NOTIFICATION_NOTIFICATION, CommandClass.NOTIFICATION),
|
||||
)
|
||||
|
||||
# Event based trigger schemas
|
||||
BASE_EVENT_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
|
||||
}
|
||||
)
|
||||
|
||||
NOTIFICATION_NOTIFICATION_SCHEMA = BASE_EVENT_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): NOTIFICATION_NOTIFICATION,
|
||||
vol.Optional(f"{ATTR_TYPE}."): vol.Coerce(int),
|
||||
vol.Optional(ATTR_LABEL): cv.string,
|
||||
vol.Optional(ATTR_EVENT): vol.Coerce(int),
|
||||
vol.Optional(ATTR_EVENT_LABEL): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
ENTRY_CONTROL_NOTIFICATION_SCHEMA = BASE_EVENT_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): ENTRY_CONTROL_NOTIFICATION,
|
||||
vol.Optional(ATTR_EVENT_TYPE): vol.Coerce(int),
|
||||
vol.Optional(ATTR_DATA_TYPE): vol.Coerce(int),
|
||||
}
|
||||
)
|
||||
|
||||
BASE_VALUE_NOTIFICATION_EVENT_SCHEMA = BASE_EVENT_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_PROPERTY): vol.Any(int, str),
|
||||
vol.Required(ATTR_PROPERTY_KEY): vol.Any(None, int, str),
|
||||
vol.Required(ATTR_ENDPOINT): vol.Coerce(int),
|
||||
vol.Required(ATTR_VALUE): vol.Coerce(int),
|
||||
vol.Required(CONF_SUBTYPE): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
BASIC_VALUE_NOTIFICATION_SCHEMA = BASE_VALUE_NOTIFICATION_EVENT_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): BASIC_VALUE_NOTIFICATION,
|
||||
}
|
||||
)
|
||||
|
||||
CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA = BASE_VALUE_NOTIFICATION_EVENT_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): CENTRAL_SCENE_VALUE_NOTIFICATION,
|
||||
}
|
||||
)
|
||||
|
||||
SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA = (
|
||||
BASE_VALUE_NOTIFICATION_EVENT_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): SCENE_ACTIVATION_VALUE_NOTIFICATION,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# State based trigger schemas
|
||||
BASE_STATE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
}
|
||||
)
|
||||
|
||||
NODE_STATUSES = ["asleep", "awake", "dead", "alive"]
|
||||
|
||||
NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): NODE_STATUS,
|
||||
vol.Optional(state.CONF_FROM): vol.In(NODE_STATUSES),
|
||||
vol.Optional(state.CONF_TO): vol.In(NODE_STATUSES),
|
||||
vol.Optional(state.CONF_FOR): cv.positive_time_period_dict,
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
NODE_STATUS_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]:
|
||||
"""List device triggers for Z-Wave JS devices."""
|
||||
dev_reg = device_registry.async_get(hass)
|
||||
node = async_get_node_from_device_id(hass, device_id, dev_reg)
|
||||
|
||||
triggers = []
|
||||
base_trigger = {
|
||||
CONF_PLATFORM: "device",
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_DOMAIN: DOMAIN,
|
||||
}
|
||||
|
||||
# We can add a node status trigger if the node status sensor is enabled
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
entity_id = async_get_node_status_sensor_entity_id(
|
||||
hass, device_id, ent_reg, dev_reg
|
||||
)
|
||||
if (entity := ent_reg.async_get(entity_id)) is not None and not entity.disabled:
|
||||
triggers.append(
|
||||
{**base_trigger, CONF_TYPE: NODE_STATUS, CONF_ENTITY_ID: entity_id}
|
||||
)
|
||||
|
||||
# Handle notification event triggers
|
||||
triggers.extend(
|
||||
[
|
||||
{**base_trigger, CONF_TYPE: event_type, ATTR_COMMAND_CLASS: command_class}
|
||||
for event_type, command_class in NOTIFICATION_EVENT_CC_MAPPINGS
|
||||
if any(cc.id == command_class for cc in node.command_classes)
|
||||
]
|
||||
)
|
||||
|
||||
# Handle central scene value notification event triggers
|
||||
triggers.extend(
|
||||
[
|
||||
{
|
||||
**base_trigger,
|
||||
CONF_TYPE: CENTRAL_SCENE_VALUE_NOTIFICATION,
|
||||
ATTR_PROPERTY: value.property_,
|
||||
ATTR_PROPERTY_KEY: value.property_key,
|
||||
ATTR_ENDPOINT: value.endpoint,
|
||||
ATTR_COMMAND_CLASS: CommandClass.CENTRAL_SCENE,
|
||||
CONF_SUBTYPE: f"Endpoint {value.endpoint} Scene {value.property_key}",
|
||||
}
|
||||
for value in node.get_command_class_values(
|
||||
CommandClass.CENTRAL_SCENE
|
||||
).values()
|
||||
if value.property_ == "scene"
|
||||
]
|
||||
)
|
||||
|
||||
# Handle scene activation value notification event triggers
|
||||
triggers.extend(
|
||||
[
|
||||
{
|
||||
**base_trigger,
|
||||
CONF_TYPE: SCENE_ACTIVATION_VALUE_NOTIFICATION,
|
||||
ATTR_PROPERTY: value.property_,
|
||||
ATTR_PROPERTY_KEY: value.property_key,
|
||||
ATTR_ENDPOINT: value.endpoint,
|
||||
ATTR_COMMAND_CLASS: CommandClass.SCENE_ACTIVATION,
|
||||
CONF_SUBTYPE: f"Endpoint {value.endpoint}",
|
||||
}
|
||||
for value in node.get_command_class_values(
|
||||
CommandClass.SCENE_ACTIVATION
|
||||
).values()
|
||||
if value.property_ == "sceneId"
|
||||
]
|
||||
)
|
||||
|
||||
# Handle basic value notification event triggers
|
||||
# Nodes will only send Basic CC value notifications if a compatibility flag is set
|
||||
if node.device_config.compat.get("treatBasicSetAsEvent", False):
|
||||
triggers.extend(
|
||||
[
|
||||
{
|
||||
**base_trigger,
|
||||
CONF_TYPE: BASIC_VALUE_NOTIFICATION,
|
||||
ATTR_PROPERTY: value.property_,
|
||||
ATTR_PROPERTY_KEY: value.property_key,
|
||||
ATTR_ENDPOINT: value.endpoint,
|
||||
ATTR_COMMAND_CLASS: CommandClass.BASIC,
|
||||
CONF_SUBTYPE: f"Endpoint {value.endpoint}",
|
||||
}
|
||||
for value in node.get_command_class_values(CommandClass.BASIC).values()
|
||||
if value.property_ == "event"
|
||||
]
|
||||
)
|
||||
|
||||
return triggers
|
||||
|
||||
|
||||
def copy_available_params(
|
||||
input_dict: dict, output_dict: dict, params: list[str]
|
||||
) -> None:
|
||||
"""Copy available params from input into output."""
|
||||
for param in params:
|
||||
if (val := input_dict.get(param)) not in ("", None):
|
||||
output_dict[param] = val
|
||||
|
||||
|
||||
async def async_attach_trigger(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
action: AutomationActionType,
|
||||
automation_info: dict,
|
||||
) -> 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]
|
||||
|
||||
# Take input data from automation trigger UI and add it to the trigger we are
|
||||
# attaching to
|
||||
if trigger_platform == "event":
|
||||
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])
|
||||
elif trigger_type == NOTIFICATION_NOTIFICATION:
|
||||
event_config[event.CONF_EVENT_TYPE] = ZWAVE_JS_NOTIFICATION_EVENT
|
||||
copy_available_params(
|
||||
config, event_data, [ATTR_LABEL, ATTR_EVENT_LABEL, ATTR_EVENT]
|
||||
)
|
||||
if (val := config.get(f"{ATTR_TYPE}.")) not in ("", None):
|
||||
event_data[ATTR_TYPE] = val
|
||||
elif trigger_type in (
|
||||
BASIC_VALUE_NOTIFICATION,
|
||||
CENTRAL_SCENE_VALUE_NOTIFICATION,
|
||||
SCENE_ACTIVATION_VALUE_NOTIFICATION,
|
||||
):
|
||||
event_config[event.CONF_EVENT_TYPE] = ZWAVE_JS_VALUE_NOTIFICATION_EVENT
|
||||
copy_available_params(
|
||||
config, event_data, [ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_ENDPOINT]
|
||||
)
|
||||
event_data[ATTR_VALUE_RAW] = config[ATTR_VALUE]
|
||||
else:
|
||||
raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")
|
||||
|
||||
event_config = event.TRIGGER_SCHEMA(event_config)
|
||||
return await event.async_attach_trigger(
|
||||
hass, event_config, action, automation_info, platform_type="device"
|
||||
)
|
||||
|
||||
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.TRIGGER_SCHEMA(state_config)
|
||||
return await state.async_attach_trigger(
|
||||
hass, state_config, action, automation_info, platform_type="device"
|
||||
)
|
||||
|
||||
raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")
|
||||
|
||||
|
||||
async def async_get_trigger_capabilities(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> dict[str, vol.Schema]:
|
||||
"""List trigger capabilities."""
|
||||
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:
|
||||
return {
|
||||
"extra_fields": vol.Schema(
|
||||
{
|
||||
vol.Optional(f"{ATTR_TYPE}."): cv.string,
|
||||
vol.Optional(ATTR_LABEL): cv.string,
|
||||
vol.Optional(ATTR_EVENT): cv.string,
|
||||
vol.Optional(ATTR_EVENT_LABEL): cv.string,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if config[CONF_TYPE] == ENTRY_CONTROL_NOTIFICATION:
|
||||
return {
|
||||
"extra_fields": vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_EVENT_TYPE): cv.string,
|
||||
vol.Optional(ATTR_DATA_TYPE): cv.string,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if config[CONF_TYPE] == NODE_STATUS:
|
||||
return {
|
||||
"extra_fields": vol.Schema(
|
||||
{
|
||||
vol.Optional(state.CONF_FROM): vol.In(NODE_STATUSES),
|
||||
vol.Optional(state.CONF_TO): vol.In(NODE_STATUSES),
|
||||
vol.Optional(state.CONF_FOR): cv.positive_time_period_dict,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if config[CONF_TYPE] in (
|
||||
BASIC_VALUE_NOTIFICATION,
|
||||
CENTRAL_SCENE_VALUE_NOTIFICATION,
|
||||
SCENE_ACTIVATION_VALUE_NOTIFICATION,
|
||||
):
|
||||
if value.metadata.states:
|
||||
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.Required(ATTR_VALUE): value_schema})}
|
||||
|
||||
return {}
|
|
@ -8,9 +8,11 @@ from zwave_js_server.client import Client as ZwaveClient
|
|||
from zwave_js_server.model.node import Node as ZwaveNode
|
||||
from zwave_js_server.model.value import Value as ZwaveValue, get_value_id
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import (
|
||||
DeviceRegistry,
|
||||
async_get as async_get_dev_reg,
|
||||
|
@ -175,3 +177,35 @@ def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveVal
|
|||
if value_id not in node.values:
|
||||
raise vol.Invalid(f"Value {value_id} can't be found on node {node}")
|
||||
return node.values[value_id]
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_node_status_sensor_entity_id(
|
||||
hass: HomeAssistant,
|
||||
device_id: str,
|
||||
ent_reg: EntityRegistry | None = None,
|
||||
dev_reg: DeviceRegistry | None = None,
|
||||
) -> str:
|
||||
"""Get the node status sensor entity ID for a given Z-Wave JS device."""
|
||||
if not ent_reg:
|
||||
ent_reg = async_get_ent_reg(hass)
|
||||
if not dev_reg:
|
||||
dev_reg = async_get_dev_reg(hass)
|
||||
device = dev_reg.async_get(device_id)
|
||||
if not device:
|
||||
raise HomeAssistantError("Invalid Device ID provided")
|
||||
|
||||
entry_id = next(entry_id for entry_id in device.config_entries)
|
||||
client = hass.data[DOMAIN][entry_id][DATA_CLIENT]
|
||||
node = async_get_node_from_device_id(hass, device_id, dev_reg)
|
||||
entity_id = ent_reg.async_get_entity_id(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{client.driver.controller.home_id}.{node.node_id}.node_status",
|
||||
)
|
||||
if not entity_id:
|
||||
raise HomeAssistantError(
|
||||
"Node status sensor entity not found. Device may not be a zwave_js device"
|
||||
)
|
||||
|
||||
return entity_id
|
||||
|
|
|
@ -99,6 +99,14 @@
|
|||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"trigger_type": {
|
||||
"event.notification.entry_control": "Sent an Entry Control notification",
|
||||
"event.notification.notification": "Sent a notification",
|
||||
"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"
|
||||
},
|
||||
"condition_type": {
|
||||
"node_status": "Node status",
|
||||
"config_parameter": "Config parameter {subtype} value",
|
||||
|
|
|
@ -56,6 +56,14 @@
|
|||
"config_parameter": "Config parameter {subtype} value",
|
||||
"node_status": "Node status",
|
||||
"value": "Current value of a Z-Wave Value"
|
||||
},
|
||||
"trigger_type": {
|
||||
"event.notification.entry_control": "Sent an Entry Control notification",
|
||||
"event.notification.notification": "Sent a notification",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue