diff --git a/ansible/host_vars/info01-dev/vars.yml b/ansible/host_vars/info01-dev/vars.yml index 786adc9..5dd1baa 100644 --- a/ansible/host_vars/info01-dev/vars.yml +++ b/ansible/host_vars/info01-dev/vars.yml @@ -2,3 +2,5 @@ ansible_host: 192.168.64.11 ansible_user: admin screen_id: info01-dev +screen_name: "Info01 Entwicklung" +screen_orientation: landscape diff --git a/ansible/host_vars/info10/vars.yml b/ansible/host_vars/info10/vars.yml index 2fd1ee6..93a1170 100644 --- a/ansible/host_vars/info10/vars.yml +++ b/ansible/host_vars/info10/vars.yml @@ -2,3 +2,5 @@ ansible_host: 10.0.0.200 ansible_user: morz screen_id: info10 +screen_name: "Info10 Schule" +screen_orientation: landscape diff --git a/ansible/roles/signage_player/templates/config.json.j2 b/ansible/roles/signage_player/templates/config.json.j2 index 55abc74..daa82ce 100644 --- a/ansible/roles/signage_player/templates/config.json.j2 +++ b/ansible/roles/signage_player/templates/config.json.j2 @@ -1,5 +1,7 @@ { "screen_id": "{{ screen_id }}", + "screen_name": "{{ screen_name | default(screen_id) }}", + "screen_orientation": "{{ screen_orientation | default('landscape') }}", "server_base_url": "{{ morz_server_base_url }}", "mqtt_broker": "{{ morz_mqtt_broker }}", "mqtt_username": "{{ morz_mqtt_username }}", diff --git a/player/agent/internal/app/app.go b/player/agent/internal/app/app.go index d51f034..69fae5c 100644 --- a/player/agent/internal/app/app.go +++ b/player/agent/internal/app/app.go @@ -1,9 +1,12 @@ package app import ( + "bytes" "context" + "encoding/json" "fmt" "log" + "net/http" "os" "sync" "time" @@ -55,6 +58,10 @@ type App struct { consecutiveReportFailures int startedAt time.Time lastHeartbeatAt time.Time + + // Playlist fetched from the backend (protected by playlistMu). + playlistMu sync.RWMutex + playlist []playerserver.PlaylistItem } type statusSender interface { @@ -140,21 +147,39 @@ 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 player_addr=%s", + "event=agent_configured screen_id=%s screen_name=%q orientation=%s server_url=%s mqtt_broker=%s heartbeat_every_seconds=%d player_addr=%s", a.Config.ScreenID, + a.Config.ScreenName, + a.Config.ScreenOrientation, a.Config.ServerBaseURL, a.Config.MQTTBroker, a.Config.HeartbeatEvery, a.Config.PlayerListenAddr, ) + // Start the player HTTP server (serves Chromium UI). ps := playerserver.New(a.Config.PlayerListenAddr, func() playerserver.NowPlaying { snap := a.Snapshot() - return playerserver.NowPlaying{ - URL: a.Config.PlayerContentURL, + a.playlistMu.RLock() + items := a.playlist + a.playlistMu.RUnlock() + + np := playerserver.NowPlaying{ Status: string(snap.Status), Connectivity: string(snap.ServerConnectivity), } + if len(items) > 0 { + np.Playlist = items + } else if a.Config.PlayerContentURL != "" { + // Fallback: single static URL when no playlist is available yet. + np.Playlist = []playerserver.PlaylistItem{{ + Src: a.Config.PlayerContentURL, + Type: "web", + Title: "", + DurationSeconds: 30, + }} + } + return np }) go func() { if err := ps.Run(ctx); err != nil { @@ -162,6 +187,12 @@ func (a *App) Run(ctx context.Context) error { } }() + // Self-register this screen in the backend (best-effort, non-blocking). + go a.registerScreen(ctx) + + // Start polling the backend for playlist updates. + go a.pollPlaylist(ctx) + a.emitHeartbeat() a.mu.Lock() a.status = StatusRunning @@ -193,6 +224,102 @@ func (a *App) Run(ctx context.Context) error { } } +// registerScreen upserts this screen in the backend so it appears in the admin UI. +// Called once at startup in a goroutine — retries every 30s until successful. +func (a *App) registerScreen(ctx context.Context) { + body, _ := json.Marshal(map[string]string{ + "slug": a.Config.ScreenID, + "name": a.Config.ScreenName, + "orientation": a.Config.ScreenOrientation, + }) + + for attempt := 1; ; attempt++ { + req, err := http.NewRequestWithContext(ctx, + http.MethodPost, + a.Config.ServerBaseURL+"/api/v1/screens/register", + bytes.NewReader(body), + ) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + a.logger.Printf("event=screen_registered screen_id=%s attempt=%d", a.Config.ScreenID, attempt) + return + } + a.logger.Printf("event=screen_register_failed screen_id=%s status=%d attempt=%d", + a.Config.ScreenID, resp.StatusCode, attempt) + } else { + a.logger.Printf("event=screen_register_failed screen_id=%s error=%v attempt=%d", + a.Config.ScreenID, err, attempt) + } + + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + } + } +} + +// pollPlaylist fetches the active playlist from the backend periodically. +func (a *App) pollPlaylist(ctx context.Context) { + // Fetch immediately on startup, then every 60s. + a.fetchPlaylist(ctx) + + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.fetchPlaylist(ctx) + } + } +} + +// remotePlaylistResponse is the JSON shape of GET /api/v1/screens/{slug}/playlist. +type remotePlaylistResponse struct { + Items []playerserver.PlaylistItem `json:"items"` +} + +func (a *App) fetchPlaylist(ctx context.Context) { + url := a.Config.ServerBaseURL + "/api/v1/screens/" + a.Config.ScreenID + "/playlist" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + a.logger.Printf("event=playlist_fetch_failed screen_id=%s error=%v", a.Config.ScreenID, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + a.logger.Printf("event=playlist_fetch_failed screen_id=%s status=%d", a.Config.ScreenID, resp.StatusCode) + return + } + + var pr remotePlaylistResponse + if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { + a.logger.Printf("event=playlist_decode_failed screen_id=%s error=%v", a.Config.ScreenID, err) + return + } + + a.playlistMu.Lock() + a.playlist = pr.Items + a.playlistMu.Unlock() + + a.logger.Printf("event=playlist_fetched screen_id=%s items=%d", a.Config.ScreenID, len(pr.Items)) +} + func (a *App) emitHeartbeat() { now := a.now() diff --git a/player/agent/internal/config/config.go b/player/agent/internal/config/config.go index b01b4a2..a28b908 100644 --- a/player/agent/internal/config/config.go +++ b/player/agent/internal/config/config.go @@ -7,7 +7,13 @@ import ( ) type Config struct { - ScreenID string `json:"screen_id"` + // ScreenID is the unique slug for this screen — used for self-registration and playlist fetching. + ScreenID string `json:"screen_id"` + // ScreenName is the human-readable display name shown in the admin UI. Falls back to ScreenID. + ScreenName string `json:"screen_name"` + // ScreenOrientation is "landscape" (default) or "portrait". + ScreenOrientation string `json:"screen_orientation"` + ServerBaseURL string `json:"server_base_url"` MQTTBroker string `json:"mqtt_broker"` MQTTUsername string `json:"mqtt_username"` @@ -15,7 +21,8 @@ type Config struct { 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"` + // PlayerContentURL is a fallback URL shown when no playlist is available from the server. + PlayerContentURL string `json:"player_content_url"` } const defaultConfigPath = "/etc/signage/config.json" @@ -36,6 +43,10 @@ func Load() (Config, error) { return Config{}, fmt.Errorf("screen id is required") } + if cfg.ScreenName == "" { + cfg.ScreenName = cfg.ScreenID + } + if cfg.HeartbeatEvery <= 0 { cfg.HeartbeatEvery = defaultConfig().HeartbeatEvery } @@ -50,6 +61,7 @@ func Load() (Config, error) { func defaultConfig() Config { return Config{ ScreenID: "unset-screen", + ScreenOrientation: "landscape", ServerBaseURL: "http://127.0.0.1:8080", MQTTBroker: "", HeartbeatEvery: 30, @@ -75,6 +87,8 @@ func overrideFromEnv(cfg *Config) { 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.ScreenName = getenv("MORZ_INFOBOARD_SCREEN_NAME", cfg.ScreenName) + cfg.ScreenOrientation = getenv("MORZ_INFOBOARD_SCREEN_ORIENTATION", cfg.ScreenOrientation) cfg.PlayerContentURL = getenv("MORZ_INFOBOARD_PLAYER_CONTENT_URL", cfg.PlayerContentURL) if value := getenv("MORZ_INFOBOARD_STATUS_REPORT_EVERY", ""); value != "" { var parsed int diff --git a/player/agent/internal/playerserver/server.go b/player/agent/internal/playerserver/server.go index ba9580a..10180d1 100644 --- a/player/agent/internal/playerserver/server.go +++ b/player/agent/internal/playerserver/server.go @@ -17,15 +17,25 @@ import ( //go:embed assets var assetsFS embed.FS -// NowPlaying describes what the player should currently display. +// PlaylistItem is a single displayable content item fetched from the backend. +type PlaylistItem struct { + Src string `json:"src"` + Type string `json:"type"` // web | image | video | pdf + Title string `json:"title,omitempty"` + DurationSeconds int `json:"duration_seconds"` +} + +// NowPlaying describes the current playback state returned to the browser. type NowPlaying struct { + // Playlist holds the ordered items to display (primary). + Playlist []PlaylistItem `json:"playlist,omitempty"` + // URL is a legacy single-URL fallback (kept for backwards compatibility). 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"` @@ -142,7 +152,7 @@ const playerHTML = ` * { 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-Hintergrund */ #splash { position: fixed; inset: 0; background-size: cover; @@ -156,8 +166,8 @@ const playerHTML = ` 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) 0%, + rgba(0,0,0,0.7) 40%, rgba(0,0,0,0.85) 100% ); display: flex; @@ -166,41 +176,29 @@ const playerHTML = ` gap: 3rem; z-index: 1; } - .info-item { - display: flex; - flex-direction: column; - } + .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; + 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; + 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 */ + /* Inhalts-iframe */ #frame { position: fixed; inset: 0; width: 100%; height: 100%; - border: none; - display: none; - z-index: 10; + 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; + background: #444; opacity: 0.6; z-index: 9999; transition: background 0.5s; } #dot.online { background: #4caf50; } @@ -220,7 +218,7 @@ const playerHTML = ` var frame = document.getElementById('frame'); var dot = document.getElementById('dot'); - // Orientierungsgerechtes Splash-Bild wählen + // ── Splash-Orientierung ─────────────────────────────────────────── function updateSplash() { var portrait = window.innerHeight > window.innerWidth; splash.style.backgroundImage = portrait @@ -230,7 +228,7 @@ const playerHTML = ` updateSplash(); window.addEventListener('resize', updateSplash); - // Sysinfo-Overlay rendern + // ── Sysinfo-Overlay ─────────────────────────────────────────────── function renderSysInfo(items) { overlay.innerHTML = ''; (items || []).forEach(function(item) { @@ -245,22 +243,82 @@ const playerHTML = ` function esc(s) { return String(s) - .replace(/&/g, '&') - .replace(//g, '>'); + .replace(/&/g,'&').replace(//g,'>'); } - // Now-Playing anwenden: iframe zeigen wenn URL gesetzt, sonst Splash + // ── Playlist-Rotation ───────────────────────────────────────────── + var items = []; // current playlist + var currentIdx = 0; + var rotateTimer = null; + + // Returns a fingerprint string for change detection. + function playlistKey(pl) { + return (pl || []).map(function(i) { return i.src + ':' + i.duration_seconds; }).join('|'); + } + + function clearRotation() { + if (rotateTimer) { clearTimeout(rotateTimer); rotateTimer = null; } + } + + function showItem(item) { + if (!item) { showSplash(); return; } + if (frame.src !== item.src) { frame.src = item.src; } + frame.style.display = ''; + // Hide splash overlay while content is visible. + overlay.style.display = 'none'; + + clearRotation(); + var ms = Math.max((item.duration_seconds || 20), 1) * 1000; + rotateTimer = setTimeout(function() { + currentIdx = (currentIdx + 1) % items.length; + showItem(items[currentIdx]); + }, ms); + } + + function showSplash() { + clearRotation(); + frame.style.display = 'none'; + overlay.style.display = ''; + } + + var lastPlaylistKey = ''; + 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'; + + // Legacy single-URL fallback. + if (data.url && (!data.playlist || data.playlist.length === 0)) { + var key = data.url + ':legacy'; + if (lastPlaylistKey !== key) { + lastPlaylistKey = key; + items = [{ src: data.url, type: 'web', duration_seconds: 30 }]; + currentIdx = 0; + showItem(items[0]); + } + return; } + + var playlist = data.playlist || []; + if (playlist.length === 0) { + showSplash(); + lastPlaylistKey = ''; + items = []; + return; + } + + var key = playlistKey(playlist); + if (key === lastPlaylistKey) { + return; // unchanged — let current rotation continue + } + + // Playlist changed: start from beginning. + lastPlaylistKey = key; + items = playlist; + currentIdx = 0; + showItem(items[0]); } + // ── Polling ─────────────────────────────────────────────────────── function pollSysInfo() { fetch('/api/sysinfo') .then(function(r) { return r.json(); }) @@ -277,8 +335,8 @@ const playerHTML = ` pollSysInfo(); pollNowPlaying(); - setInterval(pollSysInfo, 30000); - setInterval(pollNowPlaying, 15000); + setInterval(pollSysInfo, 30000); // sysinfo alle 30s + setInterval(pollNowPlaying, 30000); // playlist alle 30s ` diff --git a/server/backend/internal/httpapi/manage/register.go b/server/backend/internal/httpapi/manage/register.go new file mode 100644 index 0000000..595ea8e --- /dev/null +++ b/server/backend/internal/httpapi/manage/register.go @@ -0,0 +1,59 @@ +package manage + +import ( + "encoding/json" + "net/http" + "strings" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +// HandleRegisterScreen is called by the player agent on startup. +// It upserts the screen in the default tenant (morz) so that all +// deployed screens appear automatically in the admin UI. +// +// POST /api/v1/screens/register +// Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"} +func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Slug string `json:"slug"` + Name string `json:"name"` + Orientation string `json:"orientation"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + body.Slug = strings.TrimSpace(body.Slug) + body.Name = strings.TrimSpace(body.Name) + if body.Slug == "" { + http.Error(w, "slug required", http.StatusBadRequest) + return + } + if body.Name == "" { + body.Name = body.Slug + } + if body.Orientation == "" { + body.Orientation = "landscape" + } + + // v1: single tenant — always register under "morz". + tenant, err := tenants.Get(r.Context(), "morz") + if err != nil { + http.Error(w, "default tenant not found", http.StatusInternalServerError) + return + } + + screen, err := screens.Upsert(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation) + if err != nil { + http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) // 200 whether created or updated + json.NewEncoder(w).Encode(screen) //nolint:errcheck + } +} diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index b9fa4f8..d5ebb86 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -98,6 +98,9 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) { manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir)) // ── JSON API — screens ──────────────────────────────────────────────── + // Self-registration: called by agent on startup (must be before /{tenantSlug}/ routes) + mux.HandleFunc("POST /api/v1/screens/register", + manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore)) mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/screens", manage.HandleListScreens(d.TenantStore, d.ScreenStore)) mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/screens", diff --git a/server/backend/internal/store/store.go b/server/backend/internal/store/store.go index aff1837..9d82931 100644 --- a/server/backend/internal/store/store.go +++ b/server/backend/internal/store/store.go @@ -176,6 +176,20 @@ func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientat return scanScreen(row) } +// Upsert creates or updates a screen by slug (idempotent). +// Used by agents on startup to self-register. +func (s *ScreenStore) Upsert(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) { + row := s.pool.QueryRow(ctx, + `insert into screens(tenant_id, slug, name, orientation) + values($1,$2,$3,$4) + on conflict (slug) do update + set name = excluded.name, + orientation = excluded.orientation + returning id, tenant_id, slug, name, orientation, created_at`, + tenantID, slug, name, orientation) + return scanScreen(row) +} + func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) { row := s.pool.QueryRow(ctx, `update screens set name=$2, orientation=$3 where id=$1