Kopplung Agent↔Backend: Selbstregistrierung + Playlist-Rotation

Backend:
- ScreenStore.Upsert(): idempotentes INSERT ON CONFLICT für Self-Registration
- POST /api/v1/screens/register: Agent registriert sich beim Start (upsert)
- manage/register.go: neuer Handler, immer unter Tenant "morz"

Agent:
- config: screen_name + screen_orientation (mit Fallback auf screen_id / landscape)
- app.go: registerScreen() — POST /api/v1/screens/register beim Start (Retry 30s)
- app.go: pollPlaylist() — GET /api/v1/screens/{slug}/playlist alle 60s
- app.go: nowFn liefert Playlist statt statischer URL; PlayerContentURL als Fallback
- playerserver: PlaylistItem-Struct in NowPlaying; JS rotiert Items per duration_seconds
- JS: Playlist-Fingerprint verhindert Reset laufender Rotation bei unverändertem Stand

Ansible:
- config.json.j2: screen_name + screen_orientation ergänzt
- host_vars/info10: screen_name + screen_orientation
- host_vars/info01-dev: screen_name + screen_orientation

Kopplung per Konvention: screen_id (config.json) = slug (DB)
Beim ersten Neustart der Agents erscheinen die Bildschirme automatisch im Admin-UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 05:57:58 +01:00
parent d395804612
commit e03948f25d
9 changed files with 325 additions and 44 deletions

View file

@ -2,3 +2,5 @@
ansible_host: 192.168.64.11
ansible_user: admin
screen_id: info01-dev
screen_name: "Info01 Entwicklung"
screen_orientation: landscape

View file

@ -2,3 +2,5 @@
ansible_host: 10.0.0.200
ansible_user: morz
screen_id: info10
screen_name: "Info10 Schule"
screen_orientation: landscape

View file

@ -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 }}",

View file

@ -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()

View file

@ -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

View file

@ -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 = `<!DOCTYPE html>
* { 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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// 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 = `<!DOCTYPE html>
pollSysInfo();
pollNowPlaying();
setInterval(pollSysInfo, 30000);
setInterval(pollNowPlaying, 15000);
setInterval(pollSysInfo, 30000); // sysinfo alle 30s
setInterval(pollNowPlaying, 30000); // playlist alle 30s
</script>
</body>
</html>`

View file

@ -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
}
}

View file

@ -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",

View file

@ -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