"""Compound trigger — combines multiple trigger conditions with AND/OR logic.""" from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from homeassistant.core import HomeAssistant, callback if TYPE_CHECKING: from ...coordinator import MaintenanceCoordinator from ...sensor import MaintenanceSensor from ...const import ( CONF_COMPOUND_CONDITIONS, CONF_COMPOUND_LOGIC, EVENT_TRIGGER_ACTIVATED, EVENT_TRIGGER_DEACTIVATED, UNAVAILABLE_STATES, ) from .base_trigger import BaseTrigger _LOGGER = logging.getLogger(__name__) class CompoundSubEntity: """Proxy entity for a single compound condition. Each condition creates its own sub-triggers that call back into this proxy. The proxy aggregates per-entity states within the condition (using the condition's ``entity_logic``), then notifies the parent ``CompoundTrigger`` of the condition-level result. """ def __init__( self, parent: CompoundTrigger, condition_idx: int, condition_config: dict[str, Any], ) -> None: """Initialize the sub-entity proxy.""" self._parent = parent self._condition_idx = condition_idx self._condition_config = condition_config # Pre-seed a MULTI-entity condition with every entity at False, so # `entity_logic == "all"` quantifies over EVERY entity, not just the ones # that have already transitioned (an entity that never toggles must keep # the AND from firing). Single-entity conditions stay empty and use the # is_triggered fallback in async_update_trigger_state. (H1) _eids = condition_config.get("entity_ids") or [] self._per_entity_states: dict[str, bool] = {eid: False for eid in _eids} if len(_eids) > 1 else {} self._per_entity_values: dict[str, float | None] = {} self._entity_logic = condition_config.get("entity_logic", "any") # Mirror attributes the real entity exposes for trigger access self.entity_id = parent.entity.entity_id self._task_id = parent.entity._task_id self.coordinator = parent.entity.coordinator @callback def async_update_trigger_state( self, is_triggered: bool, current_value: float | None = None, trigger_entity_id: str | None = None, ) -> None: """Receive trigger state from a sub-trigger.""" if trigger_entity_id is not None: self._per_entity_states[trigger_entity_id] = is_triggered if current_value is not None: self._per_entity_values[trigger_entity_id] = current_value # Aggregate within this condition if not self._per_entity_states: aggregated = is_triggered elif self._entity_logic == "all": aggregated = bool(self._per_entity_states) and all(self._per_entity_states.values()) else: # "any" aggregated = any(self._per_entity_states.values()) self._parent._on_condition_changed(self._condition_idx, aggregated) @callback def async_write_ha_state(self) -> None: """No-op — the compound trigger manages state through the real entity.""" class _CompoundCoordinatorProxy: """Proxy coordinator that routes persistence to the correct condition index. Wraps the real coordinator so that ``async_persist_trigger_runtime`` stores data under ``_trigger_state.conditions[idx][entity_id]``. """ def __init__(self, real_coordinator: MaintenanceCoordinator, condition_idx: int) -> None: """Initialize the proxy.""" self._real = real_coordinator self._condition_idx = condition_idx def __getattr__(self, name: str) -> Any: """Delegate all other attributes to the real coordinator.""" return getattr(self._real, name) async def async_persist_trigger_runtime( self, task_id: str, runtime_data: dict[str, Any], entity_id: str | None = None, *, immediate: bool = False, ) -> None: """Persist under trigger_runtime as a per-condition compound key. The real coordinator always has a Store; merge_task_data reshapes these ``_compound_[_]`` keys back into ``_trigger_state["conditions"][idx]`` on read. """ store = self._real._store compound_key = f"_compound_{self._condition_idx}" if entity_id is not None: compound_key = f"_compound_{self._condition_idx}_{entity_id}" store.set_trigger_runtime(task_id, compound_key, runtime_data) if immediate: await store.async_save() else: store.async_delay_save() # A condition's sub-trigger is not the task's trigger: only the compound # decides when the TASK activates. Delegated to the real coordinator, # every sub-trigger activation wrote its own TRIGGERED history entry and # lifted the post-completion cooldown — and, since this proxy has no # trigger_already_announced, it did so again on every reload and restart # (bug audit 2026-09-27, R SCH-10). The compound records the activation. async def async_add_trigger_history_entry(self, task_id: str, trigger_value: float | None = None) -> None: """No-op for a condition — see the class comment above.""" def note_trigger_edge(self, task_id: str, *, recovered: bool = True) -> None: """No-op for a condition — the compound reports its own edge.""" class CompoundTrigger(BaseTrigger): """Compound trigger that combines multiple conditions with AND/OR logic. Two-level aggregation: 1. Within each condition: multi-entity ``entity_logic`` (any/all) 2. Across conditions: ``compound_logic`` (AND/OR) """ # Class-level defaults for instances built without __init__ (tests), like # BaseTrigger's flags; __init__ sets the real starting values. _setting_up: bool = False _settled: bool = True def __init__( self, hass: HomeAssistant, entity: MaintenanceSensor, trigger_config: dict[str, Any], ) -> None: """Initialize the compound trigger.""" # BaseTrigger expects entity_id; compound has no single monitored entity config_with_id = dict(trigger_config) config_with_id.setdefault("entity_id", "") super().__init__(hass, entity, config_with_id) # `or "AND"` (not just the .get default) so a present-but-null value in # hand-edited config doesn't crash on .upper(). self._compound_logic: str = (trigger_config.get(CONF_COMPOUND_LOGIC) or "AND").upper() self._conditions: list[dict[str, Any]] = trigger_config.get(CONF_COMPOUND_CONDITIONS) or [] self._condition_states: list[bool] = [False] * len(self._conditions) self._sub_triggers: list[list[BaseTrigger]] = [] self._sub_entities: list[CompoundSubEntity] = [] # The compound's counterpart of BaseTrigger._evaluated_once (bug # audit 2026-09-27, R SCH-10): an activation is a RESTORE of the # episode on record — repaint only — while the sub-triggers run their # initial evaluations (async_setup), and until this instance has seen # the compound inactive with every condition entity reporting (late # entities after a restart evaluate on their first state event). An # activation after that is a new edge and is announced. self._setting_up = False self._settled = False @property def condition_states(self) -> list[bool]: """Return the current state of each condition.""" return list(self._condition_states) async def async_setup(self) -> None: """Set up all sub-triggers for each condition.""" trigger_state = self.config.get("_trigger_state", {}) conditions_state = trigger_state.get("conditions", []) self._setting_up = True try: await self._async_setup_conditions(conditions_state) finally: self._setting_up = False self._maybe_settle() _LOGGER.debug( "Compound trigger setup: %d conditions with %s logic for %s", len(self._conditions), self._compound_logic, self.entity.entity_id, ) async def _async_setup_conditions(self, conditions_state: list[Any]) -> None: """Build and set up every condition's sub-triggers (their initial evaluations run here and may already activate the compound).""" from . import create_triggers for idx, condition in enumerate(self._conditions): sub_entity = CompoundSubEntity(self, idx, condition) self._sub_entities.append(sub_entity) # Build per-condition config with its persisted state cond_config = dict(condition) if idx < len(conditions_state): cond_state = conditions_state[idx] if cond_state: cond_config["_trigger_state"] = cond_state # Wrap the real coordinator with a proxy for persistence proxy_coordinator = _CompoundCoordinatorProxy(self._coordinator, idx) sub_entity.coordinator = proxy_coordinator # type: ignore[assignment] sub_triggers = create_triggers(self.hass, sub_entity, cond_config) # type: ignore[arg-type] self._sub_triggers.append(sub_triggers) for trigger in sub_triggers: await trigger.async_setup() def _all_conditions_reporting(self) -> bool: """Every condition entity has a usable state (its sub-trigger has had the chance to evaluate).""" for trigger_list in self._sub_triggers: for trigger in trigger_list: state = self.hass.states.get(trigger.entity_id) if state is None or state.state in UNAVAILABLE_STATES: return False return True def _maybe_settle(self) -> None: """Seen inactive with full information: later activations are edges.""" if not self._settled and not self._setting_up and not self._triggered and self._all_conditions_reporting(): self._settled = True async def async_teardown(self) -> None: """Tear down all sub-triggers.""" for trigger_list in self._sub_triggers: for trigger in trigger_list: await trigger.async_teardown() self._sub_triggers = [] self._sub_entities = [] self._condition_states = [False] * len(self._conditions) await super().async_teardown() def evaluate(self, value: float) -> bool: """Evaluate compound condition (aggregation of condition states).""" if self._compound_logic == "AND": return bool(self._condition_states) and all(self._condition_states) return any(self._condition_states) # OR def reset(self) -> None: """Reset all sub-triggers.""" super().reset() self._condition_states = [False] * len(self._conditions) for trigger_list in self._sub_triggers: for trigger in trigger_list: trigger.reset() @callback def _on_condition_changed(self, condition_idx: int, is_triggered: bool) -> None: """Handle a condition state change and re-aggregate.""" self._condition_states[condition_idx] = is_triggered was_triggered = self._triggered if self._compound_logic == "AND": now_triggered = bool(self._condition_states) and all(self._condition_states) else: # OR now_triggered = any(self._condition_states) self._triggered = now_triggered if now_triggered and not was_triggered: initial = self._setting_up or not self._settled if initial and self._in_completion_cooldown(): self._recovered_since_reset = False # see BaseTrigger._evaluate_and_update if initial and self._already_announced(): self._restore_activation(0.0) else: self._on_trigger_activated(0.0) elif not now_triggered and was_triggered: self._on_trigger_deactivated(0.0) self._maybe_settle() def _restore_activation(self, value: float) -> None: """Repaint an activation already on record (see ``_settled``) — no second history entry, no second event. Like the compound's own activation it carries no per-entity id or value.""" _LOGGER.debug("Compound trigger restored as active (already announced): %s", self.entity.entity_id) self.entity.async_update_trigger_state( is_triggered=True, current_value=None, trigger_entity_id=None, ) self._request_coordinator_refresh() def _on_trigger_activated(self, value: float) -> None: """Handle compound trigger activation.""" _LOGGER.info( "Compound trigger activated: %s (conditions: %s, logic: %s)", self.entity.entity_id, self._condition_states, self._compound_logic, ) self.entity.async_update_trigger_state( is_triggered=True, current_value=None, trigger_entity_id=None, ) self._track(self._coordinator.async_add_trigger_history_entry(self._task_id, trigger_value=None)) self._coordinator.note_trigger_edge(self._task_id, recovered=self._recovered_since_reset) self._request_coordinator_refresh() self.hass.bus.async_fire( EVENT_TRIGGER_ACTIVATED, { "entity_id": self.entity.entity_id, "trigger_type": "compound", "compound_logic": self._compound_logic, "condition_states": list(self._condition_states), }, ) def _on_trigger_deactivated(self, value: float) -> None: """Handle compound trigger deactivation.""" _LOGGER.info( "Compound trigger deactivated: %s (conditions: %s, logic: %s)", self.entity.entity_id, self._condition_states, self._compound_logic, ) self.entity.async_update_trigger_state( is_triggered=False, current_value=None, trigger_entity_id=None, ) self._request_coordinator_refresh() self.hass.bus.async_fire( EVENT_TRIGGER_DEACTIVATED, { "entity_id": self.entity.entity_id, "trigger_type": "compound", "compound_logic": self._compound_logic, "condition_states": list(self._condition_states), }, )