diff --git a/.gitignore b/.gitignore index 24f69ba..ee2fae9 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 761d981..fed470b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.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//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 diff --git a/MORZ-InfoScreen-Landscape.png b/MORZ-InfoScreen-Landscape.png new file mode 100644 index 0000000..a8c6e22 Binary files /dev/null and b/MORZ-InfoScreen-Landscape.png differ diff --git a/MORZ-InfoScreen-Portrait.png b/MORZ-InfoScreen-Portrait.png new file mode 100644 index 0000000..e8b37d9 Binary files /dev/null and b/MORZ-InfoScreen-Portrait.png differ diff --git a/TODO.md b/TODO.md index 31d56dc..37b25eb 100644 --- a/TODO.md +++ b/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 diff --git a/ansible/host_vars/info01-dev/vars.yml b/ansible/host_vars/info01-dev/vars.yml new file mode 100644 index 0000000..786adc9 --- /dev/null +++ b/ansible/host_vars/info01-dev/vars.yml @@ -0,0 +1,4 @@ +--- +ansible_host: 192.168.64.11 +ansible_user: admin +screen_id: info01-dev diff --git a/ansible/host_vars/info10/vars.yml b/ansible/host_vars/info10/vars.yml index 82a4a7c..2fd1ee6 100644 --- a/ansible/host_vars/info10/vars.yml +++ b/ansible/host_vars/info10/vars.yml @@ -1,4 +1,4 @@ --- ansible_host: 10.0.0.200 ansible_user: morz -screen_id: info01-dev +screen_id: info10 diff --git a/ansible/inventory.yml b/ansible/inventory.yml index 5ce26f4..d0d2cee 100644 --- a/ansible/inventory.yml +++ b/ansible/inventory.yml @@ -4,3 +4,4 @@ all: signage_players: hosts: info10: + info01-dev: diff --git a/ansible/roles/signage_display/defaults/main.yml b/ansible/roles/signage_display/defaults/main.yml new file mode 100644 index 0000000..94c0b27 --- /dev/null +++ b/ansible/roles/signage_display/defaults/main.yml @@ -0,0 +1,3 @@ +--- +signage_user: morz +morz_player_url: "http://127.0.0.1:8090/player" diff --git a/ansible/roles/signage_display/handlers/main.yml b/ansible/roles/signage_display/handlers/main.yml new file mode 100644 index 0000000..30130cb --- /dev/null +++ b/ansible/roles/signage_display/handlers/main.yml @@ -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 diff --git a/ansible/roles/signage_display/tasks/main.yml b/ansible/roles/signage_display/tasks/main.yml new file mode 100644 index 0000000..281ac69 --- /dev/null +++ b/ansible/roles/signage_display/tasks/main.yml @@ -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 diff --git a/ansible/roles/signage_display/templates/morz-kiosk.j2 b/ansible/roles/signage_display/templates/morz-kiosk.j2 new file mode 100644 index 0000000..631d58a --- /dev/null +++ b/ansible/roles/signage_display/templates/morz-kiosk.j2 @@ -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 }}" diff --git a/ansible/roles/signage_display/templates/morz-kiosk.service.j2 b/ansible/roles/signage_display/templates/morz-kiosk.service.j2 new file mode 100644 index 0000000..9ed4187 --- /dev/null +++ b/ansible/roles/signage_display/templates/morz-kiosk.service.j2 @@ -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 diff --git a/ansible/roles/signage_player/defaults/main.yml b/ansible/roles/signage_player/defaults/main.yml index c2c940f..8d0f3be 100644 --- a/ansible/roles/signage_player/defaults/main.yml +++ b/ansible/roles/signage_player/defaults/main.yml @@ -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: "" diff --git a/ansible/roles/signage_player/tasks/main.yml b/ansible/roles/signage_player/tasks/main.yml index 822a950..243a38d 100644 --- a/ansible/roles/signage_player/tasks/main.yml +++ b/ansible/roles/signage_player/tasks/main.yml @@ -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 }}" diff --git a/ansible/roles/signage_player/templates/config.json.j2 b/ansible/roles/signage_player/templates/config.json.j2 index 71e03fa..55abc74 100644 --- a/ansible/roles/signage_player/templates/config.json.j2 +++ b/ansible/roles/signage_player/templates/config.json.j2 @@ -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 }}" } diff --git a/ansible/site.yml b/ansible/site.yml index 09feb85..8288506 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -4,3 +4,4 @@ gather_facts: false roles: - signage_player + - signage_display diff --git a/player/agent/README.md b/player/agent/README.md index fa4092b..ca28261 100644 --- a/player/agent/README.md +++ b/player/agent/README.md @@ -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 diff --git a/player/agent/internal/app/app.go b/player/agent/internal/app/app.go index 605f99f..d51f034 100644 --- a/player/agent/internal/app/app.go +++ b/player/agent/internal/app/app.go @@ -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 diff --git a/player/agent/internal/config/config.go b/player/agent/internal/config/config.go index bc5e5f0..b01b4a2 100644 --- a/player/agent/internal/config/config.go +++ b/player/agent/internal/config/config.go @@ -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) diff --git a/player/agent/internal/playerserver/assets/splash-landscape.png b/player/agent/internal/playerserver/assets/splash-landscape.png new file mode 100644 index 0000000..a8c6e22 Binary files /dev/null and b/player/agent/internal/playerserver/assets/splash-landscape.png differ diff --git a/player/agent/internal/playerserver/assets/splash-portrait.png b/player/agent/internal/playerserver/assets/splash-portrait.png new file mode 100644 index 0000000..e8b37d9 Binary files /dev/null and b/player/agent/internal/playerserver/assets/splash-portrait.png differ diff --git a/player/agent/internal/playerserver/server.go b/player/agent/internal/playerserver/server.go new file mode 100644 index 0000000..ba9580a --- /dev/null +++ b/player/agent/internal/playerserver/server.go @@ -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 = ` + + + + Morz Infoboard + + + +
+
+ +
+ + + +` diff --git a/player/agent/internal/playerserver/server_test.go b/player/agent/internal/playerserver/server_test.go new file mode 100644 index 0000000..c97b1e5 --- /dev/null +++ b/player/agent/internal/playerserver/server_test.go @@ -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{"", "/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) + } +}