From 1ada5ac3341fe80bd2a266842cf0df4284648597 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 4 Jun 2026 23:54:03 +0300 Subject: [PATCH] feat(automations): weekday + timezone scheduling for time-of-day rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the time-of-day condition from a bare server-local HH:MM window to a real schedule: pick which weekdays it is active (0=Mon..6=Sun, empty = every day) and an optional IANA timezone (empty = server local). Closes the parity gap where even a $5 WLED chip has weekday timers. - Overnight windows (start > end) count toward the day they START on, so the after-midnight tail is matched against the previous weekday. - Timezones are resolved via zoneinfo, cached, and fall back to server-local with a one-time warning on an invalid name (the ~1Hz tick never log-spams). - Backward compatible: new fields default to all-days / server-local, so existing automations are unchanged (no migration). - Frontend: weekday chips + timezone input on the rule editor, day/timezone in the rule summary, styles + i18n (en/ru/zh). 10 unit tests (weekday filter, overnight start-day semantics, tz fallback, round-trip, invalid-day filtering); full suite green (1936 passed). (Geographic sunrise/sunset triggers are a natural follow-up — the daylight value source already has the solar math to reuse.) --- server/src/ledgrab/api/routes/automations.py | 2 + server/src/ledgrab/api/schemas/automations.py | 8 ++ .../core/automations/automation_engine.py | 45 ++++++++++- server/src/ledgrab/static/css/automations.css | 44 +++++++++++ .../ledgrab/static/js/features/automations.ts | 34 ++++++-- server/src/ledgrab/static/locales/en.json | 11 +++ server/src/ledgrab/static/locales/ru.json | 11 +++ server/src/ledgrab/static/locales/zh.json | 11 +++ server/src/ledgrab/storage/automation.py | 17 +++- server/tests/test_time_of_day_schedule.py | 78 +++++++++++++++++++ 10 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 server/tests/test_time_of_day_schedule.py diff --git a/server/src/ledgrab/api/routes/automations.py b/server/src/ledgrab/api/routes/automations.py index eb630d0..3208009 100644 --- a/server/src/ledgrab/api/routes/automations.py +++ b/server/src/ledgrab/api/routes/automations.py @@ -52,6 +52,8 @@ def _rule_from_schema(s: RuleSchema) -> Rule: "time_of_day": lambda: TimeOfDayRule( start_time=s.start_time or "00:00", end_time=s.end_time or "23:59", + days_of_week=s.days_of_week or [], + timezone=s.timezone or "", ), "system_idle": lambda: SystemIdleRule( idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5, diff --git a/server/src/ledgrab/api/schemas/automations.py b/server/src/ledgrab/api/schemas/automations.py index b359f00..9f90ca5 100644 --- a/server/src/ledgrab/api/schemas/automations.py +++ b/server/src/ledgrab/api/schemas/automations.py @@ -30,6 +30,14 @@ class RuleSchema(BaseModel): # Time-of-day rule fields start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)") end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)") + days_of_week: list[int] | None = Field( + None, + description="Active weekdays for time_of_day rule (0=Mon..6=Sun). Empty/null = every day.", + ) + timezone: str | None = Field( + None, + description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.", + ) # System idle rule fields idle_minutes: int | None = Field( None, description="Idle timeout in minutes (for system_idle rule)" diff --git a/server/src/ledgrab/core/automations/automation_engine.py b/server/src/ledgrab/core/automations/automation_engine.py index 2769eda..257cc42 100644 --- a/server/src/ledgrab/core/automations/automation_engine.py +++ b/server/src/ledgrab/core/automations/automation_engine.py @@ -26,6 +26,33 @@ from ledgrab.utils import get_logger logger = get_logger(__name__) +# Cache resolved IANA timezones (and remember invalid names) so the ~1 Hz +# automation tick neither re-parses tzdata nor log-spams on a bad name. +_TZ_CACHE: Dict[str, object] = {} +_TZ_WARNED: set = set() + + +def _now_in_tz(tz_name: str) -> datetime: + """Current local time, in ``tz_name`` (IANA) if given, else the server's.""" + if not tz_name: + return datetime.now() + tz = _TZ_CACHE.get(tz_name) + if tz is None: + try: + from zoneinfo import ZoneInfo + + tz = ZoneInfo(tz_name) + _TZ_CACHE[tz_name] = tz + except Exception: + if tz_name not in _TZ_WARNED: + _TZ_WARNED.add(tz_name) + logger.warning( + "Invalid timezone %r for time-of-day rule; using server local time", + tz_name, + ) + return datetime.now() + return datetime.now(tz) + @dataclass(frozen=True) class _RuleEvalContext: @@ -519,16 +546,26 @@ class AutomationEngine: @staticmethod def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool: - now = datetime.now() + now = _now_in_tz(rule.timezone) current = now.hour * 60 + now.minute parts_s = rule.start_time.split(":") parts_e = rule.end_time.split(":") start = int(parts_s[0]) * 60 + int(parts_s[1]) end = int(parts_e[0]) * 60 + int(parts_e[1]) + days = rule.days_of_week + if start <= end: - return start <= current <= end - # Overnight range (e.g. 22:00 → 06:00) - return current >= start or current <= end + if not (start <= current <= end): + return False + return not days or now.weekday() in days + + # Overnight range (e.g. 22:00 → 06:00): the window belongs to its + # START day, so the after-midnight tail is matched against yesterday. + if current >= start: # evening portion — today's window + return not days or now.weekday() in days + if current <= end: # early-morning portion — yesterday's window + return not days or ((now.weekday() - 1) % 7) in days + return False @staticmethod def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool: diff --git a/server/src/ledgrab/static/css/automations.css b/server/src/ledgrab/static/css/automations.css index 202f736..08a9478 100644 --- a/server/src/ledgrab/static/css/automations.css +++ b/server/src/ledgrab/static/css/automations.css @@ -152,6 +152,50 @@ border-left: 1px solid var(--border-color); } +/* Weekday + timezone scheduling (time_of_day rule) */ +.rule-weekday-block, +.rule-tz-block { + margin-top: 12px; +} +.rule-field-label { + display: block; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 6px; +} +.weekday-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.weekday-chip { + flex: 1 1 auto; + min-width: 40px; + padding: 6px 8px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--card-bg); + color: var(--text-muted); + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.weekday-chip:hover { + border-color: var(--primary-color); +} +.weekday-chip.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: #fff; +} +.rule-tz-block input.rule-timezone { + width: 100%; +} + .time-range-label { font-size: 0.65rem; font-weight: 700; diff --git a/server/src/ledgrab/static/js/features/automations.ts b/server/src/ledgrab/static/js/features/automations.ts index 410dd4b..4443898 100644 --- a/server/src/ledgrab/static/js/features/automations.ts +++ b/server/src/ledgrab/static/js/features/automations.ts @@ -340,11 +340,15 @@ const RULE_CHIP_RENDERERS: Record = { const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running')); return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') }; }, - time_of_day: (c) => ({ - icon: ICON_CLOCK, - text: `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`, - title: t('automations.rule.time_of_day'), - }), + time_of_day: (c) => { + const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : []; + let text = `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`; + if (days.length && days.length < 7) { + text += ` · ${[...days].sort((a, b) => a - b).map((d) => t('weekday.short.' + d)).join(' ')}`; + } + if (c.timezone) text += ` · ${c.timezone}`; + return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') }; + }, system_idle: (c) => { const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active'); return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') }; @@ -878,6 +882,11 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void { const [sh, sm] = startTime.split(':').map(Number); const [eh, em] = endTime.split(':').map(Number); const pad = (n: number) => String(n).padStart(2, '0'); + const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : []; + const tz: string = data.timezone || ''; + const dayChips = [0, 1, 2, 3, 4, 5, 6] + .map((d) => ``) + .join(''); container.innerHTML = `
@@ -901,9 +910,21 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
+
+ ${t('automations.rule.time_of_day.days')} +
${dayChips}
+ ${t('automations.rule.time_of_day.days_hint')} +
+
+ + +
${t('automations.rule.time_of_day.overnight_hint')} `; _wireTimeRangePicker(container); + container.querySelectorAll('.weekday-chip').forEach((chip) => { + chip.addEventListener('click', () => chip.classList.toggle('active')); + }); } function _renderSystemIdleFields(container: HTMLElement, data: any): void { @@ -1314,6 +1335,9 @@ const RULE_COLLECTORS: Record = { rule_type: 'time_of_day', start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00', end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59', + days_of_week: Array.from(row.querySelectorAll('.weekday-chip.active')) + .map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)), + timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(), }), system_idle: (row) => ({ rule_type: 'system_idle', diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 05e0e03..53b36f3 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -1235,6 +1235,17 @@ "automations.rule.time_of_day.start_time": "Start Time:", "automations.rule.time_of_day.end_time": "End Time:", "automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.", + "automations.rule.time_of_day.days": "Active days", + "automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.", + "automations.rule.time_of_day.timezone": "Timezone", + "automations.rule.time_of_day.timezone.placeholder": "Server local (e.g. Europe/Berlin)", + "weekday.short.0": "Mon", + "weekday.short.1": "Tue", + "weekday.short.2": "Wed", + "weekday.short.3": "Thu", + "weekday.short.4": "Fri", + "weekday.short.5": "Sat", + "weekday.short.6": "Sun", "automations.rule.system_idle": "System Idle", "automations.rule.system_idle.desc": "User idle/active", "automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 4e1651b..6ecf88e 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -1269,6 +1269,17 @@ "automations.rule.time_of_day.start_time": "Время начала:", "automations.rule.time_of_day.end_time": "Время окончания:", "automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.", + "automations.rule.time_of_day.days": "Активные дни", + "automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.", + "automations.rule.time_of_day.timezone": "Часовой пояс", + "automations.rule.time_of_day.timezone.placeholder": "Локальное время сервера (напр. Europe/Berlin)", + "weekday.short.0": "Пн", + "weekday.short.1": "Вт", + "weekday.short.2": "Ср", + "weekday.short.3": "Чт", + "weekday.short.4": "Пт", + "weekday.short.5": "Сб", + "weekday.short.6": "Вс", "automations.rule.system_idle": "Бездействие системы", "automations.rule.system_idle.desc": "Бездействие/активность", "automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 4a8d512..aedc6c3 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -1265,6 +1265,17 @@ "automations.rule.time_of_day.start_time": "开始时间:", "automations.rule.time_of_day.end_time": "结束时间:", "automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。", + "automations.rule.time_of_day.days": "生效日期", + "automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。", + "automations.rule.time_of_day.timezone": "时区", + "automations.rule.time_of_day.timezone.placeholder": "服务器本地时间(如 Europe/Berlin)", + "weekday.short.0": "周一", + "weekday.short.1": "周二", + "weekday.short.2": "周三", + "weekday.short.3": "周四", + "weekday.short.4": "周五", + "weekday.short.5": "周六", + "weekday.short.6": "周日", "automations.rule.system_idle": "系统空闲", "automations.rule.system_idle.desc": "空闲/活跃", "automations.rule.system_idle.idle_minutes": "空闲超时(分钟):", diff --git a/server/src/ledgrab/storage/automation.py b/server/src/ledgrab/storage/automation.py index 95043a1..d47693c 100644 --- a/server/src/ledgrab/storage/automation.py +++ b/server/src/ledgrab/storage/automation.py @@ -65,27 +65,40 @@ class ApplicationRule(Rule): @dataclass class TimeOfDayRule(Rule): - """Activate during a specific time range (server local time). + """Activate during a specific time range. Supports overnight ranges: if start_time > end_time, the range wraps - around midnight (e.g. 22:00 → 06:00). + around midnight (e.g. 22:00 → 06:00) — an overnight window belongs to the + day it *starts* on. ``days_of_week`` (0=Mon .. 6=Sun, empty = every day) + restricts which days the window is active. ``timezone`` is an IANA name + (e.g. "Europe/Berlin"); empty = the server's local time. """ rule_type: str = "time_of_day" start_time: str = "00:00" # HH:MM end_time: str = "23:59" # HH:MM + days_of_week: List[int] = field(default_factory=list) # 0=Mon..6=Sun; empty=all days + timezone: str = "" # IANA tz name; empty = server local time def to_dict(self) -> dict: d = super().to_dict() d["start_time"] = self.start_time d["end_time"] = self.end_time + d["days_of_week"] = self.days_of_week + d["timezone"] = self.timezone return d @classmethod def from_dict(cls, data: dict) -> "TimeOfDayRule": + raw_days = data.get("days_of_week") or [] + days = sorted( + {int(d) for d in raw_days if isinstance(d, (int, float)) and 0 <= int(d) <= 6} + ) return cls( start_time=data.get("start_time", "00:00"), end_time=data.get("end_time", "23:59"), + days_of_week=days, + timezone=data.get("timezone", "") or "", ) diff --git a/server/tests/test_time_of_day_schedule.py b/server/tests/test_time_of_day_schedule.py new file mode 100644 index 0000000..e6973e0 --- /dev/null +++ b/server/tests/test_time_of_day_schedule.py @@ -0,0 +1,78 @@ +"""Tests for time-of-day automation scheduling (weekday + timezone + overnight).""" + +import datetime as dt + +from ledgrab.core.automations import automation_engine as ae +from ledgrab.core.automations.automation_engine import AutomationEngine, _now_in_tz +from ledgrab.storage.automation import TimeOfDayRule + +_eval = AutomationEngine._evaluate_time_of_day + + +def _patch_now(monkeypatch, fixed: dt.datetime) -> None: + monkeypatch.setattr(ae, "_now_in_tz", lambda tz: fixed) + + +def test_within_window_every_day(monkeypatch): + _patch_now(monkeypatch, dt.datetime(2026, 6, 3, 20, 0)) + assert _eval(TimeOfDayRule(start_time="18:00", end_time="23:00")) is True + + +def test_outside_window(monkeypatch): + _patch_now(monkeypatch, dt.datetime(2026, 6, 3, 12, 0)) + assert _eval(TimeOfDayRule(start_time="18:00", end_time="23:00")) is False + + +def test_weekday_filter(monkeypatch): + fixed = dt.datetime(2026, 6, 3, 20, 0) + wd = fixed.weekday() + _patch_now(monkeypatch, fixed) + assert _eval(TimeOfDayRule("time_of_day", "18:00", "23:00", days_of_week=[wd])) is True + assert ( + _eval(TimeOfDayRule("time_of_day", "18:00", "23:00", days_of_week=[(wd + 1) % 7])) is False + ) + + +def test_overnight_evening_uses_today(monkeypatch): + fixed = dt.datetime(2026, 6, 3, 23, 0) # evening tail of a 22:00->06:00 window + wd = fixed.weekday() + _patch_now(monkeypatch, fixed) + assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[wd])) is True + assert ( + _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[(wd + 1) % 7])) is False + ) + + +def test_overnight_morning_uses_yesterday(monkeypatch): + fixed = dt.datetime(2026, 6, 3, 3, 0) # morning tail belongs to yesterday's window + today = fixed.weekday() + yesterday = (today - 1) % 7 + _patch_now(monkeypatch, fixed) + assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[yesterday])) is True + assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[today])) is False + + +def test_from_dict_filters_invalid_days(): + rule = TimeOfDayRule.from_dict({"days_of_week": [0, 7, -1, 3, 3, "x", 2.0]}) + assert rule.days_of_week == [0, 2, 3] + + +def test_to_dict_round_trips_new_fields(): + rule = TimeOfDayRule("time_of_day", "08:00", "20:00", days_of_week=[1, 2], timezone="UTC") + d = rule.to_dict() + assert d["days_of_week"] == [1, 2] + assert d["timezone"] == "UTC" + again = TimeOfDayRule.from_dict(d) + assert again.days_of_week == [1, 2] and again.timezone == "UTC" + + +def test_now_in_tz_invalid_falls_back_to_local(): + assert _now_in_tz("Not/AZone").tzinfo is None + + +def test_now_in_tz_valid_is_aware(): + assert _now_in_tz("UTC").tzinfo is not None + + +def test_now_in_tz_empty_is_local(): + assert _now_in_tz("").tzinfo is None