commit cdbd8abaa4e77fa2beac5be6b017b0579b02bc2f Author: Erki Aas Date: Tue Apr 7 23:46:35 2026 +0300 initial commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dc8fc1e --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +CONFDIR ?= $(HOME)/.config/led-controller + +.PHONY: build install install-service clean + +build: + go build -o led-controller . + +install: build + install -Dm755 led-controller $(DESTDIR)$(BINDIR)/led-controller + install -Dm644 config.example.toml $(DESTDIR)$(CONFDIR)/config.toml + +install-service: + install -Dm644 led-controller.service $(HOME)/.config/systemd/user/led-controller.service + systemctl --user daemon-reload + systemctl --user enable led-controller.service + @echo "Start with: systemctl --user start led-controller" + +clean: + rm -f led-controller diff --git a/README.md b/README.md new file mode 100644 index 0000000..6883f50 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# led-controller + +A daemon that automatically turns RGB LEDs on and off based on display state. When your monitor sleeps or you lock your screen, LEDs turn off. When you wake or unlock, they turn back on. + +Controls two types of lights: +- **Computer RGB** via [OpenRGB](https://openrgb.org/) CLI (motherboard, RAM, GPU, peripherals) +- **External LEDs** via CH341 USB serial relay (LED strips, desk lights, etc.) + +## Requirements + +- Linux with D-Bus (GNOME, KDE, XFCE, etc.) +- Go 1.21+ +- [OpenRGB](https://openrgb.org/) installed (for PC RGB control) +- CH341 USB relay module on `/dev/ttyUSB0` (for external LED control) + +## Build & Install + +```bash +make build +make install # binary + config to ~/.config/led-controller/ +make install-service # enable systemd user service +``` + +## Usage + +### Daemon (automatic) + +```bash +# Start via systemd +systemctl --user start led-controller + +# Or run directly +./led-controller daemon +``` + +The daemon turns LEDs on at startup, then listens for D-Bus signals to toggle them: +- **Screen lock/blank** → LEDs off +- **Screen unlock/unblank** → LEDs on +- **System suspend** → LEDs off +- **System wake** → LEDs on + +### Manual toggle + +```bash +./led-controller on +./led-controller off +``` + +## Configuration + +Config file: `~/.config/led-controller/config.toml` + +```toml +[openrgb] +enabled = true + +[relay] +enabled = true +device = "/dev/ttyUSB0" +baud = 9600 +channel = 1 + +[monitor] +# "screensaver" - screen lock/blank events only +# "logind" - system suspend/resume only +# "all" - both (default) +method = "all" +``` + +## Permissions + +The USB relay device requires read/write access. Add your user to the `dialout` group: + +```bash +sudo usermod -aG dialout $USER +``` + +Log out and back in for the group change to take effect. + +## Architecture + +``` +main.go Daemon entry point, signal handling, event loop +config/ TOML config loader +monitor/ D-Bus listener (screensaver + logind signals) +controller/ + controller.go Orchestrates OpenRGB + relay + openrgb/ OpenRGB CLI wrapper + relay/ CH341 serial relay protocol (0xA0 command format) +``` diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..d15904d --- /dev/null +++ b/config.example.toml @@ -0,0 +1,14 @@ +[openrgb] +enabled = true +host = "localhost" +port = 6742 + +[relay] +enabled = true +device = "/dev/ttyUSB0" +baud = 9600 +channel = 1 + +[monitor] +# Options: "screensaver", "logind", "all" +method = "all" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..f39b2cd --- /dev/null +++ b/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "fmt" + "os" + + "github.com/BurntSushi/toml" +) + +type Config struct { + OpenRGB OpenRGBConfig `toml:"openrgb"` + Relay RelayConfig `toml:"relay"` + Monitor MonitorConfig `toml:"monitor"` +} + +type OpenRGBConfig struct { + Host string `toml:"host"` + Port int `toml:"port"` + Enabled bool `toml:"enabled"` +} + +type RelayConfig struct { + Device string `toml:"device"` + Channel int `toml:"channel"` + Baud int `toml:"baud"` + Enabled bool `toml:"enabled"` +} + +type MonitorConfig struct { + // Which D-Bus signals to listen on. Options: "screensaver", "logind", "all" + Method string `toml:"method"` +} + +func Load(path string) (*Config, error) { + cfg := &Config{ + OpenRGB: OpenRGBConfig{ + Host: "localhost", + Port: 6742, + Enabled: true, + }, + Relay: RelayConfig{ + Device: "/dev/ttyUSB0", + Channel: 1, + Baud: 9600, + Enabled: true, + }, + Monitor: MonitorConfig{ + Method: "all", + }, + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return nil, fmt.Errorf("read config: %w", err) + } + + if err := toml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + return cfg, nil +} diff --git a/controller/controller.go b/controller/controller.go new file mode 100644 index 0000000..66b93e9 --- /dev/null +++ b/controller/controller.go @@ -0,0 +1,82 @@ +package controller + +import ( + "fmt" + "log" + + "led-controller/config" + "led-controller/controller/openrgb" + "led-controller/controller/relay" +) + +type Controller struct { + cfg *config.Config + orgb *openrgb.Client + relay *relay.Relay +} + +func New(cfg *config.Config) (*Controller, error) { + c := &Controller{cfg: cfg} + + if cfg.OpenRGB.Enabled { + client, err := openrgb.Connect() + if err != nil { + log.Printf("warning: openrgb unavailable: %v", err) + } else { + c.orgb = client + log.Println("openrgb: connected via CLI") + } + } + + if cfg.Relay.Enabled { + c.relay = relay.New(cfg.Relay.Device, cfg.Relay.Channel, cfg.Relay.Baud) + log.Printf("relay: configured on %s channel %d", cfg.Relay.Device, cfg.Relay.Channel) + } + + return c, nil +} + +func (c *Controller) SetLEDs(on bool) error { + var errs []error + + if c.cfg.OpenRGB.Enabled { + if err := c.setOpenRGB(on); err != nil { + errs = append(errs, fmt.Errorf("openrgb: %w", err)) + } + } + + if c.cfg.Relay.Enabled && c.relay != nil { + if err := c.relay.Set(on); err != nil { + errs = append(errs, fmt.Errorf("relay: %w", err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("%v", errs) + } + return nil +} + +func (c *Controller) setOpenRGB(on bool) error { + if c.orgb == nil { + client, err := openrgb.Connect() + if err != nil { + return err + } + c.orgb = client + } + + if on { + return c.orgb.SetAllOn(0xFF, 0xFF, 0xFF) + } + return c.orgb.SetAllOff() +} + +func (c *Controller) Close() { + if c.orgb != nil { + c.orgb.Close() + } + if c.relay != nil { + c.relay.Close() + } +} diff --git a/controller/openrgb/client.go b/controller/openrgb/client.go new file mode 100644 index 0000000..12e042e --- /dev/null +++ b/controller/openrgb/client.go @@ -0,0 +1,49 @@ +package openrgb + +import ( + "fmt" + "os/exec" +) + +// Client controls RGB devices via the openrgb CLI. +type Client struct { + bin string +} + +func Connect() (*Client, error) { + bin, err := exec.LookPath("openrgb") + if err != nil { + return nil, fmt.Errorf("openrgb not found in PATH: %w", err) + } + + // Verify openrgb can list devices (confirms it's running/accessible) + cmd := exec.Command(bin, "--list-devices") + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("openrgb list-devices failed: %w\n%s", err, out) + } + + return &Client{bin: bin}, nil +} + +func (c *Client) Close() error { + return nil +} + +// SetAllOff sets all devices to black (off) using direct mode. +func (c *Client) SetAllOff() error { + cmd := exec.Command(c.bin, "--mode", "direct", "--color", "000000") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("openrgb set off: %w\n%s", err, out) + } + return nil +} + +// SetAllOn sets all devices to the given color. +func (c *Client) SetAllOn(r, g, b byte) error { + color := fmt.Sprintf("%02X%02X%02X", r, g, b) + cmd := exec.Command(c.bin, "--mode", "direct", "--color", color) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("openrgb set on: %w\n%s", err, out) + } + return nil +} diff --git a/controller/relay/relay.go b/controller/relay/relay.go new file mode 100644 index 0000000..e82edad --- /dev/null +++ b/controller/relay/relay.go @@ -0,0 +1,82 @@ +package relay + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +// CH341 serial relay protocol (LCUS-type modules on /dev/ttyUSBx). +// +// Command format: +// ON: 0xA0 0x01 +// OFF: 0xA0 0x00 +// checksum = (0xA0 + channel + state) & 0xFF + +type Relay struct { + devicePath string + channel byte + baud int +} + +func New(devicePath string, channel, baud int) *Relay { + return &Relay{ + devicePath: devicePath, + channel: byte(channel), + baud: baud, + } +} + +func (r *Relay) Set(on bool) error { + f, err := os.OpenFile(r.devicePath, os.O_RDWR|unix.O_NOCTTY, 0) + if err != nil { + return fmt.Errorf("relay open %s: %w", r.devicePath, err) + } + defer f.Close() + + fd := int(f.Fd()) + if err := configureSerial(fd, r.baud); err != nil { + return fmt.Errorf("relay serial config: %w", err) + } + + var state byte + if on { + state = 0x01 + } + + checksum := (0xA0 + r.channel + state) & 0xFF + cmd := []byte{0xA0, r.channel, state, checksum} + + if _, err := f.Write(cmd); err != nil { + return fmt.Errorf("relay write: %w", err) + } + + return nil +} + +func (r *Relay) Close() error { + return nil +} + +func configureSerial(fd, baud int) error { + baudRate, ok := baudRates[baud] + if !ok { + return fmt.Errorf("unsupported baud rate: %d", baud) + } + + var t unix.Termios + t.Cflag = unix.CS8 | unix.CLOCAL | unix.CREAD | baudRate + t.Cc[unix.VMIN] = 1 + t.Cc[unix.VTIME] = 0 + + return unix.IoctlSetTermios(fd, unix.TCSETS, &t) +} + +var baudRates = map[int]uint32{ + 9600: unix.B9600, + 19200: unix.B19200, + 38400: unix.B38400, + 57600: unix.B57600, + 115200: unix.B115200, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..96e80b9 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module led-controller + +go 1.25.8 + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + golang.org/x/sys v0.27.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cfdd5b9 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/led-controller b/led-controller new file mode 100755 index 0000000..6be54c0 Binary files /dev/null and b/led-controller differ diff --git a/led-controller.service b/led-controller.service new file mode 100644 index 0000000..8f12565 --- /dev/null +++ b/led-controller.service @@ -0,0 +1,13 @@ +[Unit] +Description=LED Controller Daemon +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/led-controller --config %h/.config/led-controller/config.toml +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=graphical-session.target diff --git a/main.go b/main.go new file mode 100644 index 0000000..140e36e --- /dev/null +++ b/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "led-controller/config" + "led-controller/controller" + "led-controller/monitor" +) + +func main() { + cfgPath := flag.String("config", "/etc/led-controller/config.toml", "path to config file") + flag.Parse() + + args := flag.Args() + + cfg, err := config.Load(*cfgPath) + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + // Manual toggle: led-controller [--config ...] on|off + if len(args) > 0 { + switch args[0] { + case "on", "off": + ctrl, err := controller.New(cfg) + if err != nil { + log.Fatalf("failed to create controller: %v", err) + } + defer ctrl.Close() + + on := args[0] == "on" + if err := ctrl.SetLEDs(on); err != nil { + log.Fatalf("failed to set LEDs %s: %v", args[0], err) + } + fmt.Printf("LEDs turned %s\n", args[0]) + return + case "daemon": + // fall through to daemon mode below + default: + fmt.Fprintf(os.Stderr, "usage: led-controller [--config path] [on|off|daemon]\n") + os.Exit(1) + } + } + + // Daemon mode + log.Printf("led-controller starting (relay=%s)", cfg.Relay.Device) + + ctrl, err := controller.New(cfg) + if err != nil { + log.Fatalf("failed to create controller: %v", err) + } + defer ctrl.Close() + + // Turn LEDs on at startup — the display is on if we're booting + log.Println("startup — enabling LEDs") + if err := ctrl.SetLEDs(true); err != nil { + log.Printf("error enabling LEDs at startup: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + events := make(chan monitor.Event, 16) + mon, err := monitor.New(cfg, events) + if err != nil { + log.Fatalf("failed to create monitor: %v", err) + } + + go mon.Run(ctx) + + log.Println("daemon running, waiting for display events") + + for { + select { + case ev := <-events: + switch ev { + case monitor.DisplayOn: + log.Println("display ON — enabling LEDs") + if err := ctrl.SetLEDs(true); err != nil { + log.Printf("error enabling LEDs: %v", err) + } + case monitor.DisplayOff: + log.Println("display OFF — disabling LEDs") + if err := ctrl.SetLEDs(false); err != nil { + log.Printf("error disabling LEDs: %v", err) + } + } + case sig := <-sigCh: + log.Printf("received %v, shutting down", sig) + cancel() + return + } + } +} diff --git a/monitor/monitor.go b/monitor/monitor.go new file mode 100644 index 0000000..9c7e9d6 --- /dev/null +++ b/monitor/monitor.go @@ -0,0 +1,146 @@ +package monitor + +import ( + "context" + "log" + + "github.com/godbus/dbus/v5" + + "led-controller/config" +) + +type Event int + +const ( + DisplayOn Event = iota + DisplayOff +) + +type Monitor struct { + cfg *config.Config + events chan<- Event +} + +func New(cfg *config.Config, events chan<- Event) (*Monitor, error) { + return &Monitor{cfg: cfg, events: events}, nil +} + +func (m *Monitor) Run(ctx context.Context) { + method := m.cfg.Monitor.Method + + if method == "screensaver" || method == "all" { + go m.watchScreenSaver(ctx) + } + if method == "logind" || method == "all" { + go m.watchLogind(ctx) + } + + <-ctx.Done() +} + +// watchScreenSaver listens on the session bus for org.freedesktop.ScreenSaver.ActiveChanged +// This covers GNOME, KDE, XFCE, and other freedesktop-compliant DEs. +func (m *Monitor) watchScreenSaver(ctx context.Context) { + conn, err := dbus.ConnectSessionBus() + if err != nil { + log.Printf("screensaver monitor: cannot connect to session bus: %v", err) + return + } + defer conn.Close() + + // Match both the freedesktop and GNOME screensaver interfaces + rules := []string{ + "type='signal',interface='org.freedesktop.ScreenSaver',member='ActiveChanged'", + "type='signal',interface='org.gnome.ScreenSaver',member='ActiveChanged'", + } + + for _, rule := range rules { + call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule) + if call.Err != nil { + log.Printf("screensaver monitor: AddMatch failed for %q: %v", rule, call.Err) + } + } + + sigCh := make(chan *dbus.Signal, 16) + conn.Signal(sigCh) + + log.Println("screensaver monitor: listening for ActiveChanged signals") + + for { + select { + case <-ctx.Done(): + return + case sig := <-sigCh: + if sig == nil { + return + } + if sig.Name == "org.freedesktop.ScreenSaver.ActiveChanged" || + sig.Name == "org.gnome.ScreenSaver.ActiveChanged" { + if len(sig.Body) < 1 { + continue + } + active, ok := sig.Body[0].(bool) + if !ok { + continue + } + if active { + log.Println("screensaver monitor: screen locked/blanked") + m.events <- DisplayOff + } else { + log.Println("screensaver monitor: screen unlocked/unblanked") + m.events <- DisplayOn + } + } + } + } +} + +// watchLogind listens on the system bus for org.freedesktop.login1.Manager.PrepareForSleep. +// This catches system suspend/resume events. +func (m *Monitor) watchLogind(ctx context.Context) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + log.Printf("logind monitor: cannot connect to system bus: %v", err) + return + } + defer conn.Close() + + rule := "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep'" + call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule) + if call.Err != nil { + log.Printf("logind monitor: AddMatch failed: %v", call.Err) + return + } + + sigCh := make(chan *dbus.Signal, 16) + conn.Signal(sigCh) + + log.Println("logind monitor: listening for PrepareForSleep signals") + + for { + select { + case <-ctx.Done(): + return + case sig := <-sigCh: + if sig == nil { + return + } + if sig.Name == "org.freedesktop.login1.Manager.PrepareForSleep" { + if len(sig.Body) < 1 { + continue + } + preparing, ok := sig.Body[0].(bool) + if !ok { + continue + } + if preparing { + log.Println("logind monitor: system preparing to sleep") + m.events <- DisplayOff + } else { + log.Println("logind monitor: system woke up") + m.events <- DisplayOn + } + } + } + } +}