257 files

This commit is contained in:
Home Assistant Version Control
2026-10-02 16:06:24 +00:00
parent 7c55603942
commit c29f5cc088
258 changed files with 24621 additions and 3136 deletions
@@ -23,10 +23,17 @@ from ..helpers.entry_tasks import insert_new_task
from ..helpers.global_options import get_default_warning_days
from ..helpers.sanitize import cap_task_fields
from ..helpers.schedule import (
KIND_INTERVAL,
KIND_MANUAL,
KIND_ONE_TIME,
Schedule,
normalize_task_storage,
)
_TIME_BASED = "time_based"
# The flat service schedule_type → the Schedule kind it means.
_FLAT_TO_KIND = {_TIME_BASED: KIND_INTERVAL, KIND_ONE_TIME: KIND_ONE_TIME, KIND_MANUAL: KIND_MANUAL}
# ---------------------------------------------------------------------------
# Task CRUD
# ---------------------------------------------------------------------------
@@ -46,6 +53,53 @@ def _checked_due_date(value: Any) -> str:
return parsed.isoformat()
def _check_schedule_request(
schedule_type: str | None,
*,
interval_days: Any,
schedule: dict[str, Any] | None,
stored_kind: str | None = None,
) -> None:
"""Refuse a recurrence request that contradicts itself.
The storage model resolves a contradiction by picking one side, so the
services used to report success for a task that was quietly something
else (bug audit 2026-09-29): ``time_based`` without an interval made a
manual task (a weekday or one-time task lost its schedule), ``manual``
with ``interval_days`` an interval task, ``one_time`` with an interval
``schedule`` a one-time task without a date. ``stored_kind`` is the
task's current kind on an edit — an interval task switched to
``time_based`` keeps its interval. Raises ValueError (the services report
it as invalid input).
"""
if schedule_type is None:
return
if schedule:
parsed = Schedule.from_dict(schedule)
kind = parsed.kind
if parsed.is_calendar_kind:
if schedule_type != _TIME_BASED:
raise ValueError(f"schedule_type {schedule_type!r} contradicts the {kind!r} schedule (use time_based)")
elif _FLAT_TO_KIND.get(schedule_type) != kind:
raise ValueError(f"schedule_type {schedule_type!r} contradicts the schedule's kind {kind!r}")
return
if schedule_type == KIND_MANUAL and interval_days is not None:
raise ValueError("A manual task has no interval — leave interval_days out")
if schedule_type == _TIME_BASED and interval_days is None and stored_kind != KIND_INTERVAL:
raise ValueError("A time-based task needs interval_days")
def _implied_schedule_type(*, interval_days: Any, due_date: Any, schedule: dict[str, Any] | None) -> str:
"""What an ``add_task`` call without ``schedule_type`` asks for: the kind
its other fields describe (a bare name stays a manual task, as before)."""
if schedule:
kind = Schedule.from_dict(schedule).kind
return kind if kind in (KIND_ONE_TIME, KIND_MANUAL) else _TIME_BASED
if due_date:
return KIND_ONE_TIME
return _TIME_BASED if interval_days is not None else KIND_MANUAL
async def async_persist_task(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -77,7 +131,7 @@ async def async_create_task_simple(
entry_id: str,
name: str,
task_type: str = "custom",
schedule_type: str = "time_based",
schedule_type: str | None = None,
interval_days: int | None = None,
interval_unit: str = "days",
due_date: str | None = None,
@@ -94,7 +148,9 @@ async def async_create_task_simple(
the panel / card dialogs or the ``task/create`` WS command.
Raises ValueError if the entry_id is not a maintenance object, the name
is empty or the due date is not a date.
is empty, the due date is not a date or the recurrence contradicts itself
(:func:`_check_schedule_request`). Without ``schedule_type`` the kind the
other fields describe is created — a bare name stays a manual task.
``warning_days`` defaults to the integration-wide setting like every
other create path — it was the bare constant 7 here (bug audit
@@ -112,6 +168,12 @@ async def async_create_task_simple(
name = (name or "").strip()
if not name:
raise ValueError("Name must not be empty")
if schedule_type is None:
schedule_type = _implied_schedule_type(interval_days=interval_days, due_date=due_date, schedule=schedule)
else:
_check_schedule_request(schedule_type, interval_days=interval_days, schedule=schedule)
if schedule_type == KIND_ONE_TIME and not due_date and not (schedule and Schedule.from_dict(schedule).due_date):
raise ValueError("A one-time task needs a due_date")
task_id = uuid4().hex
task_data: dict[str, Any] = {
"id": task_id,
@@ -191,6 +253,13 @@ async def async_update_task_simple(
raise ValueError(f"No task {task_id!r} in {entry.title!r}")
task = dict(new_tasks[task_id])
stored_kind = Schedule.parse(new_tasks[task_id]).kind
_check_schedule_request(
updates.get("schedule_type"),
interval_days=updates.get("interval_days"),
schedule=updates.get("schedule"),
stored_kind=stored_kind,
)
if updates.get("due_date") is not None:
updates = {**updates, "due_date": _checked_due_date(updates["due_date"])}
for key in _UPDATABLE_FLAT_FIELDS:
@@ -222,6 +291,21 @@ async def async_update_task_simple(
# task/update — the flat fields rebuild the schedule (bug audit
# 2026-09-26).
task.pop("schedule", None)
new_kind = updates.get("schedule_type")
if (
new_kind in (KIND_MANUAL, KIND_ONE_TIME)
and not updates.get("schedule")
and (new_kind != stored_kind or new_kind == KIND_MANUAL)
):
# A switch to manual / one-time: the stored interval rode along into
# the flat overlay and rebuilt an interval, so the switch silently did
# nothing (audit 2026-09-29). The old recurrence goes; a one-time task
# needs its date.
if new_kind == KIND_ONE_TIME and not task.get("due_date"):
raise ValueError("A one-time task needs a due_date")
task.pop("schedule", None)
for key in ("interval_days", "interval_unit", "interval_anchor"):
task.pop(key, None)
# The service cannot change the completion action, so the stored one keeps
# the user it runs as — dropping ``configured_by`` here made an operator's