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
@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
import voluptuous as vol
@@ -10,21 +11,22 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import (
CONF_OBJECT,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
CONF_VACATION_END,
CONF_VACATION_EXEMPT_TASK_IDS,
CONF_VACATION_START,
DEFAULT_WARNING_DAYS,
DOMAIN,
MAX_ID_LENGTH,
MAX_INTERVAL_DAYS,
MAX_VACATION_EXEMPT_TASKS,
)
from ..helpers.aggregate import object_name
from ..helpers.dates import parse_iso_date
from ..helpers.schedule import read_legacy_fields
from ..helpers.pause import is_task_inert
from ..helpers.vacation import compute_preview, get_vacation_state
from . import _get_merged_tasks, _get_object_entries, _load_global_options, _parse_iso_date, _save_global_options
from . import ID_FIELD, _get_merged_tasks, _get_object_entries, _load_global_options, _parse_iso_date, _save_global_options
def _state_payload(hass: HomeAssistant) -> dict[str, Any]:
@@ -51,7 +53,7 @@ async def ws_vacation_state(
vol.Optional("end"): vol.Any(vol.All(str, vol.Length(max=10)), None),
vol.Optional("buffer_days"): vol.All(int, vol.Range(min=0, max=14)),
vol.Optional("exempt_task_ids"): vol.All(
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
[ID_FIELD],
vol.Length(max=MAX_VACATION_EXEMPT_TASKS),
),
}
@@ -72,11 +74,24 @@ async def ws_vacation_update(
if "enabled" in msg:
options[CONF_VACATION_ENABLED] = bool(msg["enabled"])
# A vacation is a stretch of days, not a lifetime: a date more than
# MAX_INTERVAL_DAYS (10 years) out is refused. 9999-12-31 plus the
# return buffer overflowed the calendar inside every object's refresh
# and took the objects down for the whole "vacation" (bug audit
# 2026-09-27; helpers.vacation now also clamps what is already stored).
latest = dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS)
for field, key in (("start", CONF_VACATION_START), ("end", CONF_VACATION_END)):
if field not in msg:
continue
if msg[field] is not None and _parse_iso_date(connection, msg["id"], msg[field], field=field) is None:
return
if msg[field] is not None:
parsed = _parse_iso_date(connection, msg["id"], msg[field], field=field)
if parsed is None:
return
if parsed > latest:
connection.send_error(
msg["id"], "invalid_range", f"{field} must be within {MAX_INTERVAL_DAYS} days from today"
)
return
options[key] = msg[field]
# End-vs-start sanity (only when both are present after the patch; a
@@ -133,34 +148,26 @@ async def ws_vacation_preview(
connection.send_result(msg["id"], {"rows": [], "window_end": None})
return
# Build the flat task list expected by compute_preview.
# Hand the preview each task's MERGED dict (static + Store) so it builds
# the real MaintenanceTask — a hand-picked flat subset dropped
# due_override, the seasonal window, the planned anchor and one-time due
# dates (bug audit 2026-09-26, DRY BR-A1). Inert tasks (archived,
# disabled, paused object) fire nothing during the absence either, so
# they are no preview rows.
tasks: list[dict[str, Any]] = []
for entry in _get_object_entries(hass):
obj_name = object_name(entry)
# Merge dynamic store fields (last_performed, etc.) when available.
merged = _get_merged_tasks(entry)
for task_id, task_data in merged.items():
sched = read_legacy_fields(task_data)
obj = entry.data.get(CONF_OBJECT, {})
for task_id, task_data in _get_merged_tasks(entry).items():
if is_task_inert(task_data, obj):
continue
tasks.append(
{
**task_data,
"task_id": task_id,
"entry_id": entry.entry_id,
"object_name": obj_name,
"task_name": task_data.get("name", ""),
"schedule_type": sched["schedule_type"],
"interval_days": sched["interval_days"],
"interval_unit": sched["interval_unit"],
# Nested schedule so the preview can project calendar kinds.
"schedule": task_data.get("schedule"),
"warning_days": task_data.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": task_data.get("last_performed"),
"created_at": task_data.get("created_at"),
"enabled": task_data.get("enabled", True),
# The preview's Skip button must follow the task's own
# rule — the row had no field and offered it to every
# time-based task (bug review 2026-09-04).
"allow_skip": task_data.get("allow_skip") is not False,
}
)