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 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
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
|
## 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
|
||||||
|
|
|
||||||
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_host: 10.0.0.200
|
||||||
ansible_user: morz
|
ansible_user: morz
|
||||||
screen_id: info01-dev
|
screen_id: info10
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@ all:
|
||||||
signage_players:
|
signage_players:
|
||||||
hosts:
|
hosts:
|
||||||
info10:
|
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_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: ""
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
roles:
|
roles:
|
||||||
- signage_player
|
- signage_player
|
||||||
|
- signage_display
|
||||||
|
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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