From 52bc1fbd6ff3bcdfed107ab117f99570e804db3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Thu, 26 Mar 2026 22:49:05 +0100 Subject: [PATCH] docs: Design-Spec Display-Steuerung Schritt 1 (Command-Pipeline) Co-Authored-By: Claude Sonnet 4.6 --- ...03-26-display-steuerung-schritt1-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-26-display-steuerung-schritt1-design.md diff --git a/docs/superpowers/specs/2026-03-26-display-steuerung-schritt1-design.md b/docs/superpowers/specs/2026-03-26-display-steuerung-schritt1-design.md new file mode 100644 index 0000000..1261abe --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-display-steuerung-schritt1-design.md @@ -0,0 +1,185 @@ +# 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?