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

142 lines
3.5 KiB
Go

package main
import (
"context"
"os"
"os/signal"
"syscall"
"time"
"github.com/coreos/go-systemd/v22/daemon"
log "github.com/sirupsen/logrus"
)
const (
serviceName = "lutron-control"
serviceDescription = "Bridges DMX (sACN/Art-Net) and MQTT control to Lutron GRAFIK Eye QS zones over serial or telnet"
serviceVersion = "0.1.0"
)
// App is the global application structure tying together configuration, the
// Lutron devices, and the control sources that drive them.
type App struct {
flags *Flags
config *Config
devices []*Device
sources []Source
}
var app *App
func main() {
app = new(App)
app.ParseFlags()
app.ReadConfig()
// Discovery mode: query each device for its integration IDs and exit.
if app.flags.Discover {
runDiscovery(app.config.Devices)
return
}
// Build a Lutron device for each configured interface, keyed by name so
// sources can bind to their target.
byName := make(map[string]*Device)
for _, dc := range app.config.Devices {
d := NewDevice(dc)
app.devices = append(app.devices, d)
byName[dc.Name] = d
}
// Build each control source and bind it to its target device.
for _, sc := range app.config.Sources {
dev := byName[sc.Device]
s, err := NewSource(sc, dev)
if err != nil {
log.Fatalf("Source %q: %s", sc.Name, err)
}
app.sources = append(app.sources, s)
}
// Context cancelled on shutdown so every background loop can stop cleanly.
ctx, cancel := context.WithCancel(context.Background())
// Start the sources before the devices. A source registers its feedback and
// monitoring callbacks in Start (OnZoneFeedback, NoteMonitoring, etc.), which
// the device's connect-time setup reads, so they must be in place before the
// device connects. A frame arriving before the device loops run simply lands
// in the device's target state and is flushed once the device starts.
for _, s := range app.sources {
if err := s.Start(ctx); err != nil {
log.Errorf("Failed to start source %q: %s", s.Name(), err)
}
}
for _, d := range app.devices {
d.Start(ctx)
}
log.Infof("%s %s started with %d device(s) and %d source(s)",
serviceName, serviceVersion, len(app.devices), len(app.sources))
// Notify systemd we're ready and begin feeding its watchdog.
daemon.SdNotify(false, daemon.SdNotifyReady)
daemon.SdNotify(false, "STATUS=Running")
stopWatchdog := startSDWatchdog(ctx)
// Wait for a termination signal.
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
<-c
log.Info("Shutting down")
daemon.SdNotify(false, daemon.SdNotifyStopping)
stopWatchdog()
cancel()
for _, s := range app.sources {
s.Stop()
}
for _, d := range app.devices {
d.Stop()
}
}
// startSDWatchdog periodically pings the systemd watchdog while running, using
// the interval systemd advertises via $WATCHDOG_USEC. It returns a function that
// stops the pinger. When systemd has not enabled the watchdog, it does nothing.
func startSDWatchdog(ctx context.Context) func() {
interval, err := daemon.SdWatchdogEnabled(false)
if err != nil || interval <= 0 {
return func() {}
}
// Ping at half the configured interval to stay comfortably within the deadline.
interval /= 2
if interval <= 0 {
return func() {}
}
stop := make(chan struct{})
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-stop:
return
case <-ticker.C:
daemon.SdNotify(false, daemon.SdNotifyWatchdog)
}
}
}()
var once bool
return func() {
if !once {
once = true
close(stop)
}
}
}