Baue Layout-Resolver und lokale Entwicklungsgerueste aus
This commit is contained in:
parent
6a65505304
commit
bf993a5945
30 changed files with 865 additions and 25 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Go build artefacts
|
||||
bin/
|
||||
dist/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Editor and OS junk
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Compose override files
|
||||
compose.override.yml
|
||||
|
|
@ -61,6 +61,11 @@ Spaeter zusaetzlich sinnvoll:
|
|||
- `postgresql-client`
|
||||
- `mosquitto-clients`
|
||||
|
||||
Fuer Container-Builds liegen erste Dockerfiles in:
|
||||
|
||||
- `server/backend/Dockerfile`
|
||||
- `player/agent/Dockerfile`
|
||||
|
||||
## Schnellstart
|
||||
|
||||
Repository klonen:
|
||||
|
|
@ -77,6 +82,7 @@ Dokumentationsbasis lesen:
|
|||
3. `TECH-STACK.md`
|
||||
4. `docs/SCHEMA.md`
|
||||
5. `docs/OFFENE-ARCHITEKTURFRAGEN.md`
|
||||
6. `docs/LAYOUT-JSON.md`
|
||||
|
||||
## Build-Kommandos
|
||||
|
||||
|
|
@ -100,6 +106,19 @@ go build ./...
|
|||
make build
|
||||
```
|
||||
|
||||
Aktuell bedeutet das:
|
||||
|
||||
- `make build` baut Backend und Agent
|
||||
- `make build-backend` baut nur `server/backend`
|
||||
- `make build-agent` baut nur `player/agent`
|
||||
- `make run-backend` startet das Backend
|
||||
- `make run-agent` startet den Agenten
|
||||
- `make fmt` formatiert beide Go-Module
|
||||
|
||||
Hinweis:
|
||||
|
||||
- auf dem aktuellen System dieser Session sind `make` und `go` nicht installiert; die Befehle sind fuer den Entwicklungsrechner vorbereitet
|
||||
|
||||
## Lokaler Start
|
||||
|
||||
### Backend lokal starten
|
||||
|
|
@ -138,6 +157,12 @@ Standardwerte:
|
|||
- `MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080`
|
||||
- `MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883`
|
||||
|
||||
Optional dateibasiert:
|
||||
|
||||
- `MORZ_INFOBOARD_CONFIG=/etc/signage/config.json`
|
||||
|
||||
Eine Beispielkonfiguration liegt in `player/config/config.example.json`.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```bash
|
||||
|
|
@ -163,6 +188,12 @@ go run ./cmd/agent
|
|||
4. Agent: strukturierte Logs und Health-Modell einziehen
|
||||
5. Danach erst DB-, MQTT- und API-Funktionalitaet ausbauen
|
||||
|
||||
Ergaenzt seit dem ersten Geruest:
|
||||
|
||||
- `message_wall`-Resolver im Backend
|
||||
- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides
|
||||
- lokales Compose-Grundgeruest fuer PostgreSQL und Mosquitto
|
||||
|
||||
## Arbeitsweise
|
||||
|
||||
Empfohlen:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ Die Trennung von `/srv/docker/infoboard-netboot` ist sinnvoll, damit:
|
|||
- Technologieentscheidungen: `TECH-STACK.md`
|
||||
- Entwicklungsleitfaden: `DEVELOPMENT.md`
|
||||
- Template-/Kampagnenkonzept: `docs/TEMPLATE-KONZEPT.md`
|
||||
- `layout_json` fuer `message_wall`: `docs/LAYOUT-JSON.md`
|
||||
- Provisionierungskonzept: `docs/PROVISIONIERUNGSKONZEPT.md`
|
||||
- Player-Konzept: `docs/PLAYER-KONZEPT.md`
|
||||
- Server-Konzept: `docs/SERVER-KONZEPT.md`
|
||||
|
|
@ -32,6 +33,13 @@ Die Trennung von `/srv/docker/infoboard-netboot` ist sinnvoll, damit:
|
|||
- `compose/` fuer Container-Definitionen und Stack-Bausteine
|
||||
- `scripts/` fuer Hilfsskripte
|
||||
|
||||
## Aktueller Implementierungsstand
|
||||
|
||||
- `server/backend/` enthaelt ein lauffaehiges Go-Grundgeruest mit erster Tool-API fuer `message_wall`
|
||||
- `player/agent/` enthaelt ein Go-Grundgeruest mit dateibasierter und env-basierter Konfiguration
|
||||
- `compose/` enthaelt ein lokales Grundgeruest fuer PostgreSQL und Mosquitto
|
||||
- `ansible/` enthaelt erste Platzhalter fuer Inventory und Playbook-Struktur
|
||||
|
||||
## Naechste sinnvolle Inhalte in der Struktur
|
||||
|
||||
- `docs/` fuer weitere technische Detaildokumente
|
||||
|
|
|
|||
|
|
@ -12,3 +12,8 @@ Naechster geplanter Ausbau:
|
|||
- Rolle `signage_provision`
|
||||
- Rolle `signage_player`
|
||||
- Beispiel-Inventories fuer Wand- und Einzelanzeigen
|
||||
|
||||
Aktuell vorhanden:
|
||||
|
||||
- `inventory.example.yml`
|
||||
- `site.yml` als Platzhalter-Playbook
|
||||
|
|
|
|||
16
ansible/inventory.example.yml
Normal file
16
ansible/inventory.example.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
all:
|
||||
children:
|
||||
info_wall:
|
||||
hosts:
|
||||
info01:
|
||||
ansible_host: 10.0.0.101
|
||||
screen_id: info01
|
||||
orientation: portrait
|
||||
rotation: 90
|
||||
vertretungsplan:
|
||||
hosts:
|
||||
vplan01:
|
||||
ansible_host: 10.0.1.101
|
||||
screen_id: vplan01
|
||||
orientation: landscape
|
||||
rotation: 0
|
||||
7
ansible/site.yml
Normal file
7
ansible/site.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
- name: Placeholder deployment entrypoint
|
||||
hosts: all
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Show target host placeholder
|
||||
ansible.builtin.debug:
|
||||
msg: "Placeholder fuer spaeteres signage deployment auf {{ inventory_hostname }}"
|
||||
12
compose/README.md
Normal file
12
compose/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Compose
|
||||
|
||||
Hier liegen lokale und spaetere produktionsnahe Compose-Bausteine fuer den zentralen Server-Stack.
|
||||
|
||||
Aktuell vorhanden:
|
||||
|
||||
- `server-stack.yml` als fruehes lokales Grundgeruest fuer PostgreSQL und Mosquitto
|
||||
|
||||
Geplanter naechster Ausbau:
|
||||
|
||||
- Backend-Service mit lokalem Dockerfile
|
||||
- spaeter Reverse Proxy, Worker und persistente Konfigurationspfade
|
||||
2
compose/mosquitto.conf
Normal file
2
compose/mosquitto.conf
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
listener 1883
|
||||
allow_anonymous true
|
||||
21
compose/server-stack.yml
Normal file
21
compose/server-stack.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: morz_infoboard
|
||||
POSTGRES_USER: morz_infoboard
|
||||
POSTGRES_PASSWORD: morz_infoboard
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2
|
||||
ports:
|
||||
- "1883:1883"
|
||||
volumes:
|
||||
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
213
docs/LAYOUT-JSON.md
Normal file
213
docs/LAYOUT-JSON.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# Info-Board Neu - `layout_json` fuer `message_wall`
|
||||
|
||||
## Ziel
|
||||
|
||||
Dieses Dokument definiert das `layout_json` fuer `message_wall` verbindlich fuer v1.
|
||||
|
||||
Es beschreibt nicht die eigentliche Mediendatei, sondern die serverseitige Segmentlogik, mit der aus einem Gesamtmotiv konkrete Screen-Szenen erzeugt werden.
|
||||
|
||||
## Grundentscheidung fuer v1
|
||||
|
||||
- `message_wall` wird serverseitig aufgeloest
|
||||
- der Player bekommt nur fertige Screen-Szenen
|
||||
- `layout_json` wird vom Backend oder Worker interpretiert
|
||||
- der Player berechnet keine Ausschnitte ueber mehrere Displays hinweg
|
||||
|
||||
## V1-Modell
|
||||
|
||||
Das `layout_json` beschreibt ein virtuelles Gesamtkoordinatensystem und eine Menge von Slots.
|
||||
|
||||
Jeder Slot repraesentiert einen Zielbereich im Gesamtmotiv.
|
||||
|
||||
## Struktur
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"coordinate_space": {
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"unit": "grid"
|
||||
},
|
||||
"fit_mode": "cover",
|
||||
"slots": [
|
||||
{
|
||||
"slot_id": "wall-r1-c1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 100,
|
||||
"height": 100
|
||||
},
|
||||
{
|
||||
"slot_id": "wall-r1-c2",
|
||||
"x": 100,
|
||||
"y": 0,
|
||||
"width": 100,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Felder
|
||||
|
||||
### `version`
|
||||
|
||||
- aktuell fest `1`
|
||||
- erlaubt spaetere Weiterentwicklung ohne uneindeutiges Parsing
|
||||
|
||||
### `coordinate_space`
|
||||
|
||||
Definiert den virtuellen Gesamtraum.
|
||||
|
||||
Felder:
|
||||
|
||||
- `width`
|
||||
- `height`
|
||||
- `unit`
|
||||
|
||||
Fuer v1 gilt:
|
||||
|
||||
- `unit` ist immer `grid`
|
||||
- `width` und `height` sind positive Ganzzahlen
|
||||
|
||||
Interpretation:
|
||||
|
||||
- das Gesamtmotiv wird in diesem virtuellen Raum beschrieben
|
||||
- Slots schneiden daraus Teilbereiche aus
|
||||
|
||||
### `fit_mode`
|
||||
|
||||
Definiert, wie das Ausgangsmedium auf den virtuellen Gesamtraum bezogen wird.
|
||||
|
||||
Zulaessige Werte fuer v1:
|
||||
|
||||
- `cover`
|
||||
- `contain`
|
||||
|
||||
V1-Standard:
|
||||
|
||||
- `cover`
|
||||
|
||||
Interpretation:
|
||||
|
||||
- `cover`: das Motiv fuellt den Gesamtraum vollstaendig, mit moeglichem Beschnitt
|
||||
- `contain`: das Motiv bleibt vollstaendig sichtbar, mit moeglichen Randflaechen
|
||||
|
||||
### `slots`
|
||||
|
||||
Liste aller Zielslots innerhalb des virtuellen Gesamtraums.
|
||||
|
||||
Jeder Slot hat:
|
||||
|
||||
- `slot_id`
|
||||
- `x`
|
||||
- `y`
|
||||
- `width`
|
||||
- `height`
|
||||
|
||||
Regeln:
|
||||
|
||||
- `slot_id` ist innerhalb eines Layouts eindeutig
|
||||
- `x`, `y`, `width`, `height` sind Ganzzahlen im virtuellen Koordinatensystem
|
||||
- `width > 0` und `height > 0`
|
||||
- Slots duerfen sich in v1 nicht ueberlappen
|
||||
- Slots muessen vollstaendig innerhalb des `coordinate_space` liegen
|
||||
|
||||
## Semantik
|
||||
|
||||
Ein Slot beschreibt den Ausschnitt, den ein bestimmter Screen spaeter bekommt.
|
||||
|
||||
Das Backend oder der Worker erzeugt daraus pro Zielscreen eine konkrete Scene mit:
|
||||
|
||||
- Zielscreen oder Zielslot
|
||||
- Originalmedium
|
||||
- berechnetem Crop-Bereich
|
||||
- Dauer, Timeout und Fehlerverhalten
|
||||
|
||||
## Beispiel 3x3-Infowand
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"coordinate_space": {
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"unit": "grid"
|
||||
},
|
||||
"fit_mode": "cover",
|
||||
"slots": [
|
||||
{ "slot_id": "wall-r1-c1", "x": 0, "y": 0, "width": 100, "height": 100 },
|
||||
{ "slot_id": "wall-r1-c2", "x": 100, "y": 0, "width": 100, "height": 100 },
|
||||
{ "slot_id": "wall-r1-c3", "x": 200, "y": 0, "width": 100, "height": 100 },
|
||||
{ "slot_id": "wall-r2-c1", "x": 0, "y": 100, "width": 100, "height": 100 },
|
||||
{ "slot_id": "wall-r2-c2", "x": 100, "y": 100, "width": 100, "height": 100 },
|
||||
{ "slot_id": "wall-r2-c3", "x": 200, "y": 100, "width": 100, "height": 100 },
|
||||
{ "slot_id": "wall-r3-c1", "x": 0, "y": 200, "width": 100, "height": 100 },
|
||||
{ "slot_id": "wall-r3-c2", "x": 100, "y": 200, "width": 100, "height": 100 },
|
||||
{ "slot_id": "wall-r3-c3", "x": 200, "y": 200, "width": 100, "height": 100 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Ergebnis der serverseitigen Aufloesung
|
||||
|
||||
Aus einem `message_wall`-Template mit diesem `layout_json` entstehen konkrete Screen-Szenen.
|
||||
|
||||
Jede resultierende Szene enthaelt fachlich mindestens:
|
||||
|
||||
- `slot_id`
|
||||
- `src`
|
||||
- `crop`
|
||||
- `fit_mode`
|
||||
- `duration_seconds`
|
||||
- `load_timeout_seconds`
|
||||
- `on_error`
|
||||
|
||||
Der `crop` beschreibt dann genau den Ausschnitt fuer diesen Slot.
|
||||
|
||||
## Warum Grid statt Pixel?
|
||||
|
||||
Fuer v1 wird absichtlich ein abstrahiertes Grid-Modell verwendet.
|
||||
|
||||
Vorteile:
|
||||
|
||||
- leicht lesbar und dokumentierbar
|
||||
- unabhaengiger von konkreten Asset-Abmessungen
|
||||
- einfach im Backend validierbar
|
||||
- gut fuer Admin-Vorschau und Segmentlogik
|
||||
|
||||
Pixelgenaue Berechnung kann spaeter intern aus dem Grid abgeleitet werden.
|
||||
|
||||
## Validierungsregeln fuer das Backend
|
||||
|
||||
Das Backend oder der Worker muss mindestens pruefen:
|
||||
|
||||
- `version == 1`
|
||||
- `coordinate_space.width > 0`
|
||||
- `coordinate_space.height > 0`
|
||||
- `unit == grid`
|
||||
- `fit_mode` ist erlaubt
|
||||
- keine doppelten `slot_id`
|
||||
- keine Slots ausserhalb des Gesamtraums
|
||||
- keine ueberlappenden Slots
|
||||
|
||||
## V1-Abgrenzung
|
||||
|
||||
Nicht Teil von v1:
|
||||
|
||||
- rotierte Slots
|
||||
- verschachtelte Layouts
|
||||
- freie Polygonformen
|
||||
- playerseitige Segmentlogik
|
||||
- automatische Textsatz-Engine im Layout selbst
|
||||
|
||||
## Bezug zur Implementierung
|
||||
|
||||
Die erste Backend-Implementierung soll aus diesem `layout_json` eine Liste konkreter Screen-Szenen ableiten koennen.
|
||||
|
||||
Dabei gilt:
|
||||
|
||||
- das Input-Modell bleibt bewusst einfach
|
||||
- die Aufloesung ist serverseitige Fachlogik
|
||||
- der Player bekommt nur bereits aufgeloeste Einzelinhalte
|
||||
|
|
@ -288,3 +288,5 @@ Vor der UI- und Render-Implementierung gilt:
|
|||
- `message_wall` wird serverseitig in konkrete Zielinhalte aufgeloest
|
||||
- `layout_json` beschreibt die serverseitige Segmentlogik und Admin-Vorschau
|
||||
- Slots werden geometrisch serverseitig interpretiert, nicht im Player berechnet
|
||||
|
||||
Details dazu stehen in `docs/LAYOUT-JSON.md`.
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ Geplant:
|
|||
Aktuell vorhanden:
|
||||
|
||||
- `agent/` mit erstem Go-Geruest
|
||||
- `config/config.example.json` als lokale Beispielkonfiguration
|
||||
|
|
|
|||
10
player/agent/Dockerfile
Normal file
10
player/agent/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FROM golang:1.24-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN go build -o /out/agent ./cmd/agent
|
||||
|
||||
FROM alpine:3.22
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/agent /usr/local/bin/agent
|
||||
ENTRYPOINT ["/usr/local/bin/agent"]
|
||||
|
|
@ -14,3 +14,8 @@ Geplante Unterstruktur:
|
|||
- `cmd/agent/` fuer den Startpunkt
|
||||
- `internal/app/` fuer Initialisierung und Laufzeit
|
||||
- `internal/config/` fuer Konfiguration
|
||||
|
||||
Aktuell vorhanden:
|
||||
|
||||
- Env-basierte und dateibasierte Konfiguration
|
||||
- einfacher Laufzeit-Loop mit Heartbeat-Ticks im Log
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package app
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/config"
|
||||
|
|
@ -9,20 +11,32 @@ import (
|
|||
|
||||
type App struct {
|
||||
Config config.Config
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func New() (*App, error) {
|
||||
cfg := config.Load()
|
||||
|
||||
if cfg.ScreenID == "" {
|
||||
return nil, fmt.Errorf("screen id is required")
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &App{Config: cfg}, nil
|
||||
logger := log.New(os.Stdout, "agent ", log.LstdFlags|log.LUTC)
|
||||
|
||||
return &App{Config: cfg, logger: logger}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
if a.Config.ScreenID == "" {
|
||||
return fmt.Errorf("screen id is required")
|
||||
}
|
||||
|
||||
a.logger.Printf("configured server=%s mqtt=%s heartbeat=%ds", a.Config.ServerBaseURL, a.Config.MQTTBroker, a.Config.HeartbeatEvery)
|
||||
|
||||
ticker := time.NewTicker(time.Duration(a.Config.HeartbeatEvery) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
a.logger.Printf("heartbeat tick screen=%s", a.Config.ScreenID)
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,66 @@
|
|||
package config
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ScreenID string
|
||||
ServerBaseURL string
|
||||
MQTTBroker string
|
||||
ScreenID string `json:"screen_id"`
|
||||
ServerBaseURL string `json:"server_base_url"`
|
||||
MQTTBroker string `json:"mqtt_broker"`
|
||||
HeartbeatEvery int `json:"heartbeat_every_seconds"`
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
ScreenID: getenv("MORZ_INFOBOARD_SCREEN_ID", "unset-screen"),
|
||||
ServerBaseURL: getenv("MORZ_INFOBOARD_SERVER_URL", "http://127.0.0.1:8080"),
|
||||
MQTTBroker: getenv("MORZ_INFOBOARD_MQTT_BROKER", "tcp://127.0.0.1:1883"),
|
||||
const defaultConfigPath = "/etc/signage/config.json"
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := defaultConfig()
|
||||
|
||||
configPath := getenv("MORZ_INFOBOARD_CONFIG", defaultConfigPath)
|
||||
if err := loadFromFile(configPath, &cfg); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf("load config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
overrideFromEnv(&cfg)
|
||||
|
||||
if cfg.ScreenID == "" {
|
||||
return Config{}, fmt.Errorf("screen id is required")
|
||||
}
|
||||
|
||||
if cfg.HeartbeatEvery <= 0 {
|
||||
cfg.HeartbeatEvery = defaultConfig().HeartbeatEvery
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
ScreenID: "unset-screen",
|
||||
ServerBaseURL: "http://127.0.0.1:8080",
|
||||
MQTTBroker: "tcp://127.0.0.1:1883",
|
||||
HeartbeatEvery: 30,
|
||||
}
|
||||
}
|
||||
|
||||
func loadFromFile(path string, cfg *Config) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, cfg)
|
||||
}
|
||||
|
||||
func overrideFromEnv(cfg *Config) {
|
||||
cfg.ScreenID = getenv("MORZ_INFOBOARD_SCREEN_ID", cfg.ScreenID)
|
||||
cfg.ServerBaseURL = getenv("MORZ_INFOBOARD_SERVER_URL", cfg.ServerBaseURL)
|
||||
cfg.MQTTBroker = getenv("MORZ_INFOBOARD_MQTT_BROKER", cfg.MQTTBroker)
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
|
|
|
|||
42
player/agent/internal/config/config_test.go
Normal file
42
player/agent/internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadReadsFileAndEnvOverrides(t *testing.T) {
|
||||
t.Setenv("MORZ_INFOBOARD_CONFIG", filepath.Join(t.TempDir(), "config.json"))
|
||||
configPath := os.Getenv("MORZ_INFOBOARD_CONFIG")
|
||||
|
||||
content := []byte(`{
|
||||
"screen_id": "file-screen",
|
||||
"server_base_url": "http://file.example",
|
||||
"mqtt_broker": "tcp://file-broker:1883",
|
||||
"heartbeat_every_seconds": 45
|
||||
}`)
|
||||
|
||||
if err := os.WriteFile(configPath, content, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("MORZ_INFOBOARD_SCREEN_ID", "env-screen")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if got, want := cfg.ScreenID, "env-screen"; got != want {
|
||||
t.Fatalf("ScreenID = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got, want := cfg.ServerBaseURL, "http://file.example"; got != want {
|
||||
t.Fatalf("ServerBaseURL = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got, want := cfg.HeartbeatEvery, 45; got != want {
|
||||
t.Fatalf("HeartbeatEvery = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
6
player/config/config.example.json
Normal file
6
player/config/config.example.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"screen_id": "info01-dev",
|
||||
"server_base_url": "http://127.0.0.1:8080",
|
||||
"mqtt_broker": "tcp://127.0.0.1:1883",
|
||||
"heartbeat_every_seconds": 30
|
||||
}
|
||||
11
server/backend/Dockerfile
Normal file
11
server/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM golang:1.24-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN go build -o /out/backend ./cmd/api
|
||||
|
||||
FROM alpine:3.22
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/backend /usr/local/bin/backend
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/backend"]
|
||||
|
|
@ -7,10 +7,20 @@ Ziel fuer die erste Ausbaustufe:
|
|||
- HTTP-API in Go
|
||||
- Health-Endpunkt
|
||||
- saubere Projektstruktur fuer spaetere API-, Worker- und Datenbankmodule
|
||||
- erste serverseitige Aufloesungslogik fuer `message_wall`
|
||||
|
||||
Geplante Unterstruktur:
|
||||
|
||||
- `cmd/api/` fuer den API-Startpunkt
|
||||
- `internal/app/` fuer App-Initialisierung
|
||||
- `internal/campaigns/` fuer Kampagnen- und Template-Logik
|
||||
- `internal/httpapi/` fuer HTTP-Routing und Handler
|
||||
- `internal/config/` fuer Konfiguration
|
||||
|
||||
Aktuell vorhanden:
|
||||
|
||||
- `GET /healthz`
|
||||
- `GET /api/v1`
|
||||
- `GET /api/v1/meta`
|
||||
- `POST /api/v1/tools/message-wall/resolve` als erste serverseitige Layout-Aufloesung fuer `message_wall`
|
||||
- einheitliches API-Fehlerformat im HTTP-Layer
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||
|
|
@ -25,5 +26,10 @@ func New() (*App, error) {
|
|||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
return a.server.ListenAndServe()
|
||||
err := a.server.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
131
server/backend/internal/campaigns/messagewall/resolver.go
Normal file
131
server/backend/internal/campaigns/messagewall/resolver.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package messagewall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
versionV1 = 1
|
||||
unitGrid = "grid"
|
||||
fitModeCover = "cover"
|
||||
fitModeContain = "contain"
|
||||
defaultDuration = 20
|
||||
defaultLoadTime = 15
|
||||
defaultOnError = "skip"
|
||||
)
|
||||
|
||||
func Resolve(request ResolveRequest) (ResolveResult, error) {
|
||||
if err := Validate(request.Layout); err != nil {
|
||||
return ResolveResult{}, err
|
||||
}
|
||||
|
||||
result := ResolveResult{
|
||||
Version: request.Layout.Version,
|
||||
CoordinateSpace: request.Layout.CoordinateSpace,
|
||||
FitMode: request.Layout.FitMode,
|
||||
Scenes: make([]ResolvedScene, 0, len(request.Layout.Slots)),
|
||||
}
|
||||
|
||||
duration := request.DurationSeconds
|
||||
if duration <= 0 {
|
||||
duration = defaultDuration
|
||||
}
|
||||
|
||||
loadTimeout := request.LoadTimeoutSeconds
|
||||
if loadTimeout <= 0 {
|
||||
loadTimeout = defaultLoadTime
|
||||
}
|
||||
|
||||
onError := request.OnError
|
||||
if onError == "" {
|
||||
onError = defaultOnError
|
||||
}
|
||||
|
||||
for _, slot := range request.Layout.Slots {
|
||||
result.Scenes = append(result.Scenes, ResolvedScene{
|
||||
SlotID: slot.SlotID,
|
||||
Source: request.Source,
|
||||
DurationSeconds: duration,
|
||||
LoadTimeoutSeconds: loadTimeout,
|
||||
OnError: onError,
|
||||
Crop: Crop{
|
||||
X: slot.X,
|
||||
Y: slot.Y,
|
||||
Width: slot.Width,
|
||||
Height: slot.Height,
|
||||
Unit: request.Layout.CoordinateSpace.Unit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(result.Scenes, func(i, j int) bool {
|
||||
return result.Scenes[i].SlotID < result.Scenes[j].SlotID
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Validate(layout Layout) error {
|
||||
if layout.Version != versionV1 {
|
||||
return fmt.Errorf("unsupported layout version: %d", layout.Version)
|
||||
}
|
||||
|
||||
if layout.CoordinateSpace.Width <= 0 || layout.CoordinateSpace.Height <= 0 {
|
||||
return fmt.Errorf("coordinate_space width and height must be positive")
|
||||
}
|
||||
|
||||
if layout.CoordinateSpace.Unit != unitGrid {
|
||||
return fmt.Errorf("unsupported coordinate_space unit: %s", layout.CoordinateSpace.Unit)
|
||||
}
|
||||
|
||||
if layout.FitMode != fitModeCover && layout.FitMode != fitModeContain {
|
||||
return fmt.Errorf("unsupported fit_mode: %s", layout.FitMode)
|
||||
}
|
||||
|
||||
if len(layout.Slots) == 0 {
|
||||
return fmt.Errorf("layout must contain at least one slot")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(layout.Slots))
|
||||
for i, slot := range layout.Slots {
|
||||
if slot.SlotID == "" {
|
||||
return fmt.Errorf("slot %d has empty slot_id", i)
|
||||
}
|
||||
|
||||
if _, ok := seen[slot.SlotID]; ok {
|
||||
return fmt.Errorf("duplicate slot_id: %s", slot.SlotID)
|
||||
}
|
||||
seen[slot.SlotID] = struct{}{}
|
||||
|
||||
if slot.Width <= 0 || slot.Height <= 0 {
|
||||
return fmt.Errorf("slot %s must have positive width and height", slot.SlotID)
|
||||
}
|
||||
|
||||
if slot.X < 0 || slot.Y < 0 {
|
||||
return fmt.Errorf("slot %s must not start outside coordinate space", slot.SlotID)
|
||||
}
|
||||
|
||||
if slot.X+slot.Width > layout.CoordinateSpace.Width {
|
||||
return fmt.Errorf("slot %s exceeds coordinate_space width", slot.SlotID)
|
||||
}
|
||||
|
||||
if slot.Y+slot.Height > layout.CoordinateSpace.Height {
|
||||
return fmt.Errorf("slot %s exceeds coordinate_space height", slot.SlotID)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range layout.Slots {
|
||||
for j := i + 1; j < len(layout.Slots); j++ {
|
||||
if overlaps(layout.Slots[i], layout.Slots[j]) {
|
||||
return fmt.Errorf("slots %s and %s overlap", layout.Slots[i].SlotID, layout.Slots[j].SlotID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func overlaps(a, b Slot) bool {
|
||||
return a.X < b.X+b.Width && a.X+a.Width > b.X && a.Y < b.Y+b.Height && a.Y+a.Height > b.Y
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package messagewall
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveBuildsScenes(t *testing.T) {
|
||||
request := ResolveRequest{
|
||||
Layout: Layout{
|
||||
Version: 1,
|
||||
CoordinateSpace: CoordinateSpace{
|
||||
Width: 200,
|
||||
Height: 100,
|
||||
Unit: "grid",
|
||||
},
|
||||
FitMode: "cover",
|
||||
Slots: []Slot{
|
||||
{SlotID: "wall-r1-c2", X: 100, Y: 0, Width: 100, Height: 100},
|
||||
{SlotID: "wall-r1-c1", X: 0, Y: 0, Width: 100, Height: 100},
|
||||
},
|
||||
},
|
||||
Source: "/media/banner.png",
|
||||
DurationSeconds: 30,
|
||||
LoadTimeoutSeconds: 12,
|
||||
OnError: "skip",
|
||||
}
|
||||
|
||||
result, err := Resolve(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if got, want := len(result.Scenes), 2; got != want {
|
||||
t.Fatalf("len(result.Scenes) = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
if got, want := result.Scenes[0].SlotID, "wall-r1-c1"; got != want {
|
||||
t.Fatalf("first SlotID = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
if got, want := result.Scenes[1].Crop.X, 100; got != want {
|
||||
t.Fatalf("second Crop.X = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsOverlappingSlots(t *testing.T) {
|
||||
layout := Layout{
|
||||
Version: 1,
|
||||
CoordinateSpace: CoordinateSpace{
|
||||
Width: 100,
|
||||
Height: 100,
|
||||
Unit: "grid",
|
||||
},
|
||||
FitMode: "cover",
|
||||
Slots: []Slot{
|
||||
{SlotID: "a", X: 0, Y: 0, Width: 60, Height: 60},
|
||||
{SlotID: "b", X: 50, Y: 0, Width: 50, Height: 50},
|
||||
},
|
||||
}
|
||||
|
||||
if err := Validate(layout); err == nil {
|
||||
t.Fatal("Validate() error = nil, want overlap error")
|
||||
}
|
||||
}
|
||||
54
server/backend/internal/campaigns/messagewall/types.go
Normal file
54
server/backend/internal/campaigns/messagewall/types.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package messagewall
|
||||
|
||||
type Layout struct {
|
||||
Version int `json:"version"`
|
||||
CoordinateSpace CoordinateSpace `json:"coordinate_space"`
|
||||
FitMode string `json:"fit_mode"`
|
||||
Slots []Slot `json:"slots"`
|
||||
}
|
||||
|
||||
type CoordinateSpace struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
SlotID string `json:"slot_id"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type ResolveRequest struct {
|
||||
Layout Layout `json:"layout"`
|
||||
Source string `json:"source"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
LoadTimeoutSeconds int `json:"load_timeout_seconds"`
|
||||
OnError string `json:"on_error"`
|
||||
}
|
||||
|
||||
type ResolveResult struct {
|
||||
Version int `json:"version"`
|
||||
CoordinateSpace CoordinateSpace `json:"coordinate_space"`
|
||||
FitMode string `json:"fit_mode"`
|
||||
Scenes []ResolvedScene `json:"scenes"`
|
||||
}
|
||||
|
||||
type ResolvedScene struct {
|
||||
SlotID string `json:"slot_id"`
|
||||
Source string `json:"source"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
LoadTimeoutSeconds int `json:"load_timeout_seconds"`
|
||||
OnError string `json:"on_error"`
|
||||
Crop Crop `json:"crop"`
|
||||
}
|
||||
|
||||
type Crop struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
31
server/backend/internal/httpapi/errors.go
Normal file
31
server/backend/internal/httpapi/errors.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error apiError `json:"error"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string, details any) {
|
||||
writeJSON(w, status, errorEnvelope{
|
||||
Error: apiError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dest any) error {
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(r.Body).Decode(dest)
|
||||
}
|
||||
23
server/backend/internal/httpapi/messagewall.go
Normal file
23
server/backend/internal/httpapi/messagewall.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/campaigns/messagewall"
|
||||
)
|
||||
|
||||
func handleResolveMessageWall(w http.ResponseWriter, r *http.Request) {
|
||||
var request messagewall.ResolveRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := messagewall.Resolve(request)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_layout_json", "layout_json is invalid", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
19
server/backend/internal/httpapi/messagewall_test.go
Normal file
19
server/backend/internal/httpapi/messagewall_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleResolveMessageWallInvalidJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tools/message-wall/resolve", bytes.NewBufferString("{"))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handleResolveMessageWall(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
21
server/backend/internal/httpapi/meta.go
Normal file
21
server/backend/internal/httpapi/meta.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
func handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"service": "morz-infoboard-backend",
|
||||
"version": "dev",
|
||||
"api": map[string]any{
|
||||
"base_path": "/api/v1",
|
||||
"health": "/healthz",
|
||||
"tools": []map[string]string{
|
||||
{
|
||||
"name": "message-wall-resolve",
|
||||
"method": http.MethodPost,
|
||||
"path": "/api/v1/tools/message-wall/resolve",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
13
server/backend/internal/httpapi/response.go
Normal file
13
server/backend/internal/httpapi/response.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
|
|
@ -16,18 +15,18 @@ func NewRouter() http.Handler {
|
|||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"name": "morz-infoboard-backend",
|
||||
"version": "dev",
|
||||
"tools": []string{
|
||||
"message-wall-resolve",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1/meta", handleMeta)
|
||||
|
||||
mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue