diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 2eac4b7d7b0..f17654f184a 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -4,9 +4,12 @@ from __future__ import annotations from typing import cast import voluptuous as vol -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import CommandClass from zwave_js_server.model.value import ConfigurationValue +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -22,7 +25,14 @@ from .const import ( ATTR_PROPERTY_KEY, ATTR_VALUE, ) -from .helpers import async_get_node_from_device_id, get_zwave_value_from_config +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" @@ -67,10 +77,21 @@ VALUE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( } ) -CONDITION_SCHEMA = vol.Any( - NODE_STATUS_CONDITION_SCHEMA, - CONFIG_PARAMETER_CONDITION_SCHEMA, - VALUE_CONDITION_SCHEMA, +TYPE_SCHEMA_MAP = { + NODE_STATUS_TYPE: NODE_STATUS_CONDITION_SCHEMA, + CONFIG_PARAMETER_TYPE: CONFIG_PARAMETER_CONDITION_SCHEMA, + VALUE_TYPE: VALUE_CONDITION_SCHEMA, +} + + +CONDITION_TYPE_SCHEMA = vol.Schema( + {vol.Required(CONF_TYPE): vol.In(TYPE_SCHEMA_MAP)}, extra=vol.ALLOW_EXTRA +) + +CONDITION_SCHEMA = vol.All( + remove_keys_with_empty_values, + CONDITION_TYPE_SCHEMA, + check_type_schema_map(TYPE_SCHEMA_MAP), ) @@ -79,9 +100,18 @@ async def async_validate_condition_config( ) -> ConfigType: """Validate config.""" config = CONDITION_SCHEMA(config) + + # We return early if the config entry for this device is not ready because we can't + # validate the value without knowing the state of the device + if async_is_device_config_entry_not_loaded(hass, config[CONF_DEVICE_ID]): + return config + if config[CONF_TYPE] == VALUE_TYPE: - node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) - get_zwave_value_from_config(node, config) + try: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig(err.msg) from err return config @@ -174,20 +204,8 @@ 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] - 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, - ): - value_schema = vol.Range(min=min_, max=max_) - elif config_value.configuration_value_type == ConfigurationValueType.ENUMERATED: - value_schema = vol.In( - {int(k): v for k, v in config_value.metadata.states.items()} - ) - else: + value_schema = get_value_state_schema(node.values[value_id]) + if not value_schema: return {} return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 6fab91c867d..7ed13ce2b98 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -4,10 +4,13 @@ from __future__ import annotations from typing import Any import voluptuous as vol -from zwave_js_server.const import CommandClass, ConfigurationValueType +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.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.homeassistant.triggers import event, state from homeassistant.const import ( CONF_DEVICE_ID, @@ -46,12 +49,20 @@ from .const import ( from .helpers import ( async_get_node_from_device_id, async_get_node_status_sensor_entity_id, + async_is_device_config_entry_not_loaded, + check_type_schema_map, + copy_available_params, + get_value_state_schema, get_zwave_value_from_config, + remove_keys_with_empty_values, +) +from .triggers.value_updated import ( + ATTR_FROM, + ATTR_TO, + PLATFORM_TYPE as VALUE_UPDATED_PLATFORM_TYPE, ) -from .triggers.value_updated import ATTR_FROM, ATTR_TO CONF_SUBTYPE = "subtype" -CONF_VALUE_ID = "value_id" # Trigger types ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control" @@ -59,8 +70,8 @@ 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" -CONFIG_PARAMETER_VALUE_UPDATED = f"{DOMAIN}.value_updated.config_parameter" -VALUE_VALUE_UPDATED = f"{DOMAIN}.value_updated.value" +CONFIG_PARAMETER_VALUE_UPDATED = f"{VALUE_UPDATED_PLATFORM_TYPE}.config_parameter" +VALUE_VALUE_UPDATED = f"{VALUE_UPDATED_PLATFORM_TYPE}.value" NODE_STATUS = "state.node_status" VALUE_SCHEMA = vol.Any( @@ -71,6 +82,7 @@ VALUE_SCHEMA = vol.Any( cv.string, ) + NOTIFICATION_EVENT_CC_MAPPINGS = ( (ENTRY_CONTROL_NOTIFICATION, CommandClass.ENTRY_CONTROL), (NOTIFICATION_NOTIFICATION, CommandClass.NOTIFICATION), @@ -104,7 +116,7 @@ ENTRY_CONTROL_NOTIFICATION_SCHEMA = BASE_EVENT_SCHEMA.extend( 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.Optional(ATTR_PROPERTY_KEY): vol.Any(int, str), vol.Required(ATTR_ENDPOINT): vol.Coerce(int), vol.Optional(ATTR_VALUE): vol.Coerce(int), vol.Required(CONF_SUBTYPE): cv.string, @@ -174,17 +186,61 @@ VALUE_VALUE_UPDATED_SCHEMA = BASE_VALUE_UPDATED_SCHEMA.extend( } ) -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, - CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA, - VALUE_VALUE_UPDATED_SCHEMA, - NODE_STATUS_SCHEMA, +TYPE_SCHEMA_MAP = { + ENTRY_CONTROL_NOTIFICATION: ENTRY_CONTROL_NOTIFICATION_SCHEMA, + NOTIFICATION_NOTIFICATION: NOTIFICATION_NOTIFICATION_SCHEMA, + BASIC_VALUE_NOTIFICATION: BASIC_VALUE_NOTIFICATION_SCHEMA, + CENTRAL_SCENE_VALUE_NOTIFICATION: CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA, + SCENE_ACTIVATION_VALUE_NOTIFICATION: SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA, + CONFIG_PARAMETER_VALUE_UPDATED: CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA, + VALUE_VALUE_UPDATED: VALUE_VALUE_UPDATED_SCHEMA, + NODE_STATUS: NODE_STATUS_SCHEMA, +} + + +TRIGGER_TYPE_SCHEMA = vol.Schema( + {vol.Required(CONF_TYPE): vol.In(TYPE_SCHEMA_MAP)}, extra=vol.ALLOW_EXTRA ) +TRIGGER_SCHEMA = vol.All( + remove_keys_with_empty_values, + TRIGGER_TYPE_SCHEMA, + check_type_schema_map(TYPE_SCHEMA_MAP), +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + # We return early if the config entry for this device is not ready because we can't + # validate the value without knowing the state of the device + if async_is_device_config_entry_not_loaded(hass, config[CONF_DEVICE_ID]): + return config + + trigger_type = config[CONF_TYPE] + if get_trigger_platform_from_type(trigger_type) == VALUE_UPDATED_PLATFORM_TYPE: + try: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig(err.msg) from err + + return config + + +def get_trigger_platform_from_type(trigger_type: str) -> str: + """Get trigger platform from Z-Wave JS trigger type.""" + trigger_split = trigger_type.split(".") + # Our convention for trigger types is to have the trigger type at the beginning + # delimited by a `.`. For zwave_js triggers, there is a `.` in the name + trigger_platform = trigger_split[0] + if trigger_platform == DOMAIN: + return ".".join(trigger_split[:2]) + return trigger_platform + async def async_get_triggers( hass: HomeAssistant, device_id: str @@ -298,15 +354,6 @@ async def async_get_triggers( 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, @@ -315,12 +362,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] - trigger_split = trigger_type.split(".") - # Our convention for trigger types is to have the trigger type at the beginning - # delimited by a `.`. For zwave_js triggers, there is a `.` in the name - trigger_platform = trigger_split[0] - if trigger_platform == DOMAIN: - trigger_platform = ".".join(trigger_split[:2]) + trigger_platform = get_trigger_platform_from_type(trigger_type) # Take input data from automation trigger UI and add it to the trigger we are # attaching to @@ -379,14 +421,7 @@ async def async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) - if trigger_platform == f"{DOMAIN}.value_updated": - # Try to get the value to make sure the value ID is valid - try: - node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) - get_zwave_value_from_config(node, config) - except (ValueError, vol.Invalid) as err: - raise HomeAssistantError("Invalid value specified") from err - + if trigger_platform == VALUE_UPDATED_PLATFORM_TYPE: zwave_js_config = { state.CONF_PLATFORM: trigger_platform, CONF_DEVICE_ID: config[CONF_DEVICE_ID], @@ -420,9 +455,7 @@ async def async_get_trigger_capabilities( trigger_type = config[CONF_TYPE] 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 trigger_type == NOTIFICATION_NOTIFICATION: return { @@ -462,33 +495,23 @@ async def async_get_trigger_capabilities( 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), - ) + value_schema = get_value_state_schema(get_zwave_value_from_config(node, config)) + + # We should never get here, but just in case we should add a guard + if not value_schema: + return {} return {"extra_fields": vol.Schema({vol.Optional(ATTR_VALUE): value_schema})} if trigger_type == CONFIG_PARAMETER_VALUE_UPDATED: - # We can be more deliberate about the config parameter schema here because - # there are a limited number of types - if value.configuration_value_type == ConfigurationValueType.UNDEFINED: + value_schema = get_value_state_schema(get_zwave_value_from_config(node, config)) + if not value_schema: return {} - if value.configuration_value_type == ConfigurationValueType.ENUMERATED: - 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.Optional(state.CONF_FROM): value_schema, - vol.Optional(state.CONF_TO): value_schema, + vol.Optional(ATTR_FROM): value_schema, + vol.Optional(ATTR_TO): value_schema, } ) } @@ -509,8 +532,8 @@ async def async_get_trigger_capabilities( vol.Required(ATTR_PROPERTY): cv.string, vol.Optional(ATTR_PROPERTY_KEY): cv.string, vol.Optional(ATTR_ENDPOINT): cv.string, - vol.Optional(state.CONF_FROM): cv.string, - vol.Optional(state.CONF_TO): cv.string, + vol.Optional(ATTR_FROM): cv.string, + vol.Optional(ATTR_TO): cv.string, } ) } diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 667d7a9de24..4744c7f9fc1 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -1,16 +1,21 @@ """Helper functions for Z-Wave JS integration.""" from __future__ import annotations -from typing import Any, cast +from typing import Any, Callable, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import ConfigurationValueType from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import Value as ZwaveValue, get_value_id +from zwave_js_server.model.value import ( + ConfigurationValue, + 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.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_TYPE, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -242,3 +247,69 @@ def async_get_node_status_sensor_entity_id( ) return entity_id + + +def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: + """Remove keys from config where the value is an empty string or None.""" + return {key: value for key, value in config.items() if value not in ("", None)} + + +def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: + """Check type specific schema against config.""" + + def _check_type_schema(config: ConfigType) -> ConfigType: + """Check type specific schema against config.""" + return cast(ConfigType, schema_map[str(config[CONF_TYPE])](config)) + + return _check_type_schema + + +def copy_available_params( + input_dict: dict[str, Any], output_dict: dict[str, Any], params: list[str] +) -> None: + """Copy available params from input into output.""" + output_dict.update( + {param: input_dict[param] for param in params if param in input_dict} + ) + + +@callback +def async_is_device_config_entry_not_loaded( + hass: HomeAssistant, device_id: str +) -> bool: + """Return whether device's config entries are not loaded.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(device_id) + assert device + return any( + (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.state != ConfigEntryState.LOADED + for entry_id in device.config_entries + ) + + +def get_value_state_schema( + value: ZwaveValue, +) -> vol.Schema | None: + """Return device automation schema for a config entry.""" + if isinstance(value, ConfigurationValue): + min_ = value.metadata.min + max_ = value.metadata.max + if value.configuration_value_type in ( + ConfigurationValueType.RANGE, + ConfigurationValueType.MANUAL_ENTRY, + ): + return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_)) + + if value.configuration_value_type == ConfigurationValueType.ENUMERATED: + return vol.In({int(k): v for k, v in value.metadata.states.items()}) + + return None + + if value.metadata.states: + return vol.In({int(k): v for k, v in value.metadata.states.items()}) + + return vol.All( + vol.Coerce(int), + vol.Range(min=value.metadata.min, max=value.metadata.max), + ) diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 73ac9957071..dfdbb16c8e8 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -10,6 +10,9 @@ from zwave_js_server.const import CommandClass from zwave_js_server.event import Event from homeassistant.components import automation +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.zwave_js import DOMAIN, device_condition from homeassistant.components.zwave_js.helpers import get_zwave_value_from_config from homeassistant.exceptions import HomeAssistantError @@ -519,6 +522,7 @@ async def test_get_condition_capabilities_config_parameter( { "name": "value", "required": True, + "type": "integer", "valueMin": 0, "valueMax": 124, } @@ -565,6 +569,30 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): == {} ) + INVALID_CONFIG = { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": 9999, + "property_key": 9999, + "endpoint": 9999, + "value": 9999, + } + + # Test that invalid config raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_condition.async_validate_condition_config(hass, INVALID_CONFIG) + + # Unload entry so we can verify that validation will pass on an invalid config + # since we return early + await hass.config_entries.async_unload(integration.entry_id) + assert ( + await device_condition.async_validate_condition_config(hass, INVALID_CONFIG) + == INVALID_CONFIG + ) + async def test_get_value_from_config_failure( hass, client, hank_binary_switch, integration diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index c7cd8e23943..22496d3deed 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -8,11 +8,10 @@ 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, device_trigger -from homeassistant.components.zwave_js.device_trigger import ( - async_attach_trigger, - async_get_trigger_capabilities, +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, ) +from homeassistant.components.zwave_js import DOMAIN, device_trigger from homeassistant.components.zwave_js.helpers import ( async_get_node_status_sensor_entity_id, ) @@ -1281,12 +1280,12 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_enumerate async def test_failure_scenarios(hass, client, hank_binary_switch, integration): """Test failure scenarios.""" with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "failed.test", "device_id": "invalid_device_id"}, None, {} ) with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "event.failed_type", "device_id": "invalid_device_id"}, None, @@ -1297,12 +1296,12 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "failed.test", "device_id": device.id}, None, {} ) with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "event.failed_type", "device_id": device.id}, None, @@ -1310,29 +1309,13 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): ) with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "state.failed_type", "device_id": device.id}, None, {}, ) - with pytest.raises(HomeAssistantError): - await async_attach_trigger( - hass, - { - "device_id": device.id, - "type": "zwave_js.value_updated.value", - "command_class": CommandClass.DOOR_LOCK.value, - "property": -1234, - "property_key": None, - "endpoint": None, - "from": "open", - }, - None, - {}, - ) - with patch( "homeassistant.components.zwave_js.device_trigger.async_get_node_from_device_id", return_value=None, @@ -1341,7 +1324,7 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): return_value=None, ): assert ( - await async_get_trigger_capabilities( + await device_trigger.async_get_trigger_capabilities( hass, {"type": "failed.test", "device_id": "invalid_device_id"} ) == {} @@ -1349,3 +1332,26 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): with pytest.raises(HomeAssistantError): async_get_node_status_sensor_entity_id(hass, "invalid_device_id") + + INVALID_CONFIG = { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": 9999, + "property_key": 9999, + "endpoint": 9999, + } + + # Test that invalid config raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config(hass, INVALID_CONFIG) + + # Unload entry so we can verify that validation will pass on an invalid config + # since we return early + await hass.config_entries.async_unload(integration.entry_id) + assert ( + await device_trigger.async_validate_trigger_config(hass, INVALID_CONFIG) + == INVALID_CONFIG + )