morz-infoboard/server/backend/internal/scheduler/scheduler.go
Jesko Anschütz ccec32c832 feat(scheduler): Reconciler gleicht Ist- und Soll-Display-Zustand ab
Fügt Reconcile() und desiredState() zum Scheduler-Package hinzu.
Der Reconciler läuft alle 5 Minuten, berechnet den Soll-Zustand aus
den konfigurierten Ein-/Ausschaltzeiten (inkl. Mitternacht-Überschreitung)
und sendet bei Abweichung oder unbekanntem Ist-Zustand einen MQTT-Befehl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 18:33:48 +01:00

151 lines
4.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"
}
// 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)
}
}
}