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
@@ -24,6 +24,7 @@ so the engine stays pure and sync.
from __future__ import annotations
import logging
import statistics
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
@@ -31,7 +32,9 @@ from datetime import date, timedelta
from itertools import pairwise
from typing import Any
from ..const import MAX_INTERVAL_DAYS, SCHEDULE_OFFSET_MAX_DAYS
from .dates import (
INTERVAL_UNITS,
add_interval,
interval_span_days,
next_day_of_month,
@@ -41,6 +44,8 @@ from .dates import (
roll_back_to_business_day,
)
_LOGGER = logging.getLogger(__name__)
# Recurrence kinds. Phase 2 covers the v2.6.x set; the calendar kinds
# (weekdays / nth_weekday / day_of_month) arrive with the roadmap feature.
KIND_INTERVAL = "interval"
@@ -70,12 +75,14 @@ _CALENDAR_DEFAULT_SPAN_DAYS = 7
# runtime is up) every calendar-entity schedule simply has no occurrences.
CalendarOccurrenceProvider = Callable[[str], Sequence[date]]
_calendar_provider: CalendarOccurrenceProvider | None = None
_provider_failures_logged: set[str] = set()
def set_calendar_occurrence_provider(provider: CalendarOccurrenceProvider | None) -> None:
"""Install (or, with ``None``, remove) the calendar-entity occurrence source."""
global _calendar_provider # process-wide hook by design
_calendar_provider = provider
_provider_failures_logged.clear()
def calendar_occurrences(entity_id: str) -> tuple[date, ...]:
@@ -89,6 +96,10 @@ def calendar_occurrences(entity_id: str) -> tuple[date, ...]:
try:
raw = _calendar_provider(entity_id)
except Exception: # noqa: BLE001 — a broken provider must never take next_due down
# Once per entity: next_due is recomputed on every refresh.
if entity_id not in _provider_failures_logged:
_provider_failures_logged.add(entity_id)
_LOGGER.warning("Calendar dates of %s could not be read", entity_id, exc_info=True)
return ()
return tuple(sorted({d for d in raw if isinstance(d, date)}))
@@ -98,7 +109,7 @@ _MAX_PLANNED_STEPS = 2000
# (#83) offset bound: ±15 days covers every sensible "N days before/after the
# pattern date" case without letting a bogus payload shift schedules by years.
_MAX_OFFSET_DAYS = 15
_MAX_OFFSET_DAYS = SCHEDULE_OFFSET_MAX_DAYS # const: one source for flows, WS and TS
def _coerce_int(raw: object) -> int | None:
@@ -125,10 +136,32 @@ def _coerce_int(raw: object) -> int | None:
return None
def _sanitize_every(raw: object) -> int | None:
"""Interval count >= 1, or None."""
# Longest interval per unit: MAX_INTERVAL_DAYS (10 years) in that unit. The
# flat interval_days was capped on write, the nested ``schedule.every`` was
# not — "every 8000 years" reached the refresh and died on year 10026, every
# refresh, every restart (bug audit 2026-09-27). Clamped on READ, so stored
# garbage from any write path or an old backup is harmless too.
_MAX_EVERY_BY_UNIT = {
"days": MAX_INTERVAL_DAYS,
"weeks": MAX_INTERVAL_DAYS // 7,
"months": (MAX_INTERVAL_DAYS // 365) * 12,
"years": MAX_INTERVAL_DAYS // 365,
}
def _sanitize_unit(raw: object) -> str:
"""One of ``INTERVAL_UNITS``; anything else (a list, "fortnights", None)
reads as days — the historical fallback of ``add_interval`` — instead of
being carried into storage and every consumer (bug audit 2026-09-27)."""
return raw if isinstance(raw, str) and raw in INTERVAL_UNITS else "days"
def _sanitize_every(raw: object, unit: str = "days") -> int | None:
"""Interval count >= 1 (clamped to the unit's maximum), or None."""
val = _coerce_int(raw)
return val if val is not None and val >= 1 else None
if val is None or val < 1:
return None
return min(val, _MAX_EVERY_BY_UNIT.get(unit, MAX_INTERVAL_DAYS))
def _sanitize_nth(raw: object) -> int | None:
@@ -255,13 +288,14 @@ class Schedule:
return cls(kind=KIND_ONE_TIME, due_date=parse_iso_date(due_date))
# Coerce — an imported flat payload can carry interval_days as a
# string, and `"30" <= 0` raises TypeError on every refresh.
every = _sanitize_every(interval_days)
unit = _sanitize_unit(interval_unit)
every = _sanitize_every(interval_days, unit)
if every is None:
return cls(kind=KIND_MANUAL)
return cls(
kind=KIND_INTERVAL,
every=every,
unit=interval_unit or "days",
unit=unit,
anchor=interval_anchor or "completion",
)
@@ -269,6 +303,12 @@ class Schedule:
"""True when the recurrence has an end condition (count and/or until)."""
return self.ends_count is not None or self.ends_until is not None
@property
def is_calendar_kind(self) -> bool:
"""A fixed calendar (weekdays, nth weekday, day of month, calendar
entity) rather than an interval — completions cover occurrences."""
return self.kind in _CALENDAR_KINDS
def next_due(
self,
*,
@@ -286,7 +326,36 @@ class Schedule:
- ``season_months`` rolls an off-season date to the next active month;
- the finite-series end (``ends_count`` / ``ends_until``) stops re-arming.
A one_time task keeps its fixed date and ignores season/finite.
Never raises: a date past the calendar's end (year 9999 — a
far-future reset date, an absurd interval, a season roll in 9999)
reads as "no due date". It used to raise OverflowError / ValueError
from inside the coordinator refresh, which took the whole object
down on every refresh and every restart (bug audit 2026-09-27).
"""
try:
return self._next_due(
last_performed=last_performed,
created_at=created_at,
last_planned_due=last_planned_due,
today=today,
times_performed=times_performed,
due_override=due_override,
)
except (OverflowError, ValueError):
return None
def _next_due(
self,
*,
last_performed: date | None,
created_at: date | None,
last_planned_due: date | None,
today: date,
times_performed: int,
due_override: date | None,
) -> date | None:
""":meth:`next_due` without the end-of-calendar guard."""
# Finite series exhausted by completion count → terminally done. Checked
# BEFORE the override so postponing a finished series can't resurrect it
# (there is no current cycle left to postpone).
@@ -310,6 +379,29 @@ class Schedule:
if self.kind == KIND_ONE_TIME:
return result
result = self._roll_to_season(result)
# Calendar kinds: completing or skipping ahead of the date covers that
# occurrence — complete()/skip() remember it in ``last_planned_due``.
# The next occurrence strictly after the completion day was the very
# date just done, so a Monday task completed on Saturday was due again
# two days later and overdue on Tuesday (bug audit 2026-09-26).
# EVERY occurrence up to that covered one is consumed, not just an
# exact match: a second early completion/skip (Saturday: done for
# Monday; Sunday: skip the next Monday too) re-anchored on the
# following occurrence while the next one after the new completion
# day was still the Monday already covered — the due date jumped
# back a week (bug audit 2026-09-27, R SCH-1). Only a
# last_planned_due that IS an occurrence of the current schedule
# counts: a stale value (e.g. from a former interval schedule, or a
# changed weekday set) never swallows an occurrence.
if (
self.kind in _CALENDAR_KINDS
and result is not None
and last_performed is not None
and last_planned_due is not None
and last_performed < result <= last_planned_due
and self._calendar_occurrence(last_planned_due, inclusive=True) == last_planned_due
):
result = self._roll_to_season(self._calendar_occurrence(last_planned_due, inclusive=False))
# Finite series ends once the next occurrence would fall past until.
if self.ends_until is not None and result is not None and result > self.ends_until:
@@ -539,10 +631,11 @@ class Schedule:
season = _sanitize_months(d.get("season_months"))
ends_count, ends_until = _parse_ends(d.get("ends"))
if kind == KIND_INTERVAL:
unit = _sanitize_unit(d.get("unit"))
return cls(
kind=KIND_INTERVAL,
every=_sanitize_every(d.get("every")),
unit=d.get("unit") or "days",
every=_sanitize_every(d.get("every"), unit),
unit=unit,
anchor=d.get("anchor") or "completion",
season_months=season,
ends_count=ends_count,