# Design: Display-Steuerung Schritt 1 — Command-Pipeline **Datum:** 2026-03-26 **Scope:** Command-Pipeline (MQTT + Agent + Backend-API + Ansible-DPMS-Fix) **Nicht in Scope:** Zeitplan (Schritt 2), UI-Schalter (Schritt 3) ## Ziel Das Backend kann ein einzelnes Display per API-Aufruf ein- oder ausschalten. Der Befehl wird per MQTT an den Agent auf dem Pi übermittelt. Der Agent führt `xset dpms force on/off` aus und meldet den Ist-Zustand zurück. ## Gesamtarchitektur ``` Aufrufer (manuell / später: Zeitplan oder UI) │ ▼ POST /api/v1/screens/{screenSlug}/display │ ├─► MQTT publish (retained, QoS 1) │ Topic: signage/screen/{slug}/command │ Payload: {"action": "display_on"} │ ▼ Agent (auf dem Pi) │ ├─► xset dpms force on/off │ ├─► MQTT publish (QoS 0, nicht retained) │ Topic: signage/screen/{slug}/display-state │ Payload: {"display_state": "on", "ts": "..."} │ └─► nächster HTTP-Status-Report (POST /api/v1/player/status) + display_state: "on"|"off"|"unknown" │ ▼ Backend: speichert in screen_status-Tabelle ``` ## MQTT-Vertrag ### Command-Topic (Backend → Agent) - **Topic:** `signage/screen/{screenSlug}/command` - **QoS:** 1 - **Retained:** true - **Payload:** ```json {"action": "display_on"} ``` oder ```json {"action": "display_off"} ``` Retained sorgt dafür, dass der Agent nach einem Neustart automatisch den letzten Sollzustand empfängt und wiederherstellt. ### Display-State-Topic (Agent → Welt) - **Topic:** `signage/screen/{screenSlug}/display-state` - **QoS:** 0 - **Retained:** false - **Payload:** ```json {"display_state": "on", "ts": "2026-03-26T10:00:00Z"} ``` `display_state`: `"on"` | `"off"` | `"unknown"` ## Backend-Änderungen ### 1. Neuer API-Endpunkt `POST /api/v1/screens/{screenSlug}/display` - Auth: `authScreen` (bestehende Middleware, Tenant-Isolation) - Request-Body: `{"state": "on"}` oder `{"state": "off"}` - Aktion: publiziert MQTT-Command (retained, QoS 1) - Response: `204 No Content` - Fehler: `400` bei ungültigem `state`, `404` bei unbekanntem Screen ### 2. Erweiterung `mqttnotifier` Neue Methode: ```go func (n *Notifier) SendDisplayCommand(screenSlug, action string) error ``` Publiziert auf `signage/screen/{screenSlug}/command` mit QoS 1 und `retained=true`. Das bestehende `publish()`-Helper nutzt QoS 0 und `retain=false` — `SendDisplayCommand` ruft den MQTT-Client direkt auf, um QoS 1 und `retain=true` zu setzen. Gibt Fehler zurück wenn der Publish fehlschlägt; der HTTP-Handler antwortet dann mit `502`. ### 3. Neue DB-Tabelle `screen_status` Migration: ```sql create table if not exists screen_status ( screen_id text primary key references screens(id) on delete cascade, display_state text, -- 'on' | 'off' | 'unknown' reported_at timestamptz not null default now() ); ``` ### 4. Erweiterung `POST /api/v1/player/status` Request-Payload bekommt optionales Feld: ```json {"display_state": "on"} ``` Backend schreibt/aktualisiert `screen_status` per UPSERT. ### 5. Neuer Store-Methode ```go func (s *ScreenStore) UpsertDisplayState(ctx context.Context, screenID, displayState string) error ``` ## Agent-Änderungen ### 1. Neues Package `displaycontroller` Pfad: `player/agent/internal/displaycontroller/controller.go` ```go type Controller struct { display string // DISPLAY-Env, z.B. ":0" currentState string // "on" | "off" | "unknown" mu sync.Mutex } func New(display string) *Controller func (c *Controller) TurnOn() error // xset -display :0 dpms force on func (c *Controller) TurnOff() error // xset -display :0 dpms force off func (c *Controller) State() string // aktueller Zustand (thread-safe) ``` Nach `TurnOn`/`TurnOff` publiziert der Controller den neuen Zustand per MQTT auf `display-state`-Topic. `DISPLAY` wird aus Env-Var gelesen (Default: `:0`). ### 2. Erweiterung `mqttsubscriber` Drittes Topic: `signage/screen/{slug}/command` Bei eingehender Nachricht: - JSON parsen - `action` auslesen (`display_on` oder `display_off`) - `DisplayController.TurnOn()` bzw. `TurnOff()` aufrufen - Unbekannte Actions: loggen, ignorieren ### 3. Erweiterung `statusreporter` Request-Payload bekommt Feld `display_state string` — aus `DisplayController.State()` befüllt. ## Ansible-Änderungen Datei: `ansible/roles/signage_display/templates/morz-kiosk.j2` ```bash # Vorher: xset -dpms # Nachher: xset +dpms xset dpms 0 0 0 # Timeouts deaktivieren — Steuerung nur über Backend ``` Optional (falls Agent nicht im X-Kontext läuft): `ansible/roles/signage_display/templates/morz-kiosk.service.j2`: ```ini Environment=XAUTHORITY=/home/{{ signage_user }}/.Xauthority ``` Wird beim Testen geprüft. ## Nicht in Scope (Schritt 1) - Zeitplan (`power_on_time`, `power_off_time`, `schedule_enabled`) - UI-Schalter in der Playlist-Verwaltung - Sammelschalter für mehrere Displays - `device_commands`-Tabelle (Audit-Log) - Backend-MQTT-Subscriber (Backend empfängt keine MQTT-Messages) ## Offen / beim Testen zu klären - Braucht der Agent explizit `XAUTHORITY` im Systemd-Unit, oder reicht `DISPLAY=:0`? - Funktioniert `xset` aus dem Agent-Prozess heraus, oder muss es im X-Session-Kontext laufen?