393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -15,13 +15,14 @@ every rule is individually testable.
from __future__ import annotations
import math
from collections.abc import Callable
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util
from ..const import UNAVAILABLE_STATES
from .dates import parse_persisted_utc
if TYPE_CHECKING:
from homeassistant.core import State
@@ -147,17 +148,77 @@ def evaluate_threshold(
return FallbackResult(current_value=last_value, active=active)
def _finite(raw: Any) -> float | None:
if isinstance(raw, bool):
return None
try:
value = float(raw)
except (TypeError, ValueError):
return None
return value if math.isfinite(value) else None
def counter_baseline(trigger_config: Mapping[str, Any], entity_id: str | None) -> float | None:
"""THE baseline a delta counter measures *entity_id* against.
The entity's own living baseline from the Store
(``_trigger_state[entity].baseline_value`` — moved on every completion),
else the configured initial ``trigger_baseline_value``. Four copies of
this lookup had drifted: the coordinator took the FIRST entity's
baseline for whatever entity's reading it showed, and the sensor
predictor ignored the Store and forecast from the initial baseline, so a
serviced odometer task predicted "due now" (bug audit 2026-09-26, DRY
BR-A10 / SCH-8).
"""
state = trigger_config.get("_trigger_state")
if entity_id and isinstance(state, Mapping):
own = state.get(entity_id)
if isinstance(own, Mapping) and own.get("baseline_value") is not None:
return _finite(own["baseline_value"])
return _finite(trigger_config.get("trigger_baseline_value"))
def counter_progress(
get_state: StateGetter,
trigger_config: Mapping[str, Any],
entity_ids: list[str],
) -> tuple[float | None, float | None]:
"""``(reading, baseline)`` of ONE delta-counter entity — the one furthest
along — so a progress display never subtracts one entity's baseline from
another entity's reading. Entities without a live reading only count when
none has one (then the first with a baseline, reading ``None``)."""
attribute = trigger_config.get("attribute")
best: tuple[float, float] | None = None
fallback_baseline: float | None = None
for eid in entity_ids:
baseline = counter_baseline(trigger_config, eid)
if baseline is None:
continue
if fallback_baseline is None:
fallback_baseline = baseline
value = _numeric_entity_value(get_state, eid, attribute)
if value is not None and (best is None or value - baseline > best[0] - best[1]):
best = (value, baseline)
if best is not None:
return best
return None, fallback_baseline
def evaluate_counter(
get_state: StateGetter,
trigger_config: dict[str, Any],
entity_ids: list[str],
) -> FallbackResult:
"""Counter: value (or delta from a per-entity baseline) reaches a target."""
"""Counter: value (or delta from a per-entity baseline) reaches a target.
In delta mode the reported value is the reading of the entity furthest
along (:func:`counter_progress`), so the coordinator's delta pairs the
reading with ITS baseline.
"""
attribute = trigger_config.get("attribute")
entity_logic = trigger_config.get("entity_logic", "any")
target = trigger_config.get("trigger_target_value", 0) # pragma: no mutate (WS-required; default only guards hand-edited data)
delta_mode = trigger_config.get("trigger_delta_mode", False)
trigger_state = trigger_config.get("_trigger_state", {})
per_entity: list[bool] = []
last_value: float | None = None
@@ -168,13 +229,16 @@ def evaluate_counter(
continue
last_value = value
if delta_mode:
baseline = trigger_state.get(eid, {}).get("baseline_value")
if baseline is None:
baseline = trigger_config.get("trigger_baseline_value")
baseline = counter_baseline(trigger_config, eid)
per_entity.append(baseline is not None and (value - baseline) >= target)
else:
per_entity.append(value >= target)
if delta_mode:
reading, _baseline = counter_progress(get_state, trigger_config, entity_ids)
if reading is not None:
last_value = reading
active = _aggregate(per_entity, entity_logic) if per_entity else None
return FallbackResult(current_value=last_value, active=active)
@@ -245,15 +309,11 @@ def evaluate_runtime(
total = float(seconds)
on_since = es.get("on_since")
if on_since:
on_dt = dt_util.parse_datetime(on_since)
# The live runtime trigger's own parser (naive payloads read as
# UTC so the subtraction below can't raise) — this fallback
# re-implemented it (DRY audit 2026-09-26 B).
on_dt = parse_persisted_utc(on_since)
if on_dt is not None:
# Older payloads may be naive — assume UTC (live writes are
# TZ-aware) so the subtraction below can't raise. Mirrors
# threshold.py's exceeded_since handling.
if on_dt.tzinfo is None:
from datetime import UTC
on_dt = on_dt.replace(tzinfo=UTC)
ongoing = max(0.0, (dt_util.utcnow() - on_dt).total_seconds()) # pragma: no mutate (a 1s floor shift vanishes in the 2-decimal rounding)
# #149: per-session cap — the open window contributes at most
# what the session may still book (mirrors the live tracker).