Clean up Z-Wave JS conditions (#183782)

This commit is contained in:
Artur Pragacz
2026-09-30 18:35:49 +02:00
committed by GitHub
parent 035b209704
commit 61c8df73c3
2 changed files with 43 additions and 110 deletions
+14 -44
View File
@@ -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
+29 -66
View File
@@ -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(