113 files

This commit is contained in:
Home Assistant Version Control
2026-08-26 07:17:31 +00:00
parent 0533eaca8d
commit 15c0e35c18
113 changed files with 12448 additions and 1673 deletions
@@ -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)",