diff --git a/docs/superpowers/plans/2026-03-26-display-steuerung-schritt1.md b/docs/superpowers/plans/2026-03-26-display-steuerung-schritt1.md new file mode 100644 index 0000000..e3be46f --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-display-steuerung-schritt1.md @@ -0,0 +1,915 @@ +# Display-Steuerung Schritt 1 — Command-Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Das Backend kann per `POST /api/v1/screens/{slug}/display` ein Display ein- oder ausschalten; der Befehl wird per MQTT (retained, QoS 1) an den Agent übermittelt, der `xset dpms force on/off` ausführt und den Zustand per HTTP und MQTT zurückmeldet. + +**Architecture:** Backend publiziert einen retained MQTT-Command auf `signage/screen/{slug}/command`. Der Agent abonniert dieses Topic, führt xset aus und meldet den Zustand im nächsten HTTP-Status-Report (und per MQTT-Broadcast) zurück. Der zuletzt gemeldete Zustand wird in einer neuen `screen_status`-Tabelle persistiert. + +**Tech Stack:** Go, PostgreSQL/pgx, paho.mqtt.golang, xset (X11) + +--- + +## Dateiübersicht + +**Backend (create/modify):** +- Create: `server/backend/internal/db/migrations/004_screen_status.sql` +- Modify: `server/backend/internal/store/store.go` — `ScreenStatus` struct + `UpsertDisplayState` +- Modify: `server/backend/internal/mqttnotifier/notifier.go` — `SendDisplayCommand` +- Create: `server/backend/internal/httpapi/manage/display.go` — `HandleDisplayCommand` +- Modify: `server/backend/internal/httpapi/router.go` — neue Route registrieren +- Modify: `server/backend/internal/httpapi/playerstatus.go` — `display_state` im Request + +**Agent (create/modify):** +- Create: `player/agent/internal/displaycontroller/controller.go` +- Modify: `player/agent/internal/mqttheartbeat/heartbeat.go` — `SendDisplayState` +- Modify: `player/agent/internal/app/app.go` — Interface + Wiring +- Modify: `player/agent/internal/mqttsubscriber/subscriber.go` — Command-Topic +- Modify: `player/agent/internal/statusreporter/reporter.go` — `display_state`-Feld + +**Ansible:** +- Modify: `ansible/roles/signage_display/templates/morz-kiosk.j2` + +--- + +### Task 1: DB Migration — `screen_status` + +**Files:** +- Create: `server/backend/internal/db/migrations/004_screen_status.sql` + +- [ ] **Schritt 1: Migrationsdatei anlegen** + + ```sql + -- Migration 004: Display-Steuerung – screen_status + -- Speichert den zuletzt vom Agent gemeldeten Display-Zustand pro Screen. + + create table if not exists screen_status ( + screen_id text primary key references screens(id) on delete cascade, + display_state text not null default 'unknown', + reported_at timestamptz not null default now() + ); + ``` + +- [ ] **Schritt 2: Backend neu starten, Migration prüfen** + + ```bash + docker compose -f compose/server-stack.yml up --build backend + ``` + + Erwartet: Log-Zeile `event=migration_applied version=4 file=004_screen_status.sql` + +- [ ] **Schritt 3: Committen** + + ```bash + git add server/backend/internal/db/migrations/004_screen_status.sql + git commit -m "feat(db): screen_status-Tabelle für Display-Zustand" + ``` + +--- + +### Task 2: Store — `UpsertDisplayState` + +**Files:** +- Modify: `server/backend/internal/store/store.go` + +- [ ] **Schritt 1: `ScreenStatus`-Typ nach dem `Screen`-Typ einfügen** (nach Zeile 35) + + ```go + type ScreenStatus struct { + ScreenID string `json:"screen_id"` + DisplayState string `json:"display_state"` + ReportedAt time.Time `json:"reported_at"` + } + ``` + +- [ ] **Schritt 2: `UpsertDisplayState`-Methode am Ende des ScreenStore-Blocks einfügen** (nach `scanScreen`, vor `// MediaStore`) + + ```go + // UpsertDisplayState speichert den zuletzt gemeldeten Display-Zustand eines Screens. + func (s *ScreenStore) UpsertDisplayState(ctx context.Context, screenID, displayState string) error { + _, err := s.pool.Exec(ctx, + `insert into screen_status (screen_id, display_state, reported_at) + values ($1, $2, now()) + on conflict (screen_id) do update + set display_state = excluded.display_state, + reported_at = excluded.reported_at`, + screenID, displayState) + return err + } + ``` + +- [ ] **Schritt 3: Kompilieren** + + ```bash + cd server/backend && go build ./... + ``` + + Erwartet: keine Ausgabe, Exit 0. + +- [ ] **Schritt 4: Committen** + + ```bash + git add server/backend/internal/store/store.go + git commit -m "feat(store): UpsertDisplayState für screen_status" + ``` + +--- + +### Task 3: mqttnotifier — `SendDisplayCommand` + +**Files:** +- Modify: `server/backend/internal/mqttnotifier/notifier.go` + +- [ ] **Schritt 1: Methode nach `RequestScreenshot` einfügen** (nach Zeile 94) + + Das bestehende `publish()`-Helper nutzt QoS 0 und `retain=false`. `SendDisplayCommand` publiziert direkt mit QoS 1 und `retain=true`. + + ```go + // SendDisplayCommand publiziert einen Display-Befehl (display_on/display_off) + // auf das Command-Topic des Screens. Retained + QoS 1, damit der Agent den + // letzten Sollzustand auch nach einem Reconnect erhält. + func (n *Notifier) SendDisplayCommand(screenSlug, action string) error { + if n.client == nil { + return nil + } + topic := fmt.Sprintf("signage/screen/%s/command", screenSlug) + payload := []byte(fmt.Sprintf(`{"action":%q}`, action)) + token := n.client.Publish(topic, 1, true, payload) + if !token.WaitTimeout(5 * time.Second) { + return fmt.Errorf("mqtt publish display command: timeout") + } + return token.Error() + } + ``` + +- [ ] **Schritt 2: Kompilieren** + + ```bash + cd server/backend && go build ./... + ``` + + Erwartet: keine Ausgabe, Exit 0. + +- [ ] **Schritt 3: Committen** + + ```bash + git add server/backend/internal/mqttnotifier/notifier.go + git commit -m "feat(mqtt): SendDisplayCommand mit retained QoS 1" + ``` + +--- + +### Task 4: Backend-Handler — `POST /api/v1/screens/{screenSlug}/display` + +**Files:** +- Create: `server/backend/internal/httpapi/manage/display.go` +- Modify: `server/backend/internal/httpapi/router.go` + +- [ ] **Schritt 1: Handler-Datei anlegen** + + ```go + package manage + + import ( + "encoding/json" + "log/slog" + "net/http" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" + ) + + // HandleDisplayCommand nimmt {"state":"on"} oder {"state":"off"} entgegen und + // schickt den entsprechenden MQTT-Befehl an den Agent. + func HandleDisplayCommand(screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + screenSlug := r.PathValue("screenSlug") + screen, err := screens.GetBySlug(r.Context(), screenSlug) + if err != nil { + http.Error(w, "screen not found", http.StatusNotFound) + return + } + if !requireScreenAccess(w, r, screen) { + return + } + + var body struct { + State string `json:"state"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + var action string + switch body.State { + case "on": + action = "display_on" + case "off": + action = "display_off" + default: + http.Error(w, `state must be "on" or "off"`, http.StatusBadRequest) + return + } + + if err := notifier.SendDisplayCommand(screenSlug, action); err != nil { + slog.Error("send display command", "err", err) + http.Error(w, "failed to send command", http.StatusBadGateway) + return + } + + w.WriteHeader(http.StatusNoContent) + } + } + ``` + +- [ ] **Schritt 2: Route in `router.go` registrieren** + + In `router.go` im Block der API-Screen-Routen (grep nach `api/v1/screens`) folgende Zeile ergänzen: + + ```go + mux.Handle("POST /api/v1/screens/{screenSlug}/display", + authOnly(http.HandlerFunc(manage.HandleDisplayCommand(d.ScreenStore, d.Notifier)))) + ``` + + Hinweis: `d.Notifier` heißt im router eventuell `notifier` (lokale Variable). Prüfe den bestehenden Code-Kontext in `NewRouter`. + +- [ ] **Schritt 3: Kompilieren** + + ```bash + cd server/backend && go build ./... + ``` + + Erwartet: keine Ausgabe, Exit 0. + +- [ ] **Schritt 4: Committen** + + ```bash + git add server/backend/internal/httpapi/manage/display.go \ + server/backend/internal/httpapi/router.go + git commit -m "feat(api): POST /api/v1/screens/{slug}/display" + ``` + +--- + +### Task 5: Player-Status-Handler — `display_state` persistieren + +**Files:** +- Modify: `server/backend/internal/httpapi/playerstatus.go` +- Modify: `server/backend/internal/httpapi/router.go` + +Der Handler `handlePlayerStatus` bekommt `*store.ScreenStore` als neuen Parameter, um `display_state` in `screen_status` zu speichern. + +- [ ] **Schritt 1: `display_state`-Feld zu `playerStatusRequest` hinzufügen** (nach `LastHeartbeatAt`, Zeile 49) + + ```go + type playerStatusRequest struct { + ScreenID string `json:"screen_id"` + Timestamp string `json:"ts"` + Status string `json:"status"` + ServerConnectivity string `json:"server_connectivity"` + ServerURL string `json:"server_url"` + MQTTBroker string `json:"mqtt_broker"` + HeartbeatEverySeconds int `json:"heartbeat_every_seconds"` + StartedAt string `json:"started_at"` + LastHeartbeatAt string `json:"last_heartbeat_at"` + DisplayState string `json:"display_state,omitempty"` + } + ``` + +- [ ] **Schritt 2: `*store.ScreenStore`-Parameter + Import hinzufügen** + + Funktionssignatur ändern von: + ```go + func handlePlayerStatus(store playerStatusStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc { + ``` + zu: + ```go + func handlePlayerStatus(store playerStatusStore, screenStore *storePackage.ScreenStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc { + ``` + + Import am Anfang der Datei ergänzen: + ```go + import ( + "errors" + "log/slog" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + storePackage "git.az-it.net/az/morz-infoboard/server/backend/internal/store" + ) + ``` + +- [ ] **Schritt 3: `display_state` persistieren** — nach dem `store.Save(...)` Block (nach Zeile 131) einfügen: + + ```go + if request.DisplayState != "" && screenStore != nil { + if err := screenStore.UpsertDisplayState(r.Context(), request.ScreenID, request.DisplayState); err != nil { + slog.Error("upsert display state", "screen_id", request.ScreenID, "err", err) + // Fehler ist nicht fatal — Statusmeldung wird trotzdem beantwortet + } + } + ``` + +- [ ] **Schritt 4: Route in `router.go` aktualisieren** + + In `router.go` die Zeile mit `handlePlayerStatus(` finden (grep nach `handlePlayerStatus`) und `d.ScreenStore` als zweites Argument einfügen: + + ```go + // Vorher: + handlePlayerStatus(deps.StatusStore, cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword) + + // Nachher: + handlePlayerStatus(deps.StatusStore, deps.ScreenStore, cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword) + ``` + + (Variablenname in NewRouter prüfen — evtl. `d` statt `deps`.) + +- [ ] **Schritt 5: Kompilieren** + + ```bash + cd server/backend && go build ./... + ``` + + Erwartet: keine Ausgabe, Exit 0. + +- [ ] **Schritt 6: Committen** + + ```bash + git add server/backend/internal/httpapi/playerstatus.go \ + server/backend/internal/httpapi/router.go + git commit -m "feat(api): display_state im Player-Status-Report persistieren" + ``` + +--- + +### Task 6: Agent — `displaycontroller` Package + +**Files:** +- Create: `player/agent/internal/displaycontroller/controller.go` +- Create: `player/agent/internal/displaycontroller/controller_test.go` + +- [ ] **Schritt 1: Test schreiben** + + ```go + package displaycontroller + + import "testing" + + func TestNew_initialState(t *testing.T) { + c := New(":0", "test-screen", nil) + if got := c.State(); got != "unknown" { + t.Fatalf("initial state = %q, want %q", got, "unknown") + } + } + + func TestExecute_unknownAction(t *testing.T) { + c := New(":0", "test-screen", nil) + // Unbekannte Action darf nicht paniken und Zustand darf sich nicht ändern. + c.Execute("invalid_action") + if got := c.State(); got != "unknown" { + t.Fatalf("state after unknown action = %q, want %q", got, "unknown") + } + } + ``` + +- [ ] **Schritt 2: Test ausführen (muss fehlschlagen)** + + ```bash + cd player/agent && go test ./internal/displaycontroller/... + ``` + + Erwartet: FAIL mit "no Go files" + +- [ ] **Schritt 3: Controller implementieren** + + ```go + // Package displaycontroller steuert das physische Display per xset DPMS. + package displaycontroller + + import ( + "fmt" + "log/slog" + "os/exec" + "sync" + ) + + // Controller führt xset-Befehle aus und verfolgt den letzten Display-Zustand. + // onStateChange wird nach jeder erfolgreichen Ausführung mit dem neuen Zustand + // aufgerufen (z. B. um ihn per MQTT zu publizieren). Darf nil sein. + type Controller struct { + display string // X-Display, z. B. ":0" + screenSlug string + onStateChange func(screenSlug, state string) + mu sync.Mutex + currentState string + } + + // New erstellt einen Controller. display ist der Wert der DISPLAY-Env-Var (z. B. ":0"). + func New(display, screenSlug string, onStateChange func(screenSlug, state string)) *Controller { + return &Controller{ + display: display, + screenSlug: screenSlug, + onStateChange: onStateChange, + currentState: "unknown", + } + } + + // Execute führt eine Display-Aktion aus. Bekannte Aktionen: "display_on", "display_off". + // Unbekannte Aktionen werden geloggt und ignoriert. + func (c *Controller) Execute(action string) { + switch action { + case "display_on": + if err := c.runXset("on"); err != nil { + slog.Error("display_on failed", "err", err) + return + } + c.setState("on") + case "display_off": + if err := c.runXset("off"); err != nil { + slog.Error("display_off failed", "err", err) + return + } + c.setState("off") + default: + slog.Warn("unknown display action", "action", action) + } + } + + // State gibt den zuletzt gesetzten Display-Zustand zurück: "on", "off" oder "unknown". + func (c *Controller) State() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.currentState + } + + func (c *Controller) runXset(arg string) error { + out, err := exec.Command("xset", "-display", c.display, "dpms", "force", arg).CombinedOutput() + if err != nil { + return fmt.Errorf("xset dpms force %s: %w (output: %s)", arg, err, out) + } + return nil + } + + func (c *Controller) setState(state string) { + c.mu.Lock() + c.currentState = state + c.mu.Unlock() + if c.onStateChange != nil { + go c.onStateChange(c.screenSlug, state) + } + } + ``` + +- [ ] **Schritt 4: Tests ausführen** + + ```bash + cd player/agent && go test ./internal/displaycontroller/... + ``` + + Erwartet: PASS (beide Tests grün) + +- [ ] **Schritt 5: Kompilieren** + + ```bash + cd player/agent && go build ./... + ``` + + Erwartet: keine Ausgabe, Exit 0. + +- [ ] **Schritt 6: Committen** + + ```bash + git add player/agent/internal/displaycontroller/ + git commit -m "feat(agent): displaycontroller Package (xset DPMS)" + ``` + +--- + +### Task 7: Agent — `mqttheartbeat` um `SendDisplayState` erweitern + Interface updaten + +**Files:** +- Modify: `player/agent/internal/mqttheartbeat/heartbeat.go` +- Modify: `player/agent/internal/app/app.go` — `mqttSender` Interface + +- [ ] **Schritt 1: `SendDisplayState` in `heartbeat.go` ergänzen** (nach `SendHeartbeat`, Zeile 72) + + ```go + // SendDisplayState publiziert den aktuellen Display-Zustand auf dem display-state-Topic. + // QoS 0, nicht retained — Informationszweck/Monitoring. + func (p *Publisher) SendDisplayState(screenSlug, state string) error { + type dsPayload struct { + DisplayState string `json:"display_state"` + Timestamp string `json:"ts"` + } + data, err := json.Marshal(dsPayload{ + DisplayState: state, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + return err + } + topic := "signage/screen/" + screenSlug + "/display-state" + token := p.client.Publish(topic, 0, false, data) + token.WaitTimeout(3 * time.Second) + return token.Error() + } + ``` + +- [ ] **Schritt 2: `mqttSender`-Interface in `app.go` erweitern** (Zeilen 79-82) + + ```go + type mqttSender interface { + SendHeartbeat(status, connectivity string, ts time.Time) error + SendDisplayState(screenSlug, state string) error + Close() + } + ``` + +- [ ] **Schritt 3: Kompilieren** + + ```bash + cd player/agent && go build ./... + ``` + + Erwartet: keine Ausgabe, Exit 0. + +- [ ] **Schritt 4: Committen** + + ```bash + git add player/agent/internal/mqttheartbeat/heartbeat.go \ + player/agent/internal/app/app.go + git commit -m "feat(agent): SendDisplayState im MQTT-Publisher + Interface" + ``` + +--- + +### Task 8: Agent — `mqttsubscriber` Command-Topic + +**Files:** +- Modify: `player/agent/internal/mqttsubscriber/subscriber.go` + +- [ ] **Schritt 1: Konstante + Callback-Typ ergänzen** (nach `screenshotRequestTopicTemplate`, Zeile 20) + + ```go + // commandTopicTemplate ist das Topic für eingehende Display-Befehle vom Backend. + commandTopicTemplate = "signage/screen/%s/command" + ``` + + ```go + // DisplayCommandFunc wird aufgerufen wenn ein display-command eintrifft. + type DisplayCommandFunc func(action string) + ``` + +- [ ] **Schritt 2: `commandC`-Kanal + Callback zu `Subscriber` hinzufügen** (nach `screenshotReqC`, Zeile 36) + + ```go + type Subscriber struct { + client mqtt.Client + timer *time.Timer + onChange PlaylistChangedFunc + onScreenshotRequest ScreenshotRequestFunc + onDisplayCommand DisplayCommandFunc + + resetC chan struct{} + screenshotReqC chan struct{} + commandC chan string + stopC chan struct{} + } + ``` + +- [ ] **Schritt 3: `CommandTopic`-Hilfsfunktion ergänzen** (nach `ScreenshotRequestTopic`) + + ```go + // CommandTopic returns the MQTT topic for display commands for a given screenSlug. + func CommandTopic(screenSlug string) string { + return "signage/screen/" + screenSlug + "/command" + } + ``` + +- [ ] **Schritt 4: `New()`-Signatur + Initialisierung aktualisieren** + + Signatur: + ```go + func New(broker, screenSlug, username, password string, + onChange PlaylistChangedFunc, + onScreenshotRequest ScreenshotRequestFunc, + onDisplayCommand DisplayCommandFunc, + ) *Subscriber { + ``` + + In der Struct-Initialisierung (nach `screenshotReqC: make(chan struct{}, 16)`): + ```go + s := &Subscriber{ + onChange: onChange, + onScreenshotRequest: onScreenshotRequest, + onDisplayCommand: onDisplayCommand, + resetC: make(chan struct{}, 16), + screenshotReqC: make(chan struct{}, 16), + commandC: make(chan string, 8), + stopC: make(chan struct{}), + } + ``` + +- [ ] **Schritt 5: Command-Topic im `SetOnConnectHandler` abonnieren** + + In `SetOnConnectHandler` (nach dem `c.Subscribe(screenshotTopic, ...)` Block) einfügen: + + ```go + commandTopic := CommandTopic(screenSlug) + c.Subscribe(commandTopic, 1, func(_ mqtt.Client, m mqtt.Message) { //nolint:errcheck + var cmd struct { + Action string `json:"action"` + } + if err := json.Unmarshal(m.Payload(), &cmd); err != nil { + return + } + select { + case s.commandC <- cmd.Action: + default: // Kanal voll — vorheriger Befehl wartet noch + } + }) + ``` + + Import `"encoding/json"` am Dateianfang ergänzen. + +- [ ] **Schritt 6: `run()`-Loop um Command-Case erweitern** + + Im `select` in `run()` (nach `case <-s.screenshotReqC:`): + ```go + case action := <-s.commandC: + if s.onDisplayCommand != nil { + go s.onDisplayCommand(action) + } + ``` + +- [ ] **Schritt 7: Kompilieren** + + ```bash + cd player/agent && go build ./... + ``` + + Erwartet: Compilefehler in `app.go` (zu wenig Argumente bei `mqttsubscriber.New`) — das ist erwartet und wird in Task 10 behoben. + + Tatsächlich erwartet nach Task 10 — hier nur prüfen, dass die subscriber-Datei selbst fehlerfrei kompiliert: + ```bash + cd player/agent && go vet ./internal/mqttsubscriber/... + ``` + +- [ ] **Schritt 8: Committen** + + ```bash + git add player/agent/internal/mqttsubscriber/subscriber.go + git commit -m "feat(agent): mqttsubscriber abonniert Command-Topic" + ``` + +--- + +### Task 9: Agent — `statusreporter` um `display_state` erweitern + +**Files:** +- Modify: `player/agent/internal/statusreporter/reporter.go` + +- [ ] **Schritt 1: `DisplayState`-Feld zu `Snapshot` hinzufügen** (nach `LastHeartbeatAt`, Zeile 20) + + ```go + type Snapshot struct { + Status string + ServerConnectivity string + ServerBaseURL string + MQTTBroker string + HeartbeatEverySeconds int + StartedAt time.Time + LastHeartbeatAt time.Time + DisplayState string // "on" | "off" | "unknown" + } + ``` + +- [ ] **Schritt 2: `DisplayState`-Feld zu `statusPayload` hinzufügen** (nach `LastHeartbeatAt`, Zeile 31) + + ```go + type statusPayload struct { + ScreenID string `json:"screen_id"` + Timestamp string `json:"ts"` + Status string `json:"status"` + ServerConnectivity string `json:"server_connectivity"` + ServerURL string `json:"server_url"` + MQTTBroker string `json:"mqtt_broker"` + HeartbeatEverySeconds int `json:"heartbeat_every_seconds"` + StartedAt string `json:"started_at,omitempty"` + LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"` + DisplayState string `json:"display_state,omitempty"` + } + ``` + +- [ ] **Schritt 3: `buildPayload` um `DisplayState` ergänzen** (nach `LastHeartbeatAt`-Block, Zeile 115) + + ```go + if snapshot.DisplayState != "" { + payload.DisplayState = snapshot.DisplayState + } + ``` + +- [ ] **Schritt 4: Kompilieren** + + ```bash + cd player/agent && go build ./... + ``` + + Erwartet: Compilefehler in `app.go` (neues Subscriber-Interface) — wird in Task 10 behoben. Paket selbst muss sauber sein: + ```bash + cd player/agent && go vet ./internal/statusreporter/... + ``` + +- [ ] **Schritt 5: Committen** + + ```bash + git add player/agent/internal/statusreporter/reporter.go + git commit -m "feat(agent): display_state im Status-Report" + ``` + +--- + +### Task 10: Agent — `app.go` Wiring + +**Files:** +- Modify: `player/agent/internal/app/app.go` + +- [ ] **Schritt 1: Import und Feld hinzufügen** + + Import ergänzen: + ```go + "os" + + "git.az-it.net/az/morz-infoboard/player/agent/internal/displaycontroller" + ``` + + Im `App`-Struct nach `screenshotFn` (Zeile 70): + ```go + displayCtrl *displaycontroller.Controller + ``` + +- [ ] **Schritt 2: `displayCtrl` in `Run()` initialisieren** — vor der `mqttsubscriber.New()`-Zeile (Zeile 206) einfügen: + + ```go + xDisplay := os.Getenv("DISPLAY") + if xDisplay == "" { + xDisplay = ":0" + } + a.displayCtrl = displaycontroller.New(xDisplay, a.Config.ScreenID, func(slug, state string) { + a.mqttMu.Lock() + pub := a.mqttPub + a.mqttMu.Unlock() + if pub != nil { + if err := pub.SendDisplayState(slug, state); err != nil { + a.logger.Printf("event=display_state_publish_error err=%v", err) + } + } + }) + ``` + + Hinweis: `a.Config.ScreenID` ist die Screen-ID (UUID), die als MQTT-Slug verwendet wird — prüfe ob im Projekt screenSlug oder screenID für das MQTT-Topic genutzt wird. Wenn im Subscriber der Slug (`a.Config.ScreenID`) verwendet wird, ist das konsistent. + +- [ ] **Schritt 3: `mqttsubscriber.New()`-Aufruf um Command-Callback erweitern** (Zeile 206–215) + + ```go + sub := mqttsubscriber.New( + a.Config.MQTTBroker, + a.Config.ScreenID, + a.Config.MQTTUsername, + a.Config.MQTTPassword, + func() { + select { + case a.mqttFetchC <- struct{}{}: + default: + } + a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID) + }, + a.screenshotFn, + func(action string) { + a.logger.Printf("event=display_command_received action=%s screen_id=%s", action, a.Config.ScreenID) + a.displayCtrl.Execute(action) + }, + ) + ``` + +- [ ] **Schritt 4: `applyMQTTConfig` suchen und dort ebenfalls updaten** + + In `app.go` nach `applyMQTTConfig` suchen (grep: `func.*applyMQTTConfig`). Dort gibt es einen weiteren `mqttsubscriber.New()`-Aufruf — denselben 7. Parameter einfügen: + ```go + func(action string) { + a.logger.Printf("event=display_command_received action=%s screen_id=%s", action, a.Config.ScreenID) + a.displayCtrl.Execute(action) + }, + ``` + +- [ ] **Schritt 5: `DisplayState` im Status-Snapshot mitgeben** + + In `app.go` nach `buildSnapshot` oder der Stelle suchen, wo `statusreporter.Snapshot` befüllt wird (grep: `statusreporter.Snapshot{`). Dort `DisplayState` ergänzen: + ```go + DisplayState: a.displayCtrl.State(), + ``` + + Falls `displayCtrl` nil sein könnte (z. B. in Tests), vorher absichern: + ```go + var displayState string + if a.displayCtrl != nil { + displayState = a.displayCtrl.State() + } + // Dann: DisplayState: displayState, + ``` + +- [ ] **Schritt 6: Gesamtkompilierung** + + ```bash + cd player/agent && go build ./... + ``` + + Erwartet: keine Ausgabe, Exit 0. + +- [ ] **Schritt 7: Tests ausführen** + + ```bash + cd player/agent && go test ./... + ``` + + Erwartet: alle Tests grün. + +- [ ] **Schritt 8: Committen** + + ```bash + git add player/agent/internal/app/app.go + git commit -m "feat(agent): displaycontroller in app.go verdrahtet" + ``` + +--- + +### Task 11: Ansible — DPMS aktivieren + +**Files:** +- Modify: `ansible/roles/signage_display/templates/morz-kiosk.j2` + +- [ ] **Schritt 1: DPMS-Zeilen anpassen** (Zeile 8) + + Vorher: + ```bash + xset -dpms + ``` + + Nachher: + ```bash + xset +dpms + xset dpms 0 0 0 # Timeouts deaktivieren — nur Backend schaltet das Display + ``` + + Die vollständige Datei sieht danach so aus (Zeilen 5-9): + ```bash + # Bildschirmschoner und Energiesparen konfigurieren + xset s off + xset s noblank + xset +dpms + xset dpms 0 0 0 + ``` + +- [ ] **Schritt 2: Committen** + + ```bash + git add ansible/roles/signage_display/templates/morz-kiosk.j2 + git commit -m "fix(ansible): DPMS aktivieren für Display-Steuerung" + ``` + +- [ ] **Schritt 3: Ansible ausrollen (manuell)** + + ```bash + ansible-playbook -i ansible/inventory.yml ansible/signage.yml --tags kiosk + ``` + + Danach auf dem Pi prüfen: + ```bash + xset q | grep DPMS + ``` + Erwartet: `DPMS is Enabled` + +--- + +## Manuelle Verifikation nach Fertigstellung + +1. Backend starten, Agent auf dem Pi starten +2. Display-Befehl senden: + ```bash + curl -X POST https:///api/v1/screens//display \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"state":"off"}' + ``` +3. Monitor muss innerhalb von 2 Sekunden ausgehen +4. MQTT-Topic prüfen (z. B. mit `mosquitto_sub`): + ```bash + mosquitto_sub -t 'signage/screen/+/display-state' -v + ``` +5. Nächster Status-Report des Agents soll `"display_state":"off"` enthalten +6. In der DB prüfen: `select * from screen_status;`