541 lines
22 KiB
Go
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
|
|
}
|