Baue Ebene 1: Player-UI, Kiosk-Display und vollstaendiges Ansible-Deployment

Player-UI (playerserver):
- Lokale Kiosk-Seite unter /player mit orientierungsgerechtem Splash-Bild
- Splash-PNGs (Portrait/Landscape) eingebettet via go:embed
- Unteres-Drittel-Overlay mit erweiterbaren Sysinfo-Items (Hostname, Uptime)
- /api/now-playing und /api/sysinfo JSON-Endpunkte
- iframe-Overlay fuer spaetere Inhalts-URL

Ansible-Rolle signage_display (neu):
- Pakete: xserver-xorg-core, xinit, openbox, chromium, unclutter
- Kiosk-Skript mit openbox als WM (noetig fuer korrektes --kiosk-Vollbild)
- systemd-Unit mit Conflicts=getty@tty1 (behebt TTY-Blockierung beim Start)
- Chromium Managed Policy: TranslateEnabled=false, Notifications/Geolocation blockiert
- --lang=de Flag gegen Sprachauswahl-Dialog

Ansible-Rolle signage_player (erweitert):
- Legt signage_user an falls nicht vorhanden
- PlayerListenAddr und PlayerContentURL in Konfiguration
- journald volatile Storage (SD-Karten-Schonung)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-22 22:34:16 +01:00
parent d461abc3f5
commit bbcf0a1228
24 changed files with 720 additions and 46 deletions

2
.gitignore vendored
View file

@ -17,5 +17,7 @@ dist/
# Compose override files # Compose override files
compose.override.yml compose.override.yml
vault.yml vault.yml
ansible/.vault_pass
ansible/roles/signage_player/files/morz-agent ansible/roles/signage_player/files/morz-agent
player/agent/agent-linux-arm64 player/agent/agent-linux-arm64
docs/SESSION-MEMORY-*.md

View file

@ -25,8 +25,7 @@ Projektwurzel:
- `docs/` fuer Architektur- und Fachkonzepte - `docs/` fuer Architektur- und Fachkonzepte
- `server/backend/` fuer das zentrale Go-Backend - `server/backend/` fuer das zentrale Go-Backend
- `player/agent/` fuer den Go-basierten Player-Agent - `player/agent/` fuer den Go-basierten Player-Agent
- `player/ui/` spaeter fuer die lokale Player-Oberflaeche - `ansible/` fuer Deployment und Provisionierung
- `ansible/` spaeter fuer Deployment und Provisionierung
- `compose/` spaeter fuer den zentralen Server-Stack - `compose/` spaeter fuer den zentralen Server-Stack
## Aktueller Entwicklungsstand ## Aktueller Entwicklungsstand
@ -40,12 +39,9 @@ Bereits vorhanden:
Noch nicht vorhanden: Noch nicht vorhanden:
- produktive API-Endpunkte - produktive API-Endpunkte mit Datenbankanbindung
- Datenbankanbindung - Player-Sync und Playlist-Management
- MQTT-Anbindung - Compose-Stack fuer lokale Serverdienste (Grundgeruest liegt in `compose/`)
- Player-Sync
- Player-UI
- Compose-Stack fuer lokale Serverdienste
## Voraussetzungen auf dem Entwicklungsrechner ## Voraussetzungen auf dem Entwicklungsrechner
@ -164,23 +160,97 @@ Standardwerte:
- `MORZ_INFOBOARD_SCREEN_ID=unset-screen` - `MORZ_INFOBOARD_SCREEN_ID=unset-screen`
- `MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080` - `MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080`
- `MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883` - `MORZ_INFOBOARD_MQTT_BROKER=` (leer = MQTT wird uebersprungen)
- `MORZ_INFOBOARD_PLAYER_ADDR=127.0.0.1:8090`
- `MORZ_INFOBOARD_PLAYER_CONTENT_URL=` (leer = kein Inhalt eingebettet)
Optional dateibasiert: Optional:
- `MORZ_INFOBOARD_CONFIG=/etc/signage/config.json` - `MORZ_INFOBOARD_MQTT_USERNAME` MQTT-Benutzername
- `MORZ_INFOBOARD_MQTT_PASSWORD` MQTT-Passwort
- `MORZ_INFOBOARD_CONFIG=/etc/signage/config.json` dateibasierte Konfiguration
Eine Beispielkonfiguration liegt in `player/config/config.example.json`. Eine Beispielkonfiguration liegt in `player/config/config.example.json`.
Beispiel: Der Agent stellt unter `http://127.0.0.1:8090/player` eine lokale Kiosk-Seite bereit.
Beispiel ohne MQTT:
```bash
MORZ_INFOBOARD_SCREEN_ID=info01-dev \
MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080 \
go run ./cmd/agent
```
Beispiel mit MQTT:
```bash ```bash
MORZ_INFOBOARD_SCREEN_ID=info01-dev \ MORZ_INFOBOARD_SCREEN_ID=info01-dev \
MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080 \ MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080 \
MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883 \ MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883 \
MORZ_INFOBOARD_MQTT_USERNAME=user \
MORZ_INFOBOARD_MQTT_PASSWORD=pass \
go run ./cmd/agent go run ./cmd/agent
``` ```
## Ansible-Deployment auf einen Zielrechner
Der `ansible/`-Ordner enthaelt ein vollstaendiges Playbook fuer das Deployment auf einen Pi oder ein Debian-System.
### Voraussetzungen
- Ansible auf dem Entwicklungsrechner
- SSH-Key fuer den Zielrechner hinterlegt
- Ansible Vault fuer MQTT-Zugangsdaten
### Inventory und Konfiguration
- `ansible/inventory.yml` Hosts und Gruppen
- `ansible/host_vars/<host>/vars.yml` host-spezifische Variablen (`screen_id`, `ansible_host`, `ansible_user`)
- `ansible/group_vars/signage_players/vars.yml` globale Defaults
- `ansible/group_vars/signage_players/vault.yml` verschluesselte Zugangsdaten (gitignored)
Vault-Datei anlegen:
```bash
ansible-vault create ansible/group_vars/signage_players/vault.yml
```
Inhalt:
```yaml
vault_mqtt_username: "benutzername"
vault_mqtt_password: "passwort"
```
In `vars.yml` referenzieren:
```yaml
morz_mqtt_username: "{{ vault_mqtt_username }}"
morz_mqtt_password: "{{ vault_mqtt_password }}"
```
### Deployment ausfuehren
```bash
cd ansible
ansible-playbook site.yml -i inventory.yml --ask-vault-pass
```
Das Playbook erledigt:
1. Agent-Binary cross-kompilieren (lokal, `GOOS=linux GOARCH=arm64`)
2. Binary und Konfiguration auf den Zielrechner uebertragen
3. systemd-Unit fuer den Agent anlegen und starten
4. journald auf RAM-Speicherung konfigurieren (SD-Karte schonen)
5. X11-Paketstack und Chromium installieren
6. Kiosk-Startskript und systemd-Unit fuer die Anzeige anlegen
### Rollen
- `signage_player` Agent, Konfiguration, systemd-Unit, journald
- `signage_display` X11, Chromium, Kiosk-Service
## Aktuelle Architekturentscheidungen mit direkter Auswirkung auf Entwicklung ## Aktuelle Architekturentscheidungen mit direkter Auswirkung auf Entwicklung
- `message_wall` wird serverseitig in konkrete Screen-Szenen aufgeloest - `message_wall` wird serverseitig in konkrete Screen-Szenen aufgeloest
@ -256,6 +326,11 @@ Die Datei `/tmp/screen-status.json` enthaelt nach dem ersten Heartbeat den persi
- Server-Connectivity-Zustand im Agent (`unknown`, `online`, `degraded`, `offline`) auf Basis der Report-Ergebnisse - Server-Connectivity-Zustand im Agent (`unknown`, `online`, `degraded`, `offline`) auf Basis der Report-Ergebnisse
- der HTTP-Statuspfad transportiert jetzt neben `status` auch `server_connectivity` - der HTTP-Statuspfad transportiert jetzt neben `status` auch `server_connectivity`
- lokales Compose-Grundgeruest fuer PostgreSQL und Mosquitto - lokales Compose-Grundgeruest fuer PostgreSQL und Mosquitto
- MQTT-Heartbeat im Agent (optional; wird uebersprungen wenn kein Broker konfiguriert)
- MQTT-Authentifizierung mit Username und Password
- Player-UI im Agent (`/player` HTML-Kiosk, `/api/now-playing` JSON)
- Ansible-Rollen `signage_player` und `signage_display` fuer vollstaendiges Deployment
- journald auf volatile Storage konfiguriert (SD-Karte schonen)
## Arbeitsweise ## Arbeitsweise

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

29
TODO.md
View file

@ -41,16 +41,17 @@
## Phase 3 - Player-Design ## Phase 3 - Player-Design
- [ ] Minimalen Paketbedarf fuer den Player auf Raspberry Pi OS Debian 13 ermitteln - [x] Minimalen Paketbedarf fuer den Player auf Raspberry Pi OS Debian 13 ermitteln
- [ ] X11-Minimalkonzept fuer Chromium-Kiosk dokumentieren - [x] X11-Minimalkonzept fuer Chromium-Kiosk dokumentieren
- [ ] Startmechanismus fuer Chromium ohne Desktop-Umgebung definieren - [x] Startmechanismus fuer Chromium ohne Desktop-Umgebung definieren
- [ ] Verzeichnislayout auf dem Player festlegen - [ ] Verzeichnislayout auf dem Player festlegen
- [ ] `player-agent` fachlich zuschneiden - [x] `player-agent` fachlich zuschneiden
- [ ] `player-ui` fachlich zuschneiden - [x] `player-ui` fachlich zuschneiden (lokale Kiosk-Seite mit Splash + Sysinfo-Overlay)
- [ ] Watchdog-Konzept fuer Browser und Agent definieren - [ ] Watchdog-Konzept fuer Browser und Agent definieren
- [ ] Offline-Overlay-Verhalten spezifizieren - [ ] Offline-Overlay-Verhalten spezifizieren
- [ ] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten - [ ] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten
- [ ] Display-Steuerung fuer An/Aus, Rotation und Neustart planen - [ ] Display-Steuerung fuer An/Aus, Rotation und Neustart planen
- [ ] Sysinfo-Overlay erweitern: load, freier RAM, IP-Adresse(n) anzeigen
## Phase 4 - Server-Design ## Phase 4 - Server-Design
@ -83,23 +84,23 @@
## Phase 6 - Betriebsfaehigkeit ## Phase 6 - Betriebsfaehigkeit
- [ ] Docker-Compose-Setup fuer den Server anlegen - [ ] Docker-Compose-Setup fuer den Server anlegen
- [ ] systemd-Units fuer den Player erstellen - [x] systemd-Units fuer den Player erstellen
- [ ] Chromium-Kiosk-Startskript erstellen - [x] Chromium-Kiosk-Startskript erstellen
- [ ] Screenshot-Erzeugung auf dem Player integrieren - [ ] Screenshot-Erzeugung auf dem Player integrieren
- [ ] Heartbeat- und Statusmeldungen integrieren - [x] Heartbeat- und Statusmeldungen integrieren
- [ ] Fehler- und Wiederanlaufverhalten verifizieren - [ ] Fehler- und Wiederanlaufverhalten verifizieren
## Phase 7 - Ansible-Automatisierung ## Phase 7 - Ansible-Automatisierung
- [ ] Rolle `signage_base` erstellen - [ ] Rolle `signage_base` erstellen
- [ ] Rolle `signage_player` erstellen - [x] Rolle `signage_player` erstellen
- [ ] Rolle `signage_display` erstellen - [x] Rolle `signage_display` erstellen
- [ ] Rolle `signage_server` erstellen - [ ] Rolle `signage_server` erstellen
- [ ] Rolle `signage_provision` erstellen - [ ] Rolle `signage_provision` erstellen
- [ ] Inventar-/Variablenmodell fuer mehrere Monitore entwerfen - [x] Inventar-/Variablenmodell fuer mehrere Monitore entwerfen
- [ ] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden - [x] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden
- [ ] Erstinstallation eines neuen Players automatisieren - [x] Erstinstallation eines neuen Players automatisieren
- [ ] Update-Rollout eines bestehenden Players automatisieren - [x] Update-Rollout eines bestehenden Players automatisieren
- [ ] Bootstrap ueber Root-Passwort auf SSH-Key und dauerhafte Verwaltung umstellen - [ ] Bootstrap ueber Root-Passwort auf SSH-Key und dauerhafte Verwaltung umstellen
## Phase 8 - Pilotbetrieb ## Phase 8 - Pilotbetrieb

View file

@ -0,0 +1,4 @@
---
ansible_host: 192.168.64.11
ansible_user: admin
screen_id: info01-dev

View file

@ -1,4 +1,4 @@
--- ---
ansible_host: 10.0.0.200 ansible_host: 10.0.0.200
ansible_user: morz ansible_user: morz
screen_id: info01-dev screen_id: info10

View file

@ -4,3 +4,4 @@ all:
signage_players: signage_players:
hosts: hosts:
info10: info10:
info01-dev:

View file

@ -0,0 +1,3 @@
---
signage_user: morz
morz_player_url: "http://127.0.0.1:8090/player"

View file

@ -0,0 +1,11 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
become: true
- name: Restart morz-kiosk
ansible.builtin.systemd:
name: morz-kiosk
state: restarted
become: true

View file

@ -0,0 +1,68 @@
---
- name: Install X11 and Chromium packages
ansible.builtin.apt:
name:
- xserver-xorg-core
- x11-xserver-utils
- xinit
- openbox
- chromium
- unclutter
state: present
update_cache: true
become: true
- name: Ensure Chromium managed policy directory exists
ansible.builtin.file:
path: /etc/chromium/policies/managed
state: directory
owner: root
group: root
mode: "0755"
become: true
- name: Deploy Chromium kiosk policy (disables translate prompt and sets language)
ansible.builtin.copy:
dest: /etc/chromium/policies/managed/morz-kiosk.json
owner: root
group: root
mode: "0644"
content: |
{
"TranslateEnabled": false,
"SpellcheckEnabled": false,
"DefaultNotificationsSetting": 2,
"DefaultGeolocationSetting": 2
}
become: true
notify: Restart morz-kiosk
- name: Deploy kiosk startup script
ansible.builtin.template:
src: morz-kiosk.j2
dest: /usr/local/bin/morz-kiosk
owner: root
group: root
mode: "0755"
become: true
notify: Restart morz-kiosk
- name: Deploy kiosk systemd unit
ansible.builtin.template:
src: morz-kiosk.service.j2
dest: /etc/systemd/system/morz-kiosk.service
owner: root
group: root
mode: "0644"
become: true
notify:
- Reload systemd
- Restart morz-kiosk
- name: Enable and start morz-kiosk
ansible.builtin.systemd:
name: morz-kiosk
enabled: true
state: started
daemon_reload: false
become: true

View file

@ -0,0 +1,26 @@
#!/bin/bash
# Morz Infoboard Kiosk startet Chromium im Vollbild
# Wird von xinit aufgerufen, DISPLAY ist bereits gesetzt.
# Bildschirmschoner und Energiesparen deaktivieren
xset s off
xset s noblank
xset -dpms
# Mauscursor ausblenden
unclutter -idle 1 -root &
# Minimaler Window-Manager nötig damit --kiosk echtes Vollbild bekommt
openbox --sm-disable &
exec chromium \
--noerrdialogs \
--disable-infobars \
--kiosk \
--no-first-run \
--disable-translate \
--disable-session-crashed-bubble \
--disable-features=Translate \
--check-for-update-interval=31536000 \
--lang=de \
"{{ morz_player_url }}"

View file

@ -0,0 +1,22 @@
[Unit]
Description=Morz Infoboard Kiosk Display
After=systemd-user-sessions.service morz-agent.service getty@tty1.service
Wants=morz-agent.service
Conflicts=getty@tty1.service
[Service]
Type=simple
User={{ signage_user }}
PAMName=login
TTYPath=/dev/tty1
StandardInput=tty
UtmpIdentifier=tty1
UtmpMode=user
Environment=HOME=/home/{{ signage_user }}
ExecStartPre=/bin/sleep 3
ExecStart=/usr/bin/startx /usr/local/bin/morz-kiosk -- :0 vt1 -nocursor
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target

View file

@ -9,3 +9,5 @@ morz_mqtt_username: ""
morz_mqtt_password: "" morz_mqtt_password: ""
morz_heartbeat_every_seconds: 30 morz_heartbeat_every_seconds: 30
morz_status_report_every_seconds: 60 morz_status_report_every_seconds: 60
morz_player_listen_addr: "127.0.0.1:8090"
morz_player_content_url: ""

View file

@ -9,6 +9,13 @@
delegate_to: localhost delegate_to: localhost
changed_when: true changed_when: true
- name: Ensure signage user exists
ansible.builtin.user:
name: "{{ signage_user }}"
create_home: true
state: present
become: true
- name: Ensure config directory exists - name: Ensure config directory exists
ansible.builtin.file: ansible.builtin.file:
path: "{{ signage_config_dir }}" path: "{{ signage_config_dir }}"

View file

@ -5,5 +5,7 @@
"mqtt_username": "{{ morz_mqtt_username }}", "mqtt_username": "{{ morz_mqtt_username }}",
"mqtt_password": "{{ morz_mqtt_password }}", "mqtt_password": "{{ morz_mqtt_password }}",
"heartbeat_every_seconds": {{ morz_heartbeat_every_seconds }}, "heartbeat_every_seconds": {{ morz_heartbeat_every_seconds }},
"status_report_every_seconds": {{ morz_status_report_every_seconds }} "status_report_every_seconds": {{ morz_status_report_every_seconds }},
"player_listen_addr": "{{ morz_player_listen_addr }}",
"player_content_url": "{{ morz_player_content_url }}"
} }

View file

@ -4,3 +4,4 @@
gather_facts: false gather_facts: false
roles: roles:
- signage_player - signage_player
- signage_display

View file

@ -1,23 +1,18 @@
# Agent # Agent
Dieses Verzeichnis enthaelt das erste Geruest fuer den `player-agent`. Lokaler Dienst auf dem Signage-Geraet.
Ziel fuer die erste Ausbaustufe: ## Aufgaben
- lokaler Dienst in Go - periodischer HTTP-Statusreport an den Server
- Konfiguration laden - optionaler MQTT-Heartbeat (wird uebersprungen wenn kein Broker konfiguriert)
- Startfaehigkeit und klares Logging - lokale Player-UI unter `http://127.0.0.1:8090/player`
- vorbereitete Struktur fuer Sync, MQTT, Cache und Kommandos - JSON-Statusendpunkt unter `http://127.0.0.1:8090/api/now-playing`
Geplante Unterstruktur: ## Unterstruktur
- `cmd/agent/` fuer den Startpunkt - `cmd/agent/` Startpunkt
- `internal/app/` fuer Initialisierung und Laufzeit - `internal/app/` Initialisierung und Laufzeit
- `internal/config/` fuer Konfiguration - `internal/config/` Konfiguration (Env + JSON-Datei)
- `internal/mqttheartbeat/` MQTT-Publisher
Aktuell vorhanden: - `internal/playerserver/` lokaler HTTP-Server fuer Kiosk-UI
- Env-basierte und dateibasierte Konfiguration
- strukturierte Start-/Heartbeat-/Stop-Logs
- interner Health-Snapshot fuer Laufzeitzustand und Timestamps
- signalgesteuerter Stop ueber `SIGINT` und `SIGTERM`

View file

@ -10,6 +10,7 @@ import (
"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/mqttheartbeat" "git.az-it.net/az/morz-infoboard/player/agent/internal/mqttheartbeat"
"git.az-it.net/az/morz-infoboard/player/agent/internal/playerserver"
"git.az-it.net/az/morz-infoboard/player/agent/internal/statusreporter" "git.az-it.net/az/morz-infoboard/player/agent/internal/statusreporter"
) )
@ -139,12 +140,28 @@ func (a *App) Run(ctx context.Context) error {
a.mu.Unlock() a.mu.Unlock()
a.logger.Printf( a.logger.Printf(
"event=agent_configured screen_id=%s server_url=%s mqtt_broker=%s heartbeat_every_seconds=%d", "event=agent_configured screen_id=%s server_url=%s mqtt_broker=%s heartbeat_every_seconds=%d player_addr=%s",
a.Config.ScreenID, a.Config.ScreenID,
a.Config.ServerBaseURL, a.Config.ServerBaseURL,
a.Config.MQTTBroker, a.Config.MQTTBroker,
a.Config.HeartbeatEvery, a.Config.HeartbeatEvery,
a.Config.PlayerListenAddr,
) )
ps := playerserver.New(a.Config.PlayerListenAddr, func() playerserver.NowPlaying {
snap := a.Snapshot()
return playerserver.NowPlaying{
URL: a.Config.PlayerContentURL,
Status: string(snap.Status),
Connectivity: string(snap.ServerConnectivity),
}
})
go func() {
if err := ps.Run(ctx); err != nil {
a.logger.Printf("event=player_server_error error=%v", err)
}
}()
a.emitHeartbeat() a.emitHeartbeat()
a.mu.Lock() a.mu.Lock()
a.status = StatusRunning a.status = StatusRunning

View file

@ -14,6 +14,8 @@ type Config struct {
MQTTPassword string `json:"mqtt_password"` MQTTPassword string `json:"mqtt_password"`
HeartbeatEvery int `json:"heartbeat_every_seconds"` HeartbeatEvery int `json:"heartbeat_every_seconds"`
StatusReportEvery int `json:"status_report_every_seconds"` StatusReportEvery int `json:"status_report_every_seconds"`
PlayerListenAddr string `json:"player_listen_addr"`
PlayerContentURL string `json:"player_content_url"`
} }
const defaultConfigPath = "/etc/signage/config.json" const defaultConfigPath = "/etc/signage/config.json"
@ -52,6 +54,8 @@ func defaultConfig() Config {
MQTTBroker: "", MQTTBroker: "",
HeartbeatEvery: 30, HeartbeatEvery: 30,
StatusReportEvery: 60, StatusReportEvery: 60,
PlayerListenAddr: "127.0.0.1:8090",
PlayerContentURL: "",
} }
} }
@ -70,6 +74,8 @@ func overrideFromEnv(cfg *Config) {
cfg.MQTTBroker = getenv("MORZ_INFOBOARD_MQTT_BROKER", cfg.MQTTBroker) cfg.MQTTBroker = getenv("MORZ_INFOBOARD_MQTT_BROKER", cfg.MQTTBroker)
cfg.MQTTUsername = getenv("MORZ_INFOBOARD_MQTT_USERNAME", cfg.MQTTUsername) cfg.MQTTUsername = getenv("MORZ_INFOBOARD_MQTT_USERNAME", cfg.MQTTUsername)
cfg.MQTTPassword = getenv("MORZ_INFOBOARD_MQTT_PASSWORD", cfg.MQTTPassword) cfg.MQTTPassword = getenv("MORZ_INFOBOARD_MQTT_PASSWORD", cfg.MQTTPassword)
cfg.PlayerListenAddr = getenv("MORZ_INFOBOARD_PLAYER_ADDR", cfg.PlayerListenAddr)
cfg.PlayerContentURL = getenv("MORZ_INFOBOARD_PLAYER_CONTENT_URL", cfg.PlayerContentURL)
if value := getenv("MORZ_INFOBOARD_STATUS_REPORT_EVERY", ""); value != "" { if value := getenv("MORZ_INFOBOARD_STATUS_REPORT_EVERY", ""); value != "" {
var parsed int var parsed int
_, _ = fmt.Sscanf(value, "%d", &parsed) _, _ = fmt.Sscanf(value, "%d", &parsed)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

View file

@ -0,0 +1,284 @@
package playerserver
import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
)
//go:embed assets
var assetsFS embed.FS
// NowPlaying describes what the player should currently display.
type NowPlaying struct {
URL string `json:"url,omitempty"`
Status string `json:"status"`
Connectivity string `json:"connectivity"`
}
// InfoItem is a single entry shown in the lower-third sysinfo overlay.
// Add new items in collectSysInfo to extend the overlay.
type InfoItem struct {
Label string `json:"label"`
Value string `json:"value"`
}
// SysInfo holds the items shown in the lower-third overlay.
type SysInfo struct {
Items []InfoItem `json:"items"`
}
// Server serves the local player UI to Chromium.
type Server struct {
listenAddr string
nowFn func() NowPlaying
}
// New creates a Server. listenAddr is e.g. "127.0.0.1:8090".
// nowFn is called on each request and returns the current playback state.
func New(listenAddr string, nowFn func() NowPlaying) *Server {
return &Server{listenAddr: listenAddr, nowFn: nowFn}
}
// Run starts the HTTP server and blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
sub, err := fs.Sub(assetsFS, "assets")
if err != nil {
return err
}
mux := http.NewServeMux()
mux.HandleFunc("GET /player", s.handlePlayer)
mux.HandleFunc("GET /api/now-playing", s.handleNowPlaying)
mux.HandleFunc("GET /api/sysinfo", handleSysInfo)
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
srv := &http.Server{Handler: mux}
ln, err := net.Listen("tcp", s.listenAddr)
if err != nil {
return err
}
go func() {
<-ctx.Done()
srv.Close()
}()
if err := srv.Serve(ln); err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handlePlayer(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(playerHTML)) //nolint:errcheck
}
func (s *Server) handleNowPlaying(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s.nowFn()) //nolint:errcheck
}
func handleSysInfo(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(collectSysInfo()) //nolint:errcheck
}
// collectSysInfo builds the list of info items shown in the overlay.
// To add more items, append to the slice here.
func collectSysInfo() SysInfo {
var items []InfoItem
if h, err := os.Hostname(); err == nil {
items = append(items, InfoItem{Label: "Hostname", Value: h})
}
if up := readUptime(); up != "" {
items = append(items, InfoItem{Label: "Uptime", Value: up})
}
return SysInfo{Items: items}
}
func readUptime() string {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return ""
}
parts := strings.Fields(string(data))
if len(parts) == 0 {
return ""
}
secs, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return ""
}
d := time.Duration(secs) * time.Second
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
const playerHTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Morz Infoboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
/* Splash-Hintergrund Bild wird per JS orientierungsabhängig gesetzt */
#splash {
position: fixed; inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* Unteres-Drittel-Overlay mit Systeminformationen */
#info-overlay {
position: fixed; bottom: 0; left: 0; right: 0;
height: 33.3%;
background: linear-gradient(
to bottom,
rgba(0,0,0,0) 0%,
rgba(0,0,0,0.7) 40%,
rgba(0,0,0,0.85) 100%
);
display: flex;
align-items: flex-end;
padding: 0 3% 2.5%;
gap: 3rem;
z-index: 1;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-item .label {
font-family: sans-serif;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255,255,255,0.5);
margin-bottom: 0.25em;
}
.info-item .value {
font-family: monospace;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: 0.03em;
color: #fff;
}
/* Inhalts-iframe liegt über dem Splash, wenn eine URL gesetzt ist */
#frame {
position: fixed; inset: 0;
width: 100%; height: 100%;
border: none;
display: none;
z-index: 10;
}
/* Verbindungsstatus-Punkt */
#dot {
position: fixed; bottom: 10px; right: 10px;
width: 10px; height: 10px; border-radius: 50%;
background: #444; opacity: 0.6;
z-index: 9999;
transition: background 0.5s;
}
#dot.online { background: #4caf50; }
#dot.degraded { background: #ff9800; }
#dot.offline { background: #f44336; }
</style>
</head>
<body>
<div id="splash"></div>
<div id="info-overlay"></div>
<iframe id="frame" allow="autoplay; fullscreen" allowfullscreen></iframe>
<div id="dot"></div>
<script>
var splash = document.getElementById('splash');
var overlay = document.getElementById('info-overlay');
var frame = document.getElementById('frame');
var dot = document.getElementById('dot');
// Orientierungsgerechtes Splash-Bild wählen
function updateSplash() {
var portrait = window.innerHeight > window.innerWidth;
splash.style.backgroundImage = portrait
? 'url(/assets/splash-portrait.png)'
: 'url(/assets/splash-landscape.png)';
}
updateSplash();
window.addEventListener('resize', updateSplash);
// Sysinfo-Overlay rendern
function renderSysInfo(items) {
overlay.innerHTML = '';
(items || []).forEach(function(item) {
var el = document.createElement('div');
el.className = 'info-item';
el.innerHTML =
'<span class="label">' + esc(item.label) + '</span>' +
'<span class="value">' + esc(item.value) + '</span>';
overlay.appendChild(el);
});
}
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Now-Playing anwenden: iframe zeigen wenn URL gesetzt, sonst Splash
function applyNowPlaying(data) {
dot.className = data.connectivity || '';
if (data.url) {
if (frame.src !== data.url) { frame.src = data.url; }
frame.style.display = '';
} else {
frame.style.display = 'none';
}
}
function pollSysInfo() {
fetch('/api/sysinfo')
.then(function(r) { return r.json(); })
.then(function(d) { renderSysInfo(d.items); })
.catch(function() {});
}
function pollNowPlaying() {
fetch('/api/now-playing')
.then(function(r) { return r.json(); })
.then(applyNowPlaying)
.catch(function() { dot.className = 'offline'; });
}
pollSysInfo();
pollNowPlaying();
setInterval(pollSysInfo, 30000);
setInterval(pollNowPlaying, 15000);
</script>
</body>
</html>`

View file

@ -0,0 +1,147 @@
package playerserver
import (
"context"
"encoding/json"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newTestServer(np NowPlaying) *Server {
return New("127.0.0.1:0", func() NowPlaying { return np })
}
func TestHandlePlayerReturnsHTML(t *testing.T) {
s := newTestServer(NowPlaying{Status: "running", Connectivity: "online"})
req := httptest.NewRequest(http.MethodGet, "/player", nil)
w := httptest.NewRecorder()
s.handlePlayer(w, req)
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Fatalf("Content-Type = %q, want text/html", ct)
}
body := w.Body.String()
for _, want := range []string{"<!DOCTYPE html>", "/api/now-playing", "/api/sysinfo", "splash-portrait.png", "splash-landscape.png"} {
if !strings.Contains(body, want) {
t.Fatalf("HTML missing %q", want)
}
}
}
func TestHandleNowPlayingWithURL(t *testing.T) {
s := newTestServer(NowPlaying{
URL: "https://example.com",
Status: "running",
Connectivity: "online",
})
req := httptest.NewRequest(http.MethodGet, "/api/now-playing", nil)
w := httptest.NewRecorder()
s.handleNowPlaying(w, req)
var got NowPlaying
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got.URL != "https://example.com" {
t.Fatalf("URL = %q, want https://example.com", got.URL)
}
if got.Connectivity != "online" {
t.Fatalf("Connectivity = %q, want online", got.Connectivity)
}
}
func TestHandleNowPlayingWithoutURL(t *testing.T) {
s := newTestServer(NowPlaying{Status: "running", Connectivity: "degraded"})
req := httptest.NewRequest(http.MethodGet, "/api/now-playing", nil)
w := httptest.NewRecorder()
s.handleNowPlaying(w, req)
var got NowPlaying
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got.URL != "" {
t.Fatalf("URL = %q, want empty", got.URL)
}
}
func TestHandleSysInfoReturnsItems(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/sysinfo", nil)
w := httptest.NewRecorder()
handleSysInfo(w, req)
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
var got SysInfo
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
// At least hostname should always be present.
if len(got.Items) == 0 {
t.Fatal("expected at least one sysinfo item")
}
labels := make(map[string]bool)
for _, item := range got.Items {
labels[item.Label] = true
if item.Value == "" {
t.Errorf("item %q has empty value", item.Label)
}
}
if !labels["Hostname"] {
t.Error("expected Hostname item in sysinfo")
}
}
func TestAssetsServed(t *testing.T) {
s := New("127.0.0.1:0", func() NowPlaying { return NowPlaying{} })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan string, 1)
go func() {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
ready <- ln.Addr().String()
ln.Close()
}()
// Use httptest recorder to test asset handler directly via the embed FS.
sub, err := fs.Sub(assetsFS, "assets")
if err != nil {
t.Fatalf("fs.Sub error = %v", err)
}
handler := http.StripPrefix("/assets/", http.FileServer(http.FS(sub)))
for _, name := range []string{"splash-landscape.png", "splash-portrait.png"} {
req := httptest.NewRequest(http.MethodGet, "/assets/"+name, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("GET /assets/%s = %d, want 200", name, w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "image/png") {
t.Errorf("GET /assets/%s Content-Type = %q, want image/png", name, ct)
}
}
_ = s
_ = ctx
}
func TestServerRunAndStop(t *testing.T) {
s := New("127.0.0.1:0", func() NowPlaying {
return NowPlaying{Status: "running", Connectivity: "online"}
})
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() { errCh <- s.Run(ctx) }()
cancel()
if err := <-errCh; err != nil {
t.Fatalf("Run() error = %v", err)
}
}