113 files
This commit is contained in:
@@ -103,10 +103,12 @@ 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:
|
||||
# #136: a hold window that was open when HA went down. Truthy-gated,
|
||||
# not `in`: stores written before set_trigger_runtime became a replace
|
||||
# (2026-08-22) can carry stale/None pending keys forever.
|
||||
if entity_state.get("pending_since"):
|
||||
config["trigger_state_pending_since"] = entity_state["pending_since"]
|
||||
if "pending_state" in entity_state:
|
||||
if entity_state.get("pending_state"):
|
||||
config["trigger_state_pending_state"] = entity_state["pending_state"]
|
||||
elif trigger_type == TriggerType.THRESHOLD:
|
||||
tes = entity_state.get("threshold_exceeded_since")
|
||||
|
||||
@@ -185,6 +185,17 @@ class RuntimeTrigger(BaseTrigger):
|
||||
self._on_since_dt = now
|
||||
self._on_since = now.isoformat()
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
elif not self._is_on(new_val) and self._on_since_dt is not None:
|
||||
# Restored anchor but the device APPEARS off (deferred setup
|
||||
# kept the anchor, then the first real state is OFF). Without
|
||||
# this, nothing ever cleared it and the 5-min periodic persist
|
||||
# baked wall-clock time into runtime forever — an idle pump
|
||||
# "ran" 24 h/day (bug audit 2026-08-22). Mirror the setup
|
||||
# path: accumulate the ON-until-now gap once, then clear.
|
||||
self._accumulate_elapsed()
|
||||
self._on_since_dt = None
|
||||
self._on_since = None
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
self._update_evaluation()
|
||||
return
|
||||
|
||||
@@ -239,6 +250,15 @@ class RuntimeTrigger(BaseTrigger):
|
||||
"Runtime trigger: %s turned ON (tracking started)",
|
||||
self.entity_id,
|
||||
)
|
||||
elif not now_on and self._on_since_dt is not None:
|
||||
# OFF with a lingering anchor (e.g. unavailable→off right after a
|
||||
# deferred setup kept the restored anchor: was_on reads the
|
||||
# unavailable old state as not-on, so neither branch above fired).
|
||||
# Same stale-anchor hazard as the appearance path — settle it.
|
||||
self._accumulate_elapsed()
|
||||
self._on_since_dt = None
|
||||
self._on_since = None
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
|
||||
self._update_evaluation()
|
||||
|
||||
|
||||
@@ -130,6 +130,23 @@ class ThresholdTrigger(BaseTrigger):
|
||||
@callback
|
||||
def _timer_fired(_now: datetime) -> None:
|
||||
"""Handle timer completion."""
|
||||
# Safety net (mirrors the state_change hold timer): only commit
|
||||
# while the premise still HOLDS. _threshold_exceeded is cleared
|
||||
# only by a numeric in-range reading, so a sensor that went
|
||||
# unavailable right after crossing kept it True and the timer
|
||||
# activated on a value nobody had observed for the whole window
|
||||
# (bug audit 2026-08-22). Discard the window entirely — a bare
|
||||
# return would leave the latch set and evaluate() would swallow
|
||||
# every future exceeding reading; the next one re-arms fresh.
|
||||
state = self.hass.states.get(self.entity_id)
|
||||
live = self._get_numeric_value(state) if state is not None else None
|
||||
if live is None or not self._value_exceeds_threshold(live):
|
||||
self._threshold_exceeded = False
|
||||
self._exceeded_since = None
|
||||
self._exceeded_since_dt = None
|
||||
if self.hass.is_running:
|
||||
self.hass.async_create_task(self._persist_exceeded_since())
|
||||
return
|
||||
if self._threshold_exceeded:
|
||||
_LOGGER.debug(
|
||||
"Threshold for-timer fired: %s (%d min)",
|
||||
|
||||
Reference in New Issue
Block a user