287 lines
12 KiB
Python
287 lines
12 KiB
Python
"""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.
|
|
"""
|
|
|
|
# Setup saw no usable state -> reconcile on the first real one (#131).
|
|
# Class default so hand-built test instances inherit it.
|
|
_needs_latch_reconcile: bool = False
|
|
|
|
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
|
|
self._needs_latch_reconcile = False
|
|
|
|
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 or state.state in ("unavailable", "unknown"):
|
|
# No USABLE state yet — the #131 family: trigger setup races both
|
|
# the entity's registration AND its device readiness (a Zigbee /
|
|
# Z-Wave problem sensor restores as unavailable long before it
|
|
# reports). "unavailable" must never read as "recovered" — it
|
|
# used to quietly clear a single-shot latch right here. Register
|
|
# the listener and defer the latch reconciliation to the first
|
|
# real state.
|
|
self._needs_latch_reconcile = True
|
|
_LOGGER.info(
|
|
"Trigger entity %s not ready at setup (state=%s) — listener registered, latch check deferred",
|
|
self.entity_id,
|
|
state.state if state else "missing",
|
|
)
|
|
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
|
|
return
|
|
|
|
self._last_state = state.state
|
|
self._reconcile_persisted_latch(state.state)
|
|
|
|
# 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,
|
|
)
|
|
|
|
def _reconcile_persisted_latch(self, live_state: str) -> None:
|
|
"""Align the persisted change-count latch with the LIVE entity state.
|
|
|
|
Runs at setup when the entity already exists, and again when the
|
|
entity first APPEARS (issue #131): trigger setup races HA's state
|
|
restoration, so a source that restores later kept a stale latch —
|
|
a problem sensor still on read OK, and a single-shot alarm that had
|
|
recovered while we were down stayed triggered.
|
|
|
|
A single-shot state alarm (target_changes == 1) whose entity is no
|
|
longer in its alert state is cleared 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). Otherwise the latch is restored and repainted.
|
|
"""
|
|
if self._change_count < self._target_changes:
|
|
return
|
|
if self._to_state is not None and self._target_changes == 1 and _norm_state(live_state) != self._to_state:
|
|
self._change_count = 0
|
|
self._current_value = 0.0
|
|
self._triggered = False
|
|
if self.hass.is_running:
|
|
self.hass.async_create_task(self._persist_change_count())
|
|
self.entity.async_update_trigger_state(
|
|
is_triggered=False,
|
|
current_value=0.0,
|
|
trigger_entity_id=self.entity_id,
|
|
)
|
|
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,
|
|
)
|
|
|
|
@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 — and
|
|
# reconcile the persisted latch against it (issue #131): when the
|
|
# entity restores AFTER our setup, this appearance is the first
|
|
# moment the latch can be checked against reality.
|
|
if new_val not in ("unavailable", "unknown"):
|
|
self._needs_latch_reconcile = False
|
|
self._last_state = new_val
|
|
self._reconcile_persisted_latch(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
|
|
|
|
# First REAL state after a setup that saw none/unavailable (#131
|
|
# family): reconcile the persisted latch against it instead of
|
|
# counting the restore as a transition. Mid-run unavailability
|
|
# glitches never set the flag, so their observed recovery still goes
|
|
# through the normal transition/auto-complete path below.
|
|
if self._needs_latch_reconcile:
|
|
self._needs_latch_reconcile = False
|
|
self._last_state = new_val
|
|
self._reconcile_persisted_latch(new_val)
|
|
return
|
|
|
|
# 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()
|