package main import ( "context" "encoding/json" "fmt" "os" "strconv" "strings" "sync" "time" mqtt "github.com/eclipse/paho.mqtt.golang" log "github.com/sirupsen/logrus" ) // MQTT light state values exchanged with Home Assistant. const ( mqttStateOn = "ON" mqttStateOff = "OFF" // mqttDefaultBrightness is used when a light is turned on without a level. mqttDefaultBrightness = 127 // Availability payloads published on the source's availability topic and // referenced by every discovery config so Home Assistant marks the entities // unavailable when the bridge is offline. mqttAvailable = "online" mqttNotAvailable = "offline" ) // MQTTSource exposes a device's zones as one or more dimmable lights over MQTT, // compatible with Home Assistant's JSON light schema. Each light drives its own // configured set of zones; one broker connection is shared by all of them, along // with an optional device-wide scene selector. type MQTTSource struct { cfg *SourceConfig dev *Device binding *sourceBinding client mqtt.Client lights []*mqttLight // availabilityTopic carries the bridge's online/offline state; it backs the // Last Will and every entity's availability so Home Assistant marks them // unavailable when the bridge dies. availabilityTopic string // Monitoring relay state. monitorBase/monitorPrefix root the topics raw "~" // reports are published under; monitorFamilies filters which families to // forward (nil forwards all). monitorBase string monitorPrefix string monitorFamilies map[string]bool // Scene selector state (device-wide), when enabled. sceneTopic string sceneSet string mu sync.Mutex scene int sentScene int } // mqttLight is a single Home Assistant light backed by a fixed set of QSE zones. // Brightness drives every zone in the set; the first zone is mirrored back as the // aggregate state. type mqttLight struct { src *MQTTSource name string topic string topicSet string zones []int mu sync.Mutex state string brightness int sentState string sentBrightness int published bool } // newMQTTSource constructs an MQTT control source and its lights. func newMQTTSource(cfg *SourceConfig, dev *Device, binding *sourceBinding) *MQTTSource { // Root the availability topic on the base topic when one is configured; // otherwise (multi-light setups omit it) fall back to the source name. availBase := cfg.MQTT.Topic if availBase == "" { availBase = serviceName + "/" + mqttSlug(cfg.Name) } s := &MQTTSource{ cfg: cfg, dev: dev, binding: binding, availabilityTopic: availBase + "/availability", monitorBase: availBase, monitorPrefix: cfg.MQTT.MonitorPrefix, sceneTopic: cfg.MQTT.Topic + "/scene", sceneSet: cfg.MQTT.Topic + "/scene/set", scene: -1, // Unknown until reported. sentScene: -2, } // Build the family filter (uppercased); empty means forward everything. if len(cfg.MQTT.Monitor.Families) > 0 { s.monitorFamilies = make(map[string]bool, len(cfg.MQTT.Monitor.Families)) for _, f := range cfg.MQTT.Monitor.Families { s.monitorFamilies[strings.ToUpper(f)] = true } } // Use the configured lights, or synthesize a single light over every zone when // none are listed. lights := cfg.MQTT.Lights if len(lights) == 0 { zones := make([]int, dev.Zones()) for i := range zones { zones[i] = i + 1 } lights = []MQTTLightConfig{{Name: cfg.MQTT.DeviceName, Topic: cfg.MQTT.Topic, Zones: zones}} } for _, lc := range lights { name := lc.Name if name == "" { name = cfg.MQTT.DeviceName } s.lights = append(s.lights, &mqttLight{ src: s, name: name, topic: lc.Topic, topicSet: lc.Topic + "/set", zones: append([]int(nil), lc.Zones...), state: mqttStateOff, }) } return s } // scenesEnabled reports whether this source exposes scene control. func (s *MQTTSource) scenesEnabled() bool { return s.cfg.MQTT.Scenes > 0 } // deviceSlug returns the Home Assistant device identifier shared by this source's // lights, derived from the base topic and falling back to the source name when no // base topic is configured. func (s *MQTTSource) deviceSlug() string { if slug := mqttSlug(s.cfg.MQTT.Topic); slug != "" { return slug } return mqttSlug(s.cfg.Name) } // Name returns the source's configured name. func (s *MQTTSource) Name() string { return s.cfg.Name } // Start connects to the broker and wires the command and feedback paths. func (s *MQTTSource) Start(ctx context.Context) error { // Mirror panel zone feedback into each light's aggregate state. s.dev.OnZoneFeedback(func(zone int, level byte) { for _, l := range s.lights { l.onZoneFeedback(zone, level) } }) // Mirror the panel's active scene into the scene selector, when enabled. if s.scenesEnabled() { s.dev.NoteSceneControl() s.dev.OnSceneFeedback(func(scene int) { s.mu.Lock() s.scene = scene s.mu.Unlock() s.publishScene() }) } // Relay the panel's raw monitoring out to MQTT topics, requesting the desired // monitoring types from the panel first. Enabled when the source configures // any monitoring types or a family filter. if len(s.cfg.MQTT.Monitor.Enable) > 0 || len(s.cfg.MQTT.Monitor.Families) > 0 { s.dev.NoteMonitoring(s.cfg.MQTT.Monitor.Enable...) s.dev.OnMonitor(s.publishMonitor) } clientID := s.cfg.MQTT.ClientID if clientID == "" { clientID = fmt.Sprintf("%s-%s-%d", serviceName, s.cfg.Name, os.Getpid()) } opts := mqtt.NewClientOptions() opts.AddBroker(fmt.Sprintf("tcp://%s:%d", s.cfg.MQTT.Broker, s.cfg.MQTT.Port)) opts.SetClientID(clientID) if s.cfg.MQTT.Username != "" { opts.SetUsername(s.cfg.MQTT.Username) opts.SetPassword(s.cfg.MQTT.Password) } opts.SetAutoReconnect(true) opts.SetConnectRetry(true) opts.SetConnectRetryInterval(5 * time.Second) // Register a retained Last Will so the broker marks the bridge offline if the // connection drops; the matching birth message is published in onConnect. opts.SetWill(s.availabilityTopic, mqttNotAvailable, 1, true) opts.SetOnConnectHandler(s.onConnect) opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) { log.Warnf("[%s] MQTT connection lost: %s", s.cfg.Name, err) }) s.client = mqtt.NewClient(opts) // Connect in the background so a down broker doesn't block startup. go func() { token := s.client.Connect() token.Wait() if err := token.Error(); err != nil { log.Errorf("[%s] MQTT connect failed: %s", s.cfg.Name, err) } }() go func() { <-ctx.Done() s.Stop() }() return nil } // Stop disconnects from the broker. func (s *MQTTSource) Stop() { if s.client != nil && s.client.IsConnected() { // Publish offline explicitly: a graceful disconnect doesn't trigger the // Last Will, so without this the entities would stay "available". if token := s.client.Publish(s.availabilityTopic, 1, true, mqttNotAvailable); token.WaitTimeout(250*time.Millisecond) && token.Error() != nil { log.Warnf("[%s] MQTT availability publish failed on stop: %s", s.cfg.Name, token.Error()) } s.client.Disconnect(250) } } // onConnect subscribes each light's command topic and publishes discovery and // state, then wires up the scene selector when enabled. func (s *MQTTSource) onConnect(c mqtt.Client) { log.Infof("[%s] Connected to MQTT broker", s.cfg.Name) // Announce the bridge online (retained) before discovery so entities appear // available; the Last Will flips this to offline if the connection drops. if token := c.Publish(s.availabilityTopic, 1, true, mqttAvailable); token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT availability publish failed: %s", s.cfg.Name, token.Error()) } for _, l := range s.lights { if token := c.Subscribe(l.topicSet, 0, l.onMessage); token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT subscribe failed for %s: %s", s.cfg.Name, l.topic, token.Error()) } if s.cfg.MQTT.Discovery { l.publishDiscovery() } // Force the next publish to actually go out. l.mu.Lock() l.published = false l.mu.Unlock() l.publishState() } // Wire up the scene selector when enabled. if s.scenesEnabled() { if token := c.Subscribe(s.sceneSet, 0, s.onSceneMessage); token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT scene subscribe failed: %s", s.cfg.Name, token.Error()) } if s.cfg.MQTT.Discovery { s.publishSceneDiscovery() } s.publishScene() } // Wire up the extended controls (shades, ramp buttons, locks, sequence). s.setupControls(c) } // onSceneMessage handles a scene-selection command (a plain scene number). func (s *MQTTSource) onSceneMessage(_ mqtt.Client, msg mqtt.Message) { scene, err := strconv.Atoi(strings.TrimSpace(string(msg.Payload()))) if err != nil || scene < 1 { log.Warnf("[%s] Bad MQTT scene payload: %q", s.cfg.Name, msg.Payload()) return } log.Debugf("[%s] MQTT scene RX %d", s.cfg.Name, scene) if !s.dev.ApplyScene(s.binding, scene) { // Locked out by a higher-priority source; reflect the current scene back. s.publishScene() } } // publishScene publishes the active scene number when it has changed. func (s *MQTTSource) publishScene() { if s.client == nil || !s.client.IsConnected() { return } s.mu.Lock() // Scene 0 is the panel's "unknown/none" report and isn't one of the select's // options (1..N), so don't publish it as a state. if s.scene == s.sentScene || s.scene <= 0 { s.mu.Unlock() return } s.sentScene = s.scene payload := strconv.Itoa(s.scene) s.mu.Unlock() token := s.client.Publish(s.sceneTopic, 0, true, payload) if token.Wait() && token.Error() != nil { log.Warnf("[%s] MQTT scene publish failed: %s", s.cfg.Name, token.Error()) return } log.Debugf("[%s] Published scene %s to %s", s.cfg.Name, payload, s.sceneTopic) } // publishMonitor forwards one monitoring message to an MQTT topic, after applying // the family filter. The topic is /// // and the payload is the trailing field (Lutron reports put the value last), so // every monitored message is relayed losslessly. These are raw state topics for // automations; no Home Assistant discovery is published for them. func (s *MQTTSource) publishMonitor(ev MonitorEvent) { if s.client == nil || !s.client.IsConnected() { return } if s.monitorFamilies != nil && !s.monitorFamilies[ev.Family] { return } segs := []string{s.monitorBase, s.monitorPrefix, strings.ToLower(ev.Family)} payload := ev.Raw if n := len(ev.Fields); n > 0 { segs = append(segs, ev.Fields[:n-1]...) payload = ev.Fields[n-1] } topic := strings.Join(segs, "/") token := s.client.Publish(topic, 0, true, payload) if token.Wait() && token.Error() != nil { log.Warnf("[%s] MQTT monitor publish failed for %s: %s", s.cfg.Name, topic, token.Error()) return } log.Debugf("[%s] Published monitor %q to %s", s.cfg.Name, payload, topic) } // publishSceneDiscovery publishes a Home Assistant select entity for the scenes. func (s *MQTTSource) publishSceneDiscovery() { slug := mqttSlug(s.cfg.MQTT.Topic) + "_scene" topic := fmt.Sprintf("%s/select/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug) options := make([]string, s.cfg.MQTT.Scenes) for i := range options { options[i] = strconv.Itoa(i + 1) } deviceSlug := s.deviceSlug() payload, _ := json.Marshal(map[string]any{ "name": s.cfg.MQTT.DeviceName + " Scene", "unique_id": slug, "command_topic": s.sceneSet, "state_topic": s.sceneTopic, "options": options, "availability_topic": s.availabilityTopic, "payload_available": mqttAvailable, "payload_not_available": mqttNotAvailable, "device": map[string]any{ "identifiers": []string{deviceSlug}, "name": s.cfg.MQTT.DeviceName, "manufacturer": "Lutron", "model": "GRAFIK Eye QS", }, }) token := s.client.Publish(topic, 0, true, payload) if token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT scene discovery publish failed: %s", s.cfg.Name, token.Error()) return } log.Infof("[%s] Published Home Assistant scene discovery to %s", s.cfg.Name, topic) } // setupControls subscribes the command topics and publishes discovery for the // optional extended controls: shade covers, per-light raise/lower/stop buttons, // zone/scene lock switches, and the sequence selector. Each is gated by its // config toggle, mirroring how scenes are enabled. func (s *MQTTSource) setupControls(c mqtt.Client) { base := s.cfg.MQTT.Topic // Shade columns as Home Assistant covers (open/close/stop). for col := 1; col <= s.cfg.MQTT.Shades; col++ { set := fmt.Sprintf("%s/shade/%d/set", base, col) if token := c.Subscribe(set, 0, s.shadeHandler(col)); token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT shade subscribe failed: %s", s.cfg.Name, token.Error()) } if s.cfg.MQTT.Discovery { s.publishCoverDiscovery(col, set) } } // Raise/lower/stop buttons for each light's zones. if s.cfg.MQTT.ZoneRamp { for _, l := range s.lights { for _, action := range []string{"raise", "lower", "stop"} { set := fmt.Sprintf("%s/%s/set", l.topic, action) if token := c.Subscribe(set, 0, s.rampHandler(l, action)); token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT ramp subscribe failed: %s", s.cfg.Name, token.Error()) } if s.cfg.MQTT.Discovery { s.publishButtonDiscovery(l, action, set) } } } } // Zone and scene lock switches. if s.cfg.MQTT.ZoneLock { set := base + "/zone_lock/set" if token := c.Subscribe(set, 0, s.lockHandler(false)); token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT zone-lock subscribe failed: %s", s.cfg.Name, token.Error()) } if s.cfg.MQTT.Discovery { s.publishSwitchDiscovery("zone_lock", "Zone Lock", set) } } if s.cfg.MQTT.SceneLock { set := base + "/scene_lock/set" if token := c.Subscribe(set, 0, s.lockHandler(true)); token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT scene-lock subscribe failed: %s", s.cfg.Name, token.Error()) } if s.cfg.MQTT.Discovery { s.publishSwitchDiscovery("scene_lock", "Scene Lock", set) } } // Sequence selector. if s.cfg.MQTT.Sequence { set := base + "/sequence/set" if token := c.Subscribe(set, 0, s.sequenceHandler); token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT sequence subscribe failed: %s", s.cfg.Name, token.Error()) } if s.cfg.MQTT.Discovery { s.publishSequenceDiscovery(set) } } } // sequenceOptions are the Home Assistant select options for the sequence state, // indexed by the Lutron sequence mode (0 off, 1 scenes 1-4, 2 scenes 5-16). var sequenceOptions = []string{"Off", "Scenes 1-4", "Scenes 5-16"} // shadeHandler drives a shade column from a Home Assistant cover command // (OPEN/CLOSE/STOP, case-insensitive). func (s *MQTTSource) shadeHandler(column int) mqtt.MessageHandler { return func(_ mqtt.Client, msg mqtt.Message) { action := strings.ToLower(strings.TrimSpace(string(msg.Payload()))) log.Debugf("[%s] MQTT shade %d RX %s", s.cfg.Name, column, action) if !s.dev.ApplyShade(s.binding, column, action) { log.Warnf("[%s] Unknown shade action %q", s.cfg.Name, action) } } } // rampHandler starts or stops raising/lowering every zone of a light. func (s *MQTTSource) rampHandler(l *mqttLight, action string) mqtt.MessageHandler { return func(_ mqtt.Client, _ mqtt.Message) { log.Debugf("[%s] MQTT %s %s", s.cfg.Name, l.topic, action) for _, z := range l.zones { switch action { case "raise": s.dev.RaiseZone(s.binding, z) case "lower": s.dev.LowerZone(s.binding, z) case "stop": s.dev.StopZone(s.binding, z) } } } } // lockHandler toggles the zone or scene lock from an ON/OFF switch command. func (s *MQTTSource) lockHandler(scene bool) mqtt.MessageHandler { return func(_ mqtt.Client, msg mqtt.Message) { on := strings.EqualFold(strings.TrimSpace(string(msg.Payload())), mqttStateOn) if scene { s.dev.SetSceneLock(on) } else { s.dev.SetZoneLock(on) } } } // sequenceHandler sets the sequence state from a select command. func (s *MQTTSource) sequenceHandler(_ mqtt.Client, msg mqtt.Message) { choice := strings.TrimSpace(string(msg.Payload())) for mode, opt := range sequenceOptions { if opt == choice { s.dev.SetSequence(mode) return } } log.Warnf("[%s] Unknown sequence option %q", s.cfg.Name, choice) } // haDevice returns the Home Assistant device block shared by this source's // entities so they group under one device. func (s *MQTTSource) haDevice() map[string]any { return map[string]any{ "identifiers": []string{s.deviceSlug()}, "name": s.cfg.MQTT.DeviceName, "manufacturer": "Lutron", "model": "GRAFIK Eye QS", } } // publishConfig publishes a retained Home Assistant discovery config. func (s *MQTTSource) publishConfig(topic string, payload []byte, kind string) { token := s.client.Publish(topic, 0, true, payload) if token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT %s discovery publish failed: %s", s.cfg.Name, kind, token.Error()) return } log.Infof("[%s] Published Home Assistant %s discovery to %s", s.cfg.Name, kind, topic) } // publishCoverDiscovery publishes a Home Assistant cover for a shade column. The // cover is optimistic since the panel reports no shade position. func (s *MQTTSource) publishCoverDiscovery(column int, set string) { slug := fmt.Sprintf("%s_shade_%d", mqttSlug(s.cfg.MQTT.Topic), column) topic := fmt.Sprintf("%s/cover/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug) payload, _ := json.Marshal(map[string]any{ "name": fmt.Sprintf("%s Shade %d", s.cfg.MQTT.DeviceName, column), "unique_id": slug, "command_topic": set, "payload_open": "open", "payload_close": "close", "payload_stop": "stop", "optimistic": true, "availability_topic": s.availabilityTopic, "payload_available": mqttAvailable, "payload_not_available": mqttNotAvailable, "device": s.haDevice(), }) s.publishConfig(topic, payload, "cover") } // publishButtonDiscovery publishes a Home Assistant button for a light's // raise/lower/stop action. func (s *MQTTSource) publishButtonDiscovery(l *mqttLight, action, set string) { slug := fmt.Sprintf("%s_%s", mqttSlug(l.topic), action) topic := fmt.Sprintf("%s/button/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug) label := strings.ToUpper(action[:1]) + action[1:] payload, _ := json.Marshal(map[string]any{ "name": fmt.Sprintf("%s %s", l.name, label), "unique_id": slug, "command_topic": set, "availability_topic": s.availabilityTopic, "payload_available": mqttAvailable, "payload_not_available": mqttNotAvailable, "device": s.haDevice(), }) s.publishConfig(topic, payload, "button") } // publishSwitchDiscovery publishes a Home Assistant switch for a lock. The switch // is optimistic since the panel reports no lock state. func (s *MQTTSource) publishSwitchDiscovery(key, name, set string) { slug := fmt.Sprintf("%s_%s", mqttSlug(s.cfg.MQTT.Topic), key) topic := fmt.Sprintf("%s/switch/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug) payload, _ := json.Marshal(map[string]any{ "name": s.cfg.MQTT.DeviceName + " " + name, "unique_id": slug, "command_topic": set, "payload_on": mqttStateOn, "payload_off": mqttStateOff, "optimistic": true, "availability_topic": s.availabilityTopic, "payload_available": mqttAvailable, "payload_not_available": mqttNotAvailable, "device": s.haDevice(), }) s.publishConfig(topic, payload, "switch") } // publishSequenceDiscovery publishes a Home Assistant select for the sequence // state. It is optimistic since the panel reports no sequence state. func (s *MQTTSource) publishSequenceDiscovery(set string) { slug := mqttSlug(s.cfg.MQTT.Topic) + "_sequence" topic := fmt.Sprintf("%s/select/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug) payload, _ := json.Marshal(map[string]any{ "name": s.cfg.MQTT.DeviceName + " Sequence", "unique_id": slug, "command_topic": set, "options": sequenceOptions, "optimistic": true, "availability_topic": s.availabilityTopic, "payload_available": mqttAvailable, "payload_not_available": mqttNotAvailable, "device": s.haDevice(), }) s.publishConfig(topic, payload, "select") } // onZoneFeedback mirrors the light's representative zone (the first in its set) // into the aggregate light state. func (l *mqttLight) onZoneFeedback(zone int, level byte) { if len(l.zones) == 0 || zone != l.zones[0] { return } l.mu.Lock() l.brightness = int(level) if level == 0 { l.state = mqttStateOff } else { l.state = mqttStateOn } l.mu.Unlock() l.publishState() } // onMessage handles a command on the light's set topic. func (l *mqttLight) onMessage(_ mqtt.Client, msg mqtt.Message) { var cmd struct { State string `json:"state"` Brightness *int `json:"brightness"` } if err := json.Unmarshal(msg.Payload(), &cmd); err != nil { log.Warnf("[%s] Bad MQTT payload for %s: %s", l.src.cfg.Name, l.topic, err) return } log.Debugf("[%s] MQTT RX %s %s", l.src.cfg.Name, l.topic, msg.Payload()) // Compute the requested target level. l.mu.Lock() if cmd.Brightness != nil { l.brightness = clampByte(*cmd.Brightness) } if cmd.State != "" && cmd.State != l.state { l.state = cmd.State // Turning on without a level defaults to roughly half brightness. if l.state == mqttStateOn && l.brightness == 0 { l.brightness = mqttDefaultBrightness } } target := 0 if l.state == mqttStateOn { target = l.brightness } l.mu.Unlock() // Apply the level uniformly across this light's zones only. levels := make(map[int]byte, len(l.zones)) for _, z := range l.zones { levels[z] = byte(target) } if !l.src.dev.ApplyZoneLevels(l.src.binding, levels) { // We can't take control — a higher-priority source (e.g. live DMX) is in // control, or integration control is disabled. Mirror the device's real // zone state back out instead of fighting it. targets := l.src.dev.ZoneTargets() l.mu.Lock() if len(l.zones) > 0 { if z := l.zones[0]; z >= 1 && z <= len(targets) { l.brightness = int(targets[z-1]) } } if l.brightness == 0 { l.state = mqttStateOff } else { l.state = mqttStateOn } l.published = false l.mu.Unlock() } l.publishState() } // publishState publishes the light's aggregate state when it has changed. func (l *mqttLight) publishState() { c := l.src.client if c == nil || !c.IsConnected() { return } l.mu.Lock() if l.published && l.state == l.sentState && l.brightness == l.sentBrightness { l.mu.Unlock() return } l.sentState = l.state l.sentBrightness = l.brightness l.published = true payload, _ := json.Marshal(map[string]any{ "state": l.state, "brightness": l.brightness, }) l.mu.Unlock() token := c.Publish(l.topic, 0, true, payload) if token.Wait() && token.Error() != nil { log.Warnf("[%s] MQTT publish failed for %s: %s", l.src.cfg.Name, l.topic, token.Error()) return } log.Debugf("[%s] Published %s to %s", l.src.cfg.Name, payload, l.topic) } // publishDiscovery publishes a Home Assistant MQTT discovery config (retained) so // the light appears automatically. All of a source's lights are grouped under the // same Home Assistant device. func (l *mqttLight) publishDiscovery() { slug := mqttSlug(l.topic) topic := fmt.Sprintf("%s/light/%s/config", l.src.cfg.MQTT.DiscoveryPrefix, slug) deviceSlug := l.src.deviceSlug() payload, _ := json.Marshal(map[string]any{ "schema": "json", "name": l.name, "unique_id": slug, "state_topic": l.topic, "command_topic": l.topicSet, "brightness": true, "supported_color_modes": []string{"brightness"}, "availability_topic": l.src.availabilityTopic, "payload_available": mqttAvailable, "payload_not_available": mqttNotAvailable, "device": map[string]any{ "identifiers": []string{deviceSlug}, "name": l.src.cfg.MQTT.DeviceName, "manufacturer": "Lutron", "model": "GRAFIK Eye QS", }, }) token := l.src.client.Publish(topic, 0, true, payload) if token.Wait() && token.Error() != nil { log.Errorf("[%s] MQTT discovery publish failed for %s: %s", l.src.cfg.Name, l.topic, token.Error()) return } log.Infof("[%s] Published Home Assistant discovery to %s", l.src.cfg.Name, topic) } // mqttSlug derives a stable identifier from the base topic for HA unique IDs. func mqttSlug(topic string) string { var b strings.Builder for _, r := range topic { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { b.WriteRune(r) } else { b.WriteRune('_') } } return strings.Trim(b.String(), "_") } // clampByte clamps an integer to the 0-255 DMX/brightness range. func clampByte(v int) int { if v < 0 { return 0 } if v > 255 { return 255 } return v }