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:
parent
88e10d1e67
commit
ccec32c832
2 changed files with 79 additions and 0 deletions
|
|
@ -146,6 +146,7 @@ func (a *App) Run() error {
|
||||||
|
|
||||||
// Display-Zeitplan-Scheduler
|
// Display-Zeitplan-Scheduler
|
||||||
go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.notifier)
|
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.
|
// W2: Signal-Handler für Graceful Shutdown.
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ type ScreenSlugGetter interface {
|
||||||
GetByID(ctx context.Context, id string) (*store.Screen, error)
|
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.
|
// Run startet den Scheduler-Loop. Blockiert bis ctx abgebrochen wird.
|
||||||
func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
|
func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
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.
|
// check prüft alle aktiven Zeitpläne und sendet ggf. Befehle.
|
||||||
func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
|
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).
|
// Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue