180 lines
5.4 KiB
Go
180 lines
5.4 KiB
Go
// 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"
|
||
}
|
||
|
||
// 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, 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)
|
||
}
|
||
}
|
||
}
|