197 files
This commit is contained in:
@@ -103,6 +103,11 @@ def _inject_per_entity_state(config: dict[str, Any], entity_state: dict[str, Any
|
||||
elif trigger_type == TriggerType.STATE_CHANGE:
|
||||
if "change_count" in entity_state:
|
||||
config["trigger_change_count"] = entity_state["change_count"]
|
||||
# #136: a hold window that was open when HA went down.
|
||||
if "pending_since" in entity_state:
|
||||
config["trigger_state_pending_since"] = entity_state["pending_since"]
|
||||
if "pending_state" in entity_state:
|
||||
config["trigger_state_pending_state"] = entity_state["pending_state"]
|
||||
elif trigger_type == TriggerType.THRESHOLD:
|
||||
tes = entity_state.get("threshold_exceeded_since")
|
||||
if tes:
|
||||
|
||||
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
|
||||
from ...const import (
|
||||
EVENT_TRIGGER_ACTIVATED,
|
||||
EVENT_TRIGGER_DEACTIVATED,
|
||||
UNAVAILABLE_STATES,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -80,7 +81,7 @@ class BaseTrigger(ABC):
|
||||
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_change_event)
|
||||
|
||||
# If state is unknown/unavailable, schedule a retry
|
||||
if state.state in ("unavailable", "unknown"):
|
||||
if state.state in UNAVAILABLE_STATES:
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s is '%s' — will retry evaluation in 30s",
|
||||
self.entity_id,
|
||||
@@ -115,7 +116,7 @@ class BaseTrigger(ABC):
|
||||
"""Re-check entity state after a delay."""
|
||||
self._unsub_retry = None
|
||||
state = self.hass.states.get(self.entity_id)
|
||||
if state is None or state.state in ("unavailable", "unknown"):
|
||||
if state is None or state.state in UNAVAILABLE_STATES:
|
||||
_LOGGER.debug(
|
||||
"Trigger entity %s still %s after retry",
|
||||
self.entity_id,
|
||||
@@ -176,7 +177,7 @@ class BaseTrigger(ABC):
|
||||
# trigger entity is surfaced via the missing_trigger_entity repair
|
||||
# flow instead. (Numeric-only triggers: any non-numeric value below
|
||||
# is likewise ignored via the _get_numeric_value None check.)
|
||||
if new_state.state in ("unavailable", "unknown"):
|
||||
if new_state.state in UNAVAILABLE_STATES:
|
||||
if not self._logged_unavailable:
|
||||
_LOGGER.warning(
|
||||
"Trigger entity %s became %s — keeping last trigger state",
|
||||
|
||||
@@ -24,6 +24,8 @@ from homeassistant.helpers.event import (
|
||||
async_track_time_interval,
|
||||
)
|
||||
|
||||
from ...const import UNAVAILABLE_STATES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...sensor import MaintenanceSensor
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -57,15 +59,15 @@ class RuntimeTrigger(BaseTrigger):
|
||||
self._target_hours: float = trigger_config.get("trigger_runtime_hours", 100.0)
|
||||
self._accumulated_seconds: float = trigger_config.get("trigger_accumulated_seconds", 0.0)
|
||||
|
||||
# Restore on_since timestamp for restart recovery
|
||||
# Restore on_since timestamp for restart recovery. parse_persisted_utc
|
||||
# coerces a naive legacy payload to UTC — dt_util.parse_datetime kept
|
||||
# it naive here, and `utcnow() - naive` raises TypeError in the
|
||||
# elapsed math (drift audit 2026-08; the sibling triggers coerced).
|
||||
from ...helpers.dates import parse_persisted_utc
|
||||
|
||||
on_since_str = trigger_config.get("trigger_on_since")
|
||||
self._on_since: str | None = None # ISO string stored for persistence
|
||||
self._on_since_dt: datetime | None = None # parsed datetime for calculation
|
||||
if on_since_str:
|
||||
parsed = dt_util.parse_datetime(on_since_str)
|
||||
if parsed:
|
||||
self._on_since = on_since_str
|
||||
self._on_since_dt = parsed
|
||||
self._on_since_dt: datetime | None = parse_persisted_utc(on_since_str)
|
||||
self._on_since: str | None = on_since_str if self._on_since_dt is not None else None
|
||||
|
||||
# Custom ON states (default: on, 1, true)
|
||||
custom_on = trigger_config.get("trigger_on_states")
|
||||
@@ -79,7 +81,7 @@ class RuntimeTrigger(BaseTrigger):
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up runtime trigger with state restoration."""
|
||||
state = self.hass.states.get(self.entity_id)
|
||||
if state is None or state.state in ("unavailable", "unknown"):
|
||||
if state is None or state.state in UNAVAILABLE_STATES:
|
||||
# No USABLE state yet (#131 family): "unavailable" must not read
|
||||
# as OFF — a running device whose sensor merely connects late
|
||||
# would lose its restored on_since anchor and undercount. Keep
|
||||
@@ -189,7 +191,7 @@ class RuntimeTrigger(BaseTrigger):
|
||||
old_val = self._tracked_value(old_state)
|
||||
|
||||
# Handle unavailable/unknown — pause accumulation
|
||||
if raw_state in ("unavailable", "unknown"):
|
||||
if raw_state in UNAVAILABLE_STATES:
|
||||
if self._on_since_dt is not None:
|
||||
self._accumulate_elapsed()
|
||||
self._on_since_dt = None
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import (
|
||||
EventStateChangedData,
|
||||
async_call_later,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ...const import UNAVAILABLE_STATES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...sensor import MaintenanceSensor
|
||||
@@ -34,6 +39,12 @@ class StateChangeTrigger(BaseTrigger):
|
||||
# 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
|
||||
# #136 hold-window state — class defaults for the same reason.
|
||||
_for_minutes: int = 0
|
||||
_pending_state: str | None = None
|
||||
_pending_since: str | None = None
|
||||
_interrupted_pending: str | None = None
|
||||
_timer_cancel: CALLBACK_TYPE | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -58,6 +69,29 @@ class StateChangeTrigger(BaseTrigger):
|
||||
self._last_state: str | None = None
|
||||
self._needs_latch_reconcile = False
|
||||
|
||||
# #136: a transition only counts once the NEW state has HELD for this
|
||||
# long. 0 (the default) counts immediately — deliberately, because some
|
||||
# sensors express a real event only as a brief pulse; the filter is an
|
||||
# opt-in for the flappy ones. Applies to BOTH modes: the single-shot
|
||||
# alarm latch (target_changes == 1, the reporter's vacuum problem
|
||||
# sensors glitching for seconds at night) and the cycle counter
|
||||
# (a flicker is not a wash cycle).
|
||||
self._for_minutes: int = int(trigger_config.get("trigger_for_minutes", 0) or 0)
|
||||
self._timer_cancel: CALLBACK_TYPE | None = None
|
||||
# A window cut short by an unavailability blip — the only case that
|
||||
# may re-open on recovery (see _handle_state_transition).
|
||||
self._interrupted_pending: str | None = None
|
||||
# The state currently waiting out the hold window (None = no window).
|
||||
self._pending_state: str | None = None
|
||||
self._pending_since: str | None = None
|
||||
# Persisted pending window from before a restart (consumed in setup).
|
||||
from ...helpers.dates import parse_persisted_utc
|
||||
|
||||
self._restored_pending_state: str | None = trigger_config.get("trigger_state_pending_state")
|
||||
self._restored_pending_dt: datetime | None = parse_persisted_utc(trigger_config.get("trigger_state_pending_since"))
|
||||
if self._restored_pending_dt is None:
|
||||
self._restored_pending_state = None
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up state change trigger.
|
||||
|
||||
@@ -66,7 +100,7 @@ class StateChangeTrigger(BaseTrigger):
|
||||
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"):
|
||||
if state is None or state.state in UNAVAILABLE_STATES:
|
||||
# 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
|
||||
@@ -85,6 +119,7 @@ class StateChangeTrigger(BaseTrigger):
|
||||
|
||||
self._last_state = state.state
|
||||
self._reconcile_persisted_latch(state.state)
|
||||
self._resume_pending_window(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)
|
||||
@@ -119,8 +154,7 @@ class StateChangeTrigger(BaseTrigger):
|
||||
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._persist_runtime_soon()
|
||||
self.entity.async_update_trigger_state(
|
||||
is_triggered=False,
|
||||
current_value=0.0,
|
||||
@@ -134,6 +168,113 @@ class StateChangeTrigger(BaseTrigger):
|
||||
trigger_entity_id=self.entity_id,
|
||||
)
|
||||
|
||||
def _resume_pending_window(self, live_state: str) -> None:
|
||||
"""Resume (or discard) a hold window persisted before a restart (#136).
|
||||
|
||||
Mirrors the threshold trigger's exceeded-since recovery: the wall-clock
|
||||
anchor survives the restart, so a state that kept holding through the
|
||||
downtime commits immediately once the window has fully elapsed, and
|
||||
otherwise the timer resumes with the remaining duration. A live state
|
||||
that no longer matches the anchored one discards the window.
|
||||
"""
|
||||
restored_state, restored_dt = self._restored_pending_state, self._restored_pending_dt
|
||||
self._restored_pending_state = None
|
||||
self._restored_pending_dt = None
|
||||
if self._for_minutes <= 0 or restored_dt is None or restored_state is None or self._triggered:
|
||||
return
|
||||
if _norm_state(live_state) != _norm_state(restored_state):
|
||||
self._persist_runtime_soon()
|
||||
return
|
||||
elapsed = (dt_util.utcnow() - restored_dt).total_seconds()
|
||||
if elapsed >= self._for_minutes * 60:
|
||||
_LOGGER.debug(
|
||||
"State hold recovery: elapsed %.0fs >= %ds, committing immediately: %s",
|
||||
elapsed,
|
||||
self._for_minutes * 60,
|
||||
self.entity_id,
|
||||
)
|
||||
self._commit_transition(live_state, None)
|
||||
return
|
||||
self._pending_state = restored_state
|
||||
self._pending_since = restored_dt.isoformat()
|
||||
remaining = max(self._for_minutes * 60 - elapsed, 0)
|
||||
_LOGGER.debug("State hold recovery: %.0fs remaining: %s", remaining, self.entity_id)
|
||||
self._start_hold_timer(remaining_seconds=remaining)
|
||||
|
||||
def _start_pending(self, new_val: str) -> None:
|
||||
"""(Re)open the hold window for *new_val* — commits when the timer fires."""
|
||||
self._cancel_timer()
|
||||
self._pending_state = new_val
|
||||
self._pending_since = dt_util.utcnow().isoformat()
|
||||
self._persist_runtime_soon()
|
||||
self._start_hold_timer()
|
||||
|
||||
def _start_hold_timer(self, remaining_seconds: float | None = None) -> None:
|
||||
self._cancel_timer()
|
||||
duration = remaining_seconds if remaining_seconds is not None else self._for_minutes * 60
|
||||
|
||||
@callback
|
||||
def _timer_fired(_now: datetime) -> None:
|
||||
pending = self._pending_state
|
||||
self._pending_state = None
|
||||
self._pending_since = None
|
||||
self._timer_cancel = None
|
||||
if pending is None:
|
||||
return
|
||||
# Safety net: only commit while the state still holds.
|
||||
live = self.hass.states.get(self.entity_id)
|
||||
if live is None or _norm_state(live.state) != _norm_state(pending):
|
||||
self._persist_runtime_soon()
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"State hold timer fired: %s held %r for %d min",
|
||||
self.entity_id,
|
||||
pending,
|
||||
self._for_minutes,
|
||||
)
|
||||
self._commit_transition(pending, None)
|
||||
|
||||
self._timer_cancel = async_call_later(self.hass, duration, _timer_fired)
|
||||
|
||||
def _clear_pending(self) -> None:
|
||||
"""Abandon the hold window (state moved on before it elapsed)."""
|
||||
if self._pending_state is None and self._timer_cancel is None:
|
||||
return
|
||||
self._cancel_timer()
|
||||
self._pending_state = None
|
||||
self._pending_since = None
|
||||
self._persist_runtime_soon()
|
||||
|
||||
def _cancel_timer(self) -> None:
|
||||
if self._timer_cancel is not None:
|
||||
self._timer_cancel()
|
||||
self._timer_cancel = None
|
||||
|
||||
def _commit_transition(self, new_val: str, old_val: str | None) -> None:
|
||||
"""Count one matching transition (immediately, or after its hold)."""
|
||||
self._pending_state = None
|
||||
self._pending_since = None
|
||||
self._change_count += 1
|
||||
self._current_value = float(self._change_count)
|
||||
self._persist_runtime_soon()
|
||||
_LOGGER.debug(
|
||||
"State change counted: %s (%s -> %s) count=%d/%d",
|
||||
self.entity_id,
|
||||
old_val if old_val is not None else "<held>",
|
||||
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))
|
||||
|
||||
@callback
|
||||
def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state transition and count matching changes."""
|
||||
@@ -158,7 +299,7 @@ class StateChangeTrigger(BaseTrigger):
|
||||
# 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"):
|
||||
if new_val not in UNAVAILABLE_STATES:
|
||||
self._needs_latch_reconcile = False
|
||||
self._last_state = new_val
|
||||
self._reconcile_persisted_latch(new_val)
|
||||
@@ -167,7 +308,14 @@ class StateChangeTrigger(BaseTrigger):
|
||||
old_val = old_state.state
|
||||
|
||||
# Handle unavailable/unknown with log-once pattern
|
||||
if new_val in ("unavailable", "unknown"):
|
||||
if new_val in UNAVAILABLE_STATES:
|
||||
# #136: an unavailability blip is not "the state held" — abandon
|
||||
# the hold window, but REMEMBER it: only a window that was
|
||||
# actually running may re-open when the entity comes back (else a
|
||||
# blip on a long-settled state would count a phantom transition).
|
||||
if self._pending_state is not None:
|
||||
self._interrupted_pending = self._pending_state
|
||||
self._clear_pending()
|
||||
if not self._logged_unavailable:
|
||||
_LOGGER.warning(
|
||||
"Trigger entity %s became %s",
|
||||
@@ -185,6 +333,19 @@ class StateChangeTrigger(BaseTrigger):
|
||||
new_val,
|
||||
)
|
||||
self._logged_unavailable = False
|
||||
# #136: a window was running when the blip hit and the state came
|
||||
# back unchanged — restart it (fresh clock; the normal transition
|
||||
# path below cannot, because effective_old equals new_val here).
|
||||
interrupted = self._interrupted_pending
|
||||
self._interrupted_pending = None
|
||||
if (
|
||||
self._for_minutes > 0
|
||||
and not self._triggered
|
||||
and self._pending_state is None
|
||||
and interrupted is not None
|
||||
and _norm_state(new_val) == _norm_state(interrupted)
|
||||
):
|
||||
self._start_pending(new_val)
|
||||
|
||||
# First REAL state after a setup that saw none/unavailable (#131
|
||||
# family): reconcile the persisted latch against it instead of
|
||||
@@ -199,9 +360,16 @@ class StateChangeTrigger(BaseTrigger):
|
||||
|
||||
# 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:
|
||||
if old_val in UNAVAILABLE_STATES and self._last_state is not None:
|
||||
effective_old = self._last_state
|
||||
|
||||
# #136: any real state movement means the previous state did NOT hold
|
||||
# — abandon a running hold window (a matching transition right below
|
||||
# opens a fresh one) and invalidate a blip-interruption marker.
|
||||
if effective_old != new_val:
|
||||
self._interrupted_pending = None
|
||||
self._clear_pending()
|
||||
|
||||
# Check if transition matches pattern
|
||||
matches = True
|
||||
if self._from_state is not None and _norm_state(effective_old) != self._from_state:
|
||||
@@ -210,28 +378,11 @@ class StateChangeTrigger(BaseTrigger):
|
||||
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))
|
||||
if self._for_minutes > 0:
|
||||
# #136: the transition only counts once new_val has held.
|
||||
self._start_pending(new_val)
|
||||
else:
|
||||
self._commit_transition(new_val, old_val)
|
||||
|
||||
# Latch recovery: a single-shot state alarm (target_changes == 1 — an
|
||||
# adopted problem sensor or an appliance event) clears when the entity
|
||||
@@ -247,8 +398,7 @@ class StateChangeTrigger(BaseTrigger):
|
||||
):
|
||||
self._change_count = 0
|
||||
self._current_value = 0.0
|
||||
if self.hass.is_running:
|
||||
self.hass.async_create_task(self._persist_change_count())
|
||||
self._persist_runtime_soon()
|
||||
self._triggered = False
|
||||
self._on_trigger_deactivated(0.0)
|
||||
|
||||
@@ -268,19 +418,39 @@ class StateChangeTrigger(BaseTrigger):
|
||||
"""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())
|
||||
self._persist_runtime_soon()
|
||||
_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."""
|
||||
def _persist_runtime_soon(self) -> None:
|
||||
if self.hass.is_running:
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
|
||||
async def _persist_runtime(self) -> None:
|
||||
"""Persist the full runtime dict (count + hold window) to the Store.
|
||||
|
||||
Always the COMPLETE dict: set_trigger_runtime replaces per-entity
|
||||
state wholesale, so a partial write would drop the other half.
|
||||
"""
|
||||
data: dict[str, Any] = {"change_count": self._change_count}
|
||||
if self._pending_since is not None and self._pending_state is not None:
|
||||
data["pending_since"] = self._pending_since
|
||||
data["pending_state"] = self._pending_state
|
||||
await self._coordinator.async_persist_trigger_runtime(
|
||||
self._task_id,
|
||||
{"change_count": self._change_count},
|
||||
data,
|
||||
entity_id=self.entity_id,
|
||||
)
|
||||
|
||||
async def async_teardown(self) -> None:
|
||||
"""Clean up the hold timer on teardown."""
|
||||
self._cancel_timer()
|
||||
await super().async_teardown()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset trigger and counter."""
|
||||
"""Reset trigger, counter and any running hold window."""
|
||||
super().reset()
|
||||
self._cancel_timer()
|
||||
self._pending_state = None
|
||||
self._pending_since = None
|
||||
self._interrupted_pending = None
|
||||
self.reset_count()
|
||||
|
||||
@@ -48,22 +48,11 @@ class ThresholdTrigger(BaseTrigger):
|
||||
self._timer_cancel: CALLBACK_TYPE | None = None
|
||||
|
||||
# Restore persisted exceeded-since timestamp (survives HA restarts)
|
||||
exceeded_since = trigger_config.get("trigger_threshold_exceeded_since")
|
||||
self._exceeded_since: str | None = None
|
||||
self._exceeded_since_dt: datetime | None = None
|
||||
if exceeded_since:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(exceeded_since)
|
||||
# Older payloads may be naive — assume UTC since live writes
|
||||
# use dt_util.utcnow().isoformat() (TZ-aware).
|
||||
if parsed.tzinfo is None:
|
||||
from datetime import UTC
|
||||
from ...helpers.dates import parse_persisted_utc
|
||||
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
self._exceeded_since_dt = parsed
|
||||
self._exceeded_since = exceeded_since
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user