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

541 lines
22 KiB
Go

package main
import (
"fmt"
"io"
"os"
"os/user"
"path"
"path/filepath"
"runtime"
"slices"
"github.com/kkyr/fig"
log "github.com/sirupsen/logrus"
"gopkg.in/natefinch/lumberjack.v2"
)
// Config is the root configuration: a set of Lutron devices and the control
// sources that drive them.
type Config struct {
Log *LogConfig `fig:"log" yaml:"log"`
Devices []*DeviceConfig `fig:"devices" yaml:"devices"`
Sources []*SourceConfig `fig:"sources" yaml:"sources"`
}
// LogConfig controls log verbosity, format, and output destinations.
type LogConfig struct {
// Limit the log output by the log level.
Level string `fig:"level" yaml:"level" enum:"debug,info,warn,error" default:"info"`
// How the log output should be formatted.
Type string `fig:"type" yaml:"type" enum:"json,console" default:"console"`
// The outputs the log should go to. `console` goes to stderr, a file path logs
// to that file, and `default-file` logs to /var/log/<name>.log (or beside the
// executable if that is not writable).
Outputs []string `fig:"outputs" yaml:"outputs" default:"console"`
// Maximum size of the log file in megabytes before it gets rotated.
MaxSize int `fig:"max_size" yaml:"max_size" default:"1"`
// Maximum number of backups to save.
MaxBackups int `fig:"max_backups" yaml:"max_backups" default:"3"`
// Maximum number of days to retain old log files.
MaxAge int `fig:"max_age" yaml:"max_age" default:"0"`
// Use local system time instead of UTC for rotated backup file names.
LocalTime *bool `fig:"local_time" yaml:"local_time" default:"true"`
// Whether rotated logs should be compressed.
Compress *bool `fig:"compress" yaml:"compress" default:"true"`
}
// DeviceConfig describes one Lutron integration interface (e.g. a GRAFIK Eye QS
// reached through a QSE-CI-NWK-E) and how to connect to it.
type DeviceConfig struct {
// Name referenced by sources to bind to this device.
Name string `fig:"name" yaml:"name"`
// Transport used to reach the device.
Transport string `fig:"transport" yaml:"transport" enum:"serial,telnet" default:"serial"`
Serial SerialConfig `fig:"serial" yaml:"serial"`
Telnet TelnetConfig `fig:"telnet" yaml:"telnet"`
// IntegrationID bound to the GRAFIK Eye main unit in Lutron's programming.
IntegrationID int `fig:"integration_id" yaml:"integration_id" default:"1"`
// Zones is the number of controllable zones on the model (max 24).
Zones int `fig:"zones" yaml:"zones" default:"6"`
// Fade sent with each level command; "00:00" means instant.
Fade string `fig:"fade" yaml:"fade" default:"00:00"`
// EnableComponent and DisableComponent are the GRAFIK Eye QS phantom-button
// component numbers that, when pressed, signal integration control is
// (re)enabled or disabled. These are application-specific (programmed on the
// unit) and are not standard scene buttons; a press is "~DEVICE,<id>,<component>,3".
// Both default to 0 (disabled): the signal is only acted on when a non-zero
// component is configured, so an unrelated button can't silently disable the
// bridge. Set them to match your unit's programming to enable the feature.
EnableComponent int `fig:"enable_component" yaml:"enable_component" default:"0"`
DisableComponent int `fig:"disable_component" yaml:"disable_component" default:"0"`
Monitoring MonitoringConfig `fig:"monitoring" yaml:"monitoring"`
Reliability ReliabilityConfig `fig:"reliability" yaml:"reliability"`
}
// MonitoringConfig controls the #MONITORING commands sent on connect. The
// protocol advises enabling only the monitoring you need; by default we ensure
// zone-level and reply monitoring are on (so feedback works even over RS-232 or
// if the unit's programmed defaults were changed) plus scene monitoring when a
// scene source is attached.
type MonitoringConfig struct {
// Manage enables the on-connect monitoring setup; set false to leave the
// unit's programmed monitoring untouched.
Manage *bool `fig:"manage" yaml:"manage" default:"true"`
// Enable lists extra monitoring type numbers to turn on.
Enable []int `fig:"enable" yaml:"enable"`
// Disable lists monitoring type numbers to turn off (e.g. to silence noise).
Disable []int `fig:"disable" yaml:"disable"`
}
// MonitorForward selects which panel monitoring a source relays outward. The
// device enables the union of every source's requested types on connect, then
// each source filters the resulting "~" reports it forwards.
type MonitorForward struct {
// Enable lists #MONITORING type numbers to request from the panel for this
// source (1 diagnostic, 2 event, 3 button, 4 LED, 5 zone, 8 scene, 16
// sequence, 17 HVAC, 18 mode; 255 requests all). Empty relays whatever the
// panel already reports without requesting extra types.
Enable []int `fig:"enable" yaml:"enable"`
// Families optionally restricts which "~" message families are forwarded
// (e.g. ["DEVICE","GROUP"]); empty forwards every family.
Families []string `fig:"families" yaml:"families"`
}
// SerialConfig holds the serial-transport connection settings.
type SerialConfig struct {
// Device path of the serial adapter (find yours with `ls -lah /dev/serial/by-id/`).
Device string `fig:"device" yaml:"device"`
// Baud must match the dipswitch on the QSE-CI-NWK-E.
Baud int `fig:"baud" yaml:"baud" default:"115200"`
}
// TelnetConfig holds the telnet-transport connection settings. The QSE-CI-NWK-E
// prompts for an integration login; username/password are sent when prompted.
type TelnetConfig struct {
// Address is host:port; port 23 is assumed when omitted.
Address string `fig:"address" yaml:"address"`
// Username sent at the "login:" prompt (the integration login, e.g. "nwk").
Username string `fig:"username" yaml:"username"`
// Password sent at the "password:" prompt, when one is presented.
Password string `fig:"password" yaml:"password"`
}
// ReliabilityConfig tunes the connection supervision behavior.
type ReliabilityConfig struct {
// RxTimeoutSec is how long the link may be silent (while writing) before the
// watchdog probes and ultimately reconnects.
RxTimeoutSec int `fig:"rx_timeout_sec" yaml:"rx_timeout_sec" default:"60"`
// WatchdogIntervalSec is the watchdog tick interval.
WatchdogIntervalSec int `fig:"watchdog_interval_sec" yaml:"watchdog_interval_sec" default:"15"`
// ReconnectBackoffMinSec is the initial reconnect backoff.
ReconnectBackoffMinSec int `fig:"reconnect_backoff_min_sec" yaml:"reconnect_backoff_min_sec" default:"1"`
// ReconnectBackoffMaxSec caps the reconnect backoff.
ReconnectBackoffMaxSec int `fig:"reconnect_backoff_max_sec" yaml:"reconnect_backoff_max_sec" default:"30"`
// SendAllIntervalSec is how often every zone level is resent to prevent drift;
// 0 disables the periodic resend, sending zones only when a target changes.
SendAllIntervalSec int `fig:"send_all_interval_sec" yaml:"send_all_interval_sec" default:"10"`
// ResetCooldownSec rate-limits the #RESET,0 recovery when the NWK wedges.
ResetCooldownSec int `fig:"reset_cooldown_sec" yaml:"reset_cooldown_sec" default:"10"`
}
// SourceConfig describes one control source that drives a device's zones.
type SourceConfig struct {
// Name identifies the source in logs.
Name string `fig:"name" yaml:"name"`
// Type selects the source implementation.
Type string `fig:"type" yaml:"type" enum:"mqtt,sacn,artnet,osc"`
// Device is the target device name this source controls.
Device string `fig:"device" yaml:"device"`
// Priority arbitrates between sources; a higher-priority source that is active
// locks out lower-priority ones (e.g. a live DMX stream over MQTT).
Priority int `fig:"priority" yaml:"priority" default:"0"`
// HoldSec is how long after its last update a source stays "active" for the
// purpose of locking out lower-priority sources.
HoldSec float64 `fig:"hold_sec" yaml:"hold_sec" default:"5"`
// Fade overrides the zone fade time this source applies when setting levels
// (format "SS", "MM:SS", or "HH:MM:SS"). When empty, DMX sources (sacn/artnet)
// default to instant ("00:00") so the console owns the crossfade, and other
// sources use the device's configured fade.
Fade string `fig:"fade" yaml:"fade"`
MQTT MQTTConfig `fig:"mqtt" yaml:"mqtt"`
SACN SACNConfig `fig:"sacn" yaml:"sacn"`
ArtNet ArtNetConfig `fig:"artnet" yaml:"artnet"`
OSC OSCConfig `fig:"osc" yaml:"osc"`
}
// OSCConfig holds settings for an OSC (Open Sound Control) source. The server
// listens for UDP messages whose address, under the configured prefix, selects an
// operation (zone level, raise/lower/stop, scene, shade, lock, sequence). See the
// example config for the address scheme.
type OSCConfig struct {
// Listen is the host:port the OSC server binds.
Listen string `fig:"listen" yaml:"listen" default:"0.0.0.0:9000"`
// Prefix is the OSC address namespace this source responds under.
Prefix string `fig:"prefix" yaml:"prefix" default:"/lutron"`
// Monitor selects which panel monitoring this source streams out as OSC.
Monitor MonitorForward `fig:"monitor" yaml:"monitor"`
// StreamTo lists host:port destinations the monitoring feedback is sent to.
// Streaming is off when empty.
StreamTo []string `fig:"stream_to" yaml:"stream_to"`
// LevelAsFloat sends zone levels as a 0-1 float (matching the input level
// convention) when true; false sends a raw 0-255 integer. Defaults to true.
LevelAsFloat *bool `fig:"level_as_float" yaml:"level_as_float" default:"true"`
}
// MQTTConfig holds settings for an MQTT control source, including the optional
// Home Assistant discovery announcement.
type MQTTConfig struct {
// Broker hostname or IP of the MQTT broker.
Broker string `fig:"broker" yaml:"broker"`
// Port of the MQTT broker.
Port int `fig:"port" yaml:"port" default:"1883"`
// Topic is the base state topic; commands are received on <topic>/set.
Topic string `fig:"topic" yaml:"topic"`
// Username for MQTT authentication.
Username string `fig:"username" yaml:"username"`
// Password for MQTT authentication.
Password string `fig:"password" yaml:"password"`
// ClientID for the MQTT connection; auto-generated when empty.
ClientID string `fig:"client_id" yaml:"client_id"`
// Discovery publishes a Home Assistant MQTT discovery config when true.
Discovery bool `fig:"discovery" yaml:"discovery"`
// DiscoveryPrefix is the Home Assistant discovery topic prefix.
DiscoveryPrefix string `fig:"discovery_prefix" yaml:"discovery_prefix" default:"homeassistant"`
// DeviceName is the friendly name announced to Home Assistant.
DeviceName string `fig:"device_name" yaml:"device_name" default:"Lutron"`
// Scenes, when > 0, exposes a scene selector (1..Scenes) over MQTT and, with
// discovery, a Home Assistant select entity. Commands arrive on <topic>/scene/set
// and the active scene is published to <topic>/scene.
Scenes int `fig:"scenes" yaml:"scenes" default:"0"`
// Shades, when > 0, exposes that many shade columns (1-3) as Home Assistant
// cover entities (open/close/stop). Commands arrive on <topic>/shade/<n>/set.
Shades int `fig:"shades" yaml:"shades" default:"0"`
// ZoneRamp exposes raise/lower/stop buttons for each light's zones.
ZoneRamp bool `fig:"zone_ramp" yaml:"zone_ramp"`
// ZoneLock exposes a zone-lock switch (QS Standalone).
ZoneLock bool `fig:"zone_lock" yaml:"zone_lock"`
// SceneLock exposes a scene-lock switch (QS Standalone).
SceneLock bool `fig:"scene_lock" yaml:"scene_lock"`
// Sequence exposes a scene-sequence select (Off / Scenes 1-4 / Scenes 5-16).
Sequence bool `fig:"sequence" yaml:"sequence"`
// Lights defines the individual lights exposed by this source, each driving
// its own set of zones. When omitted, a single light is synthesized from the
// base topic and device_name driving every zone.
Lights []MQTTLightConfig `fig:"lights" yaml:"lights"`
// Monitor selects which panel monitoring this source publishes to MQTT.
Monitor MonitorForward `fig:"monitor" yaml:"monitor"`
// MonitorPrefix is the sub-topic monitoring is published under, i.e.
// <topic>/<MonitorPrefix>/<family>/...
MonitorPrefix string `fig:"monitor_prefix" yaml:"monitor_prefix" default:"monitor"`
}
// MQTTLightConfig describes one Home Assistant light exposed by an MQTT source and
// the set of QSE zones it controls. The light's brightness drives every zone in
// the set; the first zone is mirrored back as the aggregate state.
type MQTTLightConfig struct {
// Name is the friendly name announced to Home Assistant; defaults to the
// source's device_name when empty.
Name string `fig:"name" yaml:"name"`
// Topic is the base state topic for this light; commands arrive on <topic>/set.
Topic string `fig:"topic" yaml:"topic"`
// Zones lists the 1-indexed QSE zones this light controls together.
Zones []int `fig:"zones" yaml:"zones"`
}
// SACNConfig holds settings for an sACN (E1.31) control source.
type SACNConfig struct {
// Bind address for the receiver socket ("" binds all interfaces).
Bind string `fig:"bind" yaml:"bind"`
// Interface is the network interface name used for multicast (optional).
Interface string `fig:"interface" yaml:"interface"`
// Universe is the sACN universe to join.
Universe uint16 `fig:"universe" yaml:"universe" default:"1"`
DMXMap `fig:",squash"`
}
// ArtNetConfig holds settings for an Art-Net control source.
type ArtNetConfig struct {
// Bind is the IP the Art-Net node listens on.
Bind string `fig:"bind" yaml:"bind" default:"0.0.0.0"`
// Net is the Art-Net Net (top 7 bits of the 15-bit port address).
Net uint8 `fig:"net" yaml:"net" default:"0"`
// SubNet is the high nibble of the low byte of the port address.
SubNet uint8 `fig:"subnet" yaml:"subnet" default:"0"`
// Universe is the low nibble of the low byte of the port address.
Universe uint8 `fig:"universe" yaml:"universe" default:"0"`
// TimeoutSec is how long the universe may be silent before the source is
// treated as lost: its zones black out and its arbitration lock is released.
// Art-Net senders may slow retransmission of an unchanged look to once every
// ~4 s, so the default leaves margin above that.
TimeoutSec float64 `fig:"timeout_sec" yaml:"timeout_sec" default:"5"`
DMXMap `fig:",squash"`
}
// DMXMap describes how a DMX universe's channels map to a device's zones and
// scenes. Use the sequential layout (start_address plus zone/scene counts) for
// the common case, or an explicit channels list for full control. When channels
// is set it takes precedence over the sequential layout.
type DMXMap struct {
// StartAddress is the 0-indexed channel of the first zone in the sequential
// layout. Zones occupy start_address.. and scenes follow them.
StartAddress int `fig:"start_address" yaml:"start_address" default:"0"`
// Zones is the number of sequential zone channels (0 = the device's zone count).
Zones int `fig:"zones" yaml:"zones" default:"0"`
// Scenes, when > 0, adds one scene-select channel after the zones; its value
// (1..Scenes) selects the scene to trigger and 0 means no action.
Scenes int `fig:"scenes" yaml:"scenes" default:"0"`
// Channels is an explicit per-channel map; when set it replaces the sequential
// layout above.
Channels []DMXChannelConfig `fig:"channels" yaml:"channels"`
}
// DMXChannelConfig maps one DMX channel to a zone level or scene selection.
type DMXChannelConfig struct {
// Channel is the 1-indexed DMX address (1-512).
Channel int `fig:"channel" yaml:"channel"`
// Type selects what the channel controls: "zone" sets a zone level, "scene" is a
// scene-select channel whose value triggers the matching scene.
Type string `fig:"type" yaml:"type" enum:"zone,scene"`
// Zone is the target zone number for type "zone".
Zone int `fig:"zone" yaml:"zone"`
}
// Apply configures the global logger from the log configuration.
func (l *LogConfig) Apply() {
// Apply the level.
switch l.Level {
case "debug":
log.SetLevel(log.DebugLevel)
case "info":
log.SetLevel(log.InfoLevel)
case "warn":
log.SetLevel(log.WarnLevel)
default:
log.SetLevel(log.ErrorLevel)
}
// Apply the formatter.
switch l.Type {
case "json":
log.SetFormatter(&log.JSONFormatter{})
default:
log.SetFormatter(&log.TextFormatter{})
}
// Resolve and attach each configured output.
var outputs []io.Writer
for _, output := range l.Outputs {
// Console output goes to stderr.
if output == "console" {
outputs = append(outputs, os.Stderr)
continue
}
// Resolve the default file location when requested.
if output == "default-file" {
resolved, ok := defaultLogPath()
if !ok {
log.Println("Unable to find a writable log path to save log to.")
continue
}
output = resolved
}
// Rotate the file output via lumberjack.
outputs = append(outputs, &lumberjack.Logger{
Filename: output,
MaxSize: l.MaxSize,
MaxBackups: l.MaxBackups,
MaxAge: l.MaxAge,
LocalTime: *l.LocalTime,
Compress: *l.Compress,
})
}
if len(outputs) != 0 {
log.SetOutput(io.MultiWriter(outputs...))
}
}
// defaultLogPath returns a writable path for the `default-file` output, trying
// /var/log first and falling back to beside the executable.
func defaultLogPath() (string, bool) {
logName := fmt.Sprintf("%s.log", serviceName)
// On *nix, prefer /var/log when writable.
if runtime.GOOS != "windows" {
logPath := filepath.Join("/var/log", logName)
if f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644); err == nil {
f.Close()
return logPath, true
}
}
// Otherwise fall back to the executable's directory.
exe, err := os.Executable()
if err != nil {
return "", false
}
logPath := filepath.Join(filepath.Dir(exe), logName)
if f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644); err == nil {
f.Close()
return logPath, true
}
return "", false
}
// ReadConfig loads, validates, and applies the configuration.
func (a *App) ReadConfig() {
usr, err := user.Current()
if err != nil {
log.Fatal(err)
}
// Configuration search paths.
localConfig, _ := filepath.Abs("./config.yaml")
homeDirConfig := filepath.Join(usr.HomeDir, ".config", serviceName, "config.yaml")
etcConfig := filepath.Join("/etc", serviceName, "config.yaml")
// Determine which configuration file to use.
var configFile string
if app.flags.ConfigPath != "" {
if _, err := os.Stat(app.flags.ConfigPath); err == nil {
configFile = app.flags.ConfigPath
}
}
if configFile == "" {
for _, candidate := range []string{localConfig, homeDirConfig, etcConfig} {
if _, err := os.Stat(candidate); err == nil {
configFile = candidate
break
}
}
}
if configFile == "" {
log.Fatal("Unable to find a configuration file.")
}
// Load the configuration file.
config := &Config{Log: &LogConfig{}}
filePath, fileName := path.Split(configFile)
if filePath == "" {
filePath = "."
}
err = fig.Load(config, fig.File(fileName), fig.Dirs(filePath))
if err != nil {
log.Fatalf("Error parsing configuration: %s", err)
}
// Validate cross-field constraints fig can't express in tags.
if err := config.Validate(); err != nil {
log.Fatalf("Invalid configuration: %s", err)
}
// The verbose flag forces debug-level logging and ensures the console output
// is present, overriding the configured log level so debug output is visible.
if app.flags.Verbose {
config.Log.Level = "debug"
if !slices.Contains(config.Log.Outputs, "console") {
config.Log.Outputs = append(config.Log.Outputs, "console")
}
}
// Apply log configuration and store globally.
config.Log.Apply()
app.config = config
log.Infof("Loaded configuration from %s", configFile)
}
// Validate checks structural constraints across the configuration.
func (c *Config) Validate() error {
if len(c.Devices) == 0 {
return fmt.Errorf("no devices configured")
}
// Validate devices and index them by name.
devices := make(map[string]*DeviceConfig, len(c.Devices))
for i, d := range c.Devices {
if d.Name == "" {
return fmt.Errorf("device %d: name is required", i)
}
if devices[d.Name] != nil {
return fmt.Errorf("duplicate device name %q", d.Name)
}
devices[d.Name] = d
switch d.Transport {
case "serial":
if d.Serial.Device == "" {
return fmt.Errorf("device %q: serial.device is required", d.Name)
}
case "telnet":
if d.Telnet.Address == "" {
return fmt.Errorf("device %q: telnet.address is required", d.Name)
}
}
if d.Zones < 1 || d.Zones > 24 {
return fmt.Errorf("device %q: zones must be 1-24", d.Name)
}
}
// Validate sources reference an existing device.
for i, s := range c.Sources {
if s.Name == "" {
return fmt.Errorf("source %d: name is required", i)
}
dev := devices[s.Device]
if dev == nil {
return fmt.Errorf("source %q: unknown device %q", s.Name, s.Device)
}
if s.Type == "mqtt" {
if s.MQTT.Broker == "" {
return fmt.Errorf("source %q: mqtt.broker is required", s.Name)
}
// A base topic is needed for the synthesized single light and for the
// scene selector; per-light topics cover the multi-light case.
if len(s.MQTT.Lights) == 0 && s.MQTT.Topic == "" {
return fmt.Errorf("source %q: mqtt.topic is required", s.Name)
}
if s.MQTT.Scenes > 0 && s.MQTT.Topic == "" {
return fmt.Errorf("source %q: mqtt.topic is required when scenes are enabled", s.Name)
}
if s.MQTT.Shades < 0 || s.MQTT.Shades > 3 {
return fmt.Errorf("source %q: mqtt.shades must be 0-3", s.Name)
}
// The shade/lock/sequence controls are rooted on the base topic.
if (s.MQTT.Shades > 0 || s.MQTT.ZoneLock || s.MQTT.SceneLock || s.MQTT.Sequence) && s.MQTT.Topic == "" {
return fmt.Errorf("source %q: mqtt.topic is required for shade/lock/sequence controls", s.Name)
}
// Validate each configured light's topic and zone set.
seen := make(map[string]bool, len(s.MQTT.Lights))
for j, l := range s.MQTT.Lights {
if l.Topic == "" {
return fmt.Errorf("source %q: mqtt.lights[%d].topic is required", s.Name, j)
}
if seen[l.Topic] {
return fmt.Errorf("source %q: duplicate mqtt light topic %q", s.Name, l.Topic)
}
seen[l.Topic] = true
if len(l.Zones) == 0 {
return fmt.Errorf("source %q: mqtt light %q controls no zones", s.Name, l.Topic)
}
for _, z := range l.Zones {
if z < 1 || z > dev.Zones {
return fmt.Errorf("source %q: mqtt light %q zone %d out of range 1-%d", s.Name, l.Topic, z, dev.Zones)
}
}
}
}
}
return nil
}