Add support for zwave_js device actions (#53038)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Raman Gupta 2021-09-28 03:06:02 -04:00 committed by GitHub
parent 552485bb05
commit e690d4b006
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 889 additions and 60 deletions

View file

@ -47,13 +47,13 @@ from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
BITMASK_SCHEMA,
CONF_DATA_COLLECTION_OPTED_IN,
DATA_CLIENT,
DOMAIN,
EVENT_DEVICE_ADDED_TO_REGISTRY,
)
from .helpers import async_enable_statistics, update_data_collection_preference
from .services import BITMASK_SCHEMA
DATA_UNSUBSCRIBE = "unsubs"

View file

@ -1,6 +1,10 @@
"""Constants for the Z-Wave JS integration."""
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
CONF_ADDON_DEVICE = "device"
CONF_ADDON_EMULATE_HARDWARE = "emulate_hardware"
CONF_ADDON_LOG_LEVEL = "log_level"
@ -56,6 +60,8 @@ ATTR_CURRENT_VALUE_RAW = "current_value_raw"
ATTR_DESCRIPTION = "description"
# service constants
SERVICE_SET_LOCK_USERCODE = "set_lock_usercode"
SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode"
SERVICE_SET_VALUE = "set_value"
SERVICE_RESET_METER = "reset_meter"
SERVICE_MULTICAST_SET_VALUE = "multicast_set_value"
@ -98,3 +104,25 @@ ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature"
ENTITY_DESC_KEY_TIMESTAMP = "timestamp"
ENTITY_DESC_KEY_MEASUREMENT = "measurement"
ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing"
# Schema Constants
# Validates that a bitmask is provided in hex form and converts it to decimal
# int equivalent since that's what the library uses
BITMASK_SCHEMA = vol.All(
cv.string,
vol.Lower,
vol.Match(
r"^(0x)?[0-9a-f]+$",
msg="Must provide an integer (e.g. 255) or a bitmask in hex form (e.g. 0xff)",
),
lambda value: int(value, 16),
)
VALUE_SCHEMA = vol.Any(
bool,
vol.Coerce(int),
vol.Coerce(float),
BITMASK_SCHEMA,
cv.string,
)

View file

@ -0,0 +1,309 @@
"""Provides device actions for Z-Wave JS."""
from __future__ import annotations
from collections import defaultdict
import re
from typing import Any
import voluptuous as vol
from zwave_js_server.const import CommandClass
from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE
from zwave_js_server.const.command_class.meter import CC_SPECIFIC_METER_TYPE
from zwave_js_server.model.value import get_value_id
from zwave_js_server.util.command_class.meter import get_meter_type
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_COMMAND_CLASS,
ATTR_CONFIG_PARAMETER,
ATTR_CONFIG_PARAMETER_BITMASK,
ATTR_ENDPOINT,
ATTR_METER_TYPE,
ATTR_PROPERTY,
ATTR_PROPERTY_KEY,
ATTR_REFRESH_ALL_VALUES,
ATTR_VALUE,
ATTR_WAIT_FOR_RESULT,
DOMAIN,
SERVICE_CLEAR_LOCK_USERCODE,
SERVICE_PING,
SERVICE_REFRESH_VALUE,
SERVICE_RESET_METER,
SERVICE_SET_CONFIG_PARAMETER,
SERVICE_SET_LOCK_USERCODE,
SERVICE_SET_VALUE,
VALUE_SCHEMA,
)
from .device_automation_helpers import (
CONF_SUBTYPE,
VALUE_ID_REGEX,
get_config_parameter_value_schema,
)
from .helpers import async_get_node_from_device_id
ACTION_TYPES = {
SERVICE_CLEAR_LOCK_USERCODE,
SERVICE_PING,
SERVICE_REFRESH_VALUE,
SERVICE_RESET_METER,
SERVICE_SET_CONFIG_PARAMETER,
SERVICE_SET_LOCK_USERCODE,
SERVICE_SET_VALUE,
}
CLEAR_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_CLEAR_LOCK_USERCODE,
vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN),
vol.Required(ATTR_CODE_SLOT): vol.Coerce(int),
}
)
PING_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_PING,
}
)
REFRESH_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_REFRESH_VALUE,
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(ATTR_REFRESH_ALL_VALUES, default=False): cv.boolean,
}
)
RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_RESET_METER,
vol.Required(CONF_ENTITY_ID): cv.entity_domain(SENSOR_DOMAIN),
vol.Optional(ATTR_METER_TYPE): vol.Coerce(int),
vol.Optional(ATTR_VALUE): vol.Coerce(int),
}
)
SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_SET_CONFIG_PARAMETER,
vol.Required(ATTR_CONFIG_PARAMETER): vol.Any(int, str),
vol.Required(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(None, int, str),
vol.Required(ATTR_VALUE): vol.Coerce(int),
vol.Required(CONF_SUBTYPE): cv.string,
}
)
SET_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_SET_LOCK_USERCODE,
vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN),
vol.Required(ATTR_CODE_SLOT): vol.Coerce(int),
vol.Required(ATTR_USERCODE): cv.string,
}
)
SET_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_SET_VALUE,
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
vol.Required(ATTR_PROPERTY): vol.Any(int, str),
vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string),
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
vol.Required(ATTR_VALUE): VALUE_SCHEMA,
vol.Optional(ATTR_WAIT_FOR_RESULT, default=False): cv.boolean,
}
)
ACTION_SCHEMA = vol.Any(
CLEAR_LOCK_USERCODE_SCHEMA,
PING_SCHEMA,
REFRESH_VALUE_SCHEMA,
RESET_METER_SCHEMA,
SET_CONFIG_PARAMETER_SCHEMA,
SET_LOCK_USERCODE_SCHEMA,
SET_VALUE_SCHEMA,
)
async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]:
"""List device actions for Z-Wave JS devices."""
registry = entity_registry.async_get(hass)
actions = []
node = async_get_node_from_device_id(hass, device_id)
base_action = {
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
}
actions.extend(
[
{**base_action, CONF_TYPE: SERVICE_SET_VALUE},
{**base_action, CONF_TYPE: SERVICE_PING},
]
)
actions.extend(
[
{
**base_action,
CONF_TYPE: SERVICE_SET_CONFIG_PARAMETER,
ATTR_CONFIG_PARAMETER: config_value.property_,
ATTR_CONFIG_PARAMETER_BITMASK: config_value.property_key,
CONF_SUBTYPE: f"{config_value.value_id} ({config_value.property_name})",
}
for config_value in node.get_configuration_values().values()
]
)
meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict)
for entry in entity_registry.async_entries_for_device(registry, device_id):
entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id}
actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE})
if entry.domain == LOCK_DOMAIN:
actions.extend(
[
{**entity_action, CONF_TYPE: SERVICE_SET_LOCK_USERCODE},
{**entity_action, CONF_TYPE: SERVICE_CLEAR_LOCK_USERCODE},
]
)
if entry.domain == SENSOR_DOMAIN:
value_id = entry.unique_id.split(".")[1]
# If this unique ID doesn't have a value ID, we know it is the node status
# sensor which doesn't have any relevant actions
if re.match(VALUE_ID_REGEX, value_id):
value = node.values[value_id]
else:
continue
# If the value has the meterType CC specific value, we can add a reset_meter
# action for it
if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific:
meter_endpoints[value.endpoint].setdefault(
CONF_ENTITY_ID, entry.entity_id
)
meter_endpoints[value.endpoint].setdefault(ATTR_METER_TYPE, set()).add(
get_meter_type(value)
)
if not meter_endpoints:
return actions
for endpoint, endpoint_data in meter_endpoints.items():
base_action[CONF_ENTITY_ID] = endpoint_data[CONF_ENTITY_ID]
actions.append(
{
**base_action,
CONF_TYPE: SERVICE_RESET_METER,
CONF_SUBTYPE: f"Endpoint {endpoint} (All)",
}
)
for meter_type in endpoint_data[ATTR_METER_TYPE]:
actions.append(
{
**base_action,
CONF_TYPE: SERVICE_RESET_METER,
ATTR_METER_TYPE: meter_type,
CONF_SUBTYPE: f"Endpoint {endpoint} ({meter_type.name})",
}
)
return actions
async def async_call_action_from_config(
hass: HomeAssistant, config: dict, variables: dict, context: Context | None
) -> None:
"""Execute a device action."""
action_type = service = config.pop(CONF_TYPE)
if action_type not in ACTION_TYPES:
raise HomeAssistantError(f"Unhandled action type {action_type}")
service_data = {k: v for k, v in config.items() if v not in (None, "")}
await hass.services.async_call(
DOMAIN, service, service_data, blocking=True, context=context
)
async def async_get_action_capabilities(
hass: HomeAssistant, config: ConfigType
) -> dict[str, vol.Schema]:
"""List action capabilities."""
action_type = config[CONF_TYPE]
node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID])
# Add additional fields to the automation action UI
if action_type == SERVICE_CLEAR_LOCK_USERCODE:
return {
"extra_fields": vol.Schema(
{
vol.Required(ATTR_CODE_SLOT): cv.string,
}
)
}
if action_type == SERVICE_SET_LOCK_USERCODE:
return {
"extra_fields": vol.Schema(
{
vol.Required(ATTR_CODE_SLOT): cv.string,
vol.Required(ATTR_USERCODE): cv.string,
}
)
}
if action_type == SERVICE_RESET_METER:
return {
"extra_fields": vol.Schema(
{
vol.Optional(ATTR_VALUE): cv.string,
}
)
}
if action_type == SERVICE_REFRESH_VALUE:
return {
"extra_fields": vol.Schema(
{
vol.Optional(ATTR_REFRESH_ALL_VALUES): cv.boolean,
}
)
}
if action_type == SERVICE_SET_VALUE:
return {
"extra_fields": vol.Schema(
{
vol.Required(ATTR_COMMAND_CLASS): vol.In(
{cc.value: cc.name for cc in CommandClass}
),
vol.Required(ATTR_PROPERTY): cv.string,
vol.Optional(ATTR_PROPERTY_KEY): cv.string,
vol.Optional(ATTR_ENDPOINT): cv.string,
vol.Required(ATTR_VALUE): cv.string,
vol.Optional(ATTR_WAIT_FOR_RESULT): cv.boolean,
}
)
}
if action_type == SERVICE_SET_CONFIG_PARAMETER:
value_id = get_value_id(
node,
CommandClass.CONFIGURATION,
config[ATTR_CONFIG_PARAMETER],
property_key=config[ATTR_CONFIG_PARAMETER_BITMASK],
)
value_schema = get_config_parameter_value_schema(node, value_id)
if value_schema is None:
return {}
return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})}
return {}

View file

@ -0,0 +1,34 @@
"""Provides helpers for Z-Wave JS device automations."""
from __future__ import annotations
from typing import cast
import voluptuous as vol
from zwave_js_server.const import ConfigurationValueType
from zwave_js_server.model.node import Node
from zwave_js_server.model.value import ConfigurationValue
NODE_STATUSES = ["asleep", "awake", "dead", "alive"]
CONF_SUBTYPE = "subtype"
CONF_VALUE_ID = "value_id"
VALUE_ID_REGEX = r"([0-9]+-[0-9]+-[0-9]+-).+"
def get_config_parameter_value_schema(node: Node, value_id: str) -> vol.Schema | None:
"""Get the extra fields schema for a config parameter value."""
config_value = cast(ConfigurationValue, node.values[value_id])
min_ = config_value.metadata.min
max_ = config_value.metadata.max
if config_value.configuration_value_type in (
ConfigurationValueType.RANGE,
ConfigurationValueType.MANUAL_ENTRY,
):
return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_))
if config_value.configuration_value_type == ConfigurationValueType.ENUMERATED:
return vol.In({int(k): v for k, v in config_value.metadata.states.items()})
return None

View file

@ -14,7 +14,6 @@ from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CON
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import DOMAIN
@ -24,34 +23,37 @@ from .const import (
ATTR_PROPERTY,
ATTR_PROPERTY_KEY,
ATTR_VALUE,
VALUE_SCHEMA,
)
from .device_automation_helpers import (
CONF_SUBTYPE,
CONF_VALUE_ID,
NODE_STATUSES,
get_config_parameter_value_schema,
)
from .helpers import (
async_get_node_from_device_id,
async_is_device_config_entry_not_loaded,
check_type_schema_map,
get_value_state_schema,
get_zwave_value_from_config,
remove_keys_with_empty_values,
)
CONF_SUBTYPE = "subtype"
CONF_VALUE_ID = "value_id"
CONF_STATUS = "status"
NODE_STATUS_TYPE = "node_status"
NODE_STATUS_TYPES = ["asleep", "awake", "dead", "alive"]
CONFIG_PARAMETER_TYPE = "config_parameter"
VALUE_TYPE = "value"
CONDITION_TYPES = {NODE_STATUS_TYPE, CONFIG_PARAMETER_TYPE, VALUE_TYPE}
NODE_STATUS_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend(
NODE_STATUS_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): NODE_STATUS_TYPE,
vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES),
vol.Required(CONF_STATUS): vol.In(NODE_STATUSES),
}
)
CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend(
CONFIG_PARAMETER_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): CONFIG_PARAMETER_TYPE,
vol.Required(CONF_VALUE_ID): cv.string,
@ -60,20 +62,14 @@ CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend(
}
)
VALUE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend(
VALUE_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): VALUE_TYPE,
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string),
vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string),
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
vol.Required(ATTR_VALUE): vol.Any(
bool,
vol.Coerce(int),
vol.Coerce(float),
cv.boolean,
cv.string,
),
vol.Required(ATTR_VALUE): VALUE_SCHEMA,
}
)
@ -204,10 +200,9 @@ async def async_get_condition_capabilities(
# Add additional fields to the automation trigger UI
if config[CONF_TYPE] == CONFIG_PARAMETER_TYPE:
value_id = config[CONF_VALUE_ID]
value_schema = get_value_state_schema(node.values[value_id])
if not value_schema:
value_schema = get_config_parameter_value_schema(node, value_id)
if value_schema is None:
return {}
return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})}
if config[CONF_TYPE] == VALUE_TYPE:
@ -234,7 +229,7 @@ async def async_get_condition_capabilities(
if config[CONF_TYPE] == NODE_STATUS_TYPE:
return {
"extra_fields": vol.Schema(
{vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES)}
{vol.Required(CONF_STATUS): vol.In(NODE_STATUSES)}
)
}

View file

@ -49,6 +49,7 @@ from .const import (
ZWAVE_JS_NOTIFICATION_EVENT,
ZWAVE_JS_VALUE_NOTIFICATION_EVENT,
)
from .device_automation_helpers import CONF_SUBTYPE, NODE_STATUSES
from .helpers import (
async_get_node_from_device_id,
async_get_node_status_sensor_entity_id,
@ -65,8 +66,6 @@ from .triggers.value_updated import (
PLATFORM_TYPE as VALUE_UPDATED_PLATFORM_TYPE,
)
CONF_SUBTYPE = "subtype"
# Trigger types
ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control"
NOTIFICATION_NOTIFICATION = "event.notification.notification"
@ -153,8 +152,6 @@ BASE_STATE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
}
)
NODE_STATUSES = ["asleep", "awake", "dead", "alive"]
NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): NODE_STATUS,

View file

@ -25,7 +25,12 @@ from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DATA_CLIENT, DOMAIN
from .const import (
DATA_CLIENT,
DOMAIN,
SERVICE_CLEAR_LOCK_USERCODE,
SERVICE_SET_LOCK_USERCODE,
)
from .discovery import ZwaveDiscoveryInfo
from .entity import ZWaveBaseEntity
@ -42,9 +47,6 @@ STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = {
},
}
SERVICE_SET_LOCK_USERCODE = "set_lock_usercode"
SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode"
async def async_setup_entry(
hass: HomeAssistant,

View file

@ -59,27 +59,6 @@ def broadcast_command(val: dict[str, Any]) -> dict[str, Any]:
)
# Validates that a bitmask is provided in hex form and converts it to decimal
# int equivalent since that's what the library uses
BITMASK_SCHEMA = vol.All(
cv.string,
vol.Lower,
vol.Match(
r"^(0x)?[0-9a-f]+$",
msg="Must provide an integer (e.g. 255) or a bitmask in hex form (e.g. 0xff)",
),
lambda value: int(value, 16),
)
VALUE_SCHEMA = vol.Any(
bool,
vol.Coerce(int),
vol.Coerce(float),
BITMASK_SCHEMA,
cv.string,
)
class ZWaveServices:
"""Class that holds our services (Zwave Commands) that should be published to hass."""
@ -198,10 +177,10 @@ class ZWaveServices:
vol.Coerce(int), cv.string
),
vol.Optional(const.ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(
vol.Coerce(int), BITMASK_SCHEMA
vol.Coerce(int), const.BITMASK_SCHEMA
),
vol.Required(const.ATTR_CONFIG_VALUE): vol.Any(
vol.Coerce(int), BITMASK_SCHEMA, cv.string
vol.Coerce(int), const.BITMASK_SCHEMA, cv.string
),
},
cv.has_at_least_one_key(
@ -232,8 +211,10 @@ class ZWaveServices:
vol.Coerce(int),
{
vol.Any(
vol.Coerce(int), BITMASK_SCHEMA, cv.string
): vol.Any(vol.Coerce(int), BITMASK_SCHEMA, cv.string)
vol.Coerce(int), const.BITMASK_SCHEMA, cv.string
): vol.Any(
vol.Coerce(int), const.BITMASK_SCHEMA, cv.string
)
},
),
},
@ -284,9 +265,11 @@ class ZWaveServices:
vol.Coerce(int), str
),
vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int),
vol.Required(const.ATTR_VALUE): VALUE_SCHEMA,
vol.Required(const.ATTR_VALUE): const.VALUE_SCHEMA,
vol.Optional(const.ATTR_WAIT_FOR_RESULT): cv.boolean,
vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA},
vol.Optional(const.ATTR_OPTIONS): {
cv.string: const.VALUE_SCHEMA
},
},
cv.has_at_least_one_key(
ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID
@ -319,8 +302,10 @@ class ZWaveServices:
vol.Coerce(int), str
),
vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int),
vol.Required(const.ATTR_VALUE): VALUE_SCHEMA,
vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA},
vol.Required(const.ATTR_VALUE): const.VALUE_SCHEMA,
vol.Optional(const.ATTR_OPTIONS): {
cv.string: const.VALUE_SCHEMA
},
},
vol.Any(
cv.has_at_least_one_key(

View file

@ -67,7 +67,9 @@
"on_supervisor": {
"title": "Select connection method",
"description": "Do you want to use the Z-Wave JS Supervisor add-on?",
"data": { "use_addon": "Use the Z-Wave JS Supervisor add-on" }
"data": {
"use_addon": "Use the Z-Wave JS Supervisor add-on"
}
},
"install_addon": {
"title": "The Z-Wave JS add-on installation has started"
@ -81,7 +83,9 @@
"emulate_hardware": "Emulate Hardware"
}
},
"start_addon": { "title": "The Z-Wave JS add-on is starting." }
"start_addon": {
"title": "The Z-Wave JS add-on is starting."
}
},
"error": {
"invalid_ws_url": "Invalid websocket URL",
@ -118,6 +122,15 @@
"node_status": "Node status",
"config_parameter": "Config parameter {subtype} value",
"value": "Current value of a Z-Wave Value"
},
"action_type": {
"clear_lock_usercode": "Clear usercode on {entity_name}",
"set_lock_usercode": "Set a usercode on {entity_name}",
"set_config_parameter": "Set value of config parameter {subtype}",
"set_value": "Set value of a Z-Wave Value",
"refresh_value": "Refresh the value(s) for {entity_name}",
"ping": "Ping device",
"reset_meter": "Reset meters on {subtype}"
}
}
}

View file

@ -58,6 +58,15 @@
}
},
"device_automation": {
"action_type": {
"clear_lock_usercode": "Clear usercode on {entity_name}",
"ping": "Ping device",
"refresh_value": "Refresh the value(s) for {entity_name}",
"reset_meter": "Reset meters on {subtype}",
"set_config_parameter": "Set value of config parameter {subtype}",
"set_lock_usercode": "Set a usercode on {entity_name}",
"set_value": "Set value of a Z-Wave Value"
},
"condition_type": {
"config_parameter": "Config parameter {subtype} value",
"node_status": "Node status",

View file

@ -0,0 +1,457 @@
"""The tests for Z-Wave JS device actions."""
import pytest
import voluptuous_serialize
from zwave_js_server.client import Client
from zwave_js_server.const import CommandClass
from zwave_js_server.model.node import Node
from homeassistant.components import automation
from homeassistant.components.zwave_js import DOMAIN, device_action
from homeassistant.components.zwave_js.helpers import get_device_id
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, device_registry
from homeassistant.setup import async_setup_component
from tests.common import async_get_device_automations, async_mock_service
async def test_get_actions(
hass: HomeAssistant,
client: Client,
lock_schlage_be469: Node,
integration: ConfigEntry,
) -> None:
"""Test we get the expected actions from a zwave_js node."""
node = lock_schlage_be469
dev_reg = device_registry.async_get(hass)
device = dev_reg.async_get_device({get_device_id(client, node)})
assert device
expected_actions = [
{
"domain": DOMAIN,
"type": "clear_lock_usercode",
"device_id": device.id,
"entity_id": "lock.touchscreen_deadbolt",
},
{
"domain": DOMAIN,
"type": "set_lock_usercode",
"device_id": device.id,
"entity_id": "lock.touchscreen_deadbolt",
},
{
"domain": DOMAIN,
"type": "refresh_value",
"device_id": device.id,
"entity_id": "lock.touchscreen_deadbolt",
},
{
"domain": DOMAIN,
"type": "set_value",
"device_id": device.id,
},
{
"domain": DOMAIN,
"type": "ping",
"device_id": device.id,
},
{
"domain": DOMAIN,
"type": "set_config_parameter",
"device_id": device.id,
"parameter": 3,
"bitmask": None,
"subtype": f"{node.node_id}-112-0-3 (Beeper)",
},
]
actions = await async_get_device_automations(hass, "action", device.id)
for action in expected_actions:
assert action in actions
async def test_get_actions_meter(
hass: HomeAssistant,
client: Client,
aeon_smart_switch_6: Node,
integration: ConfigEntry,
) -> None:
"""Test we get the expected meter actions from a zwave_js node."""
node = aeon_smart_switch_6
dev_reg = device_registry.async_get(hass)
device = dev_reg.async_get_device({get_device_id(client, node)})
assert device
actions = await async_get_device_automations(hass, "action", device.id)
filtered_actions = [action for action in actions if action["type"] == "reset_meter"]
assert len(filtered_actions) > 0
async def test_action(hass: HomeAssistant) -> None:
"""Test for turn_on and turn_off actions."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "event",
"event_type": "test_event_clear_lock_usercode",
},
"action": {
"domain": DOMAIN,
"type": "clear_lock_usercode",
"device_id": "fake",
"entity_id": "lock.touchscreen_deadbolt",
"code_slot": 1,
},
},
{
"trigger": {
"platform": "event",
"event_type": "test_event_set_lock_usercode",
},
"action": {
"domain": DOMAIN,
"type": "set_lock_usercode",
"device_id": "fake",
"entity_id": "lock.touchscreen_deadbolt",
"code_slot": 1,
"usercode": "1234",
},
},
{
"trigger": {
"platform": "event",
"event_type": "test_event_refresh_value",
},
"action": {
"domain": DOMAIN,
"type": "refresh_value",
"device_id": "fake",
"entity_id": "lock.touchscreen_deadbolt",
},
},
{
"trigger": {
"platform": "event",
"event_type": "test_event_ping",
},
"action": {
"domain": DOMAIN,
"type": "ping",
"device_id": "fake",
},
},
{
"trigger": {
"platform": "event",
"event_type": "test_event_set_value",
},
"action": {
"domain": DOMAIN,
"type": "set_value",
"device_id": "fake",
"command_class": 112,
"property": "test",
"value": 1,
},
},
{
"trigger": {
"platform": "event",
"event_type": "test_event_set_config_parameter",
},
"action": {
"domain": DOMAIN,
"type": "set_config_parameter",
"device_id": "fake",
"parameter": 3,
"bitmask": None,
"subtype": "2-112-0-3 (Beeper)",
"value": 255,
},
},
]
},
)
clear_lock_usercode = async_mock_service(hass, "zwave_js", "clear_lock_usercode")
hass.bus.async_fire("test_event_clear_lock_usercode")
await hass.async_block_till_done()
assert len(clear_lock_usercode) == 1
set_lock_usercode = async_mock_service(hass, "zwave_js", "set_lock_usercode")
hass.bus.async_fire("test_event_set_lock_usercode")
await hass.async_block_till_done()
assert len(set_lock_usercode) == 1
refresh_value = async_mock_service(hass, "zwave_js", "refresh_value")
hass.bus.async_fire("test_event_refresh_value")
await hass.async_block_till_done()
assert len(refresh_value) == 1
ping = async_mock_service(hass, "zwave_js", "ping")
hass.bus.async_fire("test_event_ping")
await hass.async_block_till_done()
assert len(ping) == 1
set_value = async_mock_service(hass, "zwave_js", "set_value")
hass.bus.async_fire("test_event_set_value")
await hass.async_block_till_done()
assert len(set_value) == 1
set_config_parameter = async_mock_service(hass, "zwave_js", "set_config_parameter")
hass.bus.async_fire("test_event_set_config_parameter")
await hass.async_block_till_done()
assert len(set_config_parameter) == 1
async def test_get_action_capabilities(
hass: HomeAssistant,
client: Client,
climate_radio_thermostat_ct100_plus: Node,
integration: ConfigEntry,
):
"""Test we get the expected action capabilities."""
node = climate_radio_thermostat_ct100_plus
dev_reg = device_registry.async_get(hass)
device = device_registry.async_entries_for_config_entry(
dev_reg, integration.entry_id
)[0]
# Test refresh_value
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"type": "refresh_value",
},
)
assert capabilities and "extra_fields" in capabilities
assert voluptuous_serialize.convert(
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
) == [{"type": "boolean", "name": "refresh_all_values", "optional": True}]
# Test ping
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"type": "ping",
},
)
assert not capabilities
# Test set_value
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"type": "set_value",
},
)
assert capabilities and "extra_fields" in capabilities
cc_options = [(cc.value, cc.name) for cc in CommandClass]
assert voluptuous_serialize.convert(
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
) == [
{
"name": "command_class",
"required": True,
"options": cc_options,
"type": "select",
},
{"name": "property", "required": True, "type": "string"},
{"name": "property_key", "optional": True, "type": "string"},
{"name": "endpoint", "optional": True, "type": "string"},
{"name": "value", "required": True, "type": "string"},
{"type": "boolean", "name": "wait_for_result", "optional": True},
]
# Test enumerated type param
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"type": "set_config_parameter",
"parameter": 1,
"bitmask": None,
"subtype": f"{node.node_id}-112-0-1 (Temperature Reporting Threshold)",
},
)
assert capabilities and "extra_fields" in capabilities
assert voluptuous_serialize.convert(
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
) == [
{
"name": "value",
"required": True,
"options": [
(0, "Disabled"),
(1, "0.5° F"),
(2, "1.0° F"),
(3, "1.5° F"),
(4, "2.0° F"),
],
"type": "select",
}
]
# Test range type param
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"type": "set_config_parameter",
"parameter": 10,
"bitmask": None,
"subtype": f"{node.node_id}-112-0-10 (Temperature Reporting Filter)",
},
)
assert capabilities and "extra_fields" in capabilities
assert voluptuous_serialize.convert(
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
) == [
{
"name": "value",
"required": True,
"type": "integer",
"valueMin": 0,
"valueMax": 124,
}
]
# Test undefined type param
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"type": "set_config_parameter",
"parameter": 2,
"bitmask": None,
"subtype": f"{node.node_id}-112-0-2 (HVAC Settings)",
},
)
assert not capabilities
async def test_get_action_capabilities_lock_triggers(
hass: HomeAssistant,
client: Client,
lock_schlage_be469: Node,
integration: ConfigEntry,
):
"""Test we get the expected action capabilities for lock triggers."""
dev_reg = device_registry.async_get(hass)
device = device_registry.async_entries_for_config_entry(
dev_reg, integration.entry_id
)[0]
# Test clear_lock_usercode
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"entity_id": "lock.touchscreen_deadbolt",
"type": "clear_lock_usercode",
},
)
assert capabilities and "extra_fields" in capabilities
assert voluptuous_serialize.convert(
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
) == [{"type": "string", "name": "code_slot", "required": True}]
# Test set_lock_usercode
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"entity_id": "lock.touchscreen_deadbolt",
"type": "set_lock_usercode",
},
)
assert capabilities and "extra_fields" in capabilities
assert voluptuous_serialize.convert(
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
) == [
{"type": "string", "name": "code_slot", "required": True},
{"type": "string", "name": "usercode", "required": True},
]
async def test_get_action_capabilities_meter_triggers(
hass: HomeAssistant,
client: Client,
aeon_smart_switch_6: Node,
integration: ConfigEntry,
) -> None:
"""Test we get the expected action capabilities for meter triggers."""
node = aeon_smart_switch_6
dev_reg = device_registry.async_get(hass)
device = dev_reg.async_get_device({get_device_id(client, node)})
assert device
capabilities = await device_action.async_get_action_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"device_id": device.id,
"entity_id": "sensor.meter",
"type": "reset_meter",
},
)
assert capabilities and "extra_fields" in capabilities
assert voluptuous_serialize.convert(
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
) == [{"type": "string", "name": "value", "optional": True}]
async def test_failure_scenarios(
hass: HomeAssistant,
client: Client,
hank_binary_switch: Node,
integration: ConfigEntry,
):
"""Test failure scenarios."""
dev_reg = device_registry.async_get(hass)
device = device_registry.async_entries_for_config_entry(
dev_reg, integration.entry_id
)[0]
with pytest.raises(HomeAssistantError):
await device_action.async_call_action_from_config(
hass, {"type": "failed.test", "device_id": device.id}, {}, None
)
assert (
await device_action.async_get_action_capabilities(
hass, {"type": "failed.test", "device_id": device.id}
)
== {}
)