182 files
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user