diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness')
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") +} |