182 files

This commit is contained in:
Home Assistant Version Control
2026-08-17 12:11:35 +00:00
parent 7dddf6bb13
commit ece15a1c1b
183 changed files with 11457 additions and 3092 deletions
@@ -79,10 +79,16 @@ 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:
if state is None or state.state in ("unavailable", "unknown"):
# 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
# the restored tracking state untouched and let the first real
# state event decide.
_LOGGER.info(
"Runtime trigger entity %s not yet available — listener registered, waiting for entity to appear",
"Runtime trigger entity %s not ready at setup (state=%s) — listener registered, waiting for a real state",
self.entity_id,
state.state if state else "missing",
)
self._unsub_listener = async_track_state_change_event(
self.hass,
@@ -31,6 +31,10 @@ class StateChangeTrigger(BaseTrigger):
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,
@@ -52,6 +56,7 @@ class StateChangeTrigger(BaseTrigger):
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.
@@ -61,40 +66,25 @@ 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:
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 yet available — listener registered, waiting for entity to appear",
"Trigger entity %s not ready at setup (state=%s) — listener registered, latch check deferred",
self.entity_id,
state.state if state else "missing",
)
# 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,
)
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)
@@ -108,6 +98,42 @@ class StateChangeTrigger(BaseTrigger):
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."""
@@ -128,9 +154,14 @@ class StateChangeTrigger(BaseTrigger):
new_val,
)
self._logged_unavailable = False
# Capture initial state but don't count as a transition
# 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
@@ -155,6 +186,17 @@ class StateChangeTrigger(BaseTrigger):
)
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: