morz-infoboard/server/backend/internal/scheduler/scheduler.go
Jesko Anschütz e76f89798f feat(scheduler): resolveDesiredState – per-Screen, global, Wochenende, Zeitplan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:13:23 +01:00

180 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package scheduler enthält den Display-Zeitplan-Scheduler.
// Er prüft jede Minute ob ein Screen ein- oder ausgeschaltet werden soll.
package scheduler
import (
"context"
"log/slog"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
// DisplayCommander sendet einen Display-Befehl per MQTT.
type DisplayCommander interface {
SendDisplayCommand(screenSlug, action string) error
}
// ScreenSlugGetter lädt den Slug für eine Screen-ID.
type ScreenSlugGetter interface {
GetByID(ctx context.Context, id string) (*store.Screen, error)
}
// DisplayStateGetter lädt den zuletzt gemeldeten Display-Zustand.
type DisplayStateGetter interface {
GetDisplayState(ctx context.Context, screenID string) (string, error)
}
// Run startet den Scheduler-Loop. Blockiert bis ctx abgebrochen wird.
func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
check(ctx, schedules, screens, notifier)
case <-ctx.Done():
return
}
}
}
// Reconcile läuft alle 5 Minuten und gleicht Ist- und Soll-Zustand ab.
func Reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, states DisplayStateGetter, notifier DisplayCommander) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
reconcile(ctx, schedules, screens, states, notifier)
case <-ctx.Done():
return
}
}
}
func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, states DisplayStateGetter, notifier DisplayCommander) {
now := time.Now().Format("15:04")
enabled, err := schedules.ListEnabled(ctx)
if err != nil {
slog.Error("reconciler: list enabled schedules failed", "err", err)
return
}
for _, sc := range enabled {
if sc.PowerOnTime == "" || sc.PowerOffTime == "" {
continue
}
want := desiredState(sc.PowerOnTime, sc.PowerOffTime, now)
got, err := states.GetDisplayState(ctx, sc.ScreenID)
if err != nil {
slog.Warn("reconciler: get display state failed", "screen_id", sc.ScreenID, "err", err)
continue
}
if got == want {
continue
}
screen, err := screens.GetByID(ctx, sc.ScreenID)
if err != nil {
slog.Warn("reconciler: screen not found", "screen_id", sc.ScreenID, "err", err)
continue
}
action := "display_" + want
if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil {
slog.Error("reconciler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err)
} else {
slog.Info("reconciler: corrected display state", "screen_id", sc.ScreenID, "slug", screen.Slug, "was", got, "want", want)
}
}
}
// desiredState berechnet den Soll-Zustand für einen einfachen Tagesplan.
// Unterstützt Mitternacht-Überschreitungen (z.B. onTime="22:00", offTime="06:00").
func desiredState(onTime, offTime, now string) string {
if onTime <= offTime {
// Normaler Fall: z.B. 06:0022:00
if now >= onTime && now < offTime {
return "on"
}
return "off"
}
// Mitternacht-Überschreitung: z.B. 22:0006:00
if now >= onTime || now < offTime {
return "on"
}
return "off"
}
// resolveDesiredState ermittelt den Soll-Zustand eines Screens unter Berücksichtigung
// aller Prioritätsstufen:
// 1. per-Screen override_on_until (höchste Priorität)
// 2. globaler Override
// 3. Wochenende (Sa/So)
// 4. normaler Zeitplan
//
// Gibt ("", false) zurück wenn keine Automatisierung aktiv ist.
func resolveDesiredState(sc store.ScreenSchedule, globalOverride *store.GlobalOverride, now time.Time) (desired string, shouldControl bool) {
// 1. Per-Screen-Override: überschreibt alles
if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) {
return "on", true
}
// 2. Globaler Override
if globalOverride != nil && now.Before(globalOverride.Until) {
return globalOverride.Type, true
}
// 3. Wochenende: Zeitpläne werden ignoriert, Monitore bleiben aus
wd := now.Weekday()
if wd == time.Saturday || wd == time.Sunday {
return "off", true
}
// 4. Normaler Zeitplan
if !sc.ScheduleEnabled || (sc.PowerOnTime == "" && sc.PowerOffTime == "") {
return "", false
}
return desiredState(sc.PowerOnTime, sc.PowerOffTime, now.Format("15:04")), true
}
// check prüft alle aktiven Zeitpläne und sendet ggf. Befehle.
func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
// Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin).
now := time.Now().Format("15:04")
enabled, err := schedules.ListEnabled(ctx)
if err != nil {
slog.Error("scheduler: list enabled schedules failed", "err", err)
return
}
for _, sc := range enabled {
screen, err := screens.GetByID(ctx, sc.ScreenID)
if err != nil {
slog.Warn("scheduler: screen not found", "screen_id", sc.ScreenID, "err", err)
continue
}
var action string
if sc.PowerOnTime != "" && sc.PowerOnTime == now {
action = "display_on"
} else if sc.PowerOffTime != "" && sc.PowerOffTime == now {
action = "display_off"
}
if action == "" {
continue
}
if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil {
slog.Error("scheduler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err)
} else {
slog.Info("scheduler: display command sent", "screen_id", sc.ScreenID, "slug", screen.Slug, "action", action)
}
}
}