morz-infoboard/docs/superpowers/plans/2026-03-26-display-steuerung-schritt1.md
Jesko Anschütz 01942aa3f3 docs: Implementierungsplan Display-Steuerung Schritt 1 (Command-Pipeline)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:59:27 +01:00

915 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 206215)
```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://<backend>/api/v1/screens/<slug>/display \
-H "Authorization: Bearer <token>" \
-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;`