- GET /manage: neue Übersichtsseite mit Bulma-Karten für screen_user mit ≥2 Screens
- handleScreenUserRedirect leitet bei ≥2 Screens auf /manage statt auf ersten Screen
- On-Demand-Screenshot-Flow via MQTT:
- Backend publiziert signage/screen/{slug}/screenshot-request beim Seitenaufruf
- Player-Agent empfängt Topic, ruft TakeAndSendOnce() auf
- Player POST /api/v1/player/screenshot → Backend speichert in ScreenshotStore (RAM)
- GET /api/v1/screens/{screenId}/screenshot liefert gespeichertes Bild (authOnly)
- ScreenshotStore: In-Memory, thread-safe, kein Persistenz-Overhead
- JS-Retry nach 4s in Templates (Screenshot braucht 1-3s für MQTT-Roundtrip)
- manageTmpl zeigt Screenshot-Thumbnail beim Einzelscreen-Aufruf
- Doku: neue Endpoints, MQTT-Topics, Screenshot-Flow in SERVER-KONZEPT.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
118 lines
3.3 KiB
Go
118 lines
3.3 KiB
Go
// Package mqttnotifier publishes playlist-changed notifications to MQTT.
|
|
// It is safe for concurrent use and applies per-screen debouncing so that
|
|
// rapid edits within a 2-second window produce at most one MQTT message.
|
|
package mqttnotifier
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
|
)
|
|
|
|
const (
|
|
// debounceDuration is the minimum time between two publish calls for the
|
|
// same screen. Any change arriving within this window resets the timer.
|
|
debounceDuration = 2 * time.Second
|
|
)
|
|
|
|
// Notifier publishes "playlist-changed" MQTT messages with per-screen debounce.
|
|
// If no broker URL is configured it behaves as a no-op (all methods are safe
|
|
// to call and do nothing).
|
|
type Notifier struct {
|
|
client mqtt.Client // nil when disabled
|
|
|
|
mu sync.Mutex
|
|
timers map[string]*time.Timer // keyed by screenSlug
|
|
}
|
|
|
|
// New creates a Notifier connected to broker (e.g. "tcp://mosquitto:1883").
|
|
// username/password may be empty. Returns a no-op Notifier when broker == "".
|
|
func New(broker, username, password string) *Notifier {
|
|
n := &Notifier{timers: make(map[string]*time.Timer)}
|
|
if broker == "" {
|
|
return n
|
|
}
|
|
|
|
opts := mqtt.NewClientOptions().
|
|
AddBroker(broker).
|
|
SetClientID("morz-backend").
|
|
SetCleanSession(true).
|
|
SetAutoReconnect(true).
|
|
SetConnectRetry(true).
|
|
SetConnectRetryInterval(10 * time.Second)
|
|
|
|
if username != "" {
|
|
opts.SetUsername(username)
|
|
opts.SetPassword(password)
|
|
}
|
|
|
|
n.client = mqtt.NewClient(opts)
|
|
n.client.Connect() // non-blocking; paho retries in background
|
|
return n
|
|
}
|
|
|
|
// Topic returns the MQTT topic for a screen's playlist-changed notification.
|
|
func Topic(screenSlug string) string {
|
|
return fmt.Sprintf("signage/screen/%s/playlist-changed", screenSlug)
|
|
}
|
|
|
|
// NotifyChanged schedules a publish for screenSlug. If another call for the
|
|
// same screen arrives within debounceDuration, the timer is reset (debounce).
|
|
// The method returns immediately; the actual publish happens in a goroutine.
|
|
func (n *Notifier) NotifyChanged(screenSlug string) {
|
|
if n.client == nil {
|
|
return
|
|
}
|
|
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
|
|
// Reset existing timer if present (debounce).
|
|
if t, ok := n.timers[screenSlug]; ok {
|
|
t.Stop()
|
|
}
|
|
|
|
n.timers[screenSlug] = time.AfterFunc(debounceDuration, func() {
|
|
n.mu.Lock()
|
|
delete(n.timers, screenSlug)
|
|
n.mu.Unlock()
|
|
|
|
n.publish(screenSlug)
|
|
})
|
|
}
|
|
|
|
// RequestScreenshot publishes a screenshot-request message to the screen's MQTT topic.
|
|
// It is a no-op when the client is not connected.
|
|
func (n *Notifier) RequestScreenshot(screenSlug string) {
|
|
if n.client == nil {
|
|
return
|
|
}
|
|
topic := fmt.Sprintf("signage/screen/%s/screenshot-request", screenSlug)
|
|
payload := []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().UnixMilli()))
|
|
token := n.client.Publish(topic, 0, false, payload)
|
|
token.WaitTimeout(3 * time.Second)
|
|
}
|
|
|
|
func (n *Notifier) publish(screenSlug string) {
|
|
topic := Topic(screenSlug)
|
|
payload := []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().UnixMilli()))
|
|
token := n.client.Publish(topic, 0, false, payload)
|
|
token.WaitTimeout(3 * time.Second)
|
|
// Errors are silently dropped — the 60 s polling in the agent is the fallback.
|
|
}
|
|
|
|
// Close disconnects the MQTT client gracefully.
|
|
func (n *Notifier) Close() {
|
|
if n.client == nil {
|
|
return
|
|
}
|
|
n.mu.Lock()
|
|
for _, t := range n.timers {
|
|
t.Stop()
|
|
}
|
|
n.timers = make(map[string]*time.Timer)
|
|
n.mu.Unlock()
|
|
n.client.Disconnect(250)
|
|
}
|