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