summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go510
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go135
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go122
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go135
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go124
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go66
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go95
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go389
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go11
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go267
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go278
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go180
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/types.go184
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go237
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go260
15 files changed, 2993 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go
new file mode 100644
index 0000000..00cf2d9
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go
@@ -0,0 +1,510 @@
+package brightness
+
+import (
+ "encoding/binary"
+ "fmt"
+ "math"
+ "os"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "golang.org/x/sys/unix"
+)
+
+const (
+ I2C_SLAVE = 0x0703
+ DDCCI_ADDR = 0x37
+ DDCCI_VCP_GET = 0x01
+ DDCCI_VCP_SET = 0x03
+ VCP_BRIGHTNESS = 0x10
+ DDC_SOURCE_ADDR = 0x51
+)
+
+func NewDDCBackend() (*DDCBackend, error) {
+ b := &DDCBackend{
+ scanInterval: 30 * time.Second,
+ debounceTimers: make(map[string]*time.Timer),
+ debouncePending: make(map[string]ddcPendingSet),
+ }
+
+ if err := b.scanI2CDevices(); err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+func (b *DDCBackend) scanI2CDevices() error {
+ return b.scanI2CDevicesInternal(false)
+}
+
+func (b *DDCBackend) ForceRescan() error {
+ return b.scanI2CDevicesInternal(true)
+}
+
+func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
+ b.scanMutex.Lock()
+ defer b.scanMutex.Unlock()
+
+ if !force && time.Since(b.lastScan) < b.scanInterval {
+ return nil
+ }
+
+ activeBuses := make(map[int]bool)
+
+ for i := 0; i < 32; i++ {
+ busPath := fmt.Sprintf("/dev/i2c-%d", i)
+ if _, err := os.Stat(busPath); os.IsNotExist(err) {
+ continue
+ }
+
+ if isIgnorableI2CBus(i) {
+ log.Debugf("Skipping ignorable i2c-%d", i)
+ continue
+ }
+
+ activeBuses[i] = true
+ id := fmt.Sprintf("ddc:i2c-%d", i)
+
+ dev, err := b.probeDDCDevice(i)
+ if err != nil || dev == nil {
+ continue
+ }
+
+ dev.id = id
+ b.devices.Store(id, dev)
+ log.Debugf("found DDC device on i2c-%d", i)
+ }
+
+ b.devices.Range(func(id string, dev *ddcDevice) bool {
+ if !activeBuses[dev.bus] {
+ b.devices.Delete(id)
+ log.Debugf("removed DDC device %s (bus no longer exists)", id)
+ }
+ return true
+ })
+
+ b.lastScan = time.Now()
+
+ return nil
+}
+
+func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
+ busPath := fmt.Sprintf("/dev/i2c-%d", bus)
+
+ fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer syscall.Close(fd)
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
+ return nil, errno
+ }
+
+ dummy := make([]byte, 32)
+ syscall.Read(fd, dummy) //nolint:errcheck
+
+ writebuf := []byte{0x00}
+ n, err := syscall.Write(fd, writebuf)
+ if err == nil && n == len(writebuf) {
+ name := b.getDDCName(bus)
+ dev := &ddcDevice{
+ bus: bus,
+ addr: DDCCI_ADDR,
+ name: name,
+ }
+ b.readInitialBrightness(fd, dev)
+ return dev, nil
+ }
+
+ readbuf := make([]byte, 4)
+ n, err = syscall.Read(fd, readbuf)
+ if err != nil || n == 0 {
+ return nil, fmt.Errorf("x37 unresponsive")
+ }
+
+ name := b.getDDCName(bus)
+
+ dev := &ddcDevice{
+ bus: bus,
+ addr: DDCCI_ADDR,
+ name: name,
+ }
+ b.readInitialBrightness(fd, dev)
+ return dev, nil
+}
+
+func (b *DDCBackend) getDDCName(bus int) string {
+ sysfsPath := fmt.Sprintf("/sys/class/i2c-adapter/i2c-%d/name", bus)
+ data, err := os.ReadFile(sysfsPath)
+ if err != nil {
+ return fmt.Sprintf("I2C-%d", bus)
+ }
+
+ name := strings.TrimSpace(string(data))
+ if name == "" {
+ name = fmt.Sprintf("I2C-%d", bus)
+ }
+
+ return name
+}
+
+func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
+ cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
+ if err != nil {
+ log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
+ return
+ }
+
+ dev.max = cap.max
+ dev.lastBrightness = cap.current
+ log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
+}
+
+func (b *DDCBackend) GetDevices() ([]Device, error) {
+ if err := b.scanI2CDevices(); err != nil {
+ log.Debugf("DDC scan error: %v", err)
+ }
+
+ devices := make([]Device, 0)
+
+ b.devices.Range(func(id string, dev *ddcDevice) bool {
+ devices = append(devices, Device{
+ Class: ClassDDC,
+ ID: id,
+ Name: dev.name,
+ Current: dev.lastBrightness,
+ Max: dev.max,
+ CurrentPercent: dev.lastBrightness,
+ Backend: "ddc",
+ })
+ return true
+ })
+
+ return devices, nil
+}
+
+func (b *DDCBackend) SetBrightness(id string, value int, exponential bool, callback func()) error {
+ return b.SetBrightnessWithExponent(id, value, exponential, 1.2, callback)
+}
+
+func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential bool, exponent float64, callback func()) error {
+ _, ok := b.devices.Load(id)
+
+ if !ok {
+ if err := b.scanI2CDevicesInternal(true); err != nil {
+ log.Debugf("rescan failed for %s: %v", id, err)
+ }
+ _, ok = b.devices.Load(id)
+ }
+
+ if !ok {
+ return fmt.Errorf("device not found: %s", id)
+ }
+
+ if value < 0 {
+ return fmt.Errorf("value out of range: %d", value)
+ }
+
+ b.debounceMutex.Lock()
+ b.debouncePending[id] = ddcPendingSet{
+ percent: value,
+ callback: callback,
+ }
+
+ if timer, exists := b.debounceTimers[id]; exists {
+ timer.Reset(200 * time.Millisecond)
+ } else {
+ b.debounceWg.Add(1)
+ b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
+ defer b.debounceWg.Done()
+ b.debounceMutex.Lock()
+ pending, exists := b.debouncePending[id]
+ if exists {
+ delete(b.debouncePending, id)
+ }
+ b.debounceMutex.Unlock()
+
+ if !exists {
+ return
+ }
+
+ err := b.setBrightnessImmediateWithExponent(id, pending.percent)
+ if err != nil {
+ log.Debugf("Failed to set brightness for %s: %v", id, err)
+ }
+
+ if pending.callback != nil {
+ pending.callback()
+ }
+ })
+ }
+ b.debounceMutex.Unlock()
+
+ return nil
+}
+
+func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) error {
+ dev, ok := b.devices.Load(id)
+
+ if !ok {
+ if err := b.scanI2CDevicesInternal(true); err != nil {
+ log.Debugf("rescan failed for %s: %v", id, err)
+ }
+ dev, ok = b.devices.Load(id)
+ }
+
+ if !ok {
+ return fmt.Errorf("device not found: %s", id)
+ }
+
+ busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
+
+ if _, err := os.Stat(busPath); os.IsNotExist(err) {
+ b.devices.Delete(id)
+ log.Debugf("removed stale DDC device %s (bus no longer exists)", id)
+ return fmt.Errorf("device disconnected: %s", id)
+ }
+
+ fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
+ if err != nil {
+ b.devices.Delete(id)
+ log.Debugf("removed DDC device %s (open failed: %v)", id, err)
+ return fmt.Errorf("open i2c device: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(dev.addr)); errno != 0 {
+ return fmt.Errorf("set i2c slave addr: %w", errno)
+ }
+
+ max := dev.max
+ if max == 0 {
+ cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
+ if err != nil {
+ return fmt.Errorf("get current capability: %w", err)
+ }
+ max = cap.max
+ dev.max = max
+ b.devices.Store(id, dev)
+ }
+
+ if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, value); err != nil {
+ return fmt.Errorf("set vcp feature: %w", err)
+ }
+
+ log.Debugf("set %s to %d/%d", id, value, max)
+
+ dev.max = max
+ dev.lastBrightness = value
+ b.devices.Store(id, dev)
+
+ return nil
+}
+
+func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
+ for flushTry := 0; flushTry < 3; flushTry++ {
+ dummy := make([]byte, 32)
+ n, _ := syscall.Read(fd, dummy)
+ if n == 0 {
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ data := []byte{
+ DDCCI_VCP_GET,
+ vcp,
+ }
+
+ payload := []byte{
+ DDC_SOURCE_ADDR,
+ byte(len(data)) | 0x80,
+ }
+ payload = append(payload, data...)
+ payload = append(payload, ddcciChecksum(payload))
+
+ n, err := syscall.Write(fd, payload)
+ if err != nil || n != len(payload) {
+ return nil, fmt.Errorf("write i2c: %w", err)
+ }
+
+ time.Sleep(50 * time.Millisecond)
+
+ pollFds := []unix.PollFd{
+ {
+ Fd: int32(fd),
+ Events: unix.POLLIN,
+ },
+ }
+
+ pollTimeout := 200
+ pollResult, err := unix.Poll(pollFds, pollTimeout)
+ if err != nil {
+ return nil, fmt.Errorf("poll i2c: %w", err)
+ }
+ if pollResult == 0 {
+ return nil, fmt.Errorf("poll timeout after %dms", pollTimeout)
+ }
+ if pollFds[0].Revents&unix.POLLIN == 0 {
+ return nil, fmt.Errorf("poll returned but POLLIN not set")
+ }
+
+ response := make([]byte, 12)
+ n, err = syscall.Read(fd, response)
+ if err != nil || n < 8 {
+ return nil, fmt.Errorf("read i2c: %w", err)
+ }
+
+ if response[0] != 0x6E || response[2] != 0x02 {
+ return nil, fmt.Errorf("invalid ddc response")
+ }
+
+ resultCode := response[3]
+ if resultCode != 0x00 {
+ return nil, fmt.Errorf("vcp feature not supported")
+ }
+
+ responseVCP := response[4]
+ if responseVCP != vcp {
+ return nil, fmt.Errorf("vcp mismatch: wanted 0x%02x, got 0x%02x", vcp, responseVCP)
+ }
+
+ maxHigh := response[6]
+ maxLow := response[7]
+ currentHigh := response[8]
+ currentLow := response[9]
+
+ max := int(binary.BigEndian.Uint16([]byte{maxHigh, maxLow}))
+ current := int(binary.BigEndian.Uint16([]byte{currentHigh, currentLow}))
+
+ return &ddcCapability{
+ vcp: vcp,
+ max: max,
+ current: current,
+ }, nil
+}
+
+func ddcciChecksum(payload []byte) byte {
+ sum := byte(0x6E)
+ for _, b := range payload {
+ sum ^= b
+ }
+ return sum
+}
+
+func (b *DDCBackend) setVCPFeature(fd int, vcp byte, value int) error {
+ data := []byte{
+ DDCCI_VCP_SET,
+ vcp,
+ byte(value >> 8),
+ byte(value & 0xFF),
+ }
+
+ payload := []byte{
+ DDC_SOURCE_ADDR,
+ byte(len(data)) | 0x80,
+ }
+ payload = append(payload, data...)
+ payload = append(payload, ddcciChecksum(payload))
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
+ return fmt.Errorf("set i2c slave for write: %w", errno)
+ }
+
+ n, err := syscall.Write(fd, payload)
+ if err != nil || n != len(payload) {
+ return fmt.Errorf("write i2c: wrote %d/%d: %w", n, len(payload), err)
+ }
+
+ time.Sleep(50 * time.Millisecond)
+
+ return nil
+}
+
+func (b *DDCBackend) percentToValue(percent int, max int, exponential bool) int {
+ const minValue = 1
+
+ if percent == 0 {
+ return minValue
+ }
+
+ usableRange := max - minValue
+ var value int
+
+ if exponential {
+ const exponent = 2.0
+ normalizedPercent := float64(percent) / 100.0
+ hardwarePercent := math.Pow(normalizedPercent, 1.0/exponent)
+ value = minValue + int(math.Round(hardwarePercent*float64(usableRange)))
+ } else {
+ value = minValue + ((percent - 1) * usableRange / 99)
+ }
+
+ if value < minValue {
+ value = minValue
+ }
+ if value > max {
+ value = max
+ }
+
+ return value
+}
+
+func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int {
+ const minValue = 1
+
+ if max == 0 {
+ return 0
+ }
+
+ if value <= minValue {
+ return 1
+ }
+
+ usableRange := max - minValue
+ if usableRange == 0 {
+ return 100
+ }
+
+ var percent int
+
+ if exponential {
+ const exponent = 2.0
+ linearPercent := 1 + ((value - minValue) * 99 / usableRange)
+ normalizedLinear := float64(linearPercent) / 100.0
+ expPercent := math.Pow(normalizedLinear, exponent)
+ percent = int(math.Round(expPercent * 100.0))
+ } else {
+ percent = 1 + ((value - minValue) * 99 / usableRange)
+ }
+
+ if percent > 100 {
+ percent = 100
+ }
+ if percent < 1 {
+ percent = 1
+ }
+
+ return percent
+}
+
+func (b *DDCBackend) WaitPending() {
+ done := make(chan struct{})
+ go func() {
+ b.debounceWg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ log.Debug("WaitPending timed out waiting for DDC writes")
+ }
+}
+
+func (b *DDCBackend) Close() {
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go
new file mode 100644
index 0000000..12fc535
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go
@@ -0,0 +1,135 @@
+package brightness
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+// isIgnorableI2CBus checks if an I2C bus should be skipped during DDC probing.
+// Based on ddcutil's sysfs_is_ignorable_i2c_device() (sysfs_base.c:1441)
+func isIgnorableI2CBus(busno int) bool {
+ name := getI2CDeviceSysfsName(busno)
+ driver := getI2CSysfsDriver(busno)
+
+ if name != "" && isIgnorableI2CDeviceName(name, driver) {
+ log.Debugf("i2c-%d: ignoring '%s' (driver: %s)", busno, name, driver)
+ return true
+ }
+
+ // Only probe display adapters (0x03xxxx) and docking stations (0x0axxxx)
+ class := getI2CDeviceSysfsClass(busno)
+ if class != 0 {
+ classHigh := class & 0xFFFF0000
+ ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
+ if ignorable {
+ log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
+ }
+ return ignorable
+ }
+
+ return false
+}
+
+// Based on ddcutil's ignorable_i2c_device_sysfs_name() (sysfs_base.c:1408)
+func isIgnorableI2CDeviceName(name, driver string) bool {
+ ignorablePrefixes := []string{
+ "SMBus",
+ "Synopsys DesignWare",
+ "soc:i2cdsi",
+ "smu",
+ "mac-io",
+ "u4",
+ "AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
+ }
+
+ for _, prefix := range ignorablePrefixes {
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+
+ // nouveau driver: only nvkm-* buses are valid
+ if driver == "nouveau" && !strings.HasPrefix(name, "nvkm-") {
+ return true
+ }
+
+ return false
+}
+
+// Based on ddcutil's get_i2c_device_sysfs_name() (sysfs_base.c:1175)
+func getI2CDeviceSysfsName(busno int) string {
+ path := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/name", busno)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(data))
+}
+
+// Based on ddcutil's get_i2c_device_sysfs_class() (sysfs_base.c:1380)
+func getI2CDeviceSysfsClass(busno int) uint32 {
+ classPath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/class", busno)
+ data, err := os.ReadFile(classPath)
+ if err != nil {
+ classPath = fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/device/device/class", busno)
+ data, err = os.ReadFile(classPath)
+ if err != nil {
+ return 0
+ }
+ }
+
+ classStr := strings.TrimSpace(string(data))
+ classStr = strings.TrimPrefix(classStr, "0x")
+
+ class, err := strconv.ParseUint(classStr, 16, 32)
+ if err != nil {
+ return 0
+ }
+
+ return uint32(class)
+}
+
+// Based on ddcutil's get_i2c_sysfs_driver_by_busno() (sysfs_base.c:1284)
+func getI2CSysfsDriver(busno int) string {
+ devicePath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno)
+ adapterPath, err := findI2CAdapter(devicePath)
+ if err != nil {
+ return ""
+ }
+
+ driverLink := filepath.Join(adapterPath, "driver")
+ target, err := os.Readlink(driverLink)
+ if err != nil {
+ return ""
+ }
+
+ return filepath.Base(target)
+}
+
+func findI2CAdapter(devicePath string) (string, error) {
+ currentPath := devicePath
+
+ for depth := 0; depth < 10; depth++ {
+ if _, err := os.Stat(filepath.Join(currentPath, "name")); err == nil {
+ return currentPath, nil
+ }
+
+ deviceLink := filepath.Join(currentPath, "device")
+ target, err := os.Readlink(deviceLink)
+ if err != nil {
+ break
+ }
+
+ if !filepath.IsAbs(target) {
+ target = filepath.Join(filepath.Dir(currentPath), target)
+ }
+ currentPath = filepath.Clean(target)
+ }
+
+ return "", fmt.Errorf("could not find adapter for %s", devicePath)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go
new file mode 100644
index 0000000..dc3751f
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go
@@ -0,0 +1,122 @@
+package brightness
+
+import (
+ "testing"
+)
+
+func TestIsIgnorableI2CDeviceName(t *testing.T) {
+ tests := []struct {
+ name string
+ deviceName string
+ driver string
+ want bool
+ }{
+ {
+ name: "AMDGPU SMU should be ignored",
+ deviceName: "AMDGPU SMU",
+ driver: "amdgpu",
+ want: true,
+ },
+ {
+ name: "SMBus should be ignored",
+ deviceName: "SMBus I801 adapter",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "Synopsys DesignWare should be ignored",
+ deviceName: "Synopsys DesignWare I2C adapter",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "smu prefix should be ignored (Mac G5)",
+ deviceName: "smu-i2c-controller",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "Regular NVIDIA DDC should not be ignored",
+ deviceName: "NVIDIA i2c adapter 1",
+ driver: "nvidia",
+ want: false,
+ },
+ {
+ name: "nouveau nvkm bus should not be ignored",
+ deviceName: "nvkm-0000:01:00.0-bus-0000",
+ driver: "nouveau",
+ want: false,
+ },
+ {
+ name: "nouveau non-nvkm bus should be ignored",
+ deviceName: "nouveau-other-bus",
+ driver: "nouveau",
+ want: true,
+ },
+ {
+ name: "Regular AMD display adapter should not be ignored",
+ deviceName: "AMDGPU DM i2c hw bus 0",
+ driver: "amdgpu",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isIgnorableI2CDeviceName(tt.deviceName, tt.driver)
+ if got != tt.want {
+ t.Errorf("isIgnorableI2CDeviceName(%q, %q) = %v, want %v",
+ tt.deviceName, tt.driver, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestClassFiltering(t *testing.T) {
+ tests := []struct {
+ name string
+ class uint32
+ want bool
+ }{
+ {
+ name: "Display adapter class should not be ignored",
+ class: 0x030000,
+ want: false,
+ },
+ {
+ name: "Docking station class should not be ignored",
+ class: 0x0a0000,
+ want: false,
+ },
+ {
+ name: "Display adapter with subclass should not be ignored",
+ class: 0x030001,
+ want: false,
+ },
+ {
+ name: "SMBus class should be ignored",
+ class: 0x0c0500,
+ want: true,
+ },
+ {
+ name: "Bridge class should be ignored",
+ class: 0x060400,
+ want: true,
+ },
+ {
+ name: "Generic system peripheral should be ignored",
+ class: 0x088000,
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ classHigh := tt.class & 0xFFFF0000
+ ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
+ if ignorable != tt.want {
+ t.Errorf("class 0x%08x: ignorable = %v, want %v", tt.class, ignorable, tt.want)
+ }
+ })
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go
new file mode 100644
index 0000000..7f85510
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go
@@ -0,0 +1,135 @@
+package brightness
+
+import (
+ "testing"
+)
+
+func TestDDCBackend_PercentConversions(t *testing.T) {
+ tests := []struct {
+ name string
+ max int
+ percent int
+ wantValue int
+ }{
+ {
+ name: "0% should map to minValue=1",
+ max: 100,
+ percent: 0,
+ wantValue: 1,
+ },
+ {
+ name: "1% should be 1",
+ max: 100,
+ percent: 1,
+ wantValue: 1,
+ },
+ {
+ name: "50% should be ~50",
+ max: 100,
+ percent: 50,
+ wantValue: 50,
+ },
+ {
+ name: "100% should be max",
+ max: 100,
+ percent: 100,
+ wantValue: 100,
+ },
+ }
+
+ b := &DDCBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.percentToValue(tt.percent, tt.max, false)
+ diff := got - tt.wantValue
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > 1 {
+ t.Errorf("percentToValue() = %v, want %v (±1)", got, tt.wantValue)
+ }
+ })
+ }
+}
+
+func TestDDCBackend_ValueToPercent(t *testing.T) {
+ tests := []struct {
+ name string
+ max int
+ value int
+ wantPercent int
+ tolerance int
+ }{
+ {
+ name: "zero value should be 1%",
+ max: 100,
+ value: 0,
+ wantPercent: 1,
+ tolerance: 0,
+ },
+ {
+ name: "min value should be 1%",
+ max: 100,
+ value: 1,
+ wantPercent: 1,
+ tolerance: 0,
+ },
+ {
+ name: "mid value should be ~50%",
+ max: 100,
+ value: 50,
+ wantPercent: 50,
+ tolerance: 2,
+ },
+ {
+ name: "max value should be 100%",
+ max: 100,
+ value: 100,
+ wantPercent: 100,
+ tolerance: 0,
+ },
+ }
+
+ b := &DDCBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.valueToPercent(tt.value, tt.max, false)
+ diff := got - tt.wantPercent
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > tt.tolerance {
+ t.Errorf("valueToPercent() = %v, want %v (±%d)", got, tt.wantPercent, tt.tolerance)
+ }
+ })
+ }
+}
+
+func TestDDCBackend_RoundTrip(t *testing.T) {
+ b := &DDCBackend{}
+
+ tests := []struct {
+ name string
+ max int
+ percent int
+ }{
+ {"1%", 100, 1},
+ {"25%", 100, 25},
+ {"50%", 100, 50},
+ {"75%", 100, 75},
+ {"100%", 100, 100},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ value := b.percentToValue(tt.percent, tt.max, false)
+ gotPercent := b.valueToPercent(value, tt.max, false)
+
+ if diff := tt.percent - gotPercent; diff < -1 || diff > 1 {
+ t.Errorf("round trip failed: wanted %d%%, got %d%% (value=%d)", tt.percent, gotPercent, value)
+ }
+ })
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go
new file mode 100644
index 0000000..c71163e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go
@@ -0,0 +1,124 @@
+package brightness
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
+ switch req.Method {
+ case "brightness.getState":
+ handleGetState(conn, req, m)
+ case "brightness.setBrightness":
+ handleSetBrightness(conn, req, m)
+ case "brightness.increment":
+ handleIncrement(conn, req, m)
+ case "brightness.decrement":
+ handleDecrement(conn, req, m)
+ case "brightness.rescan":
+ handleRescan(conn, req, m)
+ case "brightness.subscribe":
+ handleSubscribe(conn, req, m)
+ default:
+ models.RespondError(conn, req.ID, "unknown method: "+req.Method)
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, m *Manager) {
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleSetBrightness(conn net.Conn, req models.Request, m *Manager) {
+ device, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ percent, err := params.Int(req.Params, "percent")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ exponential := params.BoolOpt(req.Params, "exponential", false)
+ exponent := params.FloatOpt(req.Params, "exponent", 1.2)
+
+ if err := m.SetBrightnessWithExponent(device, percent, exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleIncrement(conn net.Conn, req models.Request, m *Manager) {
+ device, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ step := params.IntOpt(req.Params, "step", 10)
+ exponential := params.BoolOpt(req.Params, "exponential", false)
+ exponent := params.FloatOpt(req.Params, "exponent", 1.2)
+
+ if err := m.IncrementBrightnessWithExponent(device, step, exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleDecrement(conn net.Conn, req models.Request, m *Manager) {
+ device, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ step := params.IntOpt(req.Params, "step", 10)
+ exponential := params.BoolOpt(req.Params, "exponential", false)
+ exponent := params.FloatOpt(req.Params, "exponent", 1.2)
+
+ if err := m.IncrementBrightnessWithExponent(device, -step, exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleRescan(conn net.Conn, req models.Request, m *Manager) {
+ m.Rescan()
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
+ clientID := fmt.Sprintf("brightness-%d", req.ID)
+
+ ch := m.Subscribe(clientID)
+ defer m.Unsubscribe(clientID)
+
+ initialState := m.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range ch {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go
new file mode 100644
index 0000000..8449ab5
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go
@@ -0,0 +1,66 @@
+package brightness
+
+import (
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+type DBusConn interface {
+ Object(dest string, path dbus.ObjectPath) dbus.BusObject
+ Close() error
+}
+
+type LogindBackend struct {
+ conn DBusConn
+}
+
+func NewLogindBackend() (*LogindBackend, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("connect to system bus: %w", err)
+ }
+
+ obj := conn.Object("org.freedesktop.login1", "/org/freedesktop/login1/session/auto")
+ call := obj.Call("org.freedesktop.DBus.Peer.Ping", 0)
+ if call.Err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("logind not available: %w", call.Err)
+ }
+
+ conn.Close()
+
+ return &LogindBackend{}, nil
+}
+
+func NewLogindBackendWithConn(conn DBusConn) *LogindBackend {
+ return &LogindBackend{
+ conn: conn,
+ }
+}
+
+func (b *LogindBackend) SetBrightness(subsystem, name string, brightness uint32) error {
+ if b.conn == nil {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("connect to system bus: %w", err)
+ }
+ b.conn = conn
+ }
+
+ obj := b.conn.Object("org.freedesktop.login1", "/org/freedesktop/login1/session/auto")
+ call := obj.Call("org.freedesktop.login1.Session.SetBrightness", 0, subsystem, name, brightness)
+ if call.Err != nil {
+ return fmt.Errorf("dbus call failed: %w", call.Err)
+ }
+
+ log.Debugf("logind: set %s/%s to %d", subsystem, name, brightness)
+ return nil
+}
+
+func (b *LogindBackend) Close() {
+ if b.conn != nil {
+ b.conn.Close()
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go
new file mode 100644
index 0000000..3d4c2ee
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go
@@ -0,0 +1,95 @@
+package brightness
+
+import (
+ "errors"
+ "testing"
+
+ mocks_brightness "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/brightness"
+ mock_dbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestLogindBackend_SetBrightness_Success(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", dbus.Flags(0), "backlight", "nvidia_0", uint32(75)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := backend.SetBrightness("backlight", "nvidia_0", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() error = %v, want nil", err)
+ }
+}
+
+func TestLogindBackend_SetBrightness_DBusError(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ dbusErr := errors.New("permission denied")
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+ Return(&dbus.Call{Err: dbusErr}).
+ Once()
+
+ err := backend.SetBrightness("backlight", "test_device", 50)
+ if err == nil {
+ t.Error("SetBrightness() error = nil, want error")
+ }
+}
+
+func TestLogindBackend_SetBrightness_LEDDevice(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", dbus.Flags(0), "leds", "test_led", uint32(128)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := backend.SetBrightness("leds", "test_led", 128)
+ if err != nil {
+ t.Errorf("SetBrightness() error = %v, want nil", err)
+ }
+}
+
+func TestLogindBackend_Close(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Close().
+ Return(nil).
+ Once()
+
+ backend.Close()
+}
+
+func TestLogindBackend_Close_NilConn(t *testing.T) {
+ backend := &LogindBackend{conn: nil}
+ backend.Close()
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go
new file mode 100644
index 0000000..4cf7dd4
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go
@@ -0,0 +1,389 @@
+package brightness
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+func NewManager() (*Manager, error) {
+ return NewManagerWithOptions(false)
+}
+
+func NewManagerWithOptions(exponential bool) (*Manager, error) {
+ m := &Manager{
+ stopChan: make(chan struct{}),
+ exponential: exponential,
+ }
+
+ go m.initLogind()
+ go m.initSysfs()
+ go m.initDDC()
+
+ return m, nil
+}
+
+func (m *Manager) initLogind() {
+ log.Debug("Initializing logind backend...")
+ logind, err := NewLogindBackend()
+ if err != nil {
+ log.Infof("Logind backend not available: %v", err)
+ log.Info("Will use direct sysfs access for brightness control")
+ return
+ }
+
+ m.logindBackend = logind
+ m.logindReady = true
+ log.Info("Logind backend initialized - will use for brightness control")
+}
+
+func (m *Manager) initSysfs() {
+ log.Debug("Initializing sysfs backend...")
+ sysfs, err := NewSysfsBackend()
+ if err != nil {
+ log.Warnf("Failed to initialize sysfs backend: %v", err)
+ return
+ }
+
+ devices, err := sysfs.GetDevices()
+ if err != nil {
+ log.Warnf("Failed to get initial sysfs devices: %v", err)
+ m.sysfsBackend = sysfs
+ m.sysfsReady = true
+ m.updateState()
+ m.initUdev()
+ return
+ }
+
+ log.Infof("Sysfs backend initialized with %d devices", len(devices))
+ for _, d := range devices {
+ log.Debugf(" - %s: %s (%d%%)", d.ID, d.Name, d.CurrentPercent)
+ }
+
+ m.sysfsBackend = sysfs
+ m.sysfsReady = true
+ m.updateState()
+ m.initUdev()
+}
+
+func (m *Manager) initUdev() {
+ m.udevMonitor = NewUdevMonitor(m)
+}
+
+func (m *Manager) initDDC() {
+ ddc, err := NewDDCBackend()
+ if err != nil {
+ log.Debugf("Failed to initialize DDC backend: %v", err)
+ return
+ }
+
+ m.ddcBackend = ddc
+ m.ddcReady = true
+ log.Info("DDC backend initialized")
+
+ m.updateState()
+}
+
+func (m *Manager) Rescan() {
+ log.Debug("Rescanning brightness devices...")
+
+ if m.ddcReady && m.ddcBackend != nil {
+ if err := m.ddcBackend.ForceRescan(); err != nil {
+ log.Debugf("DDC force rescan failed: %v", err)
+ }
+ }
+
+ if m.sysfsReady && m.sysfsBackend != nil {
+ if err := m.sysfsBackend.Rescan(); err != nil {
+ log.Debugf("Sysfs rescan failed: %v", err)
+ }
+ }
+
+ m.updateState()
+}
+
+func sortDevices(devices []Device) {
+ sort.Slice(devices, func(i, j int) bool {
+ classOrder := map[DeviceClass]int{
+ ClassBacklight: 0,
+ ClassDDC: 1,
+ ClassLED: 2,
+ }
+
+ orderI := classOrder[devices[i].Class]
+ orderJ := classOrder[devices[j].Class]
+
+ if orderI != orderJ {
+ return orderI < orderJ
+ }
+
+ return devices[i].Name < devices[j].Name
+ })
+}
+
+func stateChanged(old, new State) bool {
+ if len(old.Devices) != len(new.Devices) {
+ return true
+ }
+
+ oldMap := make(map[string]Device)
+ for _, d := range old.Devices {
+ oldMap[d.ID] = d
+ }
+
+ for _, newDev := range new.Devices {
+ oldDev, exists := oldMap[newDev.ID]
+ if !exists {
+ return true
+ }
+ if oldDev.Current != newDev.Current || oldDev.Max != newDev.Max {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (m *Manager) updateState() {
+ allDevices := make([]Device, 0)
+
+ if m.sysfsReady && m.sysfsBackend != nil {
+ devices, err := m.sysfsBackend.GetDevices()
+ if err != nil {
+ log.Debugf("Failed to get sysfs devices: %v", err)
+ }
+ if err == nil {
+ allDevices = append(allDevices, devices...)
+ }
+ }
+
+ if m.ddcReady && m.ddcBackend != nil {
+ devices, err := m.ddcBackend.GetDevices()
+ if err != nil {
+ log.Debugf("Failed to get DDC devices: %v", err)
+ }
+ if err == nil {
+ allDevices = append(allDevices, devices...)
+ }
+ }
+
+ sortDevices(allDevices)
+
+ m.stateMutex.Lock()
+ oldState := m.state
+ newState := State{Devices: allDevices}
+
+ if !stateChanged(oldState, newState) {
+ m.stateMutex.Unlock()
+ return
+ }
+
+ m.state = newState
+ m.stateMutex.Unlock()
+ log.Debugf("State changed, notifying subscribers")
+ m.NotifySubscribers()
+}
+
+func (m *Manager) SetBrightness(deviceID string, percent int) error {
+ return m.SetBrightnessWithMode(deviceID, percent, m.exponential)
+}
+
+func (m *Manager) SetBrightnessWithMode(deviceID string, percent int, exponential bool) error {
+ return m.SetBrightnessWithExponent(deviceID, percent, exponential, 1.2)
+}
+
+func (m *Manager) SetBrightnessWithExponent(deviceID string, percent int, exponential bool, exponent float64) error {
+ if percent < 0 {
+ return fmt.Errorf("percent out of range: %d", percent)
+ }
+
+ log.Debugf("SetBrightness: %s to %d%%", deviceID, percent)
+
+ m.stateMutex.Lock()
+ currentState := m.state
+ var found bool
+ var deviceClass DeviceClass
+ var deviceIndex int
+
+ log.Debugf("Current state has %d devices", len(currentState.Devices))
+
+ for i, dev := range currentState.Devices {
+ if dev.ID == deviceID {
+ found = true
+ deviceClass = dev.Class
+ deviceIndex = i
+ break
+ }
+ }
+
+ if !found {
+ m.stateMutex.Unlock()
+ log.Debugf("Device not found in state: %s", deviceID)
+ return fmt.Errorf("device not found: %s", deviceID)
+ }
+
+ newDevices := make([]Device, len(currentState.Devices))
+ copy(newDevices, currentState.Devices)
+ newDevices[deviceIndex].CurrentPercent = percent
+ m.state = State{Devices: newDevices}
+ m.stateMutex.Unlock()
+
+ var err error
+ if deviceClass == ClassDDC {
+ log.Debugf("Calling DDC backend for %s", deviceID)
+ err = m.ddcBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, func() {
+ m.updateState()
+ m.debouncedBroadcast(deviceID)
+ })
+ } else if m.logindReady && m.logindBackend != nil {
+ log.Debugf("Calling logind backend for %s", deviceID)
+ err = m.setViaSysfsWithLogindWithExponent(deviceID, percent, exponential, exponent)
+ } else {
+ log.Debugf("Calling sysfs backend for %s", deviceID)
+ err = m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ if err != nil {
+ m.updateState()
+ return fmt.Errorf("failed to set brightness: %w", err)
+ }
+
+ if deviceClass != ClassDDC {
+ log.Debugf("Queueing broadcast for %s", deviceID)
+ m.debouncedBroadcast(deviceID)
+ }
+ return nil
+}
+
+func (m *Manager) IncrementBrightness(deviceID string, step int) error {
+ return m.IncrementBrightnessWithMode(deviceID, step, m.exponential)
+}
+
+func (m *Manager) IncrementBrightnessWithMode(deviceID string, step int, exponential bool) error {
+ return m.IncrementBrightnessWithExponent(deviceID, step, exponential, 1.2)
+}
+
+func (m *Manager) IncrementBrightnessWithExponent(deviceID string, step int, exponential bool, exponent float64) error {
+ m.stateMutex.RLock()
+ currentState := m.state
+ m.stateMutex.RUnlock()
+
+ var currentPercent int
+ var found bool
+
+ for _, dev := range currentState.Devices {
+ if dev.ID == deviceID {
+ currentPercent = dev.CurrentPercent
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ return fmt.Errorf("device not found: %s", deviceID)
+ }
+
+ newPercent := currentPercent + step
+ if newPercent > 100 {
+ newPercent = 100
+ }
+ if newPercent < 0 {
+ newPercent = 0
+ }
+
+ return m.SetBrightnessWithExponent(deviceID, newPercent, exponential, exponent)
+}
+
+func (m *Manager) DecrementBrightness(deviceID string, step int) error {
+ return m.IncrementBrightness(deviceID, -step)
+}
+
+func (m *Manager) setViaSysfsWithLogindWithExponent(deviceID string, percent int, exponential bool, exponent float64) error {
+ parts := strings.SplitN(deviceID, ":", 2)
+ if len(parts) != 2 {
+ return fmt.Errorf("invalid device id: %s", deviceID)
+ }
+
+ subsystem := parts[0]
+ name := parts[1]
+
+ dev, err := m.sysfsBackend.GetDevice(deviceID)
+ if err != nil {
+ return err
+ }
+
+ value := m.sysfsBackend.PercentToValueWithExponent(percent, dev, exponential, exponent)
+
+ if m.logindBackend == nil {
+ return m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ err = m.logindBackend.SetBrightness(subsystem, name, uint32(value))
+ if err != nil {
+ log.Debugf("logind SetBrightness failed, falling back to direct sysfs: %v", err)
+ return m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ log.Debugf("set %s to %d%% (%d/%d) via logind", deviceID, percent, value, dev.maxBrightness)
+ return nil
+}
+
+func (m *Manager) debouncedBroadcast(deviceID string) {
+ m.broadcastMutex.Lock()
+ defer m.broadcastMutex.Unlock()
+
+ m.broadcastPending = true
+ m.pendingDeviceID = deviceID
+
+ if m.broadcastTimer == nil {
+ m.broadcastTimer = time.AfterFunc(150*time.Millisecond, func() {
+ m.broadcastMutex.Lock()
+ pending := m.broadcastPending
+ deviceID := m.pendingDeviceID
+ m.broadcastPending = false
+ m.pendingDeviceID = ""
+ m.broadcastMutex.Unlock()
+
+ if !pending || deviceID == "" {
+ return
+ }
+
+ m.broadcastDeviceUpdate(deviceID)
+ })
+ } else {
+ m.broadcastTimer.Reset(150 * time.Millisecond)
+ }
+}
+
+func (m *Manager) broadcastDeviceUpdate(deviceID string) {
+ m.stateMutex.RLock()
+ var targetDevice *Device
+ for _, dev := range m.state.Devices {
+ if dev.ID == deviceID {
+ devCopy := dev
+ targetDevice = &devCopy
+ break
+ }
+ }
+ m.stateMutex.RUnlock()
+
+ if targetDevice == nil {
+ log.Debugf("Device not found for broadcast: %s", deviceID)
+ return
+ }
+
+ update := DeviceUpdate{Device: *targetDevice}
+
+ log.Debugf("Broadcasting device update: %s at %d%%", deviceID, targetDevice.CurrentPercent)
+
+ m.updateSubscribers.Range(func(key string, ch chan DeviceUpdate) bool {
+ select {
+ case ch <- update:
+ default:
+ }
+ return true
+ })
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go
new file mode 100644
index 0000000..a8ae0f8
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go
@@ -0,0 +1,11 @@
+package brightness
+
+import (
+ "testing"
+)
+
+// Manager tests can be added here as needed
+func TestManager_Placeholder(t *testing.T) {
+ // Placeholder test to keep the test file valid
+ t.Skip("No tests implemented yet")
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go
new file mode 100644
index 0000000..1ff38f9
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go
@@ -0,0 +1,267 @@
+package brightness
+
+import (
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+func NewSysfsBackend() (*SysfsBackend, error) {
+ b := &SysfsBackend{
+ basePath: "/sys/class",
+ classes: []string{"backlight", "leds"},
+ }
+
+ if err := b.scanDevices(); err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+func (b *SysfsBackend) scanDevices() error {
+ for _, class := range b.classes {
+ classPath := filepath.Join(b.basePath, class)
+ entries, err := os.ReadDir(classPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return fmt.Errorf("read %s: %w", classPath, err)
+ }
+
+ for _, entry := range entries {
+ devicePath := filepath.Join(classPath, entry.Name())
+
+ stat, err := os.Stat(devicePath)
+ if err != nil || !stat.IsDir() {
+ continue
+ }
+ maxPath := filepath.Join(devicePath, "max_brightness")
+
+ maxData, err := os.ReadFile(maxPath)
+ if err != nil {
+ log.Debugf("skip %s/%s: no max_brightness", class, entry.Name())
+ continue
+ }
+
+ maxBrightness, err := strconv.Atoi(strings.TrimSpace(string(maxData)))
+ if err != nil || maxBrightness <= 0 {
+ log.Debugf("skip %s/%s: invalid max_brightness", class, entry.Name())
+ continue
+ }
+
+ deviceClass := ClassBacklight
+ minValue := 1
+ if class == "leds" {
+ deviceClass = ClassLED
+ minValue = 0
+ }
+
+ deviceID := fmt.Sprintf("%s:%s", class, entry.Name())
+ b.deviceCache.Store(deviceID, &sysfsDevice{
+ class: deviceClass,
+ id: deviceID,
+ name: entry.Name(),
+ maxBrightness: maxBrightness,
+ minValue: minValue,
+ })
+
+ log.Debugf("found %s device: %s (max=%d)", class, entry.Name(), maxBrightness)
+ }
+ }
+
+ return nil
+}
+
+func shouldSuppressDevice(name string) bool {
+ if strings.HasSuffix(name, "::lan") {
+ return true
+ }
+
+ keyboardLEDs := []string{
+ "::scrolllock",
+ "::capslock",
+ "::numlock",
+ "::kana",
+ "::compose",
+ }
+
+ for _, suffix := range keyboardLEDs {
+ if strings.HasSuffix(name, suffix) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (b *SysfsBackend) Rescan() error {
+ return b.scanDevices()
+}
+
+func (b *SysfsBackend) GetDevices() ([]Device, error) {
+ devices := make([]Device, 0)
+
+ b.deviceCache.Range(func(key string, dev *sysfsDevice) bool {
+ if shouldSuppressDevice(dev.name) {
+ return true
+ }
+
+ parts := strings.SplitN(dev.id, ":", 2)
+ if len(parts) != 2 {
+ return true
+ }
+
+ class := parts[0]
+ name := parts[1]
+
+ devicePath := filepath.Join(b.basePath, class, name)
+ brightnessPath := filepath.Join(devicePath, "brightness")
+
+ brightnessData, err := os.ReadFile(brightnessPath)
+ if err != nil {
+ log.Debugf("failed to read brightness for %s: %v", dev.id, err)
+ return true
+ }
+
+ current, err := strconv.Atoi(strings.TrimSpace(string(brightnessData)))
+ if err != nil {
+ log.Debugf("failed to parse brightness for %s: %v", dev.id, err)
+ return true
+ }
+
+ percent := b.ValueToPercent(current, dev, false)
+
+ devices = append(devices, Device{
+ Class: dev.class,
+ ID: dev.id,
+ Name: dev.name,
+ Current: current,
+ Max: dev.maxBrightness,
+ CurrentPercent: percent,
+ Backend: "sysfs",
+ })
+ return true
+ })
+
+ return devices, nil
+}
+
+func (b *SysfsBackend) GetDevice(id string) (*sysfsDevice, error) {
+ dev, ok := b.deviceCache.Load(id)
+ if !ok {
+ return nil, fmt.Errorf("device not found: %s", id)
+ }
+
+ return dev, nil
+}
+
+func (b *SysfsBackend) SetBrightness(id string, percent int, exponential bool) error {
+ return b.SetBrightnessWithExponent(id, percent, exponential, 1.2)
+}
+
+func (b *SysfsBackend) SetBrightnessWithExponent(id string, percent int, exponential bool, exponent float64) error {
+ dev, err := b.GetDevice(id)
+ if err != nil {
+ return err
+ }
+
+ if percent < 0 {
+ return fmt.Errorf("percent out of range: %d", percent)
+ }
+
+ value := b.PercentToValueWithExponent(percent, dev, exponential, exponent)
+
+ parts := strings.SplitN(id, ":", 2)
+ if len(parts) != 2 {
+ return fmt.Errorf("invalid device id: %s", id)
+ }
+
+ class := parts[0]
+ name := parts[1]
+
+ devicePath := filepath.Join(b.basePath, class, name)
+ brightnessPath := filepath.Join(devicePath, "brightness")
+
+ data := []byte(fmt.Sprintf("%d", value))
+ if err := os.WriteFile(brightnessPath, data, 0o644); err != nil {
+ return fmt.Errorf("write brightness: %w", err)
+ }
+
+ log.Debugf("set %s to %d%% (%d/%d) via direct sysfs", id, percent, value, dev.maxBrightness)
+
+ return nil
+}
+
+func (b *SysfsBackend) PercentToValue(percent int, dev *sysfsDevice, exponential bool) int {
+ return b.PercentToValueWithExponent(percent, dev, exponential, 1.2)
+}
+
+func (b *SysfsBackend) PercentToValueWithExponent(percent int, dev *sysfsDevice, exponential bool, exponent float64) int {
+ if percent == 0 {
+ return dev.minValue
+ }
+
+ usableRange := dev.maxBrightness - dev.minValue
+ var value int
+
+ if exponential {
+ normalizedPercent := float64(percent-1) / 99.0
+ hardwarePercent := math.Pow(normalizedPercent, exponent)
+ value = dev.minValue + int(math.Round(hardwarePercent*float64(usableRange)))
+ } else {
+ value = dev.minValue + ((percent - 1) * usableRange / 99)
+ }
+
+ if value < dev.minValue {
+ value = dev.minValue
+ }
+ if value > dev.maxBrightness {
+ value = dev.maxBrightness
+ }
+
+ return value
+}
+
+func (b *SysfsBackend) ValueToPercent(value int, dev *sysfsDevice, exponential bool) int {
+ return b.ValueToPercentWithExponent(value, dev, exponential, 1.2)
+}
+
+func (b *SysfsBackend) ValueToPercentWithExponent(value int, dev *sysfsDevice, exponential bool, exponent float64) int {
+ if value <= dev.minValue {
+ if dev.minValue == 0 && value == 0 {
+ return 0
+ }
+ return 1
+ }
+
+ usableRange := dev.maxBrightness - dev.minValue
+ if usableRange == 0 {
+ return 100
+ }
+
+ var percent int
+
+ if exponential {
+ hardwarePercent := float64(value-dev.minValue) / float64(usableRange)
+ normalizedPercent := math.Pow(hardwarePercent, 1.0/exponent)
+ percent = 1 + int(math.Round(normalizedPercent*99.0))
+ } else {
+ percent = 1 + int(math.Round(float64(value-dev.minValue)*99.0/float64(usableRange)))
+ }
+
+ if percent > 100 {
+ percent = 100
+ }
+ if percent < 1 {
+ percent = 1
+ }
+
+ return percent
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go
new file mode 100644
index 0000000..6ba84b8
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go
@@ -0,0 +1,278 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ mocks_brightness "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/brightness"
+ mock_dbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestManager_SetBrightness_LogindSuccess(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "backlight", "test_backlight", uint32(75)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() with logind error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ if string(data) == "75\n" {
+ t.Error("Direct sysfs write occurred when logind should have been used")
+ }
+}
+
+func TestManager_SetBrightness_LogindFailsFallbackToSysfs(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "backlight", "test_backlight", mock.Anything).
+ Return(&dbus.Call{Err: dbus.ErrMsgNoObject}).
+ Once()
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() with fallback error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ brightness := string(data)
+ if brightness != "75" {
+ t.Errorf("Fallback sysfs write did not occur, got brightness = %q, want %q", brightness, "75")
+ }
+}
+
+func TestManager_SetBrightness_NoLogind(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: nil,
+ sysfsBackend: sysfs,
+ logindReady: false,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() without logind error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ brightness := string(data)
+ if brightness != "75" {
+ t.Errorf("Direct sysfs write = %q, want %q", brightness, "75")
+ }
+}
+
+func TestManager_SetBrightness_LEDWithLogind(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ ledsDir := filepath.Join(tmpDir, "leds", "test_led")
+ if err := os.MkdirAll(ledsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"leds"},
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassLED,
+ ID: "leds:test_led",
+ Name: "test_led",
+ Current: 128,
+ Max: 255,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "leds", "test_led", uint32(0)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := m.SetBrightness("leds:test_led", 0)
+ if err != nil {
+ t.Errorf("SetBrightness() LED with logind error = %v, want nil", err)
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go
new file mode 100644
index 0000000..8061513
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go
@@ -0,0 +1,180 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSysfsBackend_PercentConversions(t *testing.T) {
+ tests := []struct {
+ name string
+ device *sysfsDevice
+ percent int
+ wantValue int
+ tolerance int
+ }{
+ {
+ name: "backlight 0% should be minValue=1",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 0,
+ wantValue: 1,
+ tolerance: 0,
+ },
+ {
+ name: "backlight 1% should be minValue=1",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 1,
+ wantValue: 1,
+ tolerance: 0,
+ },
+ {
+ name: "backlight 50% should be ~50",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 50,
+ wantValue: 50,
+ tolerance: 1,
+ },
+ {
+ name: "backlight 100% should be max",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 100,
+ wantValue: 100,
+ tolerance: 0,
+ },
+ {
+ name: "led 0% should be 0",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 0,
+ wantValue: 0,
+ tolerance: 0,
+ },
+ {
+ name: "led 1% should be ~2-3",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 1,
+ wantValue: 2,
+ tolerance: 3,
+ },
+ {
+ name: "led 50% should be ~127",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 50,
+ wantValue: 127,
+ tolerance: 2,
+ },
+ {
+ name: "led 100% should be max",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 100,
+ wantValue: 255,
+ tolerance: 0,
+ },
+ }
+
+ b := &SysfsBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.PercentToValue(tt.percent, tt.device, false)
+ diff := got - tt.wantValue
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > tt.tolerance {
+ t.Errorf("percentToValue() = %v, want %v (±%d)", got, tt.wantValue, tt.tolerance)
+ }
+
+ gotPercent := b.ValueToPercent(got, tt.device, false)
+ if tt.percent > 1 && gotPercent == 0 {
+ t.Errorf("valueToPercent() returned 0 for non-zero input (percent=%d, got value=%d)", tt.percent, got)
+ }
+ })
+ }
+}
+
+func TestSysfsBackend_ValueToPercent(t *testing.T) {
+ tests := []struct {
+ name string
+ device *sysfsDevice
+ value int
+ wantPercent int
+ }{
+ {
+ name: "backlight min value",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ value: 1,
+ wantPercent: 1,
+ },
+ {
+ name: "backlight max value",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ value: 100,
+ wantPercent: 100,
+ },
+ {
+ name: "led zero",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ value: 0,
+ wantPercent: 0,
+ },
+ }
+
+ b := &SysfsBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.ValueToPercent(tt.value, tt.device, false)
+ if got != tt.wantPercent {
+ t.Errorf("valueToPercent() = %v, want %v", got, tt.wantPercent)
+ }
+ })
+ }
+}
+
+func TestSysfsBackend_ScanDevices(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ ledsDir := filepath.Join(tmpDir, "leds", "test_led")
+ if err := os.MkdirAll(ledsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ b := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight", "leds"},
+ }
+
+ if err := b.scanDevices(); err != nil {
+ t.Fatalf("scanDevices() error = %v", err)
+ }
+
+ backlightID := "backlight:test_backlight"
+ if _, ok := b.deviceCache.Load(backlightID); !ok {
+ t.Errorf("backlight device not found")
+ }
+
+ ledID := "leds:test_led"
+ if _, ok := b.deviceCache.Load(ledID); !ok {
+ t.Errorf("LED device not found")
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/types.go
new file mode 100644
index 0000000..81e00cf
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/types.go
@@ -0,0 +1,184 @@
+package brightness
+
+import (
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type DeviceClass string
+
+const (
+ ClassBacklight DeviceClass = "backlight"
+ ClassLED DeviceClass = "leds"
+ ClassDDC DeviceClass = "ddc"
+)
+
+type Device struct {
+ Class DeviceClass `json:"class"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Current int `json:"current"`
+ Max int `json:"max"`
+ CurrentPercent int `json:"currentPercent"`
+ Backend string `json:"backend"`
+}
+
+type State struct {
+ Devices []Device `json:"devices"`
+}
+
+type DeviceUpdate struct {
+ Device Device `json:"device"`
+}
+
+type Manager struct {
+ logindBackend *LogindBackend
+ sysfsBackend *SysfsBackend
+ ddcBackend *DDCBackend
+ udevMonitor *UdevMonitor
+
+ logindReady bool
+ sysfsReady bool
+ ddcReady bool
+
+ exponential bool
+
+ stateMutex sync.RWMutex
+ state State
+
+ subscribers syncmap.Map[string, chan State]
+ updateSubscribers syncmap.Map[string, chan DeviceUpdate]
+
+ broadcastMutex sync.Mutex
+ broadcastTimer *time.Timer
+ broadcastPending bool
+ pendingDeviceID string
+
+ stopChan chan struct{}
+}
+
+type SysfsBackend struct {
+ basePath string
+ classes []string
+
+ deviceCache syncmap.Map[string, *sysfsDevice]
+}
+
+type sysfsDevice struct {
+ class DeviceClass
+ id string
+ name string
+ maxBrightness int
+ minValue int
+}
+
+type DDCBackend struct {
+ devices syncmap.Map[string, *ddcDevice]
+
+ scanMutex sync.Mutex
+ lastScan time.Time
+ scanInterval time.Duration
+
+ debounceMutex sync.Mutex
+ debounceTimers map[string]*time.Timer
+ debouncePending map[string]ddcPendingSet
+ debounceWg sync.WaitGroup
+}
+
+type ddcPendingSet struct {
+ percent int
+ callback func()
+}
+
+type ddcDevice struct {
+ bus int
+ addr int
+ id string
+ name string
+ max int
+ lastBrightness int
+}
+
+type ddcCapability struct {
+ vcp byte
+ max int
+ current int
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 16)
+
+ m.subscribers.Store(id, ch)
+
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+
+ }
+
+}
+
+func (m *Manager) SubscribeUpdates(id string) chan DeviceUpdate {
+ ch := make(chan DeviceUpdate, 16)
+ m.updateSubscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) UnsubscribeUpdates(id string) {
+ if val, ok := m.updateSubscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) NotifySubscribers() {
+ m.stateMutex.RLock()
+ state := m.state
+ m.stateMutex.RUnlock()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- state:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return m.state
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+ m.updateSubscribers.Range(func(key string, ch chan DeviceUpdate) bool {
+ close(ch)
+ m.updateSubscribers.Delete(key)
+ return true
+ })
+
+ if m.udevMonitor != nil {
+ m.udevMonitor.Close()
+ }
+
+ if m.logindBackend != nil {
+ m.logindBackend.Close()
+ }
+
+ if m.ddcBackend != nil {
+ m.ddcBackend.Close()
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go
new file mode 100644
index 0000000..b7f02a8
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go
@@ -0,0 +1,237 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/pilebones/go-udev/netlink"
+)
+
+const (
+ udevRecvBufSize = 8 * 1024 * 1024
+ udevMaxRetries = 5
+ udevBaseDelay = 2 * time.Second
+ udevMaxDelay = 60 * time.Second
+)
+
+type UdevMonitor struct {
+ stop chan struct{}
+ rescanMutex sync.Mutex
+ rescanTimer *time.Timer
+ rescanPending bool
+}
+
+func NewUdevMonitor(manager *Manager) *UdevMonitor {
+ m := &UdevMonitor{
+ stop: make(chan struct{}),
+ }
+
+ go m.run(manager)
+ return m
+}
+
+func (m *UdevMonitor) run(manager *Manager) {
+ matcher := &netlink.RuleDefinitions{
+ Rules: []netlink.RuleDefinition{
+ {Env: map[string]string{"SUBSYSTEM": "backlight"}},
+ {Env: map[string]string{"SUBSYSTEM": "drm"}},
+ {Env: map[string]string{"SUBSYSTEM": "i2c"}},
+ },
+ }
+ if err := matcher.Compile(); err != nil {
+ log.Errorf("Failed to compile udev matcher: %v", err)
+ return
+ }
+
+ failures := 0
+ for {
+ if err := m.monitorLoop(manager, matcher); err != nil {
+ log.Errorf("Udev monitor error: %v", err)
+ }
+
+ select {
+ case <-m.stop:
+ return
+ default:
+ }
+
+ failures++
+ if failures > udevMaxRetries {
+ log.Errorf("Udev monitor exceeded %d retries, giving up", udevMaxRetries)
+ return
+ }
+
+ delay := min(udevBaseDelay*time.Duration(1<<(failures-1)), udevMaxDelay)
+ log.Infof("Udev monitor reconnecting in %v (attempt %d/%d)", delay, failures, udevMaxRetries)
+
+ select {
+ case <-m.stop:
+ return
+ case <-time.After(delay):
+ }
+ }
+}
+
+func (m *UdevMonitor) monitorLoop(manager *Manager, matcher *netlink.RuleDefinitions) error {
+ conn := &netlink.UEventConn{}
+ if err := conn.Connect(netlink.UdevEvent); err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ if err := syscall.SetsockoptInt(conn.Fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, udevRecvBufSize); err != nil {
+ log.Warnf("Failed to set udev socket receive buffer: %v", err)
+ }
+
+ events := make(chan netlink.UEvent)
+ errs := make(chan error)
+ conn.Monitor(events, errs, matcher)
+
+ log.Info("Udev monitor started for backlight/drm/i2c events")
+
+ for {
+ select {
+ case <-m.stop:
+ return nil
+ case err := <-errs:
+ return err
+ case event := <-events:
+ m.handleEvent(manager, event)
+ }
+ }
+}
+
+func (m *UdevMonitor) handleEvent(manager *Manager, event netlink.UEvent) {
+ subsystem := event.Env["SUBSYSTEM"]
+ devpath := event.Env["DEVPATH"]
+
+ if subsystem == "" || devpath == "" {
+ return
+ }
+
+ sysname := filepath.Base(devpath)
+ action := string(event.Action)
+
+ switch subsystem {
+ case "drm", "i2c":
+ m.handleDisplayEvent(manager, action, subsystem, sysname)
+ case "backlight":
+ m.handleBacklightEvent(manager, action, sysname)
+ }
+}
+
+func (m *UdevMonitor) handleDisplayEvent(manager *Manager, action, subsystem, sysname string) {
+ switch action {
+ case "add", "remove", "change":
+ log.Debugf("Udev %s event: %s:%s - queueing DDC rescan", action, subsystem, sysname)
+ m.debouncedRescan(manager)
+ }
+}
+
+func (m *UdevMonitor) debouncedRescan(manager *Manager) {
+ m.rescanMutex.Lock()
+ defer m.rescanMutex.Unlock()
+
+ m.rescanPending = true
+
+ if m.rescanTimer != nil {
+ m.rescanTimer.Reset(2 * time.Second)
+ return
+ }
+
+ m.rescanTimer = time.AfterFunc(2*time.Second, func() {
+ m.rescanMutex.Lock()
+ pending := m.rescanPending
+ m.rescanPending = false
+ m.rescanMutex.Unlock()
+
+ if !pending {
+ return
+ }
+
+ log.Debug("Executing debounced DDC rescan")
+ manager.Rescan()
+ })
+}
+
+func (m *UdevMonitor) handleBacklightEvent(manager *Manager, action, sysname string) {
+ switch action {
+ case "change":
+ m.handleChange(manager, "backlight", sysname)
+ case "add", "remove":
+ log.Debugf("Udev %s event: backlight:%s - triggering rescan", action, sysname)
+ manager.Rescan()
+ }
+}
+
+func (m *UdevMonitor) handleChange(manager *Manager, subsystem, sysname string) {
+ deviceID := subsystem + ":" + sysname
+
+ if manager.sysfsBackend == nil {
+ return
+ }
+
+ brightnessPath := filepath.Join(manager.sysfsBackend.basePath, subsystem, sysname, "brightness")
+ data, err := os.ReadFile(brightnessPath)
+ if err != nil {
+ log.Debugf("Udev change event for %s but failed to read brightness: %v", deviceID, err)
+ return
+ }
+
+ brightness, err := strconv.Atoi(strings.TrimSpace(string(data)))
+ if err != nil {
+ log.Debugf("Failed to parse brightness for %s: %v", deviceID, err)
+ return
+ }
+
+ manager.handleUdevBrightnessChange(deviceID, brightness)
+}
+
+func (m *UdevMonitor) Close() {
+ close(m.stop)
+}
+
+func (m *Manager) handleUdevBrightnessChange(deviceID string, rawBrightness int) {
+ if m.sysfsBackend == nil {
+ return
+ }
+
+ dev, err := m.sysfsBackend.GetDevice(deviceID)
+ if err != nil {
+ log.Debugf("Udev event for unknown device %s: %v", deviceID, err)
+ return
+ }
+
+ percent := m.sysfsBackend.ValueToPercent(rawBrightness, dev, false)
+
+ m.stateMutex.Lock()
+ var found bool
+ for i, d := range m.state.Devices {
+ if d.ID != deviceID {
+ continue
+ }
+ found = true
+ if d.Current == rawBrightness {
+ m.stateMutex.Unlock()
+ return
+ }
+ m.state.Devices[i].Current = rawBrightness
+ m.state.Devices[i].CurrentPercent = percent
+ break
+ }
+ m.stateMutex.Unlock()
+
+ if !found {
+ log.Debugf("Udev event for device not in state: %s", deviceID)
+ return
+ }
+
+ log.Debugf("Udev brightness change: %s -> %d (%d%%)", deviceID, rawBrightness, percent)
+ m.broadcastDeviceUpdate(deviceID)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go
new file mode 100644
index 0000000..a5c406b
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go
@@ -0,0 +1,260 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/pilebones/go-udev/netlink"
+)
+
+func setupTestManager(t *testing.T) (*Manager, string) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "intel_backlight")
+ if err := os.MkdirAll(backlightDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("1000\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("500\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ }
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ sysfsBackend: sysfs,
+ sysfsReady: true,
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:intel_backlight",
+ Name: "intel_backlight",
+ Current: 500,
+ Max: 1000,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ return m, tmpDir
+}
+
+func TestHandleUdevBrightnessChange_UpdatesState(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ m.handleUdevBrightnessChange("backlight:intel_backlight", 750)
+
+ state := m.GetState()
+ if len(state.Devices) != 1 {
+ t.Fatalf("expected 1 device, got %d", len(state.Devices))
+ }
+
+ dev := state.Devices[0]
+ if dev.Current != 750 {
+ t.Errorf("expected Current=750, got %d", dev.Current)
+ }
+ if dev.CurrentPercent != 75 {
+ t.Errorf("expected CurrentPercent=75, got %d", dev.CurrentPercent)
+ }
+}
+
+func TestHandleUdevBrightnessChange_NoChangeWhenSameValue(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ updateCh := m.SubscribeUpdates("test")
+ defer m.UnsubscribeUpdates("test")
+
+ m.handleUdevBrightnessChange("backlight:intel_backlight", 500)
+
+ select {
+ case <-updateCh:
+ t.Error("should not broadcast when brightness unchanged")
+ case <-time.After(50 * time.Millisecond):
+ }
+}
+
+func TestHandleUdevBrightnessChange_BroadcastsOnChange(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ updateCh := m.SubscribeUpdates("test")
+ defer m.UnsubscribeUpdates("test")
+
+ m.handleUdevBrightnessChange("backlight:intel_backlight", 750)
+
+ select {
+ case update := <-updateCh:
+ if update.Device.Current != 750 {
+ t.Errorf("broadcast had wrong Current: got %d, want 750", update.Device.Current)
+ }
+ case <-time.After(100 * time.Millisecond):
+ t.Error("expected broadcast on brightness change")
+ }
+}
+
+func TestHandleUdevBrightnessChange_UnknownDevice(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ m.handleUdevBrightnessChange("backlight:unknown_device", 500)
+
+ state := m.GetState()
+ if len(state.Devices) != 1 {
+ t.Errorf("state should be unchanged, got %d devices", len(state.Devices))
+ }
+}
+
+func TestHandleUdevBrightnessChange_NilSysfsBackend(t *testing.T) {
+ m := &Manager{
+ sysfsBackend: nil,
+ stopChan: make(chan struct{}),
+ }
+
+ m.handleUdevBrightnessChange("backlight:test", 500)
+}
+
+func TestHandleUdevBrightnessChange_DeviceNotInState(t *testing.T) {
+ m, _ := setupTestManager(t)
+
+ m.sysfsBackend.deviceCache.Store("backlight:other_device", &sysfsDevice{
+ class: ClassBacklight,
+ id: "backlight:other_device",
+ name: "other_device",
+ maxBrightness: 100,
+ minValue: 1,
+ })
+
+ m.handleUdevBrightnessChange("backlight:other_device", 50)
+
+ state := m.GetState()
+ for _, d := range state.Devices {
+ if d.ID == "backlight:other_device" {
+ t.Error("device should not be added to state via udev change event")
+ }
+ }
+}
+
+func TestHandleEvent_ChangeAction(t *testing.T) {
+ m, tmpDir := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness")
+ if err := os.WriteFile(brightnessPath, []byte("800\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ event := netlink.UEvent{
+ Action: netlink.CHANGE,
+ Env: map[string]string{
+ "SUBSYSTEM": "backlight",
+ "DEVPATH": "/devices/pci0000:00/0000:00:02.0/drm/card0/card0-eDP-1/intel_backlight",
+ },
+ }
+
+ um.handleEvent(m, event)
+
+ state := m.GetState()
+ if state.Devices[0].Current != 800 {
+ t.Errorf("expected Current=800 after change event, got %d", state.Devices[0].Current)
+ }
+}
+
+func TestHandleEvent_MissingEnvVars(t *testing.T) {
+ m, _ := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ event := netlink.UEvent{
+ Action: netlink.CHANGE,
+ Env: map[string]string{},
+ }
+
+ um.handleEvent(m, event)
+
+ state := m.GetState()
+ if state.Devices[0].Current != 500 {
+ t.Error("state should be unchanged with missing env vars")
+ }
+}
+
+func TestHandleEvent_MissingSubsystem(t *testing.T) {
+ m, _ := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ event := netlink.UEvent{
+ Action: netlink.CHANGE,
+ Env: map[string]string{
+ "DEVPATH": "/devices/foo/bar",
+ },
+ }
+
+ um.handleEvent(m, event)
+
+ state := m.GetState()
+ if state.Devices[0].Current != 500 {
+ t.Error("state should be unchanged with missing SUBSYSTEM")
+ }
+}
+
+func TestHandleChange_BrightnessFileNotFound(t *testing.T) {
+ m, _ := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ um.handleChange(m, "backlight", "nonexistent_device")
+
+ state := m.GetState()
+ if state.Devices[0].Current != 500 {
+ t.Error("state should be unchanged when brightness file not found")
+ }
+}
+
+func TestHandleChange_InvalidBrightnessValue(t *testing.T) {
+ m, tmpDir := setupTestManager(t)
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness")
+ if err := os.WriteFile(brightnessPath, []byte("not_a_number\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ um.handleChange(m, "backlight", "intel_backlight")
+
+ state := m.GetState()
+ if state.Devices[0].Current != 500 {
+ t.Error("state should be unchanged with invalid brightness value")
+ }
+}
+
+func TestUdevMonitor_Close(t *testing.T) {
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ um.Close()
+
+ select {
+ case <-um.stop:
+ default:
+ t.Error("stop channel should be closed")
+ }
+}
+
+func TestHandleChange_NilSysfsBackend(t *testing.T) {
+ m := &Manager{
+ sysfsBackend: nil,
+ stopChan: make(chan struct{}),
+ }
+ um := &UdevMonitor{stop: make(chan struct{})}
+
+ um.handleChange(m, "backlight", "test_device")
+}