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/.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,,,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 /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 /scene/set // and the active scene is published to /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 /shade//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. // ///... 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 /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 }