morz-infoboard/docs/superpowers/specs/2026-03-26-display-steuerung-schritt1-design.md
Jesko Anschütz 52bc1fbd6f docs: Design-Spec Display-Steuerung Schritt 1 (Command-Pipeline)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:49:05 +01:00

5.3 KiB

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:
    {"action": "display_on"}
    
    oder
    {"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:
    {"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:

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=falseSendDisplayCommand 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:

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:

{"display_state": "on"}

Backend schreibt/aktualisiert screen_status per UPSERT.

5. Neuer Store-Methode

func (s *ScreenStore) UpsertDisplayState(ctx context.Context, screenID, displayState string) error

Agent-Änderungen

1. Neues Package displaycontroller

Pfad: player/agent/internal/displaycontroller/controller.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

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

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?