diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 2c117f17b546..7304fffcab27 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -358,6 +358,21 @@ class DisabledConditionChecker(ConditionChecker): return None +class CompoundConditionChecker(ConditionChecker): + """Base class for compound condition checkers (and/or/not).""" + + def __init__(self, hass: HomeAssistant, checks: list[ConditionChecker]) -> None: + """Initialize condition checker.""" + super().__init__(hass) + self._checks = checks + + def async_unload(self) -> None: + """Clean up child conditions.""" + for check in self._checks: + check.async_unload() + super().async_unload() + + class Condition(ConditionChecker): """Condition class.""" @@ -975,31 +990,38 @@ async def async_from_config( check_factory = check_factory.func if inspect.iscoroutinefunction(check_factory): - checker = cast(ConditionCheckerType, await factory(hass, config)) + checker = await factory(hass, config) else: - checker = cast(ConditionCheckerType, factory(config)) - return LegacyConditionChecker(hass, checker) + checker = factory(config) + if isinstance(checker, ConditionChecker): + return checker + return LegacyConditionChecker(hass, cast(ConditionCheckerType, checker)) async def async_and_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'AND'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return AndConditionChecker(hass, checks) - def if_and_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class AndConditionChecker(CompoundConditionChecker): + """Condition checker for 'and' compound conditions.""" + + def _check(self, variables: TemplateVarsType) -> bool: """Test and condition.""" errors = [] - for index, check in enumerate(checks): + for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(hass, variables) is False: + if check(self._hass, variables) is False: return False except ConditionError as ex: errors.append( - ConditionErrorIndex("and", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "and", index=index, total=len(self._checks), error=ex + ) ) # Raise the errors if no check was false @@ -1008,28 +1030,31 @@ async def async_and_from_config( return True - return if_and_condition - async def async_or_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'OR'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return OrConditionChecker(hass, checks) - def if_or_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class OrConditionChecker(CompoundConditionChecker): + """Condition checker for 'or' compound conditions.""" + + def _check(self, variables: TemplateVarsType) -> bool: """Test or condition.""" errors = [] - for index, check in enumerate(checks): + for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(hass, variables) is True: + if check(self._hass, variables) is True: return True except ConditionError as ex: errors.append( - ConditionErrorIndex("or", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "or", index=index, total=len(self._checks), error=ex + ) ) # Raise the errors if no check was true @@ -1038,28 +1063,31 @@ async def async_or_from_config( return False - return if_or_condition - async def async_not_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'NOT'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return NotConditionChecker(hass, checks) - def if_not_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class NotConditionChecker(CompoundConditionChecker): + """Condition checker for 'not' compound conditions.""" + + def _check(self, variables: TemplateVarsType) -> bool: """Test not condition.""" errors = [] - for index, check in enumerate(checks): + for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(hass, variables): + if check(self._hass, variables): return False except ConditionError as ex: errors.append( - ConditionErrorIndex("not", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "not", index=index, total=len(self._checks), error=ex + ) ) # Raise the errors if no check was true @@ -1068,8 +1096,6 @@ async def async_not_from_config( return True - return if_not_condition - def numeric_state( hass: HomeAssistant, diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 826607278c4c..66ba46c5425f 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -4346,3 +4346,100 @@ async def test_state_condition_duration_unavailable_unknown( await hass.async_block_till_done() freezer.tick(timedelta(seconds=11)) assert test_all(hass) is False + + +@pytest.mark.parametrize( + "compound_type", + ["and", "or", "not"], +) +async def test_compound_condition_forwards_async_unload( + hass: HomeAssistant, compound_type: str +) -> None: + """Test that and/or/not compound conditions forward async_unload to children.""" + config = { + "condition": compound_type, + "conditions": [ + { + "condition": "state", + "entity_id": "test.entity_1", + "state": STATE_ON, + }, + { + "condition": "state", + "entity_id": "test.entity_2", + "state": STATE_ON, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + # The compound checker should hold child checkers + assert hasattr(test, "_checks") + assert len(test._checks) == 2 + + # Patch async_unload on children to verify forwarding + child_unloads = [Mock() for _ in test._checks] + for child, mock_unload in zip(test._checks, child_unloads, strict=True): + child.async_unload = mock_unload + + test.async_unload() + + for mock_unload in child_unloads: + mock_unload.assert_called_once() + + +@pytest.mark.parametrize( + ("outer_type", "inner_type"), + [ + (outer, inner) + for outer in ("and", "or", "not") + for inner in ("and", "or", "not") + ], +) +async def test_nested_compound_condition_forwards_async_unload( + hass: HomeAssistant, outer_type: str, inner_type: str +) -> None: + """Test that nested compound conditions forward async_unload recursively.""" + config = { + "condition": outer_type, + "conditions": [ + { + "condition": inner_type, + "conditions": [ + { + "condition": "state", + "entity_id": "test.entity_1", + "state": STATE_ON, + }, + ], + }, + { + "condition": "state", + "entity_id": "test.entity_2", + "state": STATE_ON, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + # Outer compound with 2 children: an inner compound and a leaf + assert len(test._checks) == 2 + inner_checker = test._checks[0] + assert hasattr(inner_checker, "_checks") + assert len(inner_checker._checks) == 1 + + # Patch the innermost leaf's async_unload + innermost_unload = Mock() + inner_checker._checks[0].async_unload = innermost_unload + + leaf_unload = Mock() + test._checks[1].async_unload = leaf_unload + + test.async_unload() + + innermost_unload.assert_called_once() + leaf_unload.assert_called_once()