915 lines
26 KiB
Markdown
915 lines
26 KiB
Markdown
# 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.go` — `ScreenStatus` struct + `UpsertDisplayState`
|
||
- Modify: `server/backend/internal/mqttnotifier/notifier.go` — `SendDisplayCommand`
|
||
- Create: `server/backend/internal/httpapi/manage/display.go` — `HandleDisplayCommand`
|
||
- Modify: `server/backend/internal/httpapi/router.go` — neue Route registrieren
|
||
- Modify: `server/backend/internal/httpapi/playerstatus.go` — `display_state` im Request
|
||
|
||
**Agent (create/modify):**
|
||
- Create: `player/agent/internal/displaycontroller/controller.go`
|
||
- Modify: `player/agent/internal/mqttheartbeat/heartbeat.go` — `SendDisplayState`
|
||
- Modify: `player/agent/internal/app/app.go` — Interface + Wiring
|
||
- Modify: `player/agent/internal/mqttsubscriber/subscriber.go` — Command-Topic
|
||
- Modify: `player/agent/internal/statusreporter/reporter.go` — `display_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**
|
||
|
||
```sql
|
||
-- 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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)
|
||
|
||
```go
|
||
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`)
|
||
|
||
```go
|
||
// 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**
|
||
|
||
```bash
|
||
cd server/backend && go build ./...
|
||
```
|
||
|
||
Erwartet: keine Ausgabe, Exit 0.
|
||
|
||
- [ ] **Schritt 4: Committen**
|
||
|
||
```bash
|
||
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`.
|
||
|
||
```go
|
||
// 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**
|
||
|
||
```bash
|
||
cd server/backend && go build ./...
|
||
```
|
||
|
||
Erwartet: keine Ausgabe, Exit 0.
|
||
|
||
- [ ] **Schritt 3: Committen**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```go
|
||
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:
|
||
|
||
```go
|
||
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**
|
||
|
||
```bash
|
||
cd server/backend && go build ./...
|
||
```
|
||
|
||
Erwartet: keine Ausgabe, Exit 0.
|
||
|
||
- [ ] **Schritt 4: Committen**
|
||
|
||
```bash
|
||
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)
|
||
|
||
```go
|
||
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:
|
||
```go
|
||
func handlePlayerStatus(store playerStatusStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc {
|
||
```
|
||
zu:
|
||
```go
|
||
func handlePlayerStatus(store playerStatusStore, screenStore *storePackage.ScreenStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc {
|
||
```
|
||
|
||
Import am Anfang der Datei ergänzen:
|
||
```go
|
||
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:
|
||
|
||
```go
|
||
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:
|
||
|
||
```go
|
||
// 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**
|
||
|
||
```bash
|
||
cd server/backend && go build ./...
|
||
```
|
||
|
||
Erwartet: keine Ausgabe, Exit 0.
|
||
|
||
- [ ] **Schritt 6: Committen**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```go
|
||
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)**
|
||
|
||
```bash
|
||
cd player/agent && go test ./internal/displaycontroller/...
|
||
```
|
||
|
||
Erwartet: FAIL mit "no Go files"
|
||
|
||
- [ ] **Schritt 3: Controller implementieren**
|
||
|
||
```go
|
||
// 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**
|
||
|
||
```bash
|
||
cd player/agent && go test ./internal/displaycontroller/...
|
||
```
|
||
|
||
Erwartet: PASS (beide Tests grün)
|
||
|
||
- [ ] **Schritt 5: Kompilieren**
|
||
|
||
```bash
|
||
cd player/agent && go build ./...
|
||
```
|
||
|
||
Erwartet: keine Ausgabe, Exit 0.
|
||
|
||
- [ ] **Schritt 6: Committen**
|
||
|
||
```bash
|
||
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.go` — `mqttSender` Interface
|
||
|
||
- [ ] **Schritt 1: `SendDisplayState` in `heartbeat.go` ergänzen** (nach `SendHeartbeat`, Zeile 72)
|
||
|
||
```go
|
||
// 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)
|
||
|
||
```go
|
||
type mqttSender interface {
|
||
SendHeartbeat(status, connectivity string, ts time.Time) error
|
||
SendDisplayState(screenSlug, state string) error
|
||
Close()
|
||
}
|
||
```
|
||
|
||
- [ ] **Schritt 3: Kompilieren**
|
||
|
||
```bash
|
||
cd player/agent && go build ./...
|
||
```
|
||
|
||
Erwartet: keine Ausgabe, Exit 0.
|
||
|
||
- [ ] **Schritt 4: Committen**
|
||
|
||
```bash
|
||
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)
|
||
|
||
```go
|
||
// commandTopicTemplate ist das Topic für eingehende Display-Befehle vom Backend.
|
||
commandTopicTemplate = "signage/screen/%s/command"
|
||
```
|
||
|
||
```go
|
||
// 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)
|
||
|
||
```go
|
||
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`)
|
||
|
||
```go
|
||
// 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:
|
||
```go
|
||
func New(broker, screenSlug, username, password string,
|
||
onChange PlaylistChangedFunc,
|
||
onScreenshotRequest ScreenshotRequestFunc,
|
||
onDisplayCommand DisplayCommandFunc,
|
||
) *Subscriber {
|
||
```
|
||
|
||
In der Struct-Initialisierung (nach `screenshotReqC: make(chan struct{}, 16)`):
|
||
```go
|
||
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:
|
||
|
||
```go
|
||
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:`):
|
||
```go
|
||
case action := <-s.commandC:
|
||
if s.onDisplayCommand != nil {
|
||
go s.onDisplayCommand(action)
|
||
}
|
||
```
|
||
|
||
- [ ] **Schritt 7: Kompilieren**
|
||
|
||
```bash
|
||
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:
|
||
```bash
|
||
cd player/agent && go vet ./internal/mqttsubscriber/...
|
||
```
|
||
|
||
- [ ] **Schritt 8: Committen**
|
||
|
||
```bash
|
||
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)
|
||
|
||
```go
|
||
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)
|
||
|
||
```go
|
||
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)
|
||
|
||
```go
|
||
if snapshot.DisplayState != "" {
|
||
payload.DisplayState = snapshot.DisplayState
|
||
}
|
||
```
|
||
|
||
- [ ] **Schritt 4: Kompilieren**
|
||
|
||
```bash
|
||
cd player/agent && go build ./...
|
||
```
|
||
|
||
Erwartet: Compilefehler in `app.go` (neues Subscriber-Interface) — wird in Task 10 behoben. Paket selbst muss sauber sein:
|
||
```bash
|
||
cd player/agent && go vet ./internal/statusreporter/...
|
||
```
|
||
|
||
- [ ] **Schritt 5: Committen**
|
||
|
||
```bash
|
||
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:
|
||
```go
|
||
"os"
|
||
|
||
"git.az-it.net/az/morz-infoboard/player/agent/internal/displaycontroller"
|
||
```
|
||
|
||
Im `App`-Struct nach `screenshotFn` (Zeile 70):
|
||
```go
|
||
displayCtrl *displaycontroller.Controller
|
||
```
|
||
|
||
- [ ] **Schritt 2: `displayCtrl` in `Run()` initialisieren** — vor der `mqttsubscriber.New()`-Zeile (Zeile 206) einfügen:
|
||
|
||
```go
|
||
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 206–215)
|
||
|
||
```go
|
||
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:
|
||
```go
|
||
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:
|
||
```go
|
||
DisplayState: a.displayCtrl.State(),
|
||
```
|
||
|
||
Falls `displayCtrl` nil sein könnte (z. B. in Tests), vorher absichern:
|
||
```go
|
||
var displayState string
|
||
if a.displayCtrl != nil {
|
||
displayState = a.displayCtrl.State()
|
||
}
|
||
// Dann: DisplayState: displayState,
|
||
```
|
||
|
||
- [ ] **Schritt 6: Gesamtkompilierung**
|
||
|
||
```bash
|
||
cd player/agent && go build ./...
|
||
```
|
||
|
||
Erwartet: keine Ausgabe, Exit 0.
|
||
|
||
- [ ] **Schritt 7: Tests ausführen**
|
||
|
||
```bash
|
||
cd player/agent && go test ./...
|
||
```
|
||
|
||
Erwartet: alle Tests grün.
|
||
|
||
- [ ] **Schritt 8: Committen**
|
||
|
||
```bash
|
||
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:
|
||
```bash
|
||
xset -dpms
|
||
```
|
||
|
||
Nachher:
|
||
```bash
|
||
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):
|
||
```bash
|
||
# Bildschirmschoner und Energiesparen konfigurieren
|
||
xset s off
|
||
xset s noblank
|
||
xset +dpms
|
||
xset dpms 0 0 0
|
||
```
|
||
|
||
- [ ] **Schritt 2: Committen**
|
||
|
||
```bash
|
||
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)**
|
||
|
||
```bash
|
||
ansible-playbook -i ansible/inventory.yml ansible/signage.yml --tags kiosk
|
||
```
|
||
|
||
Danach auf dem Pi prüfen:
|
||
```bash
|
||
xset q | grep DPMS
|
||
```
|
||
Erwartet: `DPMS is Enabled`
|
||
|
||
---
|
||
|
||
## Manuelle Verifikation nach Fertigstellung
|
||
|
||
1. Backend starten, Agent auf dem Pi starten
|
||
2. Display-Befehl senden:
|
||
```bash
|
||
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`):
|
||
```bash
|
||
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;`
|