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