diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index 3804c63..0d73925 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -146,6 +146,7 @@ func (a *App) Run() error { // Display-Zeitplan-Scheduler go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.notifier) + go scheduler.Reconcile(ctx, a.scheduleStore, a.screenStore, a.screenStore, a.notifier) // W2: Signal-Handler für Graceful Shutdown. sigCh := make(chan os.Signal, 1) diff --git a/server/backend/internal/scheduler/scheduler.go b/server/backend/internal/scheduler/scheduler.go index 23e09fa..ab052f3 100644 --- a/server/backend/internal/scheduler/scheduler.go +++ b/server/backend/internal/scheduler/scheduler.go @@ -20,6 +20,11 @@ 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) @@ -35,6 +40,79 @@ func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens Scre } } +// 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).