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:
parent
d461abc3f5
commit
bbcf0a1228
24 changed files with 720 additions and 46 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -17,5 +17,7 @@ dist/
|
|||
# Compose override files
|
||||
compose.override.yml
|
||||
vault.yml
|
||||
ansible/.vault_pass
|
||||
ansible/roles/signage_player/files/morz-agent
|
||||
player/agent/agent-linux-arm64
|
||||
docs/SESSION-MEMORY-*.md
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ Projektwurzel:
|
|||
- `docs/` fuer Architektur- und Fachkonzepte
|
||||
- `server/backend/` fuer das zentrale Go-Backend
|
||||
- `player/agent/` fuer den Go-basierten Player-Agent
|
||||
- `player/ui/` spaeter fuer die lokale Player-Oberflaeche
|
||||
- `ansible/` spaeter fuer Deployment und Provisionierung
|
||||
- `ansible/` fuer Deployment und Provisionierung
|
||||
- `compose/` spaeter fuer den zentralen Server-Stack
|
||||
|
||||
## Aktueller Entwicklungsstand
|
||||
|
|
@ -40,12 +39,9 @@ Bereits vorhanden:
|
|||
|
||||
Noch nicht vorhanden:
|
||||
|
||||
- produktive API-Endpunkte
|
||||
- Datenbankanbindung
|
||||
- MQTT-Anbindung
|
||||
- Player-Sync
|
||||
- Player-UI
|
||||
- Compose-Stack fuer lokale Serverdienste
|
||||
- produktive API-Endpunkte mit Datenbankanbindung
|
||||
- Player-Sync und Playlist-Management
|
||||
- Compose-Stack fuer lokale Serverdienste (Grundgeruest liegt in `compose/`)
|
||||
|
||||
## Voraussetzungen auf dem Entwicklungsrechner
|
||||
|
||||
|
|
@ -164,23 +160,97 @@ Standardwerte:
|
|||
|
||||
- `MORZ_INFOBOARD_SCREEN_ID=unset-screen`
|
||||
- `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`.
|
||||
|
||||
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
|
||||
MORZ_INFOBOARD_SCREEN_ID=info01-dev \
|
||||
MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080 \
|
||||
MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883 \
|
||||
MORZ_INFOBOARD_MQTT_USERNAME=user \
|
||||
MORZ_INFOBOARD_MQTT_PASSWORD=pass \
|
||||
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
|
||||
|
||||
- `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
|
||||
- der HTTP-Statuspfad transportiert jetzt neben `status` auch `server_connectivity`
|
||||
- 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
|
||||
|
||||
|
|
|
|||
BIN
MORZ-InfoScreen-Landscape.png
Normal file
BIN
MORZ-InfoScreen-Landscape.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
MORZ-InfoScreen-Portrait.png
Normal file
BIN
MORZ-InfoScreen-Portrait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 646 KiB |
29
TODO.md
29
TODO.md
|
|
@ -41,16 +41,17 @@
|
|||
|
||||
## Phase 3 - Player-Design
|
||||
|
||||
- [ ] Minimalen Paketbedarf fuer den Player auf Raspberry Pi OS Debian 13 ermitteln
|
||||
- [ ] X11-Minimalkonzept fuer Chromium-Kiosk dokumentieren
|
||||
- [ ] Startmechanismus fuer Chromium ohne Desktop-Umgebung definieren
|
||||
- [x] Minimalen Paketbedarf fuer den Player auf Raspberry Pi OS Debian 13 ermitteln
|
||||
- [x] X11-Minimalkonzept fuer Chromium-Kiosk dokumentieren
|
||||
- [x] Startmechanismus fuer Chromium ohne Desktop-Umgebung definieren
|
||||
- [ ] Verzeichnislayout auf dem Player festlegen
|
||||
- [ ] `player-agent` fachlich zuschneiden
|
||||
- [ ] `player-ui` fachlich zuschneiden
|
||||
- [x] `player-agent` fachlich zuschneiden
|
||||
- [x] `player-ui` fachlich zuschneiden (lokale Kiosk-Seite mit Splash + Sysinfo-Overlay)
|
||||
- [ ] Watchdog-Konzept fuer Browser und Agent definieren
|
||||
- [ ] Offline-Overlay-Verhalten spezifizieren
|
||||
- [ ] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten
|
||||
- [ ] Display-Steuerung fuer An/Aus, Rotation und Neustart planen
|
||||
- [ ] Sysinfo-Overlay erweitern: load, freier RAM, IP-Adresse(n) anzeigen
|
||||
|
||||
## Phase 4 - Server-Design
|
||||
|
||||
|
|
@ -83,23 +84,23 @@
|
|||
## Phase 6 - Betriebsfaehigkeit
|
||||
|
||||
- [ ] Docker-Compose-Setup fuer den Server anlegen
|
||||
- [ ] systemd-Units fuer den Player erstellen
|
||||
- [ ] Chromium-Kiosk-Startskript erstellen
|
||||
- [x] systemd-Units fuer den Player erstellen
|
||||
- [x] Chromium-Kiosk-Startskript erstellen
|
||||
- [ ] Screenshot-Erzeugung auf dem Player integrieren
|
||||
- [ ] Heartbeat- und Statusmeldungen integrieren
|
||||
- [x] Heartbeat- und Statusmeldungen integrieren
|
||||
- [ ] Fehler- und Wiederanlaufverhalten verifizieren
|
||||
|
||||
## Phase 7 - Ansible-Automatisierung
|
||||
|
||||
- [ ] Rolle `signage_base` erstellen
|
||||
- [ ] Rolle `signage_player` erstellen
|
||||
- [ ] Rolle `signage_display` erstellen
|
||||
- [x] Rolle `signage_player` erstellen
|
||||
- [x] Rolle `signage_display` erstellen
|
||||
- [ ] Rolle `signage_server` erstellen
|
||||
- [ ] Rolle `signage_provision` erstellen
|
||||
- [ ] Inventar-/Variablenmodell fuer mehrere Monitore entwerfen
|
||||
- [ ] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden
|
||||
- [ ] Erstinstallation eines neuen Players automatisieren
|
||||
- [ ] Update-Rollout eines bestehenden Players automatisieren
|
||||
- [x] Inventar-/Variablenmodell fuer mehrere Monitore entwerfen
|
||||
- [x] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden
|
||||
- [x] Erstinstallation eines neuen Players automatisieren
|
||||
- [x] Update-Rollout eines bestehenden Players automatisieren
|
||||
- [ ] Bootstrap ueber Root-Passwort auf SSH-Key und dauerhafte Verwaltung umstellen
|
||||
|
||||
## Phase 8 - Pilotbetrieb
|
||||
|
|
|
|||
4
ansible/host_vars/info01-dev/vars.yml
Normal file
4
ansible/host_vars/info01-dev/vars.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
ansible_host: 192.168.64.11
|
||||
ansible_user: admin
|
||||
screen_id: info01-dev
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
---
|
||||
ansible_host: 10.0.0.200
|
||||
ansible_user: morz
|
||||
screen_id: info01-dev
|
||||
screen_id: info10
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ all:
|
|||
signage_players:
|
||||
hosts:
|
||||
info10:
|
||||
info01-dev:
|
||||
|
|
|
|||
3
ansible/roles/signage_display/defaults/main.yml
Normal file
3
ansible/roles/signage_display/defaults/main.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
signage_user: morz
|
||||
morz_player_url: "http://127.0.0.1:8090/player"
|
||||
11
ansible/roles/signage_display/handlers/main.yml
Normal file
11
ansible/roles/signage_display/handlers/main.yml
Normal 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
|
||||
68
ansible/roles/signage_display/tasks/main.yml
Normal file
68
ansible/roles/signage_display/tasks/main.yml
Normal 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
|
||||
26
ansible/roles/signage_display/templates/morz-kiosk.j2
Normal file
26
ansible/roles/signage_display/templates/morz-kiosk.j2
Normal 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 }}"
|
||||
|
|
@ -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
|
||||
|
|
@ -9,3 +9,5 @@ morz_mqtt_username: ""
|
|||
morz_mqtt_password: ""
|
||||
morz_heartbeat_every_seconds: 30
|
||||
morz_status_report_every_seconds: 60
|
||||
morz_player_listen_addr: "127.0.0.1:8090"
|
||||
morz_player_content_url: ""
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@
|
|||
delegate_to: localhost
|
||||
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
|
||||
ansible.builtin.file:
|
||||
path: "{{ signage_config_dir }}"
|
||||
|
|
|
|||
|
|
@ -5,5 +5,7 @@
|
|||
"mqtt_username": "{{ morz_mqtt_username }}",
|
||||
"mqtt_password": "{{ morz_mqtt_password }}",
|
||||
"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 }}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@
|
|||
gather_facts: false
|
||||
roles:
|
||||
- signage_player
|
||||
- signage_display
|
||||
|
|
|
|||
|
|
@ -1,23 +1,18 @@
|
|||
# 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
|
||||
- Konfiguration laden
|
||||
- Startfaehigkeit und klares Logging
|
||||
- vorbereitete Struktur fuer Sync, MQTT, Cache und Kommandos
|
||||
- periodischer HTTP-Statusreport an den Server
|
||||
- optionaler MQTT-Heartbeat (wird uebersprungen wenn kein Broker konfiguriert)
|
||||
- lokale Player-UI unter `http://127.0.0.1:8090/player`
|
||||
- JSON-Statusendpunkt unter `http://127.0.0.1:8090/api/now-playing`
|
||||
|
||||
Geplante Unterstruktur:
|
||||
## Unterstruktur
|
||||
|
||||
- `cmd/agent/` fuer den Startpunkt
|
||||
- `internal/app/` fuer Initialisierung und Laufzeit
|
||||
- `internal/config/` fuer Konfiguration
|
||||
|
||||
Aktuell vorhanden:
|
||||
|
||||
- Env-basierte und dateibasierte Konfiguration
|
||||
- strukturierte Start-/Heartbeat-/Stop-Logs
|
||||
- interner Health-Snapshot fuer Laufzeitzustand und Timestamps
|
||||
- signalgesteuerter Stop ueber `SIGINT` und `SIGTERM`
|
||||
- `cmd/agent/` – Startpunkt
|
||||
- `internal/app/` – Initialisierung und Laufzeit
|
||||
- `internal/config/` – Konfiguration (Env + JSON-Datei)
|
||||
- `internal/mqttheartbeat/` – MQTT-Publisher
|
||||
- `internal/playerserver/` – lokaler HTTP-Server fuer Kiosk-UI
|
||||
|
|
|
|||
|
|
@ -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/mqttheartbeat"
|
||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/playerserver"
|
||||
"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.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.ServerBaseURL,
|
||||
a.Config.MQTTBroker,
|
||||
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.mu.Lock()
|
||||
a.status = StatusRunning
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type Config struct {
|
|||
MQTTPassword string `json:"mqtt_password"`
|
||||
HeartbeatEvery int `json:"heartbeat_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"
|
||||
|
|
@ -52,6 +54,8 @@ func defaultConfig() Config {
|
|||
MQTTBroker: "",
|
||||
HeartbeatEvery: 30,
|
||||
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.MQTTUsername = getenv("MORZ_INFOBOARD_MQTT_USERNAME", cfg.MQTTUsername)
|
||||
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 != "" {
|
||||
var parsed int
|
||||
_, _ = fmt.Sscanf(value, "%d", &parsed)
|
||||
|
|
|
|||
BIN
player/agent/internal/playerserver/assets/splash-landscape.png
Normal file
BIN
player/agent/internal/playerserver/assets/splash-landscape.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
player/agent/internal/playerserver/assets/splash-portrait.png
Normal file
BIN
player/agent/internal/playerserver/assets/splash-portrait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 646 KiB |
284
player/agent/internal/playerserver/server.go
Normal file
284
player/agent/internal/playerserver/server.go
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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>`
|
||||
147
player/agent/internal/playerserver/server_test.go
Normal file
147
player/agent/internal/playerserver/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue