Baue Layout-Resolver und lokale Entwicklungsgerueste aus

This commit is contained in:
Jesko Anschütz 2026-03-22 16:03:21 +01:00
parent 6a65505304
commit bf993a5945
30 changed files with 865 additions and 25 deletions

18
.gitignore vendored Normal file
View 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

View file

@ -61,6 +61,11 @@ Spaeter zusaetzlich sinnvoll:
- `postgresql-client` - `postgresql-client`
- `mosquitto-clients` - `mosquitto-clients`
Fuer Container-Builds liegen erste Dockerfiles in:
- `server/backend/Dockerfile`
- `player/agent/Dockerfile`
## Schnellstart ## Schnellstart
Repository klonen: Repository klonen:
@ -77,6 +82,7 @@ Dokumentationsbasis lesen:
3. `TECH-STACK.md` 3. `TECH-STACK.md`
4. `docs/SCHEMA.md` 4. `docs/SCHEMA.md`
5. `docs/OFFENE-ARCHITEKTURFRAGEN.md` 5. `docs/OFFENE-ARCHITEKTURFRAGEN.md`
6. `docs/LAYOUT-JSON.md`
## Build-Kommandos ## Build-Kommandos
@ -100,6 +106,19 @@ go build ./...
make 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 ## Lokaler Start
### Backend lokal starten ### Backend lokal starten
@ -138,6 +157,12 @@ Standardwerte:
- `MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080` - `MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080`
- `MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883` - `MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883`
Optional dateibasiert:
- `MORZ_INFOBOARD_CONFIG=/etc/signage/config.json`
Eine Beispielkonfiguration liegt in `player/config/config.example.json`.
Beispiel: Beispiel:
```bash ```bash
@ -163,6 +188,12 @@ go run ./cmd/agent
4. Agent: strukturierte Logs und Health-Modell einziehen 4. Agent: strukturierte Logs und Health-Modell einziehen
5. Danach erst DB-, MQTT- und API-Funktionalitaet ausbauen 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 ## Arbeitsweise
Empfohlen: Empfohlen:

View file

@ -18,6 +18,7 @@ Die Trennung von `/srv/docker/infoboard-netboot` ist sinnvoll, damit:
- Technologieentscheidungen: `TECH-STACK.md` - Technologieentscheidungen: `TECH-STACK.md`
- Entwicklungsleitfaden: `DEVELOPMENT.md` - Entwicklungsleitfaden: `DEVELOPMENT.md`
- Template-/Kampagnenkonzept: `docs/TEMPLATE-KONZEPT.md` - Template-/Kampagnenkonzept: `docs/TEMPLATE-KONZEPT.md`
- `layout_json` fuer `message_wall`: `docs/LAYOUT-JSON.md`
- Provisionierungskonzept: `docs/PROVISIONIERUNGSKONZEPT.md` - Provisionierungskonzept: `docs/PROVISIONIERUNGSKONZEPT.md`
- Player-Konzept: `docs/PLAYER-KONZEPT.md` - Player-Konzept: `docs/PLAYER-KONZEPT.md`
- Server-Konzept: `docs/SERVER-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 - `compose/` fuer Container-Definitionen und Stack-Bausteine
- `scripts/` fuer Hilfsskripte - `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 ## Naechste sinnvolle Inhalte in der Struktur
- `docs/` fuer weitere technische Detaildokumente - `docs/` fuer weitere technische Detaildokumente

View file

@ -12,3 +12,8 @@ Naechster geplanter Ausbau:
- Rolle `signage_provision` - Rolle `signage_provision`
- Rolle `signage_player` - Rolle `signage_player`
- Beispiel-Inventories fuer Wand- und Einzelanzeigen - Beispiel-Inventories fuer Wand- und Einzelanzeigen
Aktuell vorhanden:
- `inventory.example.yml`
- `site.yml` als Platzhalter-Playbook

View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
listener 1883
allow_anonymous true

21
compose/server-stack.yml Normal file
View 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
View 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

View file

@ -288,3 +288,5 @@ Vor der UI- und Render-Implementierung gilt:
- `message_wall` wird serverseitig in konkrete Zielinhalte aufgeloest - `message_wall` wird serverseitig in konkrete Zielinhalte aufgeloest
- `layout_json` beschreibt die serverseitige Segmentlogik und Admin-Vorschau - `layout_json` beschreibt die serverseitige Segmentlogik und Admin-Vorschau
- Slots werden geometrisch serverseitig interpretiert, nicht im Player berechnet - Slots werden geometrisch serverseitig interpretiert, nicht im Player berechnet
Details dazu stehen in `docs/LAYOUT-JSON.md`.

View file

@ -13,3 +13,4 @@ Geplant:
Aktuell vorhanden: Aktuell vorhanden:
- `agent/` mit erstem Go-Geruest - `agent/` mit erstem Go-Geruest
- `config/config.example.json` als lokale Beispielkonfiguration

10
player/agent/Dockerfile Normal file
View 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"]

View file

@ -14,3 +14,8 @@ Geplante Unterstruktur:
- `cmd/agent/` fuer den Startpunkt - `cmd/agent/` fuer den Startpunkt
- `internal/app/` fuer Initialisierung und Laufzeit - `internal/app/` fuer Initialisierung und Laufzeit
- `internal/config/` fuer Konfiguration - `internal/config/` fuer Konfiguration
Aktuell vorhanden:
- Env-basierte und dateibasierte Konfiguration
- einfacher Laufzeit-Loop mit Heartbeat-Ticks im Log

View file

@ -2,6 +2,8 @@ package app
import ( import (
"fmt" "fmt"
"log"
"os"
"time" "time"
"git.az-it.net/az/morz-infoboard/player/agent/internal/config" "git.az-it.net/az/morz-infoboard/player/agent/internal/config"
@ -9,20 +11,32 @@ import (
type App struct { type App struct {
Config config.Config Config config.Config
logger *log.Logger
} }
func New() (*App, error) { func New() (*App, error) {
cfg := config.Load() cfg, err := config.Load()
if err != nil {
if cfg.ScreenID == "" { return nil, err
return nil, fmt.Errorf("screen id is required")
} }
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 { 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 { for {
time.Sleep(30 * time.Second) a.logger.Printf("heartbeat tick screen=%s", a.Config.ScreenID)
<-ticker.C
} }
} }

View file

@ -1,19 +1,66 @@
package config package config
import "os" import (
"encoding/json"
"fmt"
"os"
)
type Config struct { type Config struct {
ScreenID string ScreenID string `json:"screen_id"`
ServerBaseURL string ServerBaseURL string `json:"server_base_url"`
MQTTBroker string MQTTBroker string `json:"mqtt_broker"`
HeartbeatEvery int `json:"heartbeat_every_seconds"`
} }
func Load() Config { const defaultConfigPath = "/etc/signage/config.json"
return Config{
ScreenID: getenv("MORZ_INFOBOARD_SCREEN_ID", "unset-screen"), func Load() (Config, error) {
ServerBaseURL: getenv("MORZ_INFOBOARD_SERVER_URL", "http://127.0.0.1:8080"), cfg := defaultConfig()
MQTTBroker: getenv("MORZ_INFOBOARD_MQTT_BROKER", "tcp://127.0.0.1:1883"),
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 { func getenv(key, fallback string) string {

View 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)
}
}

View 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
View 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"]

View file

@ -7,10 +7,20 @@ Ziel fuer die erste Ausbaustufe:
- HTTP-API in Go - HTTP-API in Go
- Health-Endpunkt - Health-Endpunkt
- saubere Projektstruktur fuer spaetere API-, Worker- und Datenbankmodule - saubere Projektstruktur fuer spaetere API-, Worker- und Datenbankmodule
- erste serverseitige Aufloesungslogik fuer `message_wall`
Geplante Unterstruktur: Geplante Unterstruktur:
- `cmd/api/` fuer den API-Startpunkt - `cmd/api/` fuer den API-Startpunkt
- `internal/app/` fuer App-Initialisierung - `internal/app/` fuer App-Initialisierung
- `internal/campaigns/` fuer Kampagnen- und Template-Logik
- `internal/httpapi/` fuer HTTP-Routing und Handler - `internal/httpapi/` fuer HTTP-Routing und Handler
- `internal/config/` fuer Konfiguration - `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

View file

@ -1,6 +1,7 @@
package app package app
import ( import (
"errors"
"net/http" "net/http"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config" "git.az-it.net/az/morz-infoboard/server/backend/internal/config"
@ -25,5 +26,10 @@ func New() (*App, error) {
} }
func (a *App) Run() error { func (a *App) Run() error {
return a.server.ListenAndServe() err := a.server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
} }

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

View file

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

View 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"`
}

View 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)
}

View 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)
}

View 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)
}
}

View 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",
},
},
},
})
}

View 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)
}

View file

@ -1,7 +1,6 @@
package httpapi package httpapi
import ( import (
"encoding/json"
"net/http" "net/http"
) )
@ -16,18 +15,18 @@ func NewRouter() http.Handler {
}) })
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, r *http.Request) { 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", "name": "morz-infoboard-backend",
"version": "dev", "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 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)
}