278 files
This commit is contained in:
1 parent
d7a0a08372
commit
1b4f5f68c6
278 files changed
+36892
-7150
No files matched your search
@@ -1,60 +1,115 @@
|
||||
"""Shared task-status derivation from a coordinator data dict.
|
||||
"""The task-status ladder — ONE implementation for the model and the dict twin.
|
||||
|
||||
The per-task ``sensor`` and ``binary_sensor`` entities both need to recompute a
|
||||
task's status from the coordinator's plain data dict (e.g. right after a live
|
||||
trigger update) without rebuilding a full :class:`MaintenanceTask`. They used to
|
||||
carry byte-identical ``_compute_live_status`` copies; this is the single source.
|
||||
|
||||
This is the *dict* status ladder — a lightweight mirror of
|
||||
:pyattr:`MaintenanceTask.status`. It intentionally omits the model's sub-day
|
||||
``schedule_time`` refinement and the span-capped warning window (issue #58),
|
||||
which require the live ``Schedule`` object; those are applied by the next
|
||||
coordinator refresh. Keep the trigger / overdue / due-soon precedence here in
|
||||
sync with the model property.
|
||||
:pyattr:`MaintenanceTask.status` (the coordinator's per-refresh computation)
|
||||
and :func:`compute_status_from_task_dict` (the per-task ``sensor`` /
|
||||
``binary_sensor`` recomputation right after a live trigger update, from the
|
||||
coordinator's plain data dict) used to be two hand-kept ladders; the dict
|
||||
twin silently lacked the span-capped warning window (issue #58) and the
|
||||
sub-day ``schedule_time`` refinement, and neither knew that a disabled task
|
||||
reads OK (only the coordinator's short-circuit did). Both now delegate to
|
||||
:func:`compute_status`; the coordinator publishes the inputs the dict twin
|
||||
cannot derive itself (``_warning_days_effective``) alongside
|
||||
``_days_until_due`` / ``_trigger_active`` / ``_paused``.
|
||||
``tests/test_status_twin.py`` pins the two callers to each other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import DEFAULT_WARNING_DAYS, MaintenanceStatus
|
||||
from .dates import parse_hhmm
|
||||
|
||||
|
||||
def effective_warning_days(warning_days: int, span_days: int | None) -> int:
|
||||
"""The warning window, capped at one interval measured in real days
|
||||
(issue #58: a 6-*month* task must not collapse a 14-day warning to
|
||||
``min(14, 6)``). ``span_days`` ``None``/0 = no cap (manual, one-off)."""
|
||||
return min(warning_days, span_days) if span_days else warning_days
|
||||
|
||||
|
||||
def is_past_schedule_time(schedule_time: str | None) -> bool:
|
||||
"""True iff a ``schedule_time`` is set AND the current local time is past it.
|
||||
|
||||
The sub-day refinement of the OVERDUE transition: when ``days_until_due``
|
||||
is exactly 0 the task counts as overdue once the configured HH:MM has
|
||||
passed (in HA's configured TZ). ``None``/blank keeps the historical
|
||||
"due at midnight" semantic; "HH:MM" (panel/WS) and "HH:MM:SS" (HA
|
||||
TimeSelector) parse alike.
|
||||
"""
|
||||
if not schedule_time:
|
||||
return False
|
||||
target = parse_hhmm(schedule_time)
|
||||
if target is None:
|
||||
return False
|
||||
return dt_util.now().time() >= target
|
||||
|
||||
|
||||
def compute_status(
|
||||
*,
|
||||
archived: bool,
|
||||
paused: bool = False,
|
||||
enabled: bool = True,
|
||||
days_until_due: int | None,
|
||||
trigger_active: bool,
|
||||
all_mode: bool,
|
||||
warning_days: int,
|
||||
past_schedule_time: bool,
|
||||
) -> MaintenanceStatus:
|
||||
"""The status ladder, precedence top to bottom.
|
||||
|
||||
* disabled → OK (a switched-off task is inert: no due, nothing to notify —
|
||||
the coordinator's short-circuit rule, folded in here);
|
||||
* archived → ARCHIVED (v2.10.0: retired but retained);
|
||||
* paused → PAUSED (v2.20, N3: the object's schedules are frozen);
|
||||
* trigger ∧/∨ safety interval: with the ``"all"`` combinator BOTH legs
|
||||
must be met — the trigger fired AND the due date reached (the interval
|
||||
is a minimum age, not a deadline); default ``"any"`` is whichever-first;
|
||||
* no schedule (``days_until_due`` None) → OK unless triggered;
|
||||
* past due, or due today past ``schedule_time`` → OVERDUE;
|
||||
* within the (span-capped) warning window → DUE_SOON; else OK.
|
||||
"""
|
||||
if not enabled:
|
||||
return MaintenanceStatus.OK
|
||||
if archived:
|
||||
return MaintenanceStatus.ARCHIVED
|
||||
if paused:
|
||||
return MaintenanceStatus.PAUSED
|
||||
|
||||
days = days_until_due
|
||||
time_met = days is None or days <= 0
|
||||
if trigger_active and (not all_mode or time_met):
|
||||
return MaintenanceStatus.TRIGGERED
|
||||
if days is None:
|
||||
return MaintenanceStatus.OK
|
||||
if all_mode and not trigger_active:
|
||||
# The elapsed interval alone never actions an "all" task.
|
||||
return MaintenanceStatus.OK
|
||||
if days < 0 or (days == 0 and past_schedule_time):
|
||||
return MaintenanceStatus.OVERDUE
|
||||
if days <= warning_days:
|
||||
return MaintenanceStatus.DUE_SOON
|
||||
return MaintenanceStatus.OK
|
||||
|
||||
|
||||
def compute_status_from_task_dict(task: dict[str, Any]) -> str:
|
||||
"""Compute task status from a coordinator data dict.
|
||||
|
||||
Mirrors :pyattr:`MaintenanceTask.status` for the archived / trigger /
|
||||
overdue / due-soon / ok ladder. ``_trigger_active`` and ``_days_until_due``
|
||||
are the coordinator-computed live fields.
|
||||
``_trigger_active``, ``_days_until_due``, ``_paused`` and
|
||||
``_warning_days_effective`` are the coordinator-computed live fields (the
|
||||
last falls back to the raw ``warning_days`` for payloads that predate it);
|
||||
``schedule_time`` is only present when the feature is enabled (the
|
||||
coordinator strips it otherwise), so the sub-day check is safe here.
|
||||
"""
|
||||
# Archived takes precedence over everything (v2.10.0) — see the model twin.
|
||||
if task.get("archived_at") is not None:
|
||||
return MaintenanceStatus.ARCHIVED
|
||||
# Seasonal pause (v2.20, N3): object-wide, injected by the coordinator.
|
||||
# Keeps a live trigger event from flipping a frozen task to TRIGGERED
|
||||
# between refreshes.
|
||||
if task.get("_paused"):
|
||||
return MaintenanceStatus.PAUSED
|
||||
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
|
||||
|
||||
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
|
||||
if days <= warning_days:
|
||||
return MaintenanceStatus.DUE_SOON
|
||||
return MaintenanceStatus.OK
|
||||
return compute_status(
|
||||
archived=task.get("archived_at") is not None,
|
||||
paused=bool(task.get("_paused")),
|
||||
enabled=task.get("enabled", True) is not False,
|
||||
days_until_due=task.get("_days_until_due"),
|
||||
trigger_active=bool(task.get("_trigger_active", False)),
|
||||
all_mode=(task.get("trigger_config") or {}).get("trigger_combinator") == "all",
|
||||
warning_days=task.get("_warning_days_effective", task.get("warning_days", DEFAULT_WARNING_DAYS)),
|
||||
past_schedule_time=is_past_schedule_time(task.get("schedule_time")),
|
||||
)
|
||||
Reference in new issue
Block a user