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>
This commit is contained in:
Jesko Anschütz 2026-03-27 18:33:48 +01:00
parent 88e10d1e67
commit ccec32c832
2 changed files with 79 additions and 0 deletions

View file

@ -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)

View file

@ -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: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).