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

26 KiB
Raw Blame History

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.goScreenStatus struct + UpsertDisplayState
  • Modify: server/backend/internal/mqttnotifier/notifier.goSendDisplayCommand
  • Create: server/backend/internal/httpapi/manage/display.goHandleDisplayCommand
  • Modify: server/backend/internal/httpapi/router.go — neue Route registrieren
  • Modify: server/backend/internal/httpapi/playerstatus.godisplay_state im Request

Agent (create/modify):

  • Create: player/agent/internal/displaycontroller/controller.go
  • Modify: player/agent/internal/mqttheartbeat/heartbeat.goSendDisplayState
  • Modify: player/agent/internal/app/app.go — Interface + Wiring
  • Modify: player/agent/internal/mqttsubscriber/subscriber.go — Command-Topic
  • Modify: player/agent/internal/statusreporter/reporter.godisplay_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

    -- 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

    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

    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)

    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)

    // 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

    cd server/backend && go build ./...
    

    Erwartet: keine Ausgabe, Exit 0.

  • Schritt 4: Committen

    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.

    // 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

    cd server/backend && go build ./...
    

    Erwartet: keine Ausgabe, Exit 0.

  • Schritt 3: Committen

    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

    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:

    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

    cd server/backend && go build ./...
    

    Erwartet: keine Ausgabe, Exit 0.

  • Schritt 4: Committen

    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)

    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:

    func handlePlayerStatus(store playerStatusStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc {
    

    zu:

    func handlePlayerStatus(store playerStatusStore, screenStore *storePackage.ScreenStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc {
    

    Import am Anfang der Datei ergänzen:

    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:

    	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:

    // 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

    cd server/backend && go build ./...
    

    Erwartet: keine Ausgabe, Exit 0.

  • Schritt 6: Committen

    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

    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)

    cd player/agent && go test ./internal/displaycontroller/...
    

    Erwartet: FAIL mit "no Go files"

  • Schritt 3: Controller implementieren

    // 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

    cd player/agent && go test ./internal/displaycontroller/...
    

    Erwartet: PASS (beide Tests grün)

  • Schritt 5: Kompilieren

    cd player/agent && go build ./...
    

    Erwartet: keine Ausgabe, Exit 0.

  • Schritt 6: Committen

    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.gomqttSender Interface

  • Schritt 1: SendDisplayState in heartbeat.go ergänzen (nach SendHeartbeat, Zeile 72)

    // 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)

    type mqttSender interface {
    	SendHeartbeat(status, connectivity string, ts time.Time) error
    	SendDisplayState(screenSlug, state string) error
    	Close()
    }
    
  • Schritt 3: Kompilieren

    cd player/agent && go build ./...
    

    Erwartet: keine Ausgabe, Exit 0.

  • Schritt 4: Committen

    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)

    // commandTopicTemplate ist das Topic für eingehende Display-Befehle vom Backend.
    commandTopicTemplate = "signage/screen/%s/command"
    
    // 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)

    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)

    // 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:

    func New(broker, screenSlug, username, password string,
    	onChange PlaylistChangedFunc,
    	onScreenshotRequest ScreenshotRequestFunc,
    	onDisplayCommand DisplayCommandFunc,
    ) *Subscriber {
    

    In der Struct-Initialisierung (nach screenshotReqC: make(chan struct{}, 16)):

    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:

    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:):

    case action := <-s.commandC:
    	if s.onDisplayCommand != nil {
    		go s.onDisplayCommand(action)
    	}
    
  • Schritt 7: Kompilieren

    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:

    cd player/agent && go vet ./internal/mqttsubscriber/...
    
  • Schritt 8: Committen

    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)

    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)

    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)

    if snapshot.DisplayState != "" {
    	payload.DisplayState = snapshot.DisplayState
    }
    
  • Schritt 4: Kompilieren

    cd player/agent && go build ./...
    

    Erwartet: Compilefehler in app.go (neues Subscriber-Interface) — wird in Task 10 behoben. Paket selbst muss sauber sein:

    cd player/agent && go vet ./internal/statusreporter/...
    
  • Schritt 5: Committen

    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:

    "os"
    
    "git.az-it.net/az/morz-infoboard/player/agent/internal/displaycontroller"
    

    Im App-Struct nach screenshotFn (Zeile 70):

    displayCtrl *displaycontroller.Controller
    
  • Schritt 2: displayCtrl in Run() initialisieren — vor der mqttsubscriber.New()-Zeile (Zeile 206) einfügen:

    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)

    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:

    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:

    DisplayState: a.displayCtrl.State(),
    

    Falls displayCtrl nil sein könnte (z. B. in Tests), vorher absichern:

    var displayState string
    if a.displayCtrl != nil {
    	displayState = a.displayCtrl.State()
    }
    // Dann: DisplayState: displayState,
    
  • Schritt 6: Gesamtkompilierung

    cd player/agent && go build ./...
    

    Erwartet: keine Ausgabe, Exit 0.

  • Schritt 7: Tests ausführen

    cd player/agent && go test ./...
    

    Erwartet: alle Tests grün.

  • Schritt 8: Committen

    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:

    xset -dpms
    

    Nachher:

    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):

    # Bildschirmschoner und Energiesparen konfigurieren
    xset s off
    xset s noblank
    xset +dpms
    xset dpms 0 0 0
    
  • Schritt 2: Committen

    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)

    ansible-playbook -i ansible/inventory.yml ansible/signage.yml --tags kiosk
    

    Danach auf dem Pi prüfen:

    xset q | grep DPMS
    

    Erwartet: DPMS is Enabled


Manuelle Verifikation nach Fertigstellung

  1. Backend starten, Agent auf dem Pi starten
  2. Display-Befehl senden:
    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):
    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;