feat: Add month and end_time to time window configuration

Extend TimeWindow with two new optional fields:

- month (1–12): restricts the window to a start date in the given
  calendar month. Useful for seasonal schedules.

- end_time: explicit exclusive end-of-window clock time. The effective
  end is the later of end_time and start_time + duration. Primary
  motivation is DST handling — a wall-clock end_time stays correct
  across DST transitions, whereas a fixed duration does not.

Rework window semantics so date, weekday, and month restrictions apply
to the window's start date, not to every timestamp covered by the
window. This makes overnight windows (e.g. Friday 22:00 → Saturday
06:00) behave intuitively under calendar constraints; contains() now
checks both today's and yesterday's candidate start.

duration becomes optional (default 0). All start/end/duration
computations route through _window_start_end and a new
_matches_start_date helper, so contains, earliest_start_time,
latest_start_time, and available_duration stay consistent.

Dashboard: add end_time and month inputs to the time-window editor,
render the collapsed window summary as icons + pills.

Docs, OpenAPI spec, and generated config docs are regenerated for the
new fields. Tests cover month filtering, end_time boundary cases (incl.
midnight and overnight), and start-date semantics for overnight windows.

Bump version to support automatic updates in Home Assistant.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-10-28 18:59:43 +01:00
parent 8a8cd43a19
commit c02d250639
12 changed files with 1196 additions and 472 deletions
+155 -80
View File
@@ -10,6 +10,7 @@ import numpy as np
import pandas as pd
import pendulum
from babel.dates import get_day_names
from loguru import logger
from pydantic import Field, field_serializer, field_validator, model_validator
from akkudoktoreos.core.pydantic import PydanticBaseModel
@@ -111,7 +112,7 @@ class TimeWindow(SettingsBaseModel):
},
)
duration: Duration = Field(
...,
default=Duration(0),
json_schema_extra={
"description": "Duration of the time window starting from `start_time`.",
"examples": [
@@ -119,13 +120,25 @@ class TimeWindow(SettingsBaseModel):
],
},
)
end_time: Optional[Time] = Field(
default=None,
json_schema_extra={
"description": (
"Optional exclusive end time of the window. "
"The effective end is the later of end_time and start_time + duration. "
"End time is dalight saving time change save, whereas duration is always fixed. "
"If None (the default), the window ends at start_time + duration."
),
"examples": [None, "12:00:00"],
},
)
day_of_week: Optional[Union[int, str]] = Field(
default=None,
json_schema_extra={
"description": (
"Optional day of the week restriction. "
"Optional day of the week on which the time window must start. "
"Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. "
"If None, applies every day unless `date` is set."
"For overnight windows, the restriction applies to the start date."
),
"examples": [
None,
@@ -136,15 +149,28 @@ class TimeWindow(SettingsBaseModel):
default=None,
json_schema_extra={
"description": (
"Optional specific calendar date for the time window. "
"Naive — matched against the local date of the datetime passed to contains(). "
"Overrides `day_of_week` if set."
"Optional specific calendar date on which the time window must start. "
"For overnight windows, this restriction applies to the start date, "
"not the date of each datetime tested by contains()."
),
"examples": [
None,
],
},
)
month: Optional[int] = Field(
default=None,
ge=1,
le=12,
json_schema_extra={
"description": (
"Optional calendar month restriction (1=January to 12=December). "
"The window's start date must fall in this month, including for "
"overnight windows that extend into the following month."
),
"examples": [None, 10],
},
)
locale: Optional[str] = Field(
default=None,
json_schema_extra={
@@ -159,35 +185,29 @@ class TimeWindow(SettingsBaseModel):
},
)
@field_validator("start_time", mode="after")
@field_validator("start_time", "end_time", mode="after")
@classmethod
def require_naive_start_time(cls, value: Time) -> Time:
"""Strip timezone from ``start_time`` if present, emitting a debug message.
def require_naive_time(cls, value: Optional[Time]) -> Optional[Time]:
"""Ensure configured times are naive wall-clock times.
``start_time`` must be naive: it is interpreted as wall-clock time in
the timezone of the ``date_time`` / ``reference_date`` supplied at call
time. The project's ``to_time`` helper may silently attach a timezone
during deserialisation; rather than rejecting such values the validator
strips the timezone and logs a debug message so the behaviour is
transparent without breaking normal construction.
Times are interpreted in the timezone of the supplied reference
datetime. If a timezone is present, it is removed while preserving
the hour, minute, second, and microsecond.
Args:
value: The ``Time`` value to validate.
value: The time to validate, or ``None`` for an optional field.
Returns:
A naive ``Time`` with the same hour / minute / second / microsecond
but no ``tzinfo``.
The naive time, or ``None`` if no time was configured.
"""
if value is None:
return None
if value.tzinfo is not None:
import logging
logging.getLogger(__name__).debug(
"TimeWindow.start_time received an aware Time (%s); "
"stripping timezone '%s'. start_time is always interpreted "
logger.debug(
f"TimeWindow.start_time received an aware Time ({value}); "
f"stripping timezone '{value.tzinfo}'. start_time or end_time is always interpreted "
"as wall-clock time in the timezone of the datetime passed "
"to contains() / earliest_start_time() / latest_start_time().",
value,
value.tzinfo,
"to contains() / earliest_start_time() / latest_start_time()."
)
value = value.replace(tzinfo=None)
return value
@@ -262,68 +282,129 @@ class TimeWindow(SettingsBaseModel):
return str(value)
def _window_start_end(self, reference_date: DateTime) -> tuple[DateTime, DateTime]:
"""Get the actual start and end datetimes for the time window on a given date.
"""Return the effective start and exclusive end of the time window.
``start_time`` is naive and is interpreted as a wall-clock time in
the timezone of ``reference_date``. When ``reference_date`` is
timezone-aware the resulting window boundaries carry the same timezone;
when it is naive the arithmetic is performed without timezone conversion.
The explicit end time is interpreted as the first occurrence of that
wall-clock time strictly after the window start. Consequently, an end
time less than or equal to the start time refers to the following day.
The effective end is the later of the explicit end boundary and the
end implied by the configured duration.
Args:
reference_date: The reference date on which to calculate the window.
May be timezone-aware or naive.
reference_date: Datetime identifying the calendar date on which
the window starts. Its time of day is ignored.
Returns:
tuple[DateTime, DateTime]: Start and end datetimes for the time window,
in the same timezone as ``reference_date``.
A tuple containing the start datetime and exclusive end datetime.
"""
# start_time is always naive: just replace the time components on
# reference_date directly. The result inherits reference_date's timezone
# (or lack thereof) automatically.
start = reference_date.replace(
start_day = reference_date.start_of("day")
start = start_day.replace(
hour=self.start_time.hour,
minute=self.start_time.minute,
second=self.start_time.second,
microsecond=self.start_time.microsecond,
)
end = start + self.duration
return start, end
def contains(self, date_time: DateTime, duration: Optional[Duration] = None) -> bool:
"""Check whether a datetime (and optional duration) fits within the time window.
duration_end = start + self.duration
``start_time`` is naive and is interpreted as wall-clock time in the
timezone of ``date_time``. Day-of-week and date constraints are
evaluated against ``date_time`` after any timezone conversion has
been applied.
if self.end_time is None:
return start, duration_end
explicit_end = start_day.replace(
hour=self.end_time.hour,
minute=self.end_time.minute,
second=self.end_time.second,
microsecond=self.end_time.microsecond,
)
start_clock = (
self.start_time.hour,
self.start_time.minute,
self.start_time.second,
self.start_time.microsecond,
)
end_clock = (
self.end_time.hour,
self.end_time.minute,
self.end_time.second,
self.end_time.microsecond,
)
# An end time at or before the start time belongs to the next day.
if end_clock <= start_clock:
explicit_end = explicit_end.add(days=1)
return start, max(duration_end, explicit_end)
def _matches_start_date(self, start_date: DateTime) -> bool:
"""Check whether a candidate window start date satisfies all restrictions.
Date, weekday, and month restrictions apply to the calendar date on
which the window starts. They do not change when the window extends
into the following day.
Args:
date_time: The datetime to test. May be timezone-aware or naive.
duration: An optional duration that must fit entirely within the
time window starting from ``date_time``.
start_date: A datetime identifying the candidate window's start date.
Its time of day is ignored.
Returns:
bool: True if the datetime (and optional duration) is fully
contained in the time window, False otherwise.
True if all configured date restrictions match; otherwise False.
"""
# Date and weekday constraints are checked against date_time as-is;
# since start_time is naive it is always interpreted in date_time's tz.
if self.date and date_time.date() != self.date:
if self.date is not None and start_date.date() != self.date:
return False
if self.day_of_week is not None and date_time.day_of_week != self.day_of_week:
if self.day_of_week is not None and start_date.day_of_week != self.day_of_week:
return False
start, end = self._window_start_end(date_time)
if not (start <= date_time < end):
if self.month is not None and start_date.month != self.month:
return False
if duration is not None:
return date_time + duration <= end
return True
def contains(self, date_time: DateTime, duration: Optional[Duration] = None) -> bool:
"""Check whether a datetime and optional duration fit within a window.
Date, weekday, and month restrictions apply to the candidate window's
start date. This allows an overnight window to remain applicable after
midnight, even when the new calendar date does not satisfy those
restrictions.
The window's end boundary is exclusive.
Args:
date_time: The datetime to test. Its timezone is used to interpret
the configured wall-clock times.
duration: Optional duration that must fit entirely within the window.
Returns:
True if the datetime and optional duration fit within an applicable
window; otherwise False.
"""
current_day = date_time.start_of("day")
# Check both today's window and a window that began yesterday.
# The previous-day candidate is necessary for overnight windows.
for start_day in (
current_day,
current_day.subtract(days=1),
):
if not self._matches_start_date(start_day):
continue
start, end = self._window_start_end(start_day)
if not (start <= date_time < end):
continue
if duration is not None and date_time + duration > end:
continue
return True
return False
def earliest_start_time(
self, duration: Duration, reference_date: Optional[DateTime] = None
) -> Optional[DateTime]:
@@ -339,16 +420,14 @@ class TimeWindow(SettingsBaseModel):
if reference_date is None:
reference_date = pendulum.today()
if self.date and reference_date.date() != self.date:
if not self._matches_start_date(reference_date):
return None
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
window_start, window_end = self._window_start_end(reference_date)
if duration > window_end - window_start:
return None
if duration > self.duration:
return None
window_start, _ = self._window_start_end(reference_date)
return window_start
def latest_start_time(
@@ -366,16 +445,14 @@ class TimeWindow(SettingsBaseModel):
if reference_date is None:
reference_date = pendulum.today()
if self.date and reference_date.date() != self.date:
return None
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
return None
if duration > self.duration:
if not self._matches_start_date(reference_date):
return None
window_start, window_end = self._window_start_end(reference_date)
if duration > window_end - window_start:
return None
latest_start = window_end - duration
if latest_start < window_start:
@@ -409,13 +486,11 @@ class TimeWindow(SettingsBaseModel):
if reference_date is None:
reference_date = pendulum.today()
if self.date and reference_date.date() != self.date:
if not self._matches_start_date(reference_date):
return None
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
return None
return self.duration
window_start, window_end = self._window_start_end(reference_date)
return window_end - window_start
TimeWindowT = TypeVar("TimeWindowT", bound=TimeWindow)
@@ -579,6 +579,8 @@ class HomeAppliance:
continue
if window.day_of_week is not None and anchor.day_of_week != window.day_of_week:
continue
if window.month is not None and anchor.month != window.month:
continue
opening, closing = window._window_start_end(anchor)
if opening <= start and run_end <= closing:
return True
+150 -34
View File
@@ -226,6 +226,23 @@ def UpdateError(error_text: str) -> Alert:
)
def Badge(icon: str, text: str, tone: str = "muted") -> Div:
"""A small pill with an icon and a label.
tone: 'muted' (neutral), 'accent' (highlighted), 'warn' (attention).
"""
palette = {
"muted": "bg-muted text-muted-foreground border-border",
"accent": "bg-primary/10 text-primary border-primary/30",
"warn": "bg-destructive/10 text-destructive border-destructive/30",
}[tone]
return DivLAligned(
UkIcon(icon, cls="w-3.5 h-3.5 shrink-0"),
P(text, cls="text-xs font-mono leading-none"),
cls=f"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 {palette}",
)
def make_config_update_form() -> Callable[[str, str], Grid]:
"""Factory for a form that sets a single configuration value.
@@ -632,6 +649,8 @@ def make_config_update_time_windows_windows_form(
"6 – Sunday",
]
DOW_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
def _dow_select(name: str, current: Optional[int]) -> Select:
"""Render a day-of-week dropdown pre-selected to *current*."""
return Select(
@@ -644,21 +663,91 @@ def make_config_update_time_windows_windows_form(
cls="border rounded px-2 py-1 text-sm",
)
def _window_summary(win: dict) -> str:
"""One-line human-readable label for an existing window."""
parts = [win.get("start_time", ""), win.get("duration", "")]
MONTH_LABELS = [
"1 – January",
"2 – February",
"3 – March",
"4 – April",
"5 – May",
"6 – June",
"7 – July",
"8 – August",
"9 – September",
"10 – October",
"11 – November",
"12 – December",
]
MONTH_SHORT = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
def _month_select(name: str, current: Optional[int]) -> Select:
"""Render a month dropdown pre-selected to *current*."""
return Select(
Option("— any month —", value="", selected=(current is None)),
*[
Option(lbl, value=str(i), selected=(current == i))
for i, lbl in enumerate(MONTH_LABELS, start=1)
],
name=name,
cls="border rounded px-2 py-1 text-sm",
)
def _window_summary(win: dict) -> Div:
"""Render a window as a compact row of attribute pills."""
pills: list[Any] = []
# Time range: "08:00 → 8 hours" (or "→ 16:00" if end_time given)
start = win.get("start_time") or "?"
dur = win.get("duration") or None
end_time = win.get("end_time") or None
if dur and end_time:
time_text = f"{start} · +{dur} or until {end_time}"
elif end_time:
time_text = f"{start} → {end_time}"
else:
time_text = f"{start} + {dur or '?'}"
pills.append(Badge("clock", time_text, tone="accent"))
# Value (only if the form has a value field)
if value_description is not None:
parts.append(str(win.get("value", "")))
val = win.get("value")
if val is not None:
pills.append(Badge("coins", str(val)))
# Day of week
dow = win.get("day_of_week")
if dow is not None:
parts.append(f"dow={dow}")
if dow is not None and 0 <= int(dow) < 7:
pills.append(Badge("calendar-days", DOW_SHORT[int(dow)]))
# Month
month = win.get("month")
if month is not None and 1 <= int(month) <= 12:
pills.append(Badge("calendar", MONTH_SHORT[int(month) - 1]))
# Exact date
date_val = win.get("date")
if date_val:
parts.append(f"date={date_val}")
pills.append(Badge("calendar-clock", str(date_val)))
# Locale
locale_val = win.get("locale")
if locale_val:
parts.append(f"locale={locale_val}")
return " | ".join(p for p in parts if p)
pills.append(Badge("globe", str(locale_val)))
return DivHStacked(*pills, cls="flex-wrap gap-1.5 ml-2")
def ConfigUpdateTimeWindowsWindowsForm(config_name: str, value: str) -> Grid:
config_id = config_name.lower().replace(".", "-")
@@ -669,7 +758,7 @@ def make_config_update_time_windows_windows_form(
except (json.JSONDecodeError, AttributeError):
current_windows = []
num_cols = 5 + (1 if value_description is not None else 0)
num_cols = 7 + (1 if value_description is not None else 0)
# ----------------------------------------------------------------
# Existing window rows — each is a collapsible <details> with
@@ -726,20 +815,24 @@ def make_config_update_time_windows_windows_form(
action: "update",
key: "{config_name}",
value: (() => {{
const start = document.querySelector("[name='{wid}_start_time']").value.trim();
const dur = document.querySelector("[name='{wid}_duration']").value.trim();
const start = document.querySelector("[name='{wid}_start_time']").value.trim();
const dur = document.querySelector("[name='{wid}_duration']").value.trim();
const end = document.querySelector("[name='{wid}_end_time']").value.trim();
{val_js_read}
const dowRaw = document.querySelector("[name='{wid}_dow']").value;
const date = document.querySelector("[name='{wid}_date']").value.trim();
const locale = document.querySelector("[name='{wid}_locale']").value.trim();
const dowRaw = document.querySelector("[name='{wid}_dow']").value;
const monthRaw = document.querySelector("[name='{wid}_month']").value;
const date = document.querySelector("[name='{wid}_date']").value.trim();
const locale = document.querySelector("[name='{wid}_locale']").value.trim();
if (!start || !dur || {val_js_guard}) return {json.dumps(json.dumps(current_windows))};
const edited = {{
start_time: start,
duration: dur,
start_time: start,
duration: dur,
end_time: end !== "" ? end : null,
{val_js_field}
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
date: date !== "" ? date : null,
locale: locale !== "" ? locale : null,
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
month: monthRaw !== "" ? parseInt(monthRaw) : null,
date: date !== "" ? date : null,
locale: locale !== "" ? locale : null,
}};
const updated = [...{before_json}, edited, ...{after_json}];
return JSON.stringify(updated);
@@ -762,6 +855,12 @@ def make_config_update_time_windows_windows_form(
placeholder="e.g. 8 hours",
cls="border rounded px-2 py-1 text-sm",
),
Input(
value=win.get("end_time", "") or "",
name=f"{wid}_end_time",
placeholder="e.g. 06:00 (optional)",
cls="border rounded px-2 py-1 text-sm",
),
]
if value_description is not None:
edit_cols.append(
@@ -775,6 +874,7 @@ def make_config_update_time_windows_windows_form(
)
edit_cols += [
_dow_select(f"{wid}_dow", win.get("day_of_week")),
_month_select(f"{wid}_month", win.get("month")),
Input(
value=win.get("date") or "",
name=f"{wid}_date",
@@ -794,7 +894,10 @@ def make_config_update_time_windows_windows_form(
Summary(
DivHStacked(
delete_ctrl,
P(_window_summary(win), cls="ml-2 text-sm font-mono cursor-pointer"),
Div(
_window_summary(win),
cls="cursor-pointer", # keep the whole row clickable to expand
),
),
cls="list-none",
),
@@ -813,8 +916,9 @@ def make_config_update_time_windows_windows_form(
add_wid = f"{config_id}_new"
header_cols = [
P("start_time *", cls="text-xs text-muted-foreground font-semibold"),
P("start time *", cls="text-xs text-muted-foreground font-semibold"),
P("duration *", cls="text-xs text-muted-foreground font-semibold"),
P("end time", cls="text-xs text-muted-foreground font-semibold"),
]
add_input_cols = [
Input(
@@ -827,6 +931,11 @@ def make_config_update_time_windows_windows_form(
name=f"{add_wid}_duration",
cls="border rounded px-2 py-1 text-sm",
),
Input(
placeholder="e.g. 06:00 (optional)",
name=f"{add_wid}_end_time",
cls="border rounded px-2 py-1 text-sm",
),
]
if value_description is not None:
header_cols.append(
@@ -842,12 +951,14 @@ def make_config_update_time_windows_windows_form(
)
)
header_cols += [
P("day_of_week", cls="text-xs text-muted-foreground font-semibold"),
P("day of week", cls="text-xs text-muted-foreground font-semibold"),
P("month", cls="text-xs text-muted-foreground font-semibold"),
P("date (YYYY-MM-DD)", cls="text-xs text-muted-foreground font-semibold"),
P("locale", cls="text-xs text-muted-foreground font-semibold"),
]
add_input_cols += [
_dow_select(f"{add_wid}_dow", None),
_month_select(f"{add_wid}_month", None),
Input(
placeholder="e.g. 2025-12-24",
name=f"{add_wid}_date",
@@ -882,20 +993,24 @@ def make_config_update_time_windows_windows_form(
action: "update",
key: "{config_name}",
value: (() => {{
const start = document.querySelector("[name='{add_wid}_start_time']").value.trim();
const dur = document.querySelector("[name='{add_wid}_duration']").value.trim();
const start = document.querySelector("[name='{add_wid}_start_time']").value.trim();
const dur = document.querySelector("[name='{add_wid}_duration']").value.trim();
const end = document.querySelector("[name='{add_wid}_end_time']").value.trim();
{add_val_js_read}
const dowRaw = document.querySelector("[name='{add_wid}_dow']").value;
const date = document.querySelector("[name='{add_wid}_date']").value.trim();
const locale = document.querySelector("[name='{add_wid}_locale']").value.trim();
const dowRaw = document.querySelector("[name='{add_wid}_dow']").value;
const monthRaw = document.querySelector("[name='{add_wid}_month']").value;
const date = document.querySelector("[name='{add_wid}_date']").value.trim();
const locale = document.querySelector("[name='{add_wid}_locale']").value.trim();
if (!start || !dur || {add_val_js_guard}) return {current_json};
const newWin = {{
start_time: start,
duration: dur,
start_time: start,
duration: dur,
end_time: end !== "" ? end : null,
{add_val_js_field}
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
date: date !== "" ? date : null,
locale: locale !== "" ? locale : null,
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
month: monthRaw !== "" ? parseInt(monthRaw) : null,
date: date !== "" ? date : null,
locale: locale !== "" ? locale : null,
}};
const existing = {json.dumps(current_windows)};
existing.push(newWin);
@@ -914,7 +1029,8 @@ def make_config_update_time_windows_windows_form(
*window_rows,
P("Add new window", cls="text-sm font-semibold mt-3 mb-1"),
P(
"* required | day_of_week overridden by date if both set",
"* required | date, day_of_week and month apply to the start date. "
"Overnight windows remain applicable after midnight.",
cls="text-xs text-muted-foreground mb-1",
),
add_section,