751 lines
25 KiB
Go
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
|
|
}
|