initial commit

This commit is contained in:
2026-04-07 23:46:35 +03:00
commit cdbd8abaa4
13 changed files with 681 additions and 0 deletions

21
Makefile Normal file
View File

@@ -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

90
README.md Normal file
View File

@@ -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)
```

14
config.example.toml Normal file
View File

@@ -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"

65
config/config.go Normal file
View File

@@ -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
}

82
controller/controller.go Normal file
View File

@@ -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()
}
}

View File

@@ -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
}

82
controller/relay/relay.go Normal file
View File

@@ -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 <channel> 0x01 <checksum>
// OFF: 0xA0 <channel> 0x00 <checksum>
// 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,
}

9
go.mod Normal file
View File

@@ -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
)

6
go.sum Normal file
View File

@@ -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=

BIN
led-controller Executable file

Binary file not shown.

13
led-controller.service Normal file
View File

@@ -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

104
main.go Normal file
View File

@@ -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
}
}
}

146
monitor/monitor.go Normal file
View File

@@ -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
}
}
}
}
}