"""Vacation mode (v1.2.0). Suppresses notifications for non-exempt tasks during a configured date range plus a buffer (so a task that comes due the day of return doesn't fire immediately). Sensor-triggered notifications are suppressed too unless the task is on the exempt list. The exempt list lives at the global config-entry level — it is *persistent* across vacations, not per-vacation. Use case: pool chemistry that the neighbour checks regardless of whether you're away. """ from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, Any from homeassistant.util import dt as dt_util from ..const import ( CONF_VACATION_BUFFER_DAYS, CONF_VACATION_ENABLED, CONF_VACATION_END, CONF_VACATION_EXEMPT_TASK_IDS, CONF_VACATION_START, DEFAULT_VACATION_BUFFER_DAYS, DEFAULT_WARNING_DAYS, DOMAIN, GLOBAL_UNIQUE_ID, ScheduleType, ) from .dates import parse_iso_date, try_add_interval from .pause import is_task_inert from .status import effective_warning_days if TYPE_CHECKING: from homeassistant.core import HomeAssistant from ..models.maintenance_task import MaintenanceTask @dataclass(frozen=True) class VacationState: """Frozen snapshot of vacation config. Frozen so callers can pass it around without worrying about mutation during a notification-decision window. """ enabled: bool start: date | None end: date | None buffer_days: int exempt_task_ids: frozenset[str] = field(default_factory=frozenset) @property def window_end(self) -> date | None: """Last day on which suppression still applies (inclusive).""" if self.end is None: return None try: return self.end + timedelta(days=max(0, self.buffer_days)) except OverflowError: # An end date of 9999-12-31 plus the buffer overflowed, and # is_silent_for runs inside every object's refresh — the object # went unavailable for the whole vacation (bug audit 2026-09-27). # The window simply never ends. return date.max def is_active(self, at: datetime | None = None) -> bool: """True if today falls within [start, end + buffer] and the toggle is on.""" if not self.enabled or self.start is None or self.end is None: return False today = (at or dt_util.now()).date() return self.start <= today <= (self.window_end or self.end) def is_silent_for(self, task_id: str, at: datetime | None = None) -> bool: """Return True if a notification for *task_id* must be suppressed. Suppressed iff vacation is currently active AND the task is not in the exempt list. Exempt tasks fire normally even during vacation. """ if not self.is_active(at): return False return task_id not in self.exempt_task_ids def as_wire_dict(self) -> dict[str, Any]: """Serialise for the WS wire. Single source for /vacation/state and the /settings vacation embed, so the two can never drift.""" return { "enabled": self.enabled, "start": self.start.isoformat() if self.start else None, "end": self.end.isoformat() if self.end else None, "buffer_days": self.buffer_days, "exempt_task_ids": sorted(self.exempt_task_ids), "is_active": self.is_active(), "window_end": self.window_end.isoformat() if self.window_end else None, } @classmethod def from_options(cls, options: Mapping[str, Any]) -> VacationState: """Build a VacationState from a global-entry options mapping. Same coercion rules as :func:`get_vacation_state` but from an already resolved options dict (used by the /settings embed, which may be passed an empty mapping when no global entry exists). """ raw_exempt = options.get(CONF_VACATION_EXEMPT_TASK_IDS) or [] exempt: list[str] = [] if isinstance(raw_exempt, list): for x in raw_exempt: if isinstance(x, str): stripped = x.strip() if stripped and len(stripped) <= 64: exempt.append(stripped) return cls( enabled=bool(options.get(CONF_VACATION_ENABLED, False)), start=_coerce_date(options.get(CONF_VACATION_START)), end=_coerce_date(options.get(CONF_VACATION_END)), buffer_days=_coerce_buffer(options.get(CONF_VACATION_BUFFER_DAYS, DEFAULT_VACATION_BUFFER_DAYS)), exempt_task_ids=frozenset(exempt), ) def _coerce_date(value: Any) -> date | None: """Parse an ISO YYYY-MM-DD string from config; tolerant of None / junk.""" return parse_iso_date(value) def _coerce_buffer(value: Any) -> int: try: i = int(value) except (TypeError, ValueError): return DEFAULT_VACATION_BUFFER_DAYS if i < 0 or i > 14: return DEFAULT_VACATION_BUFFER_DAYS return i def _task_warning_days(task: Mapping[str, Any]) -> int: """warning_days for a stored task, defaulting when missing/blank/invalid. Missing (or blank/non-numeric from legacy/imported storage) → the default; a real value (including 0) is kept. Never raises on a non-int value. """ value = task.get("warning_days") if value is None or value == "": return DEFAULT_WARNING_DAYS try: return int(value) except (TypeError, ValueError): return DEFAULT_WARNING_DAYS def _global_options(hass: HomeAssistant) -> Mapping[str, Any]: """Return options dict from the global config entry.""" for entry in hass.config_entries.async_entries(DOMAIN): if entry.unique_id == GLOBAL_UNIQUE_ID: return entry.options or entry.data return {} def get_vacation_state(hass: HomeAssistant) -> VacationState: """Read the current vacation state from the global config entry.""" return VacationState.from_options(_global_options(hass)) # --------------------------------------------------------------------------- # Preview # --------------------------------------------------------------------------- @dataclass(frozen=True) class PreviewEvent: """A single projected status transition during the vacation window.""" date: date status: str # "due_soon" | "overdue" | "triggered_est" def _events_from_next_due( next_due: date, warning_days: int, today: date, window_start: date, window_end: date, ) -> list[PreviewEvent]: """DUE_SOON / OVERDUE preview events for a single next-due date. Shared by the interval and calendar projections (DRY).""" due_soon_from = next_due - timedelta(days=max(0, warning_days)) events: list[PreviewEvent] = [] if window_start <= due_soon_from <= window_end and due_soon_from > today: events.append(PreviewEvent(date=due_soon_from, status="due_soon")) if window_start <= next_due <= window_end and next_due > today: events.append(PreviewEvent(date=next_due, status="overdue")) # Edge: task is already DUE_SOON or OVERDUE today and stays in that state # — surface as a "today" event so the user can act on it before leaving. if not events: if today >= next_due and today <= window_end: events.append(PreviewEvent(date=today, status="overdue")) elif today >= due_soon_from and today <= window_end and due_soon_from <= today < next_due: events.append(PreviewEvent(date=today, status="due_soon")) return events def _project_time_based( last_performed: date | None, created_at: date | None, interval_days: int | None, warning_days: int, today: date, window_start: date, window_end: date, interval_unit: str | None = None, ) -> list[PreviewEvent]: """Project DUE_SOON / OVERDUE dates for a bare interval (no task model). Kept for callers that only hold the flat numbers; the preview itself goes through :func:`task_next_due` so postpones, seasons and the planned anchor are honoured. """ if not interval_days or interval_days <= 0: return [] anchor = last_performed or created_at or today # Unit-aware (weeks/months/years), not raw days — else a 6-month task would # preview as due in 6 days during vacation planning. A due date past the # calendar's end is no event (bug audit 2026-09-27). next_due = try_add_interval(anchor, interval_days, interval_unit or "days") if next_due is None: return [] return _events_from_next_due(next_due, warning_days, today, window_start, window_end) def task_next_due(task: MaintenanceTask, today: date) -> date | None: """``MaintenanceTask.next_due`` evaluated for an explicit *today*. The preview used to re-derive the due date from the flat interval fields and so ignored a postpone (``due_override``), the seasonal window, the planned anchor, a finite series and every one-time task (bug audit 2026-09-26, DRY BR-A1). It now asks the task's own Schedule with exactly the inputs the model property uses; only *today* (the first-time anchor) is injectable. ``tests/test_audit_2026_09_26_t2_runtime.py`` pins this to the property for today == now. """ last: date | None = None if task.last_performed: try: last = date.fromisoformat(task.last_performed) except (ValueError, TypeError): return None # the model's "malformed anchor = no next_due" rule return task._schedule().next_due( last_performed=last, created_at=parse_iso_date(task.created_at), last_planned_due=parse_iso_date(task.last_planned_due), today=today, times_performed=task.times_performed, due_override=parse_iso_date(task.due_override), ) def compute_preview( state: VacationState, tasks: Iterable[Mapping[str, Any]], today: date | None = None, ) -> list[dict[str, Any]]: """Project status changes for each task during [start, end+buffer]. Each input *task* is the task's merged storage dict (static config + Store state, exactly what ``MaintenanceTask.from_dict`` reads) plus the row identity ``task_id``, ``entry_id``, ``object_name``, ``task_name``. The caller drops tasks of paused objects (it holds the object dict); archived and disabled tasks are dropped here as well. Returns a list of preview rows; tasks with no projected events in the window are omitted (caller may still want to display the task list elsewhere). """ from ..models.maintenance_task import MaintenanceTask # models import helpers if state.start is None or state.end is None: return [] today = today or dt_util.now().date() window_start = max(state.start, today) window_end = state.window_end or state.end if window_end < window_start: return [] rows: list[dict[str, Any]] = [] for t in tasks: # Archived / disabled tasks never fire — the preview listed archived # ones as "will be due" (bug audit 2026-09-26). Paused objects are the # caller's half of the same predicate. if is_task_inert(dict(t), {}): continue task_id = str(t.get("task_id") or "") if not task_id: continue task = MaintenanceTask.from_dict(dict(t)) schedule_type = task.schedule_type events: list[PreviewEvent] = [] kind: str confidence: str if schedule_type == ScheduleType.SENSOR_BASED: # Sensor triggers are non-deterministic. Surface every sensor task # in the window with a single "may fire anytime" event so the user # can decide per-task whether to exempt or pre-complete. events = [PreviewEvent(date=window_start, status="triggered_est")] kind = "sensor_based" confidence = "unpredictable" else: # Interval, calendar kinds and one-time tasks: the task's own next # due date and its span-capped warning window — the same inputs # the status ladder uses, so a weekly task with a 14-day warning # doesn't preview as "due soon" for two weeks. Manual tasks (and a # finished series / done one-off) have no next due and drop out. nd = task_next_due(task, today) if nd is None: continue warning = effective_warning_days(_task_warning_days(t), task._schedule().span_days()) events = _events_from_next_due(nd, warning, today, window_start, window_end) kind = schedule_type confidence = "deterministic" if not events: continue rows.append( { "task_id": task_id, "entry_id": t.get("entry_id"), "object_name": t.get("object_name") or "", "task_name": t.get("task_name") or "", "kind": kind, "confidence": confidence, "events": [{"date": e.date.isoformat(), "status": e.status} for e in events], "will_suppress": task_id not in state.exempt_task_ids, # Absent = allowed, mirroring the task payload (#150). "allow_skip": t.get("allow_skip") is not False, } ) # Stable ordering: object name then task name, matches #40 sort rows.sort(key=lambda r: ((r["object_name"] or "").lower(), (r["task_name"] or "").lower())) return rows