🎉 live server seems to be working now
This commit is contained in:
241
internal/live/mqtt.go
Normal file
241
internal/live/mqtt.go
Normal 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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user