lutron-control/mqtt.go
2026-06-28 08:32:22 -05:00

751 lines
25 KiB
Go

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 <base>/<prefix>/<family>/<identifying fields>
// 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
}