"""Threshold trigger for maintenance tasks.""" from __future__ import annotations import logging from datetime import datetime from typing import TYPE_CHECKING, Any from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util if TYPE_CHECKING: from ...sensor import MaintenanceSensor from ...helpers.managed_timer import ManagedTimer from ...helpers.trigger_fallback import threshold_exceeds from .base_trigger import BaseTrigger _LOGGER = logging.getLogger(__name__) class ThresholdTrigger(BaseTrigger): """Trigger that activates when a sensor value exceeds thresholds. Supports: - Above threshold (value > above) - Below threshold (value < below) - Equals / not-equals a discrete level (value = / ≠ equals) - Duration requirement (value must exceed for X minutes) """ def __init__( self, hass: HomeAssistant, entity: MaintenanceSensor, trigger_config: dict[str, Any], ) -> None: """Initialize threshold trigger.""" super().__init__(hass, entity, trigger_config) self._above: float | None = trigger_config.get("trigger_above") self._below: float | None = trigger_config.get("trigger_below") self._equals: float | None = trigger_config.get("trigger_equals") self._not_equals: float | None = trigger_config.get("trigger_not_equals") self._for_minutes: int = trigger_config.get("trigger_for_minutes", 0) self._threshold_exceeded = False self._for_timer = ManagedTimer(hass, f"ThresholdTrigger:{self.entity_id}:for") # Restore persisted exceeded-since timestamp (survives HA restarts) from ...helpers.dates import parse_persisted_utc exceeded_since = trigger_config.get("trigger_threshold_exceeded_since") self._exceeded_since_dt: datetime | None = parse_persisted_utc(exceeded_since) self._exceeded_since: str | None = exceeded_since if self._exceeded_since_dt is not None else None def _value_exceeds_threshold(self, value: float) -> bool: """Check if the value exceeds configured thresholds.""" return threshold_exceeds( value, above=self._above, below=self._below, equals=self._equals, not_equals=self._not_equals, ) def evaluate(self, value: float) -> bool: """Evaluate threshold condition.""" exceeds = self._value_exceeds_threshold(value) if exceeds: if self._for_minutes > 0: if not self._threshold_exceeded: self._threshold_exceeded = True # Restart recovery: check persisted exceeded_since if self._exceeded_since_dt is not None: elapsed = (dt_util.utcnow() - self._exceeded_since_dt).total_seconds() if elapsed >= self._for_minutes * 60: _LOGGER.debug( "Threshold recovery: elapsed %.0fs >= %ds, triggering immediately: %s", elapsed, self._for_minutes * 60, self.entity_id, ) self._triggered = True return True remaining = max(self._for_minutes * 60 - elapsed, 0) _LOGGER.debug( "Threshold recovery: %.0fs remaining: %s", remaining, self.entity_id, ) self._exceeded_since_dt = None # consumed self._start_for_timer(remaining_seconds=remaining) return False # Fresh start: persist timestamp and start full timer self._exceeded_since = dt_util.utcnow().isoformat() self._exceeded_since_dt = None if self.hass.is_running: self._track(self._persist_exceeded_since()) self._start_for_timer() return False # Timer running or already triggered return self._triggered return True # Value back in normal range if self._threshold_exceeded and self._exceeded_since is not None: self._exceeded_since = None self._exceeded_since_dt = None if self.hass.is_running: self._track(self._persist_exceeded_since()) self._threshold_exceeded = False self._for_timer.cancel() return False def _start_for_timer(self, remaining_seconds: float | None = None) -> None: """Start the for-duration timer. If *remaining_seconds* is provided (e.g. after a restart recovery), the timer uses that value instead of the full ``for_minutes`` duration. The exceeded-since timestamp is persisted so the timer survives HA restarts. """ duration = remaining_seconds if remaining_seconds is not None else self._for_minutes * 60 self._for_timer.schedule(duration, self._for_timer_fired) @callback def _for_timer_fired(self, _now: datetime) -> None: """Handle for-timer completion.""" # Safety net (mirrors the state_change hold timer): only commit # while the premise still HOLDS. _threshold_exceeded is cleared # only by a numeric in-range reading, so a sensor that went # unavailable right after crossing kept it True and the timer # activated on a value nobody had observed for the whole window # (bug audit 2026-08-22). Discard the window entirely — a bare # return would leave the latch set and evaluate() would swallow # every future exceeding reading; the next one re-arms fresh. state = self.hass.states.get(self.entity_id) live = self._get_numeric_value(state) if state is not None else None if live is None or not self._value_exceeds_threshold(live): self._threshold_exceeded = False self._exceeded_since = None self._exceeded_since_dt = None if self.hass.is_running: self._track(self._persist_exceeded_since()) return if self._threshold_exceeded: _LOGGER.debug( "Threshold for-timer fired: %s (%d min)", self.entity_id, self._for_minutes, ) self._triggered = True self._on_trigger_activated(self._current_value or 0.0) async def _persist_exceeded_since(self) -> None: """Persist exceeded-since timestamp for survival across restarts.""" await self._coordinator.async_persist_trigger_runtime( self._task_id, {"threshold_exceeded_since": self._exceeded_since}, entity_id=self.entity_id, ) async def async_teardown(self) -> None: """Clean up timer on teardown.""" self._for_timer.close() await super().async_teardown() def reset(self) -> None: """Reset trigger state.""" super().reset() self._threshold_exceeded = False self._exceeded_since = None self._exceeded_since_dt = None if self.hass.is_running: self._track(self._persist_exceeded_since()) self._for_timer.cancel()