🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

241
internal/live/mqtt.go Normal file
View File

@@ -0,0 +1,241 @@
package live
import (
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// MQTTConfig holds the bridge configuration loaded from CLI flags / env.
type MQTTConfig struct {
URL string
Username string
Password string
BaseTopic string // e.g., "sendspin/live"
DiscoveryPrefix string // e.g., "homeassistant"
InstanceID string // used in HA discovery object_ids; defaults from BaseTopic
}
type MQTTBridge struct {
cfg MQTTConfig
mgr *Manager
client mqtt.Client
}
func NewMQTTBridge(cfg MQTTConfig, mgr *Manager) *MQTTBridge {
if cfg.BaseTopic == "" {
cfg.BaseTopic = "sendspin/live"
}
if cfg.DiscoveryPrefix == "" {
cfg.DiscoveryPrefix = "homeassistant"
}
if cfg.InstanceID == "" {
cfg.InstanceID = strings.ReplaceAll(cfg.BaseTopic, "/", "_")
}
return &MQTTBridge{cfg: cfg, mgr: mgr}
}
func (b *MQTTBridge) Start() error {
opts := mqtt.NewClientOptions().
AddBroker(b.cfg.URL).
SetClientID("sendspin-live-" + b.cfg.InstanceID).
SetUsername(b.cfg.Username).
SetPassword(b.cfg.Password).
SetAutoReconnect(true).
SetCleanSession(true).
SetKeepAlive(30 * time.Second).
SetWill(b.topic("availability"), "offline", 1, true).
SetOnConnectHandler(b.onConnect).
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Printf("mqtt connection lost: %v", err)
})
b.client = mqtt.NewClient(opts)
tok := b.client.Connect()
tok.Wait()
if err := tok.Error(); err != nil {
return fmt.Errorf("mqtt connect: %w", err)
}
// React to manager state changes.
go b.publishLoop(b.mgr.Subscribe())
return nil
}
func (b *MQTTBridge) Stop() {
if b.client != nil && b.client.IsConnected() {
_ = b.publish("availability", "offline", true)
b.client.Disconnect(250)
}
}
func (b *MQTTBridge) topic(suffix string) string {
return b.cfg.BaseTopic + "/" + suffix
}
func (b *MQTTBridge) publish(suffix, payload string, retain bool) error {
t := b.client.Publish(b.topic(suffix), 1, retain, payload)
t.Wait()
return t.Error()
}
func (b *MQTTBridge) onConnect(_ mqtt.Client) {
log.Printf("mqtt connected")
_ = b.publish("availability", "online", true)
b.publishDiscovery()
// Snapshot the current state and publish.
active, pb, vol, _ := b.mgr.State()
b.publishState(StateChange{
ActivePreset: active,
Playback: pb,
Volume: vol,
Presets: b.mgr.store.List(),
})
// Subscribe command topics.
b.client.Subscribe(b.topic("active_preset/set"), 1, b.handleActivePresetSet)
b.client.Subscribe(b.topic("playback/set"), 1, b.handlePlaybackSet)
b.client.Subscribe(b.topic("volume/set"), 1, b.handleVolumeSet)
}
func (b *MQTTBridge) handleActivePresetSet(_ mqtt.Client, msg mqtt.Message) {
id := strings.TrimSpace(string(msg.Payload()))
if err := b.mgr.SetActivePreset(id); err != nil {
log.Printf("mqtt active_preset/set %q: %v", id, err)
}
}
func (b *MQTTBridge) handlePlaybackSet(_ mqtt.Client, msg mqtt.Message) {
cmd := strings.TrimSpace(string(msg.Payload()))
if err := b.mgr.SetPlayback(cmd); err != nil {
log.Printf("mqtt playback/set %q: %v", cmd, err)
}
}
func (b *MQTTBridge) handleVolumeSet(_ mqtt.Client, msg mqtt.Message) {
v, err := strconv.Atoi(strings.TrimSpace(string(msg.Payload())))
if err != nil {
log.Printf("mqtt volume/set bad payload: %v", err)
return
}
if err := b.mgr.SetVolume(v); err != nil {
log.Printf("mqtt volume/set: %v", err)
}
}
func (b *MQTTBridge) publishLoop(events <-chan StateChange) {
for ev := range events {
b.publishState(ev)
}
}
func (b *MQTTBridge) publishState(ev StateChange) {
_ = b.publish("active_preset", ev.ActivePreset, true)
_ = b.publish("playback", string(ev.Playback), true)
_ = b.publish("volume", strconv.Itoa(ev.Volume), true)
type p struct {
ID string `json:"id"`
Name string `json:"name"`
}
out := make([]p, 0, len(ev.Presets))
for _, pr := range ev.Presets {
out = append(out, p{ID: pr.ID, Name: pr.Name})
}
data, _ := json.Marshal(out)
_ = b.publish("presets", string(data), true)
// HA discovery for the preset select depends on the list of preset
// IDs, so republish the select config whenever presets change.
b.publishSelectConfig(ev.Presets)
}
// publishDiscovery emits HA MQTT discovery payloads for the entities
// this bridge exposes. Retained + idempotent so repeating on every
// connect is harmless.
func (b *MQTTBridge) publishDiscovery() {
b.publishSelectConfig(b.mgr.store.List())
// Playback select.
b.publishConfig("select", "playback", map[string]any{
"name": "Sendspin Live Playback",
"unique_id": b.cfg.InstanceID + "_playback",
"command_topic": b.topic("playback/set"),
"state_topic": b.topic("playback"),
"options": []string{"playing", "paused", "off"},
"availability_topic": b.topic("availability"),
"device": b.deviceBlock(),
})
// Volume number.
b.publishConfig("number", "volume", map[string]any{
"name": "Sendspin Live Volume",
"unique_id": b.cfg.InstanceID + "_volume",
"command_topic": b.topic("volume/set"),
"state_topic": b.topic("volume"),
"min": 0,
"max": 100,
"step": 1,
"mode": "slider",
"availability_topic": b.topic("availability"),
"device": b.deviceBlock(),
})
// Availability binary_sensor.
b.publishConfig("binary_sensor", "availability", map[string]any{
"name": "Sendspin Live Available",
"unique_id": b.cfg.InstanceID + "_availability",
"state_topic": b.topic("availability"),
"payload_on": "online",
"payload_off": "offline",
"device_class": "connectivity",
"device": b.deviceBlock(),
})
}
func (b *MQTTBridge) publishSelectConfig(presets []Preset) {
options := make([]string, 0, len(presets)+1)
options = append(options, "")
for _, p := range presets {
options = append(options, p.ID)
}
b.publishConfig("select", "active_preset", map[string]any{
"name": "Sendspin Live Preset",
"unique_id": b.cfg.InstanceID + "_active_preset",
"command_topic": b.topic("active_preset/set"),
"state_topic": b.topic("active_preset"),
"options": options,
"availability_topic": b.topic("availability"),
"device": b.deviceBlock(),
})
}
func (b *MQTTBridge) publishConfig(component, objectID string, payload map[string]any) {
topic := fmt.Sprintf("%s/%s/%s/%s/config",
b.cfg.DiscoveryPrefix, component, b.cfg.InstanceID, objectID)
data, err := json.Marshal(payload)
if err != nil {
log.Printf("mqtt discovery marshal: %v", err)
return
}
t := b.client.Publish(topic, 1, true, data)
t.Wait()
if err := t.Error(); err != nil {
log.Printf("mqtt publish %s: %v", topic, err)
}
}
func (b *MQTTBridge) deviceBlock() map[string]any {
return map[string]any{
"identifiers": []string{b.cfg.InstanceID},
"name": "Sendspin Live",
"manufacturer": "sendspin",
"model": "live-server",
}
}