393 files
This commit is contained in:
@@ -54,6 +54,21 @@ def normalize_entity_ids(trigger_config: dict[str, Any]) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def primary_entity_id(trigger_config: Any) -> str | None:
|
||||
"""The entity a single-source trigger watches — the first of
|
||||
:func:`normalize_entity_ids` — or None for a compound trigger (no single
|
||||
source), a trigger without an entity, or no trigger at all.
|
||||
|
||||
Readers that only looked at the legacy singular ``entity_id`` (the
|
||||
``list_tasks`` unit, the notification context's ``trigger_entity_id``,
|
||||
the legacy-runtime split in the Store) missed triggers stored with only
|
||||
the plural ``entity_ids`` list (DRY audit 2026-09-26 B).
|
||||
"""
|
||||
if not isinstance(trigger_config, dict) or trigger_config.get("type") == TriggerType.COMPOUND:
|
||||
return None
|
||||
return next((eid for eid in normalize_entity_ids(trigger_config) if isinstance(eid, str) and eid), None)
|
||||
|
||||
|
||||
def _migrate_flat_to_per_entity(config: dict[str, Any], first_entity_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Create ``_trigger_state`` from legacy flat keys on first load.
|
||||
|
||||
@@ -197,4 +212,5 @@ __all__ = [
|
||||
"create_trigger",
|
||||
"create_triggers",
|
||||
"normalize_entity_ids",
|
||||
"primary_entity_id",
|
||||
]
|
||||
|
||||
@@ -36,9 +36,10 @@ 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__).
|
||||
# Class-level defaults: subclasses built without __init__ in tests still
|
||||
# carry the flags (see the instance comments in __init__).
|
||||
_recovered_since_reset: bool = True
|
||||
_evaluated_once: bool = True
|
||||
"""Base class for all maintenance triggers."""
|
||||
|
||||
def __init__(
|
||||
@@ -61,6 +62,10 @@ class BaseTrigger(ABC):
|
||||
# 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
|
||||
# False until the first real evaluation of this instance — the one
|
||||
# that runs after every restart and every entry reload (each task
|
||||
# edit reloads). See _evaluate_and_update.
|
||||
self._evaluated_once = False
|
||||
self._current_value: float | None = None
|
||||
self._unsub_listener: CALLBACK_TYPE | None = None
|
||||
# One timer per purpose (a retry and a subclass's for/hold window can
|
||||
@@ -230,14 +235,69 @@ class BaseTrigger(ABC):
|
||||
was_triggered = self._triggered
|
||||
is_triggered = self.evaluate(value)
|
||||
self._triggered = is_triggered
|
||||
initial = not self._evaluated_once
|
||||
self._evaluated_once = True
|
||||
if initial and self._in_completion_cooldown():
|
||||
# A fresh instance (reload / restart) inside the post-completion
|
||||
# cooldown behaves like the live one after its reset(): still on
|
||||
# the triggering side is not a new edge, so the activation keeps
|
||||
# the cooldown instead of lifting it. Only the live instance knew
|
||||
# that, and a task edit right after "Complete" re-triggered the
|
||||
# task and pushed a reminder (bug audit 2026-09-27).
|
||||
self._recovered_since_reset = False
|
||||
if not is_triggered:
|
||||
if not self._recovered_since_reset:
|
||||
# Seen back on the normal side after the completion — told to
|
||||
# the coordinator (persisted), so a reload's fresh instance
|
||||
# does not mistake the next activation for the old one.
|
||||
self._note_recovered()
|
||||
self._recovered_since_reset = True
|
||||
|
||||
if is_triggered and not was_triggered:
|
||||
self._on_trigger_activated(value)
|
||||
if initial and self._already_announced():
|
||||
self._restore_activation(value)
|
||||
else:
|
||||
self._on_trigger_activated(value)
|
||||
elif not is_triggered and was_triggered:
|
||||
self._on_trigger_deactivated(value)
|
||||
|
||||
def _already_announced(self) -> bool:
|
||||
"""Whether the task's activation is already on record (the coordinator
|
||||
reads its history). Looked up on the coordinator's CLASS: a mocked
|
||||
coordinator (unit tests) and the compound condition proxy, which only
|
||||
delegates per instance, have none and keep announcing as before."""
|
||||
check = getattr(type(self._coordinator), "trigger_already_announced", None)
|
||||
return callable(check) and check(self._coordinator, self._task_id) is True
|
||||
|
||||
def _in_completion_cooldown(self) -> bool:
|
||||
"""Whether the task was completed moments ago (the coordinator's
|
||||
post-completion cooldown, which survives reloads). Class lookup for
|
||||
the same reason as :meth:`_already_announced`."""
|
||||
check = getattr(type(self._coordinator), "in_completion_cooldown", None)
|
||||
return callable(check) and check(self._coordinator, self._task_id) is True
|
||||
|
||||
def _note_recovered(self) -> None:
|
||||
"""Report "recovered since the reset" (class lookup, see above — a
|
||||
compound condition's proxy has none: the compound owns the task)."""
|
||||
note = getattr(type(self._coordinator), "note_trigger_recovered", None)
|
||||
if callable(note):
|
||||
note(self._coordinator, self._task_id)
|
||||
|
||||
def _restore_activation(self, value: float) -> None:
|
||||
"""Re-latch an activation that was announced before this instance
|
||||
existed — repaint only: no second TRIGGERED history entry, no second
|
||||
activation event. A threshold/counter/runtime task still over its
|
||||
limit wrote both again on every restart and every task edit (entry
|
||||
reload), while the state-change latch already only repainted (bug
|
||||
audit 2026-09-26, SCH-10)."""
|
||||
_LOGGER.debug("Trigger %s restored as active (already announced): %s", self.entity_id, value)
|
||||
self.entity.async_update_trigger_state(
|
||||
is_triggered=True,
|
||||
current_value=value,
|
||||
trigger_entity_id=self.entity_id,
|
||||
)
|
||||
self._request_coordinator_refresh()
|
||||
|
||||
@abstractmethod
|
||||
def evaluate(self, value: float) -> bool:
|
||||
"""Evaluate whether the trigger condition is met.
|
||||
@@ -299,8 +359,10 @@ class BaseTrigger(ABC):
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
# Update entity state
|
||||
self.entity.async_update_trigger_state(
|
||||
# Update entity state — it answers whether the TASK recovered (all
|
||||
# entities of "any" clear, the "all" set broken up), not just this
|
||||
# entity; None (a proxy entity) keeps the per-entity meaning.
|
||||
task_recovered = self.entity.async_update_trigger_state(
|
||||
is_triggered=False,
|
||||
current_value=value,
|
||||
trigger_entity_id=self.entity_id,
|
||||
@@ -326,7 +388,10 @@ class BaseTrigger(ABC):
|
||||
# This hook only fires on the evaluate path: a manual complete/skip
|
||||
# resets the trigger via reset(), which never lands here, so the
|
||||
# manual flow cannot double-record.
|
||||
if self.config.get("auto_complete_on_recovery"):
|
||||
# Only on the task's own recovery (bug audit 2026-09-26, SCH-5): one of
|
||||
# several sensors clearing while another still holds the task — or an
|
||||
# "all" task whose set was never complete — is not the work being done.
|
||||
if self.config.get("auto_complete_on_recovery") and task_recovered is not False:
|
||||
# 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)
|
||||
|
||||
@@ -16,6 +16,7 @@ from ...const import (
|
||||
CONF_COMPOUND_LOGIC,
|
||||
EVENT_TRIGGER_ACTIVATED,
|
||||
EVENT_TRIGGER_DEACTIVATED,
|
||||
UNAVAILABLE_STATES,
|
||||
)
|
||||
from .base_trigger import BaseTrigger
|
||||
|
||||
@@ -123,6 +124,19 @@ class _CompoundCoordinatorProxy:
|
||||
else:
|
||||
store.async_delay_save()
|
||||
|
||||
# A condition's sub-trigger is not the task's trigger: only the compound
|
||||
# decides when the TASK activates. Delegated to the real coordinator,
|
||||
# every sub-trigger activation wrote its own TRIGGERED history entry and
|
||||
# lifted the post-completion cooldown — and, since this proxy has no
|
||||
# trigger_already_announced, it did so again on every reload and restart
|
||||
# (bug audit 2026-09-27, R SCH-10). The compound records the activation.
|
||||
|
||||
async def async_add_trigger_history_entry(self, task_id: str, trigger_value: float | None = None) -> None:
|
||||
"""No-op for a condition — see the class comment above."""
|
||||
|
||||
def note_trigger_edge(self, task_id: str, *, recovered: bool = True) -> None:
|
||||
"""No-op for a condition — the compound reports its own edge."""
|
||||
|
||||
|
||||
class CompoundTrigger(BaseTrigger):
|
||||
"""Compound trigger that combines multiple conditions with AND/OR logic.
|
||||
@@ -132,6 +146,11 @@ class CompoundTrigger(BaseTrigger):
|
||||
2. Across conditions: ``compound_logic`` (AND/OR)
|
||||
"""
|
||||
|
||||
# Class-level defaults for instances built without __init__ (tests), like
|
||||
# BaseTrigger's flags; __init__ sets the real starting values.
|
||||
_setting_up: bool = False
|
||||
_settled: bool = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -151,6 +170,15 @@ class CompoundTrigger(BaseTrigger):
|
||||
self._condition_states: list[bool] = [False] * len(self._conditions)
|
||||
self._sub_triggers: list[list[BaseTrigger]] = []
|
||||
self._sub_entities: list[CompoundSubEntity] = []
|
||||
# The compound's counterpart of BaseTrigger._evaluated_once (bug
|
||||
# audit 2026-09-27, R SCH-10): an activation is a RESTORE of the
|
||||
# episode on record — repaint only — while the sub-triggers run their
|
||||
# initial evaluations (async_setup), and until this instance has seen
|
||||
# the compound inactive with every condition entity reporting (late
|
||||
# entities after a restart evaluate on their first state event). An
|
||||
# activation after that is a new edge and is announced.
|
||||
self._setting_up = False
|
||||
self._settled = False
|
||||
|
||||
@property
|
||||
def condition_states(self) -> list[bool]:
|
||||
@@ -159,11 +187,28 @@ class CompoundTrigger(BaseTrigger):
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up all sub-triggers for each condition."""
|
||||
from . import create_triggers
|
||||
|
||||
trigger_state = self.config.get("_trigger_state", {})
|
||||
conditions_state = trigger_state.get("conditions", [])
|
||||
|
||||
self._setting_up = True
|
||||
try:
|
||||
await self._async_setup_conditions(conditions_state)
|
||||
finally:
|
||||
self._setting_up = False
|
||||
self._maybe_settle()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Compound trigger setup: %d conditions with %s logic for %s",
|
||||
len(self._conditions),
|
||||
self._compound_logic,
|
||||
self.entity.entity_id,
|
||||
)
|
||||
|
||||
async def _async_setup_conditions(self, conditions_state: list[Any]) -> None:
|
||||
"""Build and set up every condition's sub-triggers (their initial
|
||||
evaluations run here and may already activate the compound)."""
|
||||
from . import create_triggers
|
||||
|
||||
for idx, condition in enumerate(self._conditions):
|
||||
sub_entity = CompoundSubEntity(self, idx, condition)
|
||||
self._sub_entities.append(sub_entity)
|
||||
@@ -185,12 +230,20 @@ class CompoundTrigger(BaseTrigger):
|
||||
for trigger in sub_triggers:
|
||||
await trigger.async_setup()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Compound trigger setup: %d conditions with %s logic for %s",
|
||||
len(self._conditions),
|
||||
self._compound_logic,
|
||||
self.entity.entity_id,
|
||||
)
|
||||
def _all_conditions_reporting(self) -> bool:
|
||||
"""Every condition entity has a usable state (its sub-trigger has
|
||||
had the chance to evaluate)."""
|
||||
for trigger_list in self._sub_triggers:
|
||||
for trigger in trigger_list:
|
||||
state = self.hass.states.get(trigger.entity_id)
|
||||
if state is None or state.state in UNAVAILABLE_STATES:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _maybe_settle(self) -> None:
|
||||
"""Seen inactive with full information: later activations are edges."""
|
||||
if not self._settled and not self._setting_up and not self._triggered and self._all_conditions_reporting():
|
||||
self._settled = True
|
||||
|
||||
async def async_teardown(self) -> None:
|
||||
"""Tear down all sub-triggers."""
|
||||
@@ -230,9 +283,28 @@ class CompoundTrigger(BaseTrigger):
|
||||
self._triggered = now_triggered
|
||||
|
||||
if now_triggered and not was_triggered:
|
||||
self._on_trigger_activated(0.0)
|
||||
initial = self._setting_up or not self._settled
|
||||
if initial and self._in_completion_cooldown():
|
||||
self._recovered_since_reset = False # see BaseTrigger._evaluate_and_update
|
||||
if initial and self._already_announced():
|
||||
self._restore_activation(0.0)
|
||||
else:
|
||||
self._on_trigger_activated(0.0)
|
||||
elif not now_triggered and was_triggered:
|
||||
self._on_trigger_deactivated(0.0)
|
||||
self._maybe_settle()
|
||||
|
||||
def _restore_activation(self, value: float) -> None:
|
||||
"""Repaint an activation already on record (see ``_settled``) — no
|
||||
second history entry, no second event. Like the compound's own
|
||||
activation it carries no per-entity id or value."""
|
||||
_LOGGER.debug("Compound trigger restored as active (already announced): %s", self.entity.entity_id)
|
||||
self.entity.async_update_trigger_state(
|
||||
is_triggered=True,
|
||||
current_value=None,
|
||||
trigger_entity_id=None,
|
||||
)
|
||||
self._request_coordinator_refresh()
|
||||
|
||||
def _on_trigger_activated(self, value: float) -> None:
|
||||
"""Handle compound trigger activation."""
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
if TYPE_CHECKING:
|
||||
from ...sensor import MaintenanceSensor
|
||||
|
||||
from ...helpers.trigger_fallback import counter_baseline
|
||||
from .base_trigger import BaseTrigger
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -50,7 +51,9 @@ class CounterTrigger(BaseTrigger):
|
||||
# e.g. 80, falling back to the config's 0 on restart would re-fire the
|
||||
# just-completed task (issue #102 family).
|
||||
if self._delta_mode:
|
||||
saved = self.config.get("_trigger_state", {}).get(self.entity_id, {}).get("baseline_value")
|
||||
# The shared lookup (helpers.trigger_fallback.counter_baseline):
|
||||
# the entity's Store baseline, else the configured initial one.
|
||||
saved = counter_baseline(self.config, self.entity_id)
|
||||
if saved is not None:
|
||||
self._baseline_value = saved
|
||||
_LOGGER.debug(
|
||||
|
||||
Reference in New Issue
Block a user