185 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-19 11:38:13 +00:00
1 parent 5f9330abd7
commit a718af6423
185 files changed
+16868 -3088

No files matched your search

@@ -102,6 +102,11 @@ def register_action_listener(hass: HomeAssistant) -> Callable[[], None]:
task_id = event.data.get("task_id")
if not entry_id or not task_id:
return
# #133: a pure backfill records maintenance that happened long ago —
# running the on_complete_action NOW (reset a device counter, toggle
# a helper) would act on the live device for stale work.
if event.data.get("backfill"):
return
action = _resolve_task_action(hass, entry_id, task_id)
if action is None:
return
@@ -35,13 +35,23 @@ def compute_status_from_task_dict(task: dict[str, Any]) -> str:
# between refreshes.
if task.get("_paused"):
return MaintenanceStatus.PAUSED
if task.get("_trigger_active", False):
days = task.get("_days_until_due")
# "all" combinator: trigger AND elapsed safety interval must both be met
# before the task actions (model twin: MaintenanceTask.status).
all_mode = (task.get("trigger_config") or {}).get("trigger_combinator") == "all"
time_met = days is None or days <= 0
trigger_active = task.get("_trigger_active", False)
if trigger_active and (not all_mode or time_met):
return MaintenanceStatus.TRIGGERED
days = task.get("_days_until_due")
if days is None:
return MaintenanceStatus.OK
if all_mode and not trigger_active:
return MaintenanceStatus.OK
warning_days = task.get("warning_days", DEFAULT_WARNING_DAYS)
if days < 0:
return MaintenanceStatus.OVERDUE
@@ -14,6 +14,7 @@ every rule is individually testable.
from __future__ import annotations
import math
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
@@ -44,6 +45,30 @@ def _aggregate(per_entity: list[bool], entity_logic: str) -> bool:
return all(per_entity) if entity_logic == "all" else any(per_entity)
def threshold_exceeds(
value: float,
*,
above: float | None,
below: float | None,
equals: float | None = None,
not_equals: float | None = None,
) -> bool:
"""Whether *value* violates any configured threshold limit.
The single predicate shared by the event-driven ThresholdTrigger and the
refresh-time fallback below — keep both surfaces on this one rule.
Equality uses ``math.isclose`` so float round-trips (state strings, JSON)
can't miss a discrete level like 3.0.
"""
if above is not None and value > above:
return True
if below is not None and value < below:
return True
if equals is not None and math.isclose(value, equals, rel_tol=1e-9, abs_tol=1e-9):
return True
return not_equals is not None and not math.isclose(value, not_equals, rel_tol=1e-9, abs_tol=1e-9)
def _numeric_entity_value(get_state: StateGetter, entity_id: str, attribute: str | None) -> float | None:
"""Read a numeric value from an entity state/attribute (None when unusable)."""
state = get_state(entity_id)
@@ -69,6 +94,8 @@ def evaluate_threshold(
for_minutes = trigger_config.get("trigger_for_minutes", 0)
above = trigger_config.get("trigger_above")
below = trigger_config.get("trigger_below")
equals = trigger_config.get("trigger_equals")
not_equals = trigger_config.get("trigger_not_equals")
per_entity: list[bool] = []
last_value: float | None = None
@@ -78,8 +105,7 @@ def evaluate_threshold(
per_entity.append(False)
continue
last_value = value
exceeds = (above is not None and value > above) or (below is not None and value < below)
per_entity.append(exceeds)
per_entity.append(threshold_exceeds(value, above=above, below=below, equals=equals, not_equals=not_equals))
aggregated = _aggregate(per_entity, entity_logic) if per_entity else False