From 61c8df73c36c570a151d9d209e8f67762aca70d0 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:35:49 +0200 Subject: [PATCH] Clean up Z-Wave JS conditions (#183782) --- .../components/zwave_js/condition.py | 58 +++-------- tests/components/zwave_js/test_condition.py | 95 ++++++------------- 2 files changed, 43 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/zwave_js/condition.py b/homeassistant/components/zwave_js/condition.py index 525fd43767bf..bd3d0a55e256 100644 --- a/homeassistant/components/zwave_js/condition.py +++ b/homeassistant/components/zwave_js/condition.py @@ -2,7 +2,6 @@ import abc from collections.abc import Callable, Iterable -from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Unpack, override import probatio @@ -12,7 +11,6 @@ from zwave_js_server.model.node import Node as ZwaveNode from homeassistant.const import ATTR_DEVICE_ID, CONF_OPTIONS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( ATTR_BEHAVIOR, BEHAVIOR_ALL, @@ -88,47 +86,25 @@ def _condition_schema( ) -@dataclass(slots=True) -class _ResolvedNodes: - """Z-Wave nodes resolved from the targeted devices.""" - - nodes: set[ZwaveNode] = field(default_factory=set) - unresolved: int = 0 - - @callback def _async_resolve_nodes( hass: HomeAssistant, device_ids: Iterable[str] -) -> _ResolvedNodes: - """Resolve targeted device IDs to Z-Wave nodes.""" - resolved = _ResolvedNodes() +) -> set[ZwaveNode]: + """Resolve targeted device IDs to Z-Wave nodes, skipping any that don't resolve.""" + nodes: set[ZwaveNode] = set() for device_id in set(device_ids): try: - node = async_get_node_from_device_id(hass, device_id) + nodes.add(async_get_node_from_device_id(hass, device_id)) except ValueError: - resolved.unresolved += 1 - else: - resolved.nodes.add(node) - return resolved + continue + return nodes class _ZwaveNodeCondition(Condition): """Base for conditions evaluated per Z-Wave node.""" - options_schema_dict: dict[probatio.Marker, Any] _schema: probatio.Schema - @classmethod - @override - async def async_validate_complete_config( - cls, hass: HomeAssistant, complete_config: ConfigType - ) -> ConfigType: - """Validate complete config.""" - complete_config = move_top_level_schema_fields_to_options( - complete_config, cls.options_schema_dict - ) - return await super().async_validate_complete_config(hass, complete_config) - @classmethod @override async def async_validate_config( @@ -140,10 +116,10 @@ class _ZwaveNodeCondition(Condition): if async_bypass_dynamic_config_validation(hass, {ATTR_DEVICE_ID: device_ids}): return config - resolved = _async_resolve_nodes(hass, device_ids) - if not resolved.nodes: + nodes = _async_resolve_nodes(hass, device_ids) + if not nodes: raise probatio.Invalid("No nodes found for the given devices") - cls._validate_nodes(resolved.nodes, config[CONF_OPTIONS]) + cls._validate_nodes(nodes, config[CONF_OPTIONS]) return config @classmethod @@ -164,20 +140,16 @@ class _ZwaveNodeCondition(Condition): @override def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test the condition against all targeted nodes.""" - resolved = _async_resolve_nodes(self._hass, self._options[ATTR_DEVICE_ID]) - if not resolved.nodes: - return False - behavior_all = self._options[ATTR_BEHAVIOR] == BEHAVIOR_ALL - if behavior_all and resolved.unresolved: - return False - combine: Callable[[Iterable[object]], bool] = all if behavior_all else any - return combine(self._node_matches(node) for node in resolved.nodes) + nodes = _async_resolve_nodes(self._hass, self._options[ATTR_DEVICE_ID]) + combine: Callable[[Iterable[object]], bool] = ( + all if self._options[ATTR_BEHAVIOR] == BEHAVIOR_ALL else any + ) + return combine(self._node_matches(node) for node in nodes) class NodeStatusCondition(_ZwaveNodeCondition): """Test the status of Z-Wave nodes.""" - options_schema_dict = _NODE_STATUS_OPTIONS_SCHEMA_DICT _schema = _condition_schema(_NODE_STATUS_OPTIONS_SCHEMA_DICT) @override @@ -224,7 +196,6 @@ class _ZwaveValueCondition(_ZwaveNodeCondition): class ValueCondition(_ZwaveValueCondition): """Test a Z-Wave value.""" - options_schema_dict = _VALUE_OPTIONS_SCHEMA_DICT _schema = _condition_schema(_VALUE_OPTIONS_SCHEMA_DICT) @classmethod @@ -247,7 +218,6 @@ class ValueCondition(_ZwaveValueCondition): class ConfigParameterCondition(_ZwaveValueCondition): """Test a Z-Wave configuration parameter.""" - options_schema_dict = _CONFIG_PARAMETER_OPTIONS_SCHEMA_DICT _schema = _condition_schema(_CONFIG_PARAMETER_OPTIONS_SCHEMA_DICT) @classmethod diff --git a/tests/components/zwave_js/test_condition.py b/tests/components/zwave_js/test_condition.py index b2f7f6dac3f1..44edf779fb38 100644 --- a/tests/components/zwave_js/test_condition.py +++ b/tests/components/zwave_js/test_condition.py @@ -423,42 +423,20 @@ async def test_config_parameter_with_bitmask( assert checker.async_check() is True -async def test_top_level_fields_moved_to_options( +@pytest.mark.parametrize( + ("behavior", "expected"), + [("any", False), ("all", True)], +) +async def test_check_when_nodes_disappear( hass: HomeAssistant, client: MagicMock, lock_schlage_be469: Node, integration: MockConfigEntry, device_registry: dr.DeviceRegistry, + behavior: str, + expected: bool, ) -> None: - """Test top level option fields are moved into the options block.""" - device_id = _device_id(device_registry, client, lock_schlage_be469, integration) - validated = await condition.async_validate_condition_config( - hass, - cv.CONDITION_SCHEMA( - { - "condition": f"{DOMAIN}.node_status", - "device_id": device_id, - "status": "alive", - "behavior": "all", - } - ), - ) - assert validated["options"] == { - "device_id": [device_id], - "behavior": "all", - "status": "alive", - } - assert "status" not in validated - - -async def test_check_false_when_nodes_disappear( - hass: HomeAssistant, - client: MagicMock, - lock_schlage_be469: Node, - integration: MockConfigEntry, - device_registry: dr.DeviceRegistry, -) -> None: - """Test the condition is False once the devices no longer resolve to nodes.""" + """Test an empty node set follows any/all semantics like entity conditions.""" checker = await _checker( hass, { @@ -467,19 +445,17 @@ async def test_check_false_when_nodes_disappear( "device_id": _device_id( device_registry, client, lock_schlage_be469, integration ), + "behavior": behavior, "status": "alive", }, }, ) assert checker.async_check() is True await hass.config_entries.async_unload(integration.entry_id) - assert checker.async_check() is False + assert checker.async_check() is expected -@pytest.mark.parametrize( - ("behavior", "expected"), - [("any", True), ("all", False)], -) +@pytest.mark.parametrize("behavior", ["any", "all"]) async def test_partially_unresolved_target( hass: HomeAssistant, client: MagicMock, @@ -488,9 +464,8 @@ async def test_partially_unresolved_target( integration: MockConfigEntry, device_registry: dr.DeviceRegistry, behavior: str, - expected: bool, ) -> None: - """Test a targeted Z-Wave node that cannot be resolved fails an all behavior.""" + """Test a targeted Z-Wave node that cannot be resolved is ignored.""" device_ids = [ _device_id(device_registry, client, lock_schlage_be469, integration), _device_id(device_registry, client, multisensor_6, integration), @@ -507,7 +482,7 @@ async def test_partially_unresolved_target( }, }, ) - assert checker.async_check() is expected + assert checker.async_check() is True async def test_config_parameter_missing_on_node( @@ -539,7 +514,7 @@ async def test_condition_description_fields_match_schema( hass: HomeAssistant, condition_type: str ) -> None: """Test the described fields and required flags match the options schema.""" - schema = CONDITIONS[condition_type].options_schema_dict + schema = next(iter(CONDITIONS[condition_type]._schema.schema.values())) descriptions = await condition.async_get_all_descriptions(hass) description = descriptions[f"{DOMAIN}.{condition_type}"] # Nodes are targeted with a device selector field, not a target selector @@ -587,14 +562,16 @@ async def test_node_status_selector_translations(hass: HomeAssistant) -> None: } == {"alive", "asleep", "awake", "dead"} -async def test_non_zwave_device_is_unresolved( +@pytest.mark.parametrize("behavior", ["any", "all"]) +async def test_non_zwave_device_is_ignored( hass: HomeAssistant, client: MagicMock, lock_schlage_be469: Node, integration: MockConfigEntry, device_registry: dr.DeviceRegistry, + behavior: str, ) -> None: - """Test a device from another integration counts as an unresolved node.""" + """Test a device from another integration is ignored when checking.""" other_entry = MockConfigEntry(domain="other") other_entry.add_to_hass(hass) other_device = device_registry.async_get_or_create( @@ -604,32 +581,18 @@ async def test_non_zwave_device_is_unresolved( _device_id(device_registry, client, lock_schlage_be469, integration), other_device.id, ] - assert ( - await _checker( - hass, - { - "condition": f"{DOMAIN}.node_status", - "options": { - "device_id": device_ids, - "behavior": "any", - "status": "alive", - }, + checker = await _checker( + hass, + { + "condition": f"{DOMAIN}.node_status", + "options": { + "device_id": device_ids, + "behavior": behavior, + "status": "alive", }, - ) - ).async_check() is True - assert ( - await _checker( - hass, - { - "condition": f"{DOMAIN}.node_status", - "options": { - "device_id": device_ids, - "behavior": "all", - "status": "alive", - }, - }, - ) - ).async_check() is False + }, + ) + assert checker.async_check() is True async def test_value_empty_property_key(