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
This commit is contained in:
Raman Gupta 2021-08-20 15:21:55 -04:00 committed by GitHub
parent dc74a52f58
commit 63f6a3b46b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 531 additions and 0 deletions

View File

@ -48,6 +48,13 @@ ATTR_OPTIONS = "options"
ATTR_NODE = "node" ATTR_NODE = "node"
ATTR_ZWAVE_VALUE = "zwave_value" 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 constants
SERVICE_SET_VALUE = "set_value" SERVICE_SET_VALUE = "set_value"
SERVICE_RESET_METER = "reset_meter" SERVICE_RESET_METER = "reset_meter"

View File

@ -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
),
)

View File

@ -0,0 +1 @@
"""Z-Wave JS triggers."""

View File

@ -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 <DOMAIN>.<SUBMODULE_NAME>
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

View File

@ -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()