Compare commits
20 commits
ba08220ec5
...
79fcc20b79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79fcc20b79 | ||
|
|
96135266f1 | ||
|
|
c91e49dc57 | ||
|
|
a3255a0ced | ||
|
|
1047572157 | ||
|
|
bcc50635e5 | ||
|
|
4ef16048ad | ||
|
|
f985a99ea1 | ||
|
|
fbcda1e2b8 | ||
|
|
0d51d951a2 | ||
|
|
c359757e31 | ||
|
|
a833220ca6 | ||
|
|
01942aa3f3 | ||
|
|
52bc1fbd6f | ||
|
|
052cf199ae | ||
|
|
4fab5fe28a | ||
|
|
1c11aa9877 | ||
|
|
b463aeeae1 | ||
|
|
30325dc1b6 | ||
|
|
37a56903f2 |
24 changed files with 1774 additions and 29 deletions
|
|
@ -17,3 +17,5 @@ Aktuell vorhanden:
|
||||||
|
|
||||||
- `inventory.example.yml`
|
- `inventory.example.yml`
|
||||||
- `site.yml` als Platzhalter-Playbook
|
- `site.yml` als Platzhalter-Playbook
|
||||||
|
|
||||||
|
Die Rolle `signage_display` setzt `BlockThirdPartyCookies: false` global fuer Chromium, damit eingebettete WebUntis-Seiten auf den Playern korrekt laufen.
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,12 @@
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
- name: Deploy Chromium kiosk policy (disables translate prompt and sets language)
|
- name: Deploy Chromium kiosk policy (disables translate prompt and sets language)
|
||||||
ansible.builtin.copy:
|
ansible.builtin.template:
|
||||||
|
src: morz-kiosk.json.j2
|
||||||
dest: /etc/chromium/policies/managed/morz-kiosk.json
|
dest: /etc/chromium/policies/managed/morz-kiosk.json
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
content: |
|
|
||||||
{
|
|
||||||
"TranslateEnabled": false,
|
|
||||||
"SpellcheckEnabled": false,
|
|
||||||
"DefaultNotificationsSetting": 2,
|
|
||||||
"DefaultGeolocationSetting": 2
|
|
||||||
}
|
|
||||||
become: true
|
become: true
|
||||||
notify: Restart morz-kiosk
|
notify: Restart morz-kiosk
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
# Bildschirmschoner und Energiesparen deaktivieren
|
# Bildschirmschoner und Energiesparen deaktivieren
|
||||||
xset s off
|
xset s off
|
||||||
xset s noblank
|
xset s noblank
|
||||||
xset -dpms
|
xset +dpms
|
||||||
|
xset dpms 0 0 0 # Timeouts deaktivieren — nur Backend schaltet das Display
|
||||||
|
|
||||||
# Mauscursor ausblenden
|
# Mauscursor ausblenden
|
||||||
unclutter -idle 1 -root &
|
unclutter -idle 1 -root &
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"TranslateEnabled": false,
|
||||||
|
"SpellcheckEnabled": false,
|
||||||
|
"DefaultNotificationsSetting": 2,
|
||||||
|
"DefaultGeolocationSetting": 2,
|
||||||
|
"BlockThirdPartyCookies": false
|
||||||
|
}
|
||||||
915
docs/superpowers/plans/2026-03-26-display-steuerung-schritt1.md
Normal file
915
docs/superpowers/plans/2026-03-26-display-steuerung-schritt1.md
Normal file
|
|
@ -0,0 +1,915 @@
|
||||||
|
# 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;`
|
||||||
252
docs/superpowers/plans/2026-03-26-reorder-validation.md
Normal file
252
docs/superpowers/plans/2026-03-26-reorder-validation.md
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# Reorder-Validierung 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:** `PlaylistStore.Reorder` soll mit `ErrReorderMismatch` abbrechen, wenn die übergebene ID-Liste nicht vollständig oder nicht korrekt ist; beide Handler geben dann 400 zurück.
|
||||||
|
|
||||||
|
**Architecture:** Sentinel-Fehler im store-Package; Vollständigkeitsprüfung per COUNT + RowsAffected-Check im Store; beide HTTP-Handler unterscheiden Validierungsfehler von DB-Fehlern via `errors.Is`.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, pgx/v5, net/http
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Sentinel-Fehler + Validierung in `store.go`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/backend/internal/store/store.go:4-10` (imports)
|
||||||
|
- Modify: `server/backend/internal/store/store.go:533-550` (Reorder)
|
||||||
|
|
||||||
|
- [ ] **Schritt 1: `errors` zu den Imports hinzufügen**
|
||||||
|
|
||||||
|
Datei: `server/backend/internal/store/store.go`, Zeilen 4–10.
|
||||||
|
|
||||||
|
Vorher:
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nachher:
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Schritt 2: Sentinel-Variable nach den Imports einfügen**
|
||||||
|
|
||||||
|
Direkt nach dem Import-Block (vor der ersten Typdefinition) einfügen:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ErrReorderMismatch wird von Reorder zurückgegeben, wenn die übergebene
|
||||||
|
// ID-Liste nicht mit den tatsächlichen Items der Playlist übereinstimmt.
|
||||||
|
var ErrReorderMismatch = errors.New("reorder: item list does not match playlist")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Schritt 3: `Reorder`-Funktion ersetzen**
|
||||||
|
|
||||||
|
Datei: `server/backend/internal/store/store.go`, die gesamte `Reorder`-Funktion (aktuell Zeilen 533–550) ersetzen durch:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Reorder sets order_index for each item ID in the given slice order.
|
||||||
|
// Returns ErrReorderMismatch if the number of provided IDs does not match
|
||||||
|
// the number of items in the playlist, or if any ID does not belong to it.
|
||||||
|
func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`select count(*) from playlist_items where playlist_id=$1`, playlistID,
|
||||||
|
).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != len(itemIDs) {
|
||||||
|
return fmt.Errorf("%w: got %d ids, playlist has %d items",
|
||||||
|
ErrReorderMismatch, len(itemIDs), count)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, id := range itemIDs {
|
||||||
|
tag, err := tx.Exec(ctx,
|
||||||
|
`update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`,
|
||||||
|
i, id, playlistID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() != 1 {
|
||||||
|
return fmt.Errorf("%w: id %s not found in playlist %s",
|
||||||
|
ErrReorderMismatch, id, playlistID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Schritt 4: Kompilieren**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server/backend && go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: keine Ausgabe, Exit 0.
|
||||||
|
|
||||||
|
- [ ] **Schritt 5: Committen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/backend/internal/store/store.go
|
||||||
|
git commit -m "fix(store): Reorder validiert Vollständigkeit und RowsAffected"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `HandleReorderUI` gibt 400 bei Mismatch zurück
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/backend/internal/httpapi/manage/ui.go:1-20` (imports)
|
||||||
|
- Modify: `server/backend/internal/httpapi/manage/ui.go:743-746` (Fehlerbehandlung)
|
||||||
|
|
||||||
|
- [ ] **Schritt 1: `errors` zu den Imports hinzufügen**
|
||||||
|
|
||||||
|
Datei: `server/backend/internal/httpapi/manage/ui.go`. Im import-Block `"errors"` ergänzen (alphabetisch vor `"encoding/json"`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Schritt 2: Fehlerbehandlung in `HandleReorderUI` ersetzen**
|
||||||
|
|
||||||
|
Datei: `server/backend/internal/httpapi/manage/ui.go`, Zeilen 743–746. Den Block:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := playlists.Reorder(r.Context(), playlist.ID, ids); err != nil {
|
||||||
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ersetzen durch:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := playlists.Reorder(r.Context(), playlist.ID, ids); err != nil {
|
||||||
|
if errors.Is(err, store.ErrReorderMismatch) {
|
||||||
|
http.Error(w, "item list mismatch", http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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/ui.go
|
||||||
|
git commit -m "fix(manage): HandleReorderUI gibt 400 bei Mismatch zurück"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `HandleReorder` gibt 400 bei Mismatch zurück
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/backend/internal/httpapi/manage/playlist.go:1-14` (imports)
|
||||||
|
- Modify: `server/backend/internal/httpapi/manage/playlist.go:244-247` (Fehlerbehandlung)
|
||||||
|
|
||||||
|
- [ ] **Schritt 1: `errors` zu den Imports hinzufügen**
|
||||||
|
|
||||||
|
Datei: `server/backend/internal/httpapi/manage/playlist.go`. Im import-Block `"errors"` ergänzen:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Schritt 2: Fehlerbehandlung in `HandleReorder` ersetzen**
|
||||||
|
|
||||||
|
Datei: `server/backend/internal/httpapi/manage/playlist.go`, Zeilen 244–247. Den Block:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := playlists.Reorder(r.Context(), playlistID, ids); err != nil {
|
||||||
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ersetzen durch:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := playlists.Reorder(r.Context(), playlistID, ids); err != nil {
|
||||||
|
if errors.Is(err, store.ErrReorderMismatch) {
|
||||||
|
http.Error(w, "item list mismatch", http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Schritt 3: Kompilieren und Tests ausführen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server/backend && go build ./... && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: keine Ausgabe bei build, alle Tests grün.
|
||||||
|
|
||||||
|
- [ ] **Schritt 4: Committen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/backend/internal/httpapi/manage/playlist.go
|
||||||
|
git commit -m "fix(manage): HandleReorder gibt 400 bei Mismatch zurück"
|
||||||
|
```
|
||||||
|
|
@ -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?
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Design: Reorder-Validierung im PlaylistStore
|
||||||
|
|
||||||
|
**Datum:** 2026-03-26
|
||||||
|
**Scope:** `store.go` (Reorder), `manage/ui.go` (HandleReorderUI), `manage/playlist.go` (HandleReorder)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`PlaylistStore.Reorder` akzeptiert beliebige ID-Listen ohne Prüfung:
|
||||||
|
|
||||||
|
- Unbekannte oder fremde IDs werden stillschweigend ignoriert (0 Rows affected, kein Fehler)
|
||||||
|
- Eine Teilliste korrumpiert die Sortierung: nicht geschickte Items behalten ihren alten `order_index`
|
||||||
|
- Beide Fehlerszenarien sind für den Client unsichtbar — der Response ist trotzdem 204
|
||||||
|
|
||||||
|
## Lösung
|
||||||
|
|
||||||
|
### 1. Sentinel-Fehler in `store.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
var ErrReorderMismatch = errors.New("reorder: item list does not match playlist")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Vollständigkeitsprüfung in `Reorder`
|
||||||
|
|
||||||
|
Vor den Updates: `COUNT(*)` der Items in der Playlist innerhalb der Transaktion.
|
||||||
|
Wenn `len(itemIDs) != count` → `ErrReorderMismatch` zurückgeben.
|
||||||
|
|
||||||
|
### 3. RowsAffected-Check pro Update
|
||||||
|
|
||||||
|
Nach jedem `tx.Exec`: wenn `RowsAffected() != 1` → `ErrReorderMismatch` zurückgeben.
|
||||||
|
(Fängt fremde IDs, die die `AND playlist_id=`-Bedingung passiert hätten.)
|
||||||
|
|
||||||
|
### 4. Handler-Anpassung (400 statt 500)
|
||||||
|
|
||||||
|
Beide Handler prüfen `errors.Is(err, store.ErrReorderMismatch)`:
|
||||||
|
- Match → `http.StatusBadRequest` (400)
|
||||||
|
- Sonst → `http.StatusInternalServerError` (500)
|
||||||
|
|
||||||
|
## Verhalten nach Fix
|
||||||
|
|
||||||
|
| Szenario | Vorher | Nachher |
|
||||||
|
|---|---|---|
|
||||||
|
| Vollständige, korrekte Liste | 204 ✓ | 204 ✓ |
|
||||||
|
| Teilliste | 204 (stille Korruption) | 400 → UI reloaded |
|
||||||
|
| Fremde/unbekannte ID | 204 (still ignoriert) | 400 → UI reloaded |
|
||||||
|
| DB-Fehler | 500 | 500 |
|
||||||
|
|
||||||
|
## Nicht im Scope
|
||||||
|
|
||||||
|
- Änderung des Drag&Drop-Frontends (schickt bereits die vollständige Liste)
|
||||||
|
- Neue Tests (werden im Implementierungsplan ergänzt falls vorhanden)
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/config"
|
"git.az-it.net/az/morz-infoboard/player/agent/internal/config"
|
||||||
|
"git.az-it.net/az/morz-infoboard/player/agent/internal/displaycontroller"
|
||||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/mqttheartbeat"
|
"git.az-it.net/az/morz-infoboard/player/agent/internal/mqttheartbeat"
|
||||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/mqttsubscriber"
|
"git.az-it.net/az/morz-infoboard/player/agent/internal/mqttsubscriber"
|
||||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/playerserver"
|
"git.az-it.net/az/morz-infoboard/player/agent/internal/playerserver"
|
||||||
|
|
@ -78,6 +79,8 @@ type App struct {
|
||||||
|
|
||||||
// screenshotFn is kept so that applyMQTTConfig can pass it to the new subscriber.
|
// screenshotFn is kept so that applyMQTTConfig can pass it to the new subscriber.
|
||||||
screenshotFn func()
|
screenshotFn func()
|
||||||
|
|
||||||
|
displayCtrl *displaycontroller.Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
// mqttCloser is implemented by mqttsubscriber.Subscriber.
|
// mqttCloser is implemented by mqttsubscriber.Subscriber.
|
||||||
|
|
@ -91,6 +94,7 @@ type statusSender interface {
|
||||||
|
|
||||||
type mqttSender interface {
|
type mqttSender interface {
|
||||||
SendHeartbeat(status, connectivity string, ts time.Time) error
|
SendHeartbeat(status, connectivity string, ts time.Time) error
|
||||||
|
SendDisplayState(screenSlug, state string) error
|
||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,6 +225,21 @@ func (a *App) Run(ctx context.Context) error {
|
||||||
go ss.TakeAndSendOnce(ctx)
|
go ss.TakeAndSendOnce(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Subscribe to playlist-changed and screenshot-request MQTT notifications (optional; fallback = polling).
|
// Subscribe to playlist-changed and screenshot-request MQTT notifications (optional; fallback = polling).
|
||||||
sub := mqttsubscriber.New(
|
sub := mqttsubscriber.New(
|
||||||
a.Config.MQTTBroker,
|
a.Config.MQTTBroker,
|
||||||
|
|
@ -236,6 +255,10 @@ func (a *App) Run(ctx context.Context) error {
|
||||||
a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID)
|
a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID)
|
||||||
},
|
},
|
||||||
a.screenshotFn,
|
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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
a.mqttMu.Lock()
|
a.mqttMu.Lock()
|
||||||
a.mqttSub = sub
|
a.mqttSub = sub
|
||||||
|
|
@ -434,6 +457,11 @@ func (a *App) reportStatus(ctx context.Context) {
|
||||||
payloadConnectivity = ConnectivityOnline
|
payloadConnectivity = ConnectivityOnline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var displayState string
|
||||||
|
if a.displayCtrl != nil {
|
||||||
|
displayState = a.displayCtrl.State()
|
||||||
|
}
|
||||||
|
|
||||||
mqttCfg, err := a.reporter.Send(ctx, statusreporter.Snapshot{
|
mqttCfg, err := a.reporter.Send(ctx, statusreporter.Snapshot{
|
||||||
Status: string(snapshot.Status),
|
Status: string(snapshot.Status),
|
||||||
ServerConnectivity: string(payloadConnectivity),
|
ServerConnectivity: string(payloadConnectivity),
|
||||||
|
|
@ -443,6 +471,7 @@ func (a *App) reportStatus(ctx context.Context) {
|
||||||
HeartbeatEverySeconds: snapshot.HeartbeatEvery,
|
HeartbeatEverySeconds: snapshot.HeartbeatEvery,
|
||||||
StartedAt: snapshot.StartedAt,
|
StartedAt: snapshot.StartedAt,
|
||||||
LastHeartbeatAt: snapshot.LastHeartbeatAt,
|
LastHeartbeatAt: snapshot.LastHeartbeatAt,
|
||||||
|
DisplayState: displayState,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
|
|
@ -510,7 +539,12 @@ func (a *App) applyMQTTConfig(broker, username, password string) {
|
||||||
}
|
}
|
||||||
a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID)
|
a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID)
|
||||||
}
|
}
|
||||||
sub := mqttsubscriber.New(broker, a.Config.ScreenID, username, password, playlistChangedFn, a.screenshotFn)
|
sub := mqttsubscriber.New(broker, a.Config.ScreenID, username, password, playlistChangedFn, 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)
|
||||||
|
},
|
||||||
|
)
|
||||||
a.mqttSub = sub
|
a.mqttSub = sub
|
||||||
if sub != nil {
|
if sub != nil {
|
||||||
a.logger.Printf("event=mqtt_subscriber_restarted screen_id=%s broker=%s", a.Config.ScreenID, broker)
|
a.logger.Printf("event=mqtt_subscriber_restarted screen_id=%s broker=%s", a.Config.ScreenID, broker)
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,8 @@ func (r *recordingMQTTSender) SendHeartbeat(status, connectivity string, _ time.
|
||||||
return r.err
|
return r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *recordingMQTTSender) SendDisplayState(_, _ string) error { return nil }
|
||||||
|
|
||||||
func (r *recordingMQTTSender) Close() {}
|
func (r *recordingMQTTSender) Close() {}
|
||||||
|
|
||||||
func TestEmitHeartbeatCallsMQTTPublisher(t *testing.T) {
|
func TestEmitHeartbeatCallsMQTTPublisher(t *testing.T) {
|
||||||
|
|
|
||||||
74
player/agent/internal/displaycontroller/controller.go
Normal file
74
player/agent/internal/displaycontroller/controller.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// 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
|
||||||
|
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".
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
player/agent/internal/displaycontroller/controller_test.go
Normal file
18
player/agent/internal/displaycontroller/controller_test.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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)
|
||||||
|
c.Execute("invalid_action")
|
||||||
|
if got := c.State(); got != "unknown" {
|
||||||
|
t.Fatalf("state after unknown action = %q, want %q", got, "unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -78,6 +78,26 @@ func (p *Publisher) SendHeartbeat(status, connectivity string, ts time.Time) err
|
||||||
return token.Error()
|
return token.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
// Close disconnects from the broker gracefully.
|
// Close disconnects from the broker gracefully.
|
||||||
func (p *Publisher) Close() {
|
func (p *Publisher) Close() {
|
||||||
p.client.Disconnect(250)
|
p.client.Disconnect(250)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
package mqttsubscriber
|
package mqttsubscriber
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
|
@ -20,6 +21,8 @@ const (
|
||||||
|
|
||||||
// screenshotRequestTopicTemplate is the topic the backend publishes to for on-demand screenshots.
|
// screenshotRequestTopicTemplate is the topic the backend publishes to for on-demand screenshots.
|
||||||
screenshotRequestTopicTemplate = "signage/screen/%s/screenshot-request"
|
screenshotRequestTopicTemplate = "signage/screen/%s/screenshot-request"
|
||||||
|
|
||||||
|
commandTopicTemplate = "signage/screen/%s/command"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PlaylistChangedFunc is called when a debounced playlist-changed notification arrives.
|
// PlaylistChangedFunc is called when a debounced playlist-changed notification arrives.
|
||||||
|
|
@ -28,6 +31,9 @@ type PlaylistChangedFunc func()
|
||||||
// ScreenshotRequestFunc is called when a screenshot-request notification arrives.
|
// ScreenshotRequestFunc is called when a screenshot-request notification arrives.
|
||||||
type ScreenshotRequestFunc func()
|
type ScreenshotRequestFunc func()
|
||||||
|
|
||||||
|
// DisplayCommandFunc wird aufgerufen wenn ein display-command eintrifft.
|
||||||
|
type DisplayCommandFunc func(action string)
|
||||||
|
|
||||||
// Subscriber listens for playlist-changed notifications on MQTT and calls the
|
// Subscriber listens for playlist-changed notifications on MQTT and calls the
|
||||||
// provided callback at most once per debounceDuration.
|
// provided callback at most once per debounceDuration.
|
||||||
type Subscriber struct {
|
type Subscriber struct {
|
||||||
|
|
@ -35,10 +41,12 @@ type Subscriber struct {
|
||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
onChange PlaylistChangedFunc
|
onChange PlaylistChangedFunc
|
||||||
onScreenshotRequest ScreenshotRequestFunc
|
onScreenshotRequest ScreenshotRequestFunc
|
||||||
|
onDisplayCommand DisplayCommandFunc
|
||||||
|
|
||||||
// timerC serializes timer resets through a dedicated goroutine.
|
// timerC serializes timer resets through a dedicated goroutine.
|
||||||
resetC chan struct{}
|
resetC chan struct{}
|
||||||
screenshotReqC chan struct{}
|
screenshotReqC chan struct{}
|
||||||
|
commandC chan string
|
||||||
stopC chan struct{}
|
stopC chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,13 +60,19 @@ func ScreenshotRequestTopic(screenSlug string) string {
|
||||||
return "signage/screen/" + screenSlug + "/screenshot-request"
|
return "signage/screen/" + screenSlug + "/screenshot-request"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandTopic returns the MQTT topic for display commands for a given screenSlug.
|
||||||
|
func CommandTopic(screenSlug string) string {
|
||||||
|
return "signage/screen/" + screenSlug + "/command"
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a Subscriber that connects to broker and subscribes to the
|
// New creates a Subscriber that connects to broker and subscribes to the
|
||||||
// playlist-changed topic for screenSlug. onChange is called (in its own
|
// playlist-changed topic for screenSlug. onChange is called (in its own
|
||||||
// goroutine) at most once per debounceDuration.
|
// goroutine) at most once per debounceDuration.
|
||||||
// onScreenshotRequest is called (in its own goroutine) when a screenshot-request message arrives.
|
// onScreenshotRequest is called (in its own goroutine) when a screenshot-request message arrives.
|
||||||
|
// onDisplayCommand is called (in its own goroutine) when a display command arrives.
|
||||||
//
|
//
|
||||||
// Returns nil when broker is empty — callers must handle nil.
|
// Returns nil when broker is empty — callers must handle nil.
|
||||||
func New(broker, screenSlug, username, password string, onChange PlaylistChangedFunc, onScreenshotRequest ScreenshotRequestFunc) *Subscriber {
|
func New(broker, screenSlug, username, password string, onChange PlaylistChangedFunc, onScreenshotRequest ScreenshotRequestFunc, onDisplayCommand DisplayCommandFunc) *Subscriber {
|
||||||
if broker == "" {
|
if broker == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -66,8 +80,10 @@ func New(broker, screenSlug, username, password string, onChange PlaylistChanged
|
||||||
s := &Subscriber{
|
s := &Subscriber{
|
||||||
onChange: onChange,
|
onChange: onChange,
|
||||||
onScreenshotRequest: onScreenshotRequest,
|
onScreenshotRequest: onScreenshotRequest,
|
||||||
|
onDisplayCommand: onDisplayCommand,
|
||||||
resetC: make(chan struct{}, 16),
|
resetC: make(chan struct{}, 16),
|
||||||
screenshotReqC: make(chan struct{}, 16),
|
screenshotReqC: make(chan struct{}, 16),
|
||||||
|
commandC: make(chan string, 8),
|
||||||
stopC: make(chan struct{}),
|
stopC: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +111,19 @@ func New(broker, screenSlug, username, password string, onChange PlaylistChanged
|
||||||
default: // channel full — request already pending
|
default: // channel full — request already pending
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
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:
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if username != "" {
|
if username != "" {
|
||||||
|
|
@ -131,6 +160,10 @@ func (s *Subscriber) run() {
|
||||||
if s.onScreenshotRequest != nil {
|
if s.onScreenshotRequest != nil {
|
||||||
go s.onScreenshotRequest()
|
go s.onScreenshotRequest()
|
||||||
}
|
}
|
||||||
|
case action := <-s.commandC:
|
||||||
|
if s.onDisplayCommand != nil {
|
||||||
|
go s.onDisplayCommand(action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type Snapshot struct {
|
||||||
HeartbeatEverySeconds int
|
HeartbeatEverySeconds int
|
||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
LastHeartbeatAt time.Time
|
LastHeartbeatAt time.Time
|
||||||
|
DisplayState string
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusPayload struct {
|
type statusPayload struct {
|
||||||
|
|
@ -31,6 +32,7 @@ type statusPayload struct {
|
||||||
HeartbeatEverySeconds int `json:"heartbeat_every_seconds"`
|
HeartbeatEverySeconds int `json:"heartbeat_every_seconds"`
|
||||||
StartedAt string `json:"started_at,omitempty"`
|
StartedAt string `json:"started_at,omitempty"`
|
||||||
LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"`
|
LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"`
|
||||||
|
DisplayState string `json:"display_state,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MQTTConfig holds the MQTT broker configuration returned by the server in the
|
// MQTTConfig holds the MQTT broker configuration returned by the server in the
|
||||||
|
|
@ -138,5 +140,9 @@ func buildPayload(snapshot Snapshot, now time.Time) statusPayload {
|
||||||
payload.LastHeartbeatAt = snapshot.LastHeartbeatAt.Format(time.RFC3339)
|
payload.LastHeartbeatAt = snapshot.LastHeartbeatAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if snapshot.DisplayState != "" {
|
||||||
|
payload.DisplayState = snapshot.DisplayState
|
||||||
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- 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()
|
||||||
|
);
|
||||||
44
server/backend/internal/httpapi/manage/display.go
Normal file
44
server/backend/internal/httpapi/manage/display.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package manage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleDisplayCommand nimmt {"state":"on"} oder {"state":"off"} entgegen und
|
||||||
|
// schickt den entsprechenden MQTT-Befehl an den Agent.
|
||||||
|
func HandleDisplayCommand(notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
screenSlug := r.PathValue("screenSlug")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@ package manage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -242,7 +244,12 @@ func HandleReorder(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifi
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := playlists.Reorder(r.Context(), playlistID, ids); err != nil {
|
if err := playlists.Reorder(r.Context(), playlistID, ids); err != nil {
|
||||||
|
if errors.Is(err, store.ErrReorderMismatch) {
|
||||||
|
http.Error(w, "item list mismatch", http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
slog.Error("reorder failed", "err", err)
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package manage
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -741,7 +742,12 @@ func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := playlists.Reorder(r.Context(), playlist.ID, ids); err != nil {
|
if err := playlists.Reorder(r.Context(), playlist.ID, ids); err != nil {
|
||||||
|
if errors.Is(err, store.ErrReorderMismatch) {
|
||||||
|
http.Error(w, "item list mismatch", http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
slog.Error("reorder failed", "err", err)
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
notifier.NotifyChanged(screenSlug)
|
notifier.NotifyChanged(screenSlug)
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
storePackage "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type screenStatusSummary struct {
|
type screenStatusSummary struct {
|
||||||
|
|
@ -47,6 +50,7 @@ type playerStatusRequest struct {
|
||||||
HeartbeatEverySeconds int `json:"heartbeat_every_seconds"`
|
HeartbeatEverySeconds int `json:"heartbeat_every_seconds"`
|
||||||
StartedAt string `json:"started_at"`
|
StartedAt string `json:"started_at"`
|
||||||
LastHeartbeatAt string `json:"last_heartbeat_at"`
|
LastHeartbeatAt string `json:"last_heartbeat_at"`
|
||||||
|
DisplayState string `json:"display_state,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// playerStatusMQTTConfig is the MQTT configuration returned to agents in the
|
// playerStatusMQTTConfig is the MQTT configuration returned to agents in the
|
||||||
|
|
@ -63,7 +67,7 @@ type playerStatusResponse struct {
|
||||||
MQTT *playerStatusMQTTConfig `json:"mqtt,omitempty"`
|
MQTT *playerStatusMQTTConfig `json:"mqtt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePlayerStatus(store playerStatusStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc {
|
func handlePlayerStatus(store playerStatusStore, screenStore *storePackage.ScreenStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var request playerStatusRequest
|
var request playerStatusRequest
|
||||||
if err := decodeJSON(r, &request); err != nil {
|
if err := decodeJSON(r, &request); err != nil {
|
||||||
|
|
@ -130,6 +134,14 @@ func handlePlayerStatus(store playerStatusStore, mqttBroker, mqttUsername, mqttP
|
||||||
LastHeartbeatAt: request.LastHeartbeatAt,
|
LastHeartbeatAt: request.LastHeartbeatAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if request.DisplayState != "" && screenStore != nil {
|
||||||
|
if screen, err := screenStore.GetBySlug(r.Context(), request.ScreenID); err == nil {
|
||||||
|
if err := screenStore.UpsertDisplayState(r.Context(), screen.ID, request.DisplayState); err != nil {
|
||||||
|
slog.Error("upsert display state", "screen_id", screen.ID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp := playerStatusResponse{Status: "accepted"}
|
resp := playerStatusResponse{Status: "accepted"}
|
||||||
if mqttBroker != "" {
|
if mqttBroker != "" {
|
||||||
resp.MQTT = &playerStatusMQTTConfig{
|
resp.MQTT = &playerStatusMQTTConfig{
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ func TestHandlePlayerStatusAccepted(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(store, "", "", "")(w, req)
|
handlePlayerStatus(store, nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusOK; got != want {
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -66,7 +66,7 @@ func TestHandlePlayerStatusRejectsInvalidJSON(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString("{"))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString("{"))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -83,7 +83,7 @@ func TestHandlePlayerStatusRejectsMissingScreenID(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -102,7 +102,7 @@ func TestHandlePlayerStatusStoresNormalizedScreenID(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(store, "", "", "")(w, req)
|
handlePlayerStatus(store, nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusOK; got != want {
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -122,7 +122,7 @@ func TestHandlePlayerStatusRejectsMissingTimestamp(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -138,7 +138,7 @@ func TestHandlePlayerStatusRejectsMissingStatus(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -155,7 +155,7 @@ func TestHandlePlayerStatusRejectsUnknownStatus(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -173,7 +173,7 @@ func TestHandlePlayerStatusRejectsUnknownServerConnectivity(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -191,7 +191,7 @@ func TestHandlePlayerStatusRejectsNonPositiveHeartbeatInterval(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -208,7 +208,7 @@ func TestHandlePlayerStatusRejectsMalformedTimestamps(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -226,7 +226,7 @@ func TestHandlePlayerStatusRejectsMalformedStartedAt(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -244,7 +244,7 @@ func TestHandlePlayerStatusRejectsMalformedLastHeartbeatAt(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore(), nil, "", "", "")(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ func NewRouter(deps RouterDeps) http.Handler {
|
||||||
mux.HandleFunc("GET /api/v1/meta", handleMeta)
|
mux.HandleFunc("GET /api/v1/meta", handleMeta)
|
||||||
|
|
||||||
// ── Player status (existing) ──────────────────────────────────────────
|
// ── Player status (existing) ──────────────────────────────────────────
|
||||||
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore, deps.Config.MQTTBroker, deps.Config.MQTTUsername, deps.Config.MQTTPassword))
|
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore, deps.ScreenStore, deps.Config.MQTTBroker, deps.Config.MQTTUsername, deps.Config.MQTTPassword))
|
||||||
mux.HandleFunc("POST /api/v1/player/screenshot", handlePlayerScreenshot(deps.ScreenshotStore))
|
mux.HandleFunc("POST /api/v1/player/screenshot", handlePlayerScreenshot(deps.ScreenshotStore))
|
||||||
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(deps.StatusStore))
|
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(deps.StatusStore))
|
||||||
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(deps.StatusStore))
|
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(deps.StatusStore))
|
||||||
|
|
@ -183,6 +183,10 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||||
mux.Handle("GET /api/v1/screens/{screenId}/screenshot",
|
mux.Handle("GET /api/v1/screens/{screenId}/screenshot",
|
||||||
authOnly(http.HandlerFunc(handleGetScreenshot(d.ScreenshotStore))))
|
authOnly(http.HandlerFunc(handleGetScreenshot(d.ScreenshotStore))))
|
||||||
|
|
||||||
|
// ── Display control ───────────────────────────────────────────────────
|
||||||
|
mux.Handle("POST /api/v1/screens/{screenSlug}/display",
|
||||||
|
authScreen(http.HandlerFunc(manage.HandleDisplayCommand(notifier))))
|
||||||
|
|
||||||
// ── JSON API — screens ────────────────────────────────────────────────
|
// ── JSON API — screens ────────────────────────────────────────────────
|
||||||
// Self-registration: no auth (player calls this on startup).
|
// Self-registration: no auth (player calls this on startup).
|
||||||
mux.HandleFunc("POST /api/v1/screens/register",
|
mux.HandleFunc("POST /api/v1/screens/register",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
package mqttnotifier
|
package mqttnotifier
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -95,6 +96,27 @@ func (n *Notifier) RequestScreenshot(screenSlug string) {
|
||||||
token.WaitTimeout(3 * time.Second)
|
token.WaitTimeout(3 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
b, err := json.Marshal(struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}{Action: action})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal display command: %w", err)
|
||||||
|
}
|
||||||
|
token := n.client.Publish(topic, 1, true, b)
|
||||||
|
if !token.WaitTimeout(5 * time.Second) {
|
||||||
|
return fmt.Errorf("mqtt publish display command: timeout")
|
||||||
|
}
|
||||||
|
return token.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func (n *Notifier) publish(screenSlug string) {
|
func (n *Notifier) publish(screenSlug string) {
|
||||||
topic := Topic(screenSlug)
|
topic := Topic(screenSlug)
|
||||||
payload := []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().UnixMilli()))
|
payload := []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().UnixMilli()))
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,17 @@ package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrReorderMismatch wird von Reorder zurückgegeben, wenn die übergebene
|
||||||
|
// ID-Liste nicht mit den tatsächlichen Items der Playlist übereinstimmt.
|
||||||
|
var ErrReorderMismatch = errors.New("reorder: item list does not match playlist")
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Domain types
|
// Domain types
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -29,6 +34,12 @@ type Screen struct {
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScreenStatus struct {
|
||||||
|
ScreenID string `json:"screen_id"`
|
||||||
|
DisplayState string `json:"display_state"`
|
||||||
|
ReportedAt time.Time `json:"reported_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type MediaAsset struct {
|
type MediaAsset struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
|
|
@ -305,6 +316,18 @@ func scanScreen(row interface {
|
||||||
return &sc, nil
|
return &sc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// MediaStore
|
// MediaStore
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -531,20 +554,46 @@ func (s *PlaylistStore) ScreenSlugByItemID(ctx context.Context, itemID string) (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reorder sets order_index for each item ID in the given slice order.
|
// Reorder sets order_index for each item ID in the given slice order.
|
||||||
|
// Returns ErrReorderMismatch if the number of provided IDs does not match
|
||||||
|
// the number of items in the playlist, or if any ID does not belong to it.
|
||||||
func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
|
func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
|
||||||
|
seen := make(map[string]struct{}, len(itemIDs))
|
||||||
|
for _, id := range itemIDs {
|
||||||
|
if _, dup := seen[id]; dup {
|
||||||
|
return fmt.Errorf("%w: duplicate id %s", ErrReorderMismatch, id)
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx) //nolint:errcheck
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`select count(*) from playlist_items where playlist_id=$1`, playlistID,
|
||||||
|
).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != len(itemIDs) {
|
||||||
|
return fmt.Errorf("%w: got %d ids, playlist has %d items",
|
||||||
|
ErrReorderMismatch, len(itemIDs), count)
|
||||||
|
}
|
||||||
|
|
||||||
for i, id := range itemIDs {
|
for i, id := range itemIDs {
|
||||||
if _, err := tx.Exec(ctx,
|
tag, err := tx.Exec(ctx,
|
||||||
`update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`,
|
`update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`,
|
||||||
i, id, playlistID,
|
i, id, playlistID,
|
||||||
); err != nil {
|
)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if tag.RowsAffected() != 1 {
|
||||||
|
return fmt.Errorf("%w: id %s not found in playlist %s",
|
||||||
|
ErrReorderMismatch, id, playlistID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue