"""State change trigger for maintenance tasks.""" from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, ) if TYPE_CHECKING: from ...sensor import MaintenanceSensor from .base_trigger import BaseTrigger _LOGGER = logging.getLogger(__name__) def _norm_state(value: str | None) -> str | None: """Case/whitespace-insensitive state form for from/to comparisons.""" return value.strip().casefold() if isinstance(value, str) else value class StateChangeTrigger(BaseTrigger): """Trigger that activates after counting state transitions. Counts transitions matching from_state -> to_state pattern. Triggers when count reaches target_changes. """ def __init__( self, hass: HomeAssistant, entity: MaintenanceSensor, trigger_config: dict[str, Any], ) -> None: """Initialize state change trigger.""" super().__init__(hass, entity, trigger_config) # Case-insensitive matching, like RuntimeTrigger's on_states: the # options flow lowercases these on save while the panel keeps the # user's casing, and HA states themselves can be capitalized # (input_select "Home") — normalizing BOTH sides at compare time is # the only variant that works for every surface combination. self._from_state: str | None = _norm_state(trigger_config.get("trigger_from_state")) self._to_state: str | None = _norm_state(trigger_config.get("trigger_to_state")) self._target_changes: int = trigger_config.get("trigger_target_changes", 1) # Restore persisted change count from config, default to 0 self._change_count: int = trigger_config.get("trigger_change_count", 0) self._current_value = float(self._change_count) self._last_state: str | None = None async def async_setup(self) -> None: """Set up state change trigger. IMPORTANT: The listener is ALWAYS registered, even when the entity does not exist yet. HA fires a state_change event when an entity first appears (old_state=None), so the trigger will self-heal automatically. """ state = self.hass.states.get(self.entity_id) if state is None: _LOGGER.info( "Trigger entity %s not yet available — listener registered, waiting for entity to appear", self.entity_id, ) # Register listener anyway so we catch the entity appearing self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition) return self._last_state = state.state # Restore triggered state from persisted change count if self._change_count >= self._target_changes: # Latch reconciliation: a single-shot state alarm (target_changes # == 1) that already left its alert state while we were down is no # longer active. Clear it quietly — the recovery transition was # never observed, so we must NOT auto-complete for it here (that # path only runs on a live off event, guarded against double-count). if ( self._to_state is not None and self._target_changes == 1 and _norm_state(state.state) != self._to_state ): self._change_count = 0 self._current_value = 0.0 if self.hass.is_running: self.hass.async_create_task(self._persist_change_count()) else: self._triggered = True self.entity.async_update_trigger_state( is_triggered=True, current_value=float(self._change_count), trigger_entity_id=self.entity_id, ) # Register state change listener (override base: we handle events differently) self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition) _LOGGER.debug( "State change trigger setup: %s (target=%d, count=%d, from=%s, to=%s)", self.entity_id, self._target_changes, self._change_count, self._from_state, self._to_state, ) @callback def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None: """Handle state transition and count matching changes.""" old_state = event.data.get("old_state") new_state = event.data.get("new_state") if new_state is None: # Entity removed from state machine return new_val = new_state.state # Entity appeared for the first time (old_state=None) if old_state is None: _LOGGER.info( "Trigger entity %s appeared in state machine (state=%s)", self.entity_id, new_val, ) self._logged_unavailable = False # Capture initial state but don't count as a transition if new_val not in ("unavailable", "unknown"): self._last_state = new_val return old_val = old_state.state # Handle unavailable/unknown with log-once pattern if new_val in ("unavailable", "unknown"): if not self._logged_unavailable: _LOGGER.warning( "Trigger entity %s became %s", self.entity_id, new_val, ) self._logged_unavailable = True return # Entity is back to a valid state if self._logged_unavailable: _LOGGER.info( "Trigger entity %s is available again (state=%s)", self.entity_id, new_val, ) self._logged_unavailable = False # Use _last_state as fallback when old_val is unavailable/unknown effective_old = old_val if old_val in ("unavailable", "unknown") and self._last_state is not None: effective_old = self._last_state # Check if transition matches pattern matches = True if self._from_state is not None and _norm_state(effective_old) != self._from_state: matches = False if self._to_state is not None and _norm_state(new_val) != self._to_state: matches = False if matches and effective_old != new_val: self._change_count += 1 self._current_value = float(self._change_count) # Persist change count to survive restarts if self.hass.is_running: self.hass.async_create_task(self._persist_change_count()) _LOGGER.debug( "State change counted: %s (%s -> %s) count=%d/%d", self.entity_id, old_val, new_val, self._change_count, self._target_changes, ) was_triggered = self._triggered is_triggered = self._change_count >= self._target_changes self._triggered = is_triggered if is_triggered and not was_triggered: self._on_trigger_activated(float(self._change_count)) elif not is_triggered and was_triggered: self._on_trigger_deactivated(float(self._change_count)) # Latch recovery: a single-shot state alarm (target_changes == 1 — an # adopted problem sensor or an appliance event) clears when the entity # leaves its alert state. Reset the counter so the next occurrence can # fire again, and run the deactivation path — which auto-completes on # recovery when opted in. Multi-count triggers keep accumulating and # only reset on manual completion, so they are untouched here. elif ( self._to_state is not None and self._target_changes == 1 and self._triggered and _norm_state(new_val) != self._to_state ): self._change_count = 0 self._current_value = 0.0 if self.hass.is_running: self.hass.async_create_task(self._persist_change_count()) self._triggered = False self._on_trigger_deactivated(0.0) self._last_state = new_val def evaluate(self, value: float) -> bool: """Evaluate is handled by _handle_state_transition directly.""" # State change triggers use event-driven evaluation only return self._triggered @property def change_count(self) -> int: """Return the current change count.""" return self._change_count def reset_count(self) -> None: """Reset the change counter (after maintenance).""" self._change_count = 0 self._current_value = 0.0 if self.hass.is_running: self.hass.async_create_task(self._persist_change_count()) _LOGGER.debug("State change counter reset: %s", self.entity_id) async def _persist_change_count(self) -> None: """Persist change count to the Store for survival across restarts.""" await self._coordinator.async_persist_trigger_runtime( self._task_id, {"change_count": self._change_count}, entity_id=self.entity_id, ) def reset(self) -> None: """Reset trigger and counter.""" super().reset() self.reset_count()