// 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: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" } // 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) } } }