// 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) } // AllScreensLister lädt alle bekannten Screens. type AllScreensLister interface { ListAll(ctx context.Context) ([]*store.Screen, error) } // Run startet den Scheduler-Loop. Blockiert bis ctx abgebrochen wird. func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: check(ctx, schedules, screens, globalOverrides, 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 AllScreensLister, states DisplayStateGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: reconcile(ctx, schedules, screens, states, globalOverrides, notifier) case <-ctx.Done(): return } } } func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, allScreens AllScreensLister, states DisplayStateGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { now := time.Now() screenList, err := allScreens.ListAll(ctx) if err != nil { slog.Error("reconciler: list all screens failed", "err", err) return } globalOverride, err := globalOverrides.Get(ctx) if err != nil { slog.Warn("reconciler: get global override failed", "err", err) // nicht fatal — ohne Override fortfahren } for _, screen := range screenList { sc, err := schedules.Get(ctx, screen.ID) if err != nil { slog.Warn("reconciler: get schedule failed", "screen_id", screen.ID, "err", err) continue } want, shouldControl := resolveDesiredState(*sc, globalOverride, now) if !shouldControl { continue } got, err := states.GetDisplayState(ctx, screen.ID) if err != nil { slog.Warn("reconciler: get display state failed", "screen_id", screen.ID, "err", err) continue } if got == want { continue } action := "display_" + want if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil { slog.Error("reconciler: send command failed", "screen_id", screen.ID, "action", action, "err", err) } else { slog.Info("reconciler: corrected display state", "screen_id", screen.ID, "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:00–22:00 if now >= onTime && now < offTime { return "on" } return "off" } // Mitternacht-Überschreitung: z.B. 22:00–06: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, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { // Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin). now := time.Now() nowStr := now.Format("15:04") // Wochenende: keine Einschalte-Kommandos senden isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday // Globaler Override "off" aktiv? globalOverrideOff := false if o, err := globalOverrides.Get(ctx); err == nil && o != nil && now.Before(o.Until) && o.Type == "off" { globalOverrideOff = true } 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 == nowStr { action = "display_on" } else if sc.PowerOffTime != "" && sc.PowerOffTime == nowStr { action = "display_off" } if action == "" { continue } // display_on unterdrücken wenn per-Screen-Override, Wochenende oder globaler Override "off" if action == "display_on" { if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) { // per-Screen Override aktiv → Kommando trotzdem senden (Override = on) } else if isWeekend { slog.Info("scheduler: display_on unterdrückt (Wochenende)", "screen_id", sc.ScreenID) continue } else if globalOverrideOff { slog.Info("scheduler: display_on unterdrückt (globaler Override off)", "screen_id", sc.ScreenID) 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) } } }