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
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime, time
from datetime import date, datetime
from typing import Any
from uuid import uuid4
@@ -19,7 +19,10 @@ from ..const import (
ScheduleType,
)
from ..helpers.dates import parse_iso_date
from ..helpers.history import completed_entries
from ..helpers.phases import current_phase
from ..helpers.schedule import Schedule, read_legacy_fields
from ..helpers.status import compute_status, effective_warning_days, is_past_schedule_time
@dataclass
@@ -155,14 +158,10 @@ class MaintenanceTask:
@property
def current_phase_name(self) -> str | None:
"""Name of the cycle phase currently due (#139), or None."""
if not (self.phases and self.phase_sequence):
return None
from ..helpers.phases import clamp_phase_cursor
phase_id = self.phase_sequence[clamp_phase_cursor(self.phase_cursor, len(self.phase_sequence))]
definition = self.phases.get(phase_id)
return definition.get("name") if isinstance(definition, dict) else None
"""Name of the cycle phase currently due (#139), or None — the model's
view of ``helpers.phases.current_phase`` (the cursor clamp lives there)."""
phase = current_phase({"phases": self.phases, "phase_sequence": self.phase_sequence, "phase_cursor": self.phase_cursor})
return phase.get("name") if phase is not None else None
def _planned_grid_due(self) -> date | None:
"""``next_due`` WITHOUT the postpone override — the drift-free grid.
@@ -220,16 +219,14 @@ class MaintenanceTask:
passed (in HA's configured TZ). Returns False when no `schedule_time` is set
— that preserves the historical "due at midnight" semantic.
"""
if not self.schedule_time:
return False
try:
# Tolerate both "HH:MM" (panel/WS) and "HH:MM:SS" (HA TimeSelector
# in the config-flow) — take the first two components.
parts = str(self.schedule_time).split(":")
target = time(int(parts[0]), int(parts[1]))
except (ValueError, TypeError, IndexError):
return False
return dt_util.now().time() >= target
return is_past_schedule_time(self.schedule_time)
@property
def effective_warning_days(self) -> int:
"""The warning window capped at one interval in real days (#58) —
what the status ladder compares ``days_until_due`` against; published
to the coordinator payload for the dict twin (helpers.status)."""
return effective_warning_days(self.warning_days, self._schedule().span_days())
@property
def archived(self) -> bool:
@@ -238,50 +235,21 @@ class MaintenanceTask:
@property
def status(self) -> MaintenanceStatus:
"""Determine the current status of this task."""
# Archived takes precedence over everything — a retired task is inert.
# Mirror this in helpers/status.compute_status_from_task_dict (the dict
# twin used where only coordinator data is available).
if self.archived_at is not None:
return MaintenanceStatus.ARCHIVED
"""Determine the current status of this task.
days = self.days_until_due
# Trigger ∧/∨ safety interval: with the "all" combinator BOTH legs must
# be met — the trigger must have fired AND the due date been reached
# (the interval is a minimum age, not a deadline). Default "any" keeps
# the historical whichever-first behaviour. Mirror any change in
# helpers/status.compute_status_from_task_dict (the dict twin).
all_mode = (self.trigger_config or {}).get("trigger_combinator") == "all"
time_met = days is None or days <= 0
# Trigger takes precedence
if self._trigger_active and (not all_mode or time_met):
return MaintenanceStatus.TRIGGERED
if days is None:
# Manual task or no schedule: always OK unless triggered
return MaintenanceStatus.OK
if all_mode and not self._trigger_active:
# The elapsed interval alone never actions an "all" task.
return MaintenanceStatus.OK
if days < 0:
return MaintenanceStatus.OVERDUE
# Sub-day refinement: same-day past schedule_time also counts as overdue.
# Without this, a task with schedule_time="09:00" would only flip at midnight.
if days == 0 and self._is_past_schedule_time():
return MaintenanceStatus.OVERDUE
# Don't let the warning window exceed one interval, measured in real
# days via the Schedule (a 6-*month* task must not collapse a 14-day
# warning to min(14, 6) — issue #58). Single source: the Schedule, not
# raw interval fields.
span = self._schedule().span_days()
effective_warning = min(self.warning_days, span) if span else self.warning_days
if days <= effective_warning:
return MaintenanceStatus.DUE_SOON
return MaintenanceStatus.OK
The ladder itself is ``helpers.status.compute_status`` — shared with
the dict twin the entities use after a live trigger update — so the
two cannot drift; this property only supplies the model's inputs.
"""
return compute_status(
archived=self.archived_at is not None,
enabled=self.enabled,
days_until_due=self.days_until_due,
trigger_active=self._trigger_active,
all_mode=(self.trigger_config or {}).get("trigger_combinator") == "all",
warning_days=self.effective_warning_days,
past_schedule_time=self._is_past_schedule_time(),
)
@property
def can_complete_now(self) -> bool:
@@ -317,7 +285,7 @@ class MaintenanceTask:
@property
def times_performed(self) -> int:
"""Count the number of completed maintenance entries in history."""
return sum(1 for entry in self.history if entry.get("type") == HistoryEntryType.COMPLETED)
return len(completed_entries(self.history))
@property
def total_cost(self) -> float:
@@ -354,6 +322,21 @@ class MaintenanceTask:
# --- Methods ---
def would_be_latest(self, ts: datetime) -> bool:
"""Whether a completion stamped ``ts`` would be the task's LATEST one
(else it is a backfill). String comparison, deliberately - history
timestamps mix TZ-aware (live completions) and naive (hand-edited)
values, and the edit reconciliation already compares them as strings.
last_performed (date-only ISO) joins the anchors: an imported or
history-trimmed task has a cycle anchor but no lifecycle entries, and
a backfill must not drag that anchor backwards either. A full
timestamp on the same day sorts after the bare date, so a same-day
completion still counts as latest."""
anchors = [h.get("timestamp") or "" for h in self.history if h.get("type") in LIFECYCLE_HISTORY_TYPES]
if self.last_performed:
anchors.append(self.last_performed)
return ts.isoformat() >= max(anchors, default="")
def complete(
self,
notes: str | None = None,
@@ -399,10 +382,7 @@ class MaintenanceTask:
# must not drag that anchor backwards either. A full timestamp on the
# same day sorts after the bare date, so a same-day completion still
# counts as latest.
anchors = [h.get("timestamp") or "" for h in self.history if h.get("type") in LIFECYCLE_HISTORY_TYPES]
if self.last_performed:
anchors.append(self.last_performed)
is_latest = ts_iso >= max(anchors, default="")
is_latest = self.would_be_latest(ts)
# Phases (#139): the step being completed is the one currently due.
# Resolved BEFORE the cursor advances; a pure backfill gets no phase