Files
HomeAssistantVS/custom_components/maintenance_supporter/models/maintenance_task.py
T

643 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Maintenance task model."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime, time
from typing import Any
from uuid import uuid4
from homeassistant.util import dt as dt_util
from ..const import (
DEFAULT_MAX_HISTORY_ENTRIES,
DEFAULT_WARNING_DAYS,
LIFECYCLE_HISTORY_TYPES,
HistoryEntryType,
MaintenanceStatus,
MaintenanceTypeEnum,
ScheduleType,
)
from ..helpers.dates import parse_iso_date
from ..helpers.schedule import Schedule, read_legacy_fields
@dataclass
class MaintenanceTask:
"""Represents a specific maintenance task belonging to an object.
Examples: 'Filter Cleaning' for Pool Pump, 'Oil Change' for Car.
"""
# --- Identity ---
id: str = field(default_factory=lambda: uuid4().hex)
object_id: str = ""
name: str = ""
type: str = MaintenanceTypeEnum.CUSTOM
enabled: bool = True
# --- Schedule ---
schedule_type: str = ScheduleType.TIME_BASED
interval_days: int | None = None
interval_unit: str = "days" # days | weeks | months | years (calendar-aware)
warning_days: int = DEFAULT_WARNING_DAYS
# Completion window: max days *before* the due date a task may be completed.
# None = no restriction (complete any time). 0 = only on/after the due date.
earliest_completion_days: int | None = None
last_performed: str | None = None # ISO format YYYY-MM-DD
created_at: str | None = None # ISO date: fallback anchor for next_due when last_performed is None
interval_anchor: str = "completion" # "completion" or "planned"
last_planned_due: str | None = None # ISO date: anchor for planned mode
schedule_time: str | None = None # "HH:MM" in HA's configured TZ; None = midnight (default)
due_date: str | None = None # ISO date: one-time task due date (ScheduleType.ONE_TIME)
# Per-occurrence postpone: an ISO date that overrides just the CURRENT
# cycle's due date (see Schedule.next_due). Cleared on completion so the
# next cycle returns to the normal cadence. Dynamic (lives in the Store).
due_override: str | None = None
# Raw nested schedule dict (schedule-model v2). The source of truth for the
# calendar kinds (weekdays/nth_weekday/day_of_month), which the flat fields
# above can't represent. None for legacy/flat-only data.
schedule_raw: dict[str, Any] | None = None
# --- Trigger ---
trigger_config: dict[str, Any] | None = None
# --- Metadata ---
notes: str | None = None
documentation_url: str | None = None
custom_icon: str | None = None
nfc_tag_id: str | None = None
priority: str = "normal"
labels: list[str] = field(default_factory=list)
# --- Meter readings (v2.20, #83) ---
# Display unit for the recorded value of a `reading`-type task ("kWh",
# "m³", …). The value itself lives per completion in the history entry
# (`reading_value`), so the unit is task-level config, not state.
reading_unit: str | None = None
# --- Archive (v2.10.0) ---
# archived_at is an ISO timestamp; None means active. When set, the task
# reads the ARCHIVED status (highest precedence) and goes inert everywhere
# except budget/cost history. archived_reason is one of ARCHIVE_REASON_*
# (manual | auto | object) and drives the auto-delete + object-cascade rules.
archived_at: str | None = None
archived_reason: str | None = None
# --- User Assignment ---
responsible_user_id: str | None = None # HA user UUID (current pointer)
assignee_pool: list[str] = field(default_factory=list) # HA user UUIDs
rotation_strategy: str | None = None # round_robin | least_completed | random
# --- Checklist ---
checklist: list[str] = field(default_factory=list)
# --- Adaptive Scheduling ---
adaptive_config: dict[str, Any] | None = None
# --- History ---
history: list[dict[str, Any]] = field(default_factory=list)
# --- Runtime (not persisted) ---
_trigger_active: bool = field(default=False, repr=False)
_trigger_current_value: float | None = field(default=None, repr=False)
# --- Computed Properties ---
@property
def next_due(self) -> date | None:
"""Calculate the next due date.
- ``one_time`` tasks are due on ``due_date`` and return ``None`` once
completed (they archive instead of re-arming).
- Otherwise the date is ``last_performed (or created_at) + interval``,
where the interval honours ``interval_unit`` (days/weeks/months/years).
With ``interval_anchor == "planned"`` the date is computed from the
previously planned due date to avoid drift for strictly periodic tasks.
Example (30-day interval, planned for March 1):
- Completed March 5 → next due: March 31 (not April 4)
- If never completed the date may be in the past → OVERDUE.
"""
# Recurrence is computed by the Schedule value object
# (helpers/schedule.py, Phase 2). ISO parsing stays here at the boundary;
# a *malformed* last_performed preserves the historical "no next_due".
last: date | None = None
if self.last_performed:
try:
last = date.fromisoformat(self.last_performed)
except (ValueError, TypeError):
return None
return self._schedule().next_due(
last_performed=last,
created_at=parse_iso_date(self.created_at),
last_planned_due=parse_iso_date(self.last_planned_due),
today=dt_util.now().date(),
times_performed=self.times_performed,
due_override=parse_iso_date(self.due_override),
)
def _schedule(self) -> Schedule:
"""The recurrence as a value object (see docs/design/schedule-model-v2.md).
A nested ``schedule_raw`` is authoritative — it's the only representation
that carries the calendar kinds (weekdays / nth_weekday / day_of_month).
Otherwise fall back to the flat fields (legacy / old exports)."""
if isinstance(self.schedule_raw, dict) and self.schedule_raw.get("kind"):
return Schedule.from_dict(self.schedule_raw)
return Schedule.from_legacy(
schedule_type=self.schedule_type,
interval_days=self.interval_days,
interval_unit=self.interval_unit,
interval_anchor=self.interval_anchor,
due_date=self.due_date,
)
@property
def days_until_due(self) -> int | None:
"""Calculate days until the task is due. Negative means overdue."""
due = self.next_due
if due is None:
return None
return (due - dt_util.now().date()).days
def _is_past_schedule_time(self) -> bool:
"""True iff a `schedule_time` is set AND current local time is past it.
Used as a sub-day refinement of the OVERDUE transition: when `days_until_due`
is exactly 0, the task is considered overdue once the configured HH:MM has
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
@property
def archived(self) -> bool:
"""True iff the task is archived (retired but retained)."""
return self.archived_at is not None
@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
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
@property
def can_complete_now(self) -> bool:
"""Whether completion is currently allowed by the completion window.
A task with ``earliest_completion_days`` set may only be completed once
it's within that many days of its due date (or overdue). Tasks without
a schedule (``days_until_due is None``) are always completable.
"""
if self.earliest_completion_days is None:
return True
days = self.days_until_due
if days is None:
return True
return days <= self.earliest_completion_days
@property
def is_done(self) -> bool:
"""True for a completed one-time task, or a finite series that has ended.
("Done" is the terminal state of a one-off. It is distinct from
"archived" — see ``archived`` — which is the retire-but-retain state
added in v2.10.0 and reserved for that feature.)
"""
if self.schedule_type == ScheduleType.ONE_TIME and self.last_performed is not None:
return True
# A finite recurring series (repeat N times / recur until a date) is
# terminally done once it has been performed and no longer re-arms.
if self.last_performed is not None and self._schedule().is_finite() and self.next_due is None:
return True
return False
@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)
@property
def total_cost(self) -> float:
"""Sum of all costs in history."""
total = 0.0
for entry in self.history:
cost = entry.get("cost")
if cost is None:
continue
try:
total += float(cost)
except (ValueError, TypeError):
continue
return total
@property
def average_duration(self) -> float | None:
"""Average duration of completed maintenance in minutes."""
durations = [
entry["duration"]
for entry in self.history
if entry.get("type") == HistoryEntryType.COMPLETED and entry.get("duration") is not None
]
if not durations:
return None
return float(sum(durations)) / len(durations)
@property
def last_entry(self) -> dict[str, Any] | None:
"""Return the most recent history entry."""
if not self.history:
return None
return self.history[-1]
# --- Methods ---
def complete(
self,
notes: str | None = None,
cost: float | None = None,
duration: int | None = None,
checklist_state: dict[str, bool] | None = None,
feedback: str | None = None,
completed_by: str | None = None,
photo_doc_id: str | None = None,
reading_value: float | None = None,
used_parts: list[dict[str, Any]] | None = None,
auto: bool = False,
completed_at: datetime | None = None,
) -> bool:
"""Mark this task as completed.
``auto`` marks a completion nobody performed in the UI (a trigger
recovering on its own): the history entry is flagged so surfaces can
label it, and the rotation pointer stays put — advancing it would
credit/skip a pool member for work nobody attributed.
``completed_at`` (#133) records the completion at a past moment
instead of now. When that moment is still the LATEST lifecycle entry
("did it three days ago, logging it now") the cycle advances exactly
like a normal completion. When it is OLDER than the latest lifecycle
entry it is a pure backfill: only the history entry is written — the
cycle anchor, trigger latch, postpone override, planned anchor and
rotation pointer all stay put (moving them would throw the live cycle
backwards). Mirrors the history-edit reconciliation's
max-by-timestamp rule (websocket/tasks_history.py).
Returns True when the completion advanced the cycle (it was the
latest lifecycle entry), False for a pure backfill.
"""
ts = completed_at if completed_at is not None else dt_util.now()
ts_iso = ts.isoformat()
# 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)
is_latest = ts_iso >= max(anchors, default="")
if is_latest:
# Save current next_due as anchor for planned mode before resetting
if self.interval_anchor == "planned" and self.next_due is not None:
self.last_planned_due = self.next_due.isoformat()
self.last_performed = ts.date().isoformat()
self._trigger_active = False
self._trigger_current_value = None
# A postponed occurrence is consumed by completing it — the next
# cycle returns to the normal cadence.
self.due_override = None
self.add_history_entry(
entry_type=HistoryEntryType.COMPLETED,
notes=notes,
cost=cost,
duration=duration,
checklist_state=checklist_state,
feedback=feedback,
completed_by=completed_by,
photo_doc_id=photo_doc_id,
reading_value=reading_value,
used_parts=used_parts,
auto=auto,
timestamp=ts_iso,
)
# Shared tasks: rotate the "currently responsible" pointer to the next
# assignee for the coming cycle (after this completion is recorded, so
# least_completed sees it). Auto-completions don't rotate — see above.
# Pure backfills don't either: the coming cycle's assignee was already
# decided by the real latest completion.
if not auto and is_latest:
self.advance_rotation()
return is_latest
def advance_rotation(self) -> None:
"""Advance ``responsible_user_id`` to the next pool member.
No-op unless a rotation strategy is set and the pool has ≥2 members.
- ``round_robin``: next after the current pointer (wraps).
- ``least_completed``: the pool member credited with the fewest
completions in history (tie-break by pool order).
- ``random``: a random *other* member (falls back to the pool if the
current pointer is the only entry left).
"""
pool = [u for u in self.assignee_pool if u]
if len(pool) < 2 or not self.rotation_strategy:
return
current = self.responsible_user_id
if self.rotation_strategy == "round_robin":
idx = pool.index(current) if current in pool else -1
self.responsible_user_id = pool[(idx + 1) % len(pool)]
elif self.rotation_strategy == "least_completed":
counts = dict.fromkeys(pool, 0)
for h in self.history:
who = h.get("completed_by")
if who in counts:
counts[who] += 1
self.responsible_user_id = min(pool, key=lambda u: counts[u])
elif self.rotation_strategy == "random":
import random
others = [u for u in pool if u != current] or pool
self.responsible_user_id = random.choice(others)
def reset(self, reset_date: date | None = None) -> None:
"""Reset last performed to a specific date."""
if reset_date is None:
reset_date = dt_util.now().date()
self.last_performed = reset_date.isoformat()
# Clear planned anchor so next_due is computed from the reset date
self.last_planned_due = None
self.add_history_entry(
entry_type=HistoryEntryType.RESET,
notes=f"Reset to {reset_date.isoformat()}",
)
def skip(self, reason: str | None = None, *, as_missed: bool = False) -> None:
"""Skip the current maintenance cycle.
``as_missed=True`` records the cycle as MISSED (was due and never done)
rather than a deliberate SKIPPED — clearer history + compliance views.
The cycle restarts either way.
"""
# Save current next_due as anchor for planned mode before resetting
if self.interval_anchor == "planned" and self.next_due is not None:
self.last_planned_due = self.next_due.isoformat()
# Move last_performed to today to restart the cycle
self.last_performed = dt_util.now().date().isoformat()
self._trigger_active = False
# A postponed occurrence is consumed by skipping it too — otherwise the
# override (> last_performed) would keep winning in next_due and the
# "cycle restarts either way" promise above would be false.
self.due_override = None
self.add_history_entry(
entry_type=HistoryEntryType.MISSED if as_missed else HistoryEntryType.SKIPPED,
notes=reason,
)
def add_history_entry(
self,
entry_type: str,
notes: str | None = None,
cost: float | None = None,
duration: int | None = None,
trigger_value: float | None = None,
checklist_state: dict[str, bool] | None = None,
feedback: str | None = None,
completed_by: str | None = None,
photo_doc_id: str | None = None,
reading_value: float | None = None,
used_parts: list[dict[str, Any]] | None = None,
auto: bool = False,
timestamp: str | None = None,
) -> None:
"""Add an entry to the maintenance history.
``timestamp`` overrides the default "now" (backdated completions,
#133). Entries are APPENDED regardless of chronology — consumers that
need order sort defensively, same as after a history-edit."""
entry: dict[str, Any] = {
"timestamp": timestamp or dt_util.now().isoformat(),
"type": entry_type,
}
if auto:
entry["auto"] = True
if notes is not None:
entry["notes"] = notes
if cost is not None:
entry["cost"] = cost
if duration is not None:
entry["duration"] = duration
if trigger_value is not None:
entry["trigger_value"] = trigger_value
if checklist_state is not None:
entry["checklist_state"] = checklist_state
if feedback is not None:
entry["feedback"] = feedback
if completed_by is not None:
entry["completed_by"] = completed_by
if photo_doc_id is not None:
entry["photo_doc_id"] = photo_doc_id
# Meter readings (v2.20, #83): the recorded value rides on the
# completion entry — the delta view derives from consecutive entries.
if reading_value is not None:
entry["reading_value"] = reading_value
if used_parts:
# #99: the parts actually used on THIS completion ([{part_id,
# name, quantity}]) — explicit per-completion consumption record.
entry["used_parts"] = used_parts
self.history.append(entry)
# Trim history to max entries — TYPE-AWARE (journey K1): trigger
# noise is evicted first, so a flapping sensor (hundreds of
# `triggered` entries) can never push out the completion record that
# `times_performed` / `total_cost` / adaptive learning are computed
# from. Only if dropping all trigger entries still isn't enough does
# the trim fall back to oldest-first over everything.
if len(self.history) > DEFAULT_MAX_HISTORY_ENTRIES:
overflow = len(self.history) - DEFAULT_MAX_HISTORY_ENTRIES
noise_types = {
HistoryEntryType.TRIGGERED,
HistoryEntryType.TRIGGER_REMOVED,
}
kept: list[dict[str, Any]] = []
dropped = 0
for h in self.history:
if dropped < overflow and h.get("type") in noise_types:
dropped += 1
continue
kept.append(h)
if len(kept) > DEFAULT_MAX_HISTORY_ENTRIES:
kept = kept[-DEFAULT_MAX_HISTORY_ENTRIES:]
self.history = kept
# --- Serialization ---
def to_dict(self) -> dict[str, Any]:
"""Serialize to dictionary for config entry storage."""
data: dict[str, Any] = {
"id": self.id,
"object_id": self.object_id,
"name": self.name,
"type": self.type,
"enabled": self.enabled,
"warning_days": self.warning_days,
"history": self.history,
}
# Recurrence as the nested `schedule` value object (schedule-model v2).
# Sensor-ness stays in trigger_config; schedule_type is derived on read
# via read_legacy_fields, so it is no longer stored.
data["schedule"] = self._schedule().to_dict()
if self.last_planned_due is not None:
data["last_planned_due"] = self.last_planned_due
if self.due_override is not None:
data["due_override"] = self.due_override
if self.schedule_time is not None:
data["schedule_time"] = self.schedule_time
if self.last_performed is not None:
data["last_performed"] = self.last_performed
if self.created_at is not None:
data["created_at"] = self.created_at
if self.trigger_config is not None:
data["trigger_config"] = self.trigger_config
if self.notes is not None:
data["notes"] = self.notes
if self.documentation_url is not None:
data["documentation_url"] = self.documentation_url
if self.custom_icon is not None:
data["custom_icon"] = self.custom_icon
if self.nfc_tag_id is not None:
data["nfc_tag_id"] = self.nfc_tag_id
if self.reading_unit is not None:
data["reading_unit"] = self.reading_unit
# Only persist a non-default priority to keep stored dicts lean.
if self.priority and self.priority != "normal":
data["priority"] = self.priority
if self.labels:
data["labels"] = self.labels
if self.earliest_completion_days is not None:
data["earliest_completion_days"] = self.earliest_completion_days
if self.archived_at is not None:
data["archived_at"] = self.archived_at
if self.archived_reason is not None:
data["archived_reason"] = self.archived_reason
if self.responsible_user_id is not None:
data["responsible_user_id"] = self.responsible_user_id
if self.assignee_pool:
data["assignee_pool"] = self.assignee_pool
if self.rotation_strategy:
data["rotation_strategy"] = self.rotation_strategy
if self.checklist:
data["checklist"] = self.checklist
if self.adaptive_config is not None:
data["adaptive_config"] = self.adaptive_config
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> MaintenanceTask:
"""Deserialize from dictionary.
Accepts both the nested ``schedule`` storage (Phase 3) and the flat
v2.6.x fields via :func:`read_legacy_fields` — the recurrence is held
in flat attributes in memory either way, so every existing reader of
``task.interval_days`` etc. is unaffected.
"""
sched = read_legacy_fields(data)
return cls(
id=data.get("id", uuid4().hex),
object_id=data.get("object_id", ""),
name=data.get("name", ""),
type=data.get("type", MaintenanceTypeEnum.CUSTOM),
enabled=data.get("enabled", True),
schedule_type=sched["schedule_type"],
interval_days=sched["interval_days"],
interval_unit=sched["interval_unit"],
due_date=sched["due_date"],
schedule_raw=data.get("schedule") if isinstance(data.get("schedule"), dict) else None,
warning_days=data.get("warning_days", DEFAULT_WARNING_DAYS),
earliest_completion_days=data.get("earliest_completion_days"),
last_performed=data.get("last_performed"),
created_at=data.get("created_at"),
interval_anchor=sched["interval_anchor"],
last_planned_due=data.get("last_planned_due"),
due_override=data.get("due_override"),
schedule_time=data.get("schedule_time"),
trigger_config=data.get("trigger_config"),
notes=data.get("notes"),
documentation_url=data.get("documentation_url"),
custom_icon=data.get("custom_icon"),
nfc_tag_id=data.get("nfc_tag_id"),
reading_unit=data.get("reading_unit"),
priority=data.get("priority", "normal"),
labels=data.get("labels", []),
archived_at=data.get("archived_at"),
archived_reason=data.get("archived_reason"),
responsible_user_id=data.get("responsible_user_id"),
assignee_pool=data.get("assignee_pool", []),
rotation_strategy=data.get("rotation_strategy"),
checklist=data.get("checklist", []),
adaptive_config=data.get("adaptive_config"),
history=data.get("history", []),
)