"""Counter trigger for maintenance tasks.""" from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from homeassistant.core import HomeAssistant if TYPE_CHECKING: from ...sensor import MaintenanceSensor from .base_trigger import BaseTrigger _LOGGER = logging.getLogger(__name__) class CounterTrigger(BaseTrigger): """Trigger that activates when a counter reaches a target value. Supports: - Absolute mode: triggers when value >= target - Delta mode: triggers when (value - baseline) >= target """ def __init__( self, hass: HomeAssistant, entity: MaintenanceSensor, trigger_config: dict[str, Any], ) -> None: """Initialize counter trigger.""" super().__init__(hass, entity, trigger_config) self._target_value: float = trigger_config.get("trigger_target_value", 0) self._delta_mode: bool = trigger_config.get("trigger_delta_mode", False) self._baseline_value: float | None = trigger_config.get("trigger_baseline_value") # Set when reset_baseline is called while the source is unavailable, so # the re-baseline is applied against the next real value (M4). self._reset_pending: bool = False async def async_setup(self) -> None: """Set up counter trigger with baseline initialization.""" # Restore persisted baseline BEFORE super().async_setup() which calls # evaluate(). Without this, evaluate() sets _baseline_value to the # current sensor value, discarding the saved baseline from the Store. # The Store baseline wins even over an explicit trigger_baseline_value # from the config: the config value is only the INITIAL baseline, while # the Store holds the LIVING one — after a completion re-baselines to # e.g. 80, falling back to the config's 0 on restart would re-fire the # just-completed task (issue #102 family). if self._delta_mode: saved = self.config.get("_trigger_state", {}).get(self.entity_id, {}).get("baseline_value") if saved is not None: self._baseline_value = saved _LOGGER.debug( "Counter baseline restored from store: %s = %s", self.entity_id, self._baseline_value, ) await super().async_setup() # Set initial baseline if in delta mode and no baseline exists if self._delta_mode and self._baseline_value is None and self._current_value is not None: self._baseline_value = self._current_value await self._persist_baseline() _LOGGER.debug( "Counter baseline initialized: %s = %s", self.entity_id, self._baseline_value, ) def _evaluate_and_update(self, value: float) -> None: """Initialize/re-baseline before evaluation, then persist.""" if self._delta_mode and self._reset_pending: # Apply the deferred post-completion re-baseline now that a real # value is available (the source was unavailable at completion). (M4) self._baseline_value = value self._reset_pending = False self.hass.async_create_task( self._persist_baseline(), eager_start=False, ) elif self._delta_mode and self._baseline_value is None: self._baseline_value = value self.hass.async_create_task( self._persist_baseline(), eager_start=False, ) _LOGGER.debug( "Counter baseline initialized on state change: %s = %s", self.entity_id, self._baseline_value, ) super()._evaluate_and_update(value) def evaluate(self, value: float) -> bool: """Evaluate counter condition.""" if self._delta_mode: if self._baseline_value is None: # Fallback — should be caught by _evaluate_and_update self._baseline_value = value return False if value < self._baseline_value: # Source counter reset / rolled over (device reboot, meter # rollover) — re-baseline to the new lower value so we don't # miss the next interval waiting for it to climb back past the # old baseline. (M3) self._baseline_value = value self.hass.async_create_task( self._persist_baseline(), eager_start=False, ) return False delta = value - self._baseline_value return delta >= self._target_value # Absolute mode return value >= self._target_value @property def current_delta(self) -> float | None: """Return the current delta from baseline.""" if not self._delta_mode or self._baseline_value is None: return None if self._current_value is None: return None return self._current_value - self._baseline_value def reset_baseline(self) -> None: """Reset the baseline to current value (after maintenance).""" if self._current_value is not None: self._baseline_value = self._current_value self._reset_pending = False self.hass.async_create_task(self._persist_baseline()) _LOGGER.debug( "Counter baseline reset: %s = %s", self.entity_id, self._baseline_value, ) elif self._delta_mode: # Source unavailable at completion — defer the re-baseline to the # next real value so a stale baseline can't immediately re-trigger # the just-completed task when the entity returns. (M4; delta-only, # absolute mode has no baseline to defer.) self._reset_pending = True _LOGGER.debug( "Counter baseline reset deferred (source unavailable): %s", self.entity_id, ) async def _persist_baseline(self) -> None: """Persist baseline value to the Store for survival across restarts.""" if self._baseline_value is not None: await self._coordinator.async_persist_trigger_runtime( self._task_id, {"baseline_value": self._baseline_value}, entity_id=self.entity_id, immediate=True, ) # Surface the moved baseline in the coordinator read-model right # away. Without this, a freshly adopted delta task shows no delta # for up to a full update interval and the panel's progress bar # falls back to the RAW counter value — a 27,000 km odometer reads # as "27000/15000, overdue" (issue #102). Debounced upstream, and # the periodic refresh never writes baselines, so no loop. # Debounced ON PURPOSE: a counter entity can change many times # a minute, and this is the path the ten-second coalescing # window exists for. User actions use async_refresh_now. await self._coordinator.async_request_refresh() def reset(self) -> None: """Reset trigger and baseline.""" super().reset() self.reset_baseline()