# 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;`