278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
@@ -5,13 +5,13 @@ from __future__ import annotations
import logging
import math
from abc import ABC, abstractmethod
from collections.abc import Coroutine
from datetime import datetime
from typing import TYPE_CHECKING, Any
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback
from homeassistant.helpers.event import (
EventStateChangedData,
async_call_later,
async_track_state_change_event,
)
@@ -24,11 +24,21 @@ from ...const import (
EVENT_TRIGGER_DEACTIVATED,
UNAVAILABLE_STATES,
)
from ...helpers.managed_timer import ManagedTimer
_LOGGER = logging.getLogger(__name__)
# The initial-evaluation retry: an entity that is unknown/unavailable at
# setup is re-checked this often, this many times, before the state-change
# listener alone is trusted to catch its recovery (issue #1 family).
RETRY_DELAY_SECONDS = 30.0
RETRY_MAX_ATTEMPTS = 10
class BaseTrigger(ABC):
# Class-level default: subclasses built without __init__ in tests still
# carry the flag (see the instance comment in __init__).
_recovered_since_reset: bool = True
"""Base class for all maintenance triggers."""
def __init__(
@@ -45,9 +55,18 @@ class BaseTrigger(ABC):
self.attribute = trigger_config.get("attribute")
self._triggered = False
# Bug audit 2026-09-12: after a completion resets the trigger, the
# sensor may still read beyond the threshold (the user tapped Complete
# before refilling). Such a re-activation is NOT a new edge and must
# not lift the coordinator's post-completion cooldown - only an
# activation after the value was seen on the other side is.
self._recovered_since_reset = True
self._current_value: float | None = None
self._unsub_listener: CALLBACK_TYPE | None = None
self._unsub_retry: CALLBACK_TYPE | None = None
# One timer per purpose (a retry and a subclass's for/hold window can
# be pending at the same time); the tasks a trigger spawns (history
# entry, refresh, persist) are tracked here and cancelled at teardown.
self._retry_timer = ManagedTimer(hass, f"{type(self).__name__}:{self.entity_id}:retry")
self._logged_unavailable = False # Log-once pattern for unavailable
@property
@@ -108,42 +127,47 @@ class BaseTrigger(ABC):
)
def _schedule_retry(self) -> None:
"""Schedule a retry of the initial evaluation after 30 seconds."""
self._cancel_retry()
"""Schedule a retry of the initial evaluation (30 s, capped).
@callback
def _retry_initial_evaluation(_now: datetime) -> None:
"""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_STATES:
_LOGGER.debug(
"Trigger entity %s still %s after retry",
self.entity_id,
state.state if state else "missing",
)
return
value = self._get_numeric_value(state)
if value is not None:
self._current_value = value
self._evaluate_and_update(value)
_LOGGER.info(
"Trigger entity %s recovered after retry (value=%s)",
self.entity_id,
value,
)
Before the ManagedTimer this was a single hard-coded shot: an entity
still unavailable 30 s after setup was never re-checked by the timer
again (the state-change listener catches a LATER recovery, but not
one that happened while we were not looking). Now it re-arms until
the entity reports or the budget is spent.
"""
self._retry_timer.retry(self._retry_initial_evaluation, delay=RETRY_DELAY_SECONDS, max_attempts=RETRY_MAX_ATTEMPTS)
self._unsub_retry = async_call_later(self.hass, 30, _retry_initial_evaluation)
@callback
def _retry_initial_evaluation(self, _now: datetime) -> None:
"""Re-check entity state after a delay."""
state = self.hass.states.get(self.entity_id)
if state is None or state.state in UNAVAILABLE_STATES:
_LOGGER.debug(
"Trigger entity %s still %s after retry",
self.entity_id,
state.state if state else "missing",
)
self._schedule_retry()
return
self._retry_timer.reset_retries()
value = self._get_numeric_value(state)
if value is not None:
self._current_value = value
self._evaluate_and_update(value)
_LOGGER.info(
"Trigger entity %s recovered after retry (value=%s)",
self.entity_id,
value,
)
def _cancel_retry(self) -> None:
"""Cancel pending retry timer."""
if self._unsub_retry is not None:
self._unsub_retry()
self._unsub_retry = None
def _track(self, coro: Coroutine[Any, Any, Any], *, cancel_on_close: bool = True) -> None:
"""Spawn a fire-and-forget task the trigger owns (cancelled at
teardown unless it must land regardless — see the auto-complete)."""
self._retry_timer.track_task(coro, cancel_on_close=cancel_on_close)
async def async_teardown(self) -> None:
"""Remove the trigger listener."""
self._cancel_retry()
"""Remove the trigger listener, the retry timer and owned tasks."""
self._retry_timer.close()
if self._unsub_listener is not None:
self._unsub_listener()
self._unsub_listener = None
@@ -206,6 +230,8 @@ class BaseTrigger(ABC):
was_triggered = self._triggered
is_triggered = self.evaluate(value)
self._triggered = is_triggered
if not is_triggered:
self._recovered_since_reset = True
if is_triggered and not was_triggered:
self._on_trigger_activated(value)
@@ -229,7 +255,7 @@ class BaseTrigger(ABC):
full update interval — 30 s once, 4 min the next time. Debounced on
purpose (HA's ten-second window): a noisy sensor must not recompute the
object on every state change; user actions use async_refresh_now."""
self.hass.async_create_task(self._coordinator.async_request_refresh())
self._track(self._coordinator.async_request_refresh())
def _on_trigger_activated(self, value: float) -> None:
"""Handle trigger activation."""
@@ -248,8 +274,8 @@ class BaseTrigger(ABC):
)
# Add history entry for the trigger activation
self.hass.async_create_task(self._coordinator.async_add_trigger_history_entry(self._task_id, trigger_value=value))
self._coordinator.note_trigger_edge(self._task_id)
self._track(self._coordinator.async_add_trigger_history_entry(self._task_id, trigger_value=value))
self._coordinator.note_trigger_edge(self._task_id, recovered=self._recovered_since_reset)
self._request_coordinator_refresh()
# Fire event
@@ -301,7 +327,9 @@ class BaseTrigger(ABC):
# resets the trigger via reset(), which never lands here, so the
# manual flow cannot double-record.
if self.config.get("auto_complete_on_recovery"):
self.hass.async_create_task(self._coordinator.async_auto_complete_on_recovery(self._task_id, value))
# cancel_on_close=False: a completion in flight must land even
# when the recovery coincides with a reload of the entry.
self._track(self._coordinator.async_auto_complete_on_recovery(self._task_id, value), cancel_on_close=False)
def _get_numeric_value(self, state: State) -> float | None:
"""Extract numeric value from state or attribute."""
@@ -323,3 +351,4 @@ class BaseTrigger(ABC):
def reset(self) -> None:
"""Reset the trigger (called after maintenance completion)."""
self._triggered = False
self._recovered_since_reset = False