mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2025-12-15 09:52:50 -05:00
switch hto monorepo structure
This commit is contained in:
476
backend/internal/server/brightness/ddc.go
Normal file
476
backend/internal/server/brightness/ddc.go
Normal file
@@ -0,0 +1,476 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/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{
|
||||
devices: make(map[string]*ddcDevice),
|
||||
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 {
|
||||
b.scanMutex.Lock()
|
||||
lastScan := b.lastScan
|
||||
b.scanMutex.Unlock()
|
||||
|
||||
if time.Since(lastScan) < b.scanInterval {
|
||||
return nil
|
||||
}
|
||||
|
||||
b.scanMutex.Lock()
|
||||
defer b.scanMutex.Unlock()
|
||||
|
||||
if time.Since(b.lastScan) < b.scanInterval {
|
||||
return nil
|
||||
}
|
||||
|
||||
b.devicesMutex.Lock()
|
||||
defer b.devicesMutex.Unlock()
|
||||
|
||||
b.devices = make(map[string]*ddcDevice)
|
||||
|
||||
for i := 0; i < 32; i++ {
|
||||
busPath := fmt.Sprintf("/dev/i2c-%d", i)
|
||||
if _, err := os.Stat(busPath); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip SMBus, GPU internal buses (e.g. AMDGPU SMU) to prevent GPU hangs
|
||||
if isIgnorableI2CBus(i) {
|
||||
log.Debugf("Skipping ignorable i2c-%d", i)
|
||||
continue
|
||||
}
|
||||
|
||||
dev, err := b.probeDDCDevice(i)
|
||||
if err != nil || dev == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("ddc:i2c-%d", i)
|
||||
dev.id = id
|
||||
b.devices[id] = dev
|
||||
log.Debugf("found DDC device on i2c-%d", i)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
b.devicesMutex.Lock()
|
||||
defer b.devicesMutex.Unlock()
|
||||
|
||||
devices := make([]Device, 0, len(b.devices))
|
||||
|
||||
for id, dev := range b.devices {
|
||||
devices = append(devices, Device{
|
||||
Class: ClassDDC,
|
||||
ID: id,
|
||||
Name: dev.name,
|
||||
Current: dev.lastBrightness,
|
||||
Max: dev.max,
|
||||
CurrentPercent: dev.lastBrightness,
|
||||
Backend: "ddc",
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
b.devicesMutex.RLock()
|
||||
_, ok := b.devices[id]
|
||||
b.devicesMutex.RUnlock()
|
||||
|
||||
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()
|
||||
defer b.debounceMutex.Unlock()
|
||||
|
||||
b.debouncePending[id] = ddcPendingSet{
|
||||
percent: value,
|
||||
callback: callback,
|
||||
}
|
||||
|
||||
if timer, exists := b.debounceTimers[id]; exists {
|
||||
timer.Reset(200 * time.Millisecond)
|
||||
} else {
|
||||
b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) error {
|
||||
b.devicesMutex.RLock()
|
||||
dev, ok := b.devices[id]
|
||||
b.devicesMutex.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("device not found: %s", id)
|
||||
}
|
||||
|
||||
busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
|
||||
|
||||
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
|
||||
if err != nil {
|
||||
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
|
||||
b.devicesMutex.Lock()
|
||||
dev.max = max
|
||||
b.devicesMutex.Unlock()
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
b.devicesMutex.Lock()
|
||||
dev.max = max
|
||||
dev.lastBrightness = value
|
||||
b.devicesMutex.Unlock()
|
||||
|
||||
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) Close() {
|
||||
}
|
||||
135
backend/internal/server/brightness/ddc_filter.go
Normal file
135
backend/internal/server/brightness/ddc_filter.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/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)
|
||||
}
|
||||
122
backend/internal/server/brightness/ddc_filter_test.go
Normal file
122
backend/internal/server/brightness/ddc_filter_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
135
backend/internal/server/brightness/ddc_test.go
Normal file
135
backend/internal/server/brightness/ddc_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
163
backend/internal/server/brightness/handlers.go
Normal file
163
backend/internal/server/brightness/handlers.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
|
||||
)
|
||||
|
||||
func HandleRequest(conn net.Conn, req 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.(int), "unknown method: "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetState(conn net.Conn, req Request, m *Manager) {
|
||||
state := m.GetState()
|
||||
models.Respond(conn, req.ID.(int), state)
|
||||
}
|
||||
|
||||
func handleSetBrightness(conn net.Conn, req Request, m *Manager) {
|
||||
var params SetBrightnessParams
|
||||
|
||||
device, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID.(int), "missing or invalid device parameter")
|
||||
return
|
||||
}
|
||||
params.Device = device
|
||||
|
||||
percentFloat, ok := req.Params["percent"].(float64)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID.(int), "missing or invalid percent parameter")
|
||||
return
|
||||
}
|
||||
params.Percent = int(percentFloat)
|
||||
|
||||
if exponential, ok := req.Params["exponential"].(bool); ok {
|
||||
params.Exponential = exponential
|
||||
}
|
||||
|
||||
exponent := 1.2
|
||||
if exponentFloat, ok := req.Params["exponent"].(float64); ok {
|
||||
params.Exponent = exponentFloat
|
||||
exponent = exponentFloat
|
||||
}
|
||||
|
||||
if err := m.SetBrightnessWithExponent(params.Device, params.Percent, params.Exponential, exponent); err != nil {
|
||||
models.RespondError(conn, req.ID.(int), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
state := m.GetState()
|
||||
models.Respond(conn, req.ID.(int), state)
|
||||
}
|
||||
|
||||
func handleIncrement(conn net.Conn, req Request, m *Manager) {
|
||||
device, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID.(int), "missing or invalid device parameter")
|
||||
return
|
||||
}
|
||||
|
||||
step := 10
|
||||
if stepFloat, ok := req.Params["step"].(float64); ok {
|
||||
step = int(stepFloat)
|
||||
}
|
||||
|
||||
exponential := false
|
||||
if expBool, ok := req.Params["exponential"].(bool); ok {
|
||||
exponential = expBool
|
||||
}
|
||||
|
||||
exponent := 1.2
|
||||
if exponentFloat, ok := req.Params["exponent"].(float64); ok {
|
||||
exponent = exponentFloat
|
||||
}
|
||||
|
||||
if err := m.IncrementBrightnessWithExponent(device, step, exponential, exponent); err != nil {
|
||||
models.RespondError(conn, req.ID.(int), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
state := m.GetState()
|
||||
models.Respond(conn, req.ID.(int), state)
|
||||
}
|
||||
|
||||
func handleDecrement(conn net.Conn, req Request, m *Manager) {
|
||||
device, ok := req.Params["device"].(string)
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID.(int), "missing or invalid device parameter")
|
||||
return
|
||||
}
|
||||
|
||||
step := 10
|
||||
if stepFloat, ok := req.Params["step"].(float64); ok {
|
||||
step = int(stepFloat)
|
||||
}
|
||||
|
||||
exponential := false
|
||||
if expBool, ok := req.Params["exponential"].(bool); ok {
|
||||
exponential = expBool
|
||||
}
|
||||
|
||||
exponent := 1.2
|
||||
if exponentFloat, ok := req.Params["exponent"].(float64); ok {
|
||||
exponent = exponentFloat
|
||||
}
|
||||
|
||||
if err := m.IncrementBrightnessWithExponent(device, -step, exponential, exponent); err != nil {
|
||||
models.RespondError(conn, req.ID.(int), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
state := m.GetState()
|
||||
models.Respond(conn, req.ID.(int), state)
|
||||
}
|
||||
|
||||
func handleRescan(conn net.Conn, req Request, m *Manager) {
|
||||
m.Rescan()
|
||||
state := m.GetState()
|
||||
models.Respond(conn, req.ID.(int), state)
|
||||
}
|
||||
|
||||
func handleSubscribe(conn net.Conn, req Request, m *Manager) {
|
||||
clientID := "brightness-subscriber"
|
||||
if idStr, ok := req.ID.(string); ok && idStr != "" {
|
||||
clientID = idStr
|
||||
}
|
||||
|
||||
ch := m.Subscribe(clientID)
|
||||
defer m.Unsubscribe(clientID)
|
||||
|
||||
initialState := m.GetState()
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
||||
ID: req.ID.(int),
|
||||
Result: &initialState,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for state := range ch {
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
||||
ID: req.ID.(int),
|
||||
Result: &state,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
67
backend/internal/server/brightness/logind.go
Normal file
67
backend/internal/server/brightness/logind.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/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
|
||||
connOnce bool
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
95
backend/internal/server/brightness/logind_test.go
Normal file
95
backend/internal/server/brightness/logind_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
mocks_brightness "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/brightness"
|
||||
mock_dbus "github.com/AvengeMedia/DankMaterialShell/backend/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()
|
||||
}
|
||||
379
backend/internal/server/brightness/manager.go
Normal file
379
backend/internal/server/brightness/manager.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
|
||||
)
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
return NewManagerWithOptions(false)
|
||||
}
|
||||
|
||||
func NewManagerWithOptions(exponential bool) (*Manager, error) {
|
||||
m := &Manager{
|
||||
subscribers: make(map[string]chan State),
|
||||
updateSubscribers: make(map[string]chan DeviceUpdate),
|
||||
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()
|
||||
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()
|
||||
}
|
||||
|
||||
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...")
|
||||
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}
|
||||
|
||||
m.subMutex.RLock()
|
||||
defer m.subMutex.RUnlock()
|
||||
|
||||
if len(m.updateSubscribers) == 0 {
|
||||
log.Debugf("No update subscribers for device: %s", deviceID)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Broadcasting device update: %s at %d%%", deviceID, targetDevice.CurrentPercent)
|
||||
|
||||
for _, ch := range m.updateSubscribers {
|
||||
select {
|
||||
case ch <- update:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/internal/server/brightness/manager_test.go
Normal file
11
backend/internal/server/brightness/manager_test.go
Normal file
@@ -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")
|
||||
}
|
||||
272
backend/internal/server/brightness/sysfs.go
Normal file
272
backend/internal/server/brightness/sysfs.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
|
||||
)
|
||||
|
||||
func NewSysfsBackend() (*SysfsBackend, error) {
|
||||
b := &SysfsBackend{
|
||||
basePath: "/sys/class",
|
||||
classes: []string{"backlight", "leds"},
|
||||
deviceCache: make(map[string]*sysfsDevice),
|
||||
}
|
||||
|
||||
if err := b.scanDevices(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (b *SysfsBackend) scanDevices() error {
|
||||
b.deviceCacheMutex.Lock()
|
||||
defer b.deviceCacheMutex.Unlock()
|
||||
|
||||
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[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) GetDevices() ([]Device, error) {
|
||||
b.deviceCacheMutex.RLock()
|
||||
defer b.deviceCacheMutex.RUnlock()
|
||||
|
||||
devices := make([]Device, 0, len(b.deviceCache))
|
||||
|
||||
for _, dev := range b.deviceCache {
|
||||
if shouldSuppressDevice(dev.name) {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(dev.id, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
continue
|
||||
}
|
||||
|
||||
current, err := strconv.Atoi(strings.TrimSpace(string(brightnessData)))
|
||||
if err != nil {
|
||||
log.Debugf("failed to parse brightness for %s: %v", dev.id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
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 devices, nil
|
||||
}
|
||||
|
||||
func (b *SysfsBackend) GetDevice(id string) (*sysfsDevice, error) {
|
||||
b.deviceCacheMutex.RLock()
|
||||
defer b.deviceCacheMutex.RUnlock()
|
||||
|
||||
dev, ok := b.deviceCache[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, 0644); 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
|
||||
}
|
||||
290
backend/internal/server/brightness/sysfs_logind_test.go
Normal file
290
backend/internal/server/brightness/sysfs_logind_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
mocks_brightness "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/brightness"
|
||||
mock_dbus "github.com/AvengeMedia/DankMaterialShell/backend/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, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); 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"},
|
||||
deviceCache: make(map[string]*sysfsDevice),
|
||||
}
|
||||
|
||||
if err := sysfs.scanDevices(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
logindBackend: mockLogind,
|
||||
sysfsBackend: sysfs,
|
||||
logindReady: true,
|
||||
sysfsReady: true,
|
||||
subscribers: make(map[string]chan State),
|
||||
updateSubscribers: make(map[string]chan DeviceUpdate),
|
||||
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, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); 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"},
|
||||
deviceCache: make(map[string]*sysfsDevice),
|
||||
}
|
||||
|
||||
if err := sysfs.scanDevices(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
logindBackend: mockLogind,
|
||||
sysfsBackend: sysfs,
|
||||
logindReady: true,
|
||||
sysfsReady: true,
|
||||
subscribers: make(map[string]chan State),
|
||||
updateSubscribers: make(map[string]chan DeviceUpdate),
|
||||
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, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sysfs := &SysfsBackend{
|
||||
basePath: tmpDir,
|
||||
classes: []string{"backlight"},
|
||||
deviceCache: make(map[string]*sysfsDevice),
|
||||
}
|
||||
|
||||
if err := sysfs.scanDevices(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
logindBackend: nil,
|
||||
sysfsBackend: sysfs,
|
||||
logindReady: false,
|
||||
sysfsReady: true,
|
||||
subscribers: make(map[string]chan State),
|
||||
updateSubscribers: make(map[string]chan DeviceUpdate),
|
||||
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, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0644); 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"},
|
||||
deviceCache: make(map[string]*sysfsDevice),
|
||||
}
|
||||
|
||||
if err := sysfs.scanDevices(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
logindBackend: mockLogind,
|
||||
sysfsBackend: sysfs,
|
||||
logindReady: true,
|
||||
sysfsReady: true,
|
||||
subscribers: make(map[string]chan State),
|
||||
updateSubscribers: make(map[string]chan DeviceUpdate),
|
||||
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)
|
||||
}
|
||||
}
|
||||
185
backend/internal/server/brightness/sysfs_test.go
Normal file
185
backend/internal/server/brightness/sysfs_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
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, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ledsDir := filepath.Join(tmpDir, "leds", "test_led")
|
||||
if err := os.MkdirAll(ledsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := &SysfsBackend{
|
||||
basePath: tmpDir,
|
||||
classes: []string{"backlight", "leds"},
|
||||
deviceCache: make(map[string]*sysfsDevice),
|
||||
}
|
||||
|
||||
if err := b.scanDevices(); err != nil {
|
||||
t.Fatalf("scanDevices() error = %v", err)
|
||||
}
|
||||
|
||||
if len(b.deviceCache) != 2 {
|
||||
t.Errorf("expected 2 devices, got %d", len(b.deviceCache))
|
||||
}
|
||||
|
||||
backlightID := "backlight:test_backlight"
|
||||
if _, ok := b.deviceCache[backlightID]; !ok {
|
||||
t.Errorf("backlight device not found")
|
||||
}
|
||||
|
||||
ledID := "leds:test_led"
|
||||
if _, ok := b.deviceCache[ledID]; !ok {
|
||||
t.Errorf("LED device not found")
|
||||
}
|
||||
}
|
||||
199
backend/internal/server/brightness/types.go
Normal file
199
backend/internal/server/brightness/types.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 Request struct {
|
||||
ID interface{} `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
logindBackend *LogindBackend
|
||||
sysfsBackend *SysfsBackend
|
||||
ddcBackend *DDCBackend
|
||||
|
||||
logindReady bool
|
||||
sysfsReady bool
|
||||
ddcReady bool
|
||||
|
||||
exponential bool
|
||||
|
||||
stateMutex sync.RWMutex
|
||||
state State
|
||||
|
||||
subscribers map[string]chan State
|
||||
updateSubscribers map[string]chan DeviceUpdate
|
||||
subMutex sync.RWMutex
|
||||
|
||||
broadcastMutex sync.Mutex
|
||||
broadcastTimer *time.Timer
|
||||
broadcastPending bool
|
||||
pendingDeviceID string
|
||||
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
type SysfsBackend struct {
|
||||
basePath string
|
||||
classes []string
|
||||
|
||||
deviceCache map[string]*sysfsDevice
|
||||
deviceCacheMutex sync.RWMutex
|
||||
}
|
||||
|
||||
type sysfsDevice struct {
|
||||
class DeviceClass
|
||||
id string
|
||||
name string
|
||||
maxBrightness int
|
||||
minValue int
|
||||
}
|
||||
|
||||
type DDCBackend struct {
|
||||
devices map[string]*ddcDevice
|
||||
devicesMutex sync.RWMutex
|
||||
|
||||
scanMutex sync.Mutex
|
||||
lastScan time.Time
|
||||
scanInterval time.Duration
|
||||
|
||||
debounceMutex sync.Mutex
|
||||
debounceTimers map[string]*time.Timer
|
||||
debouncePending map[string]ddcPendingSet
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type SetBrightnessParams struct {
|
||||
Device string `json:"device"`
|
||||
Percent int `json:"percent"`
|
||||
Exponential bool `json:"exponential,omitempty"`
|
||||
Exponent float64 `json:"exponent,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(id string) chan State {
|
||||
ch := make(chan State, 16)
|
||||
m.subMutex.Lock()
|
||||
m.subscribers[id] = ch
|
||||
m.subMutex.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(id string) {
|
||||
m.subMutex.Lock()
|
||||
if ch, ok := m.subscribers[id]; ok {
|
||||
close(ch)
|
||||
delete(m.subscribers, id)
|
||||
}
|
||||
m.subMutex.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) SubscribeUpdates(id string) chan DeviceUpdate {
|
||||
ch := make(chan DeviceUpdate, 16)
|
||||
m.subMutex.Lock()
|
||||
m.updateSubscribers[id] = ch
|
||||
m.subMutex.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) UnsubscribeUpdates(id string) {
|
||||
m.subMutex.Lock()
|
||||
if ch, ok := m.updateSubscribers[id]; ok {
|
||||
close(ch)
|
||||
delete(m.updateSubscribers, id)
|
||||
}
|
||||
m.subMutex.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) NotifySubscribers() {
|
||||
m.stateMutex.RLock()
|
||||
state := m.state
|
||||
m.stateMutex.RUnlock()
|
||||
|
||||
m.subMutex.RLock()
|
||||
defer m.subMutex.RUnlock()
|
||||
|
||||
for _, ch := range m.subscribers {
|
||||
select {
|
||||
case ch <- state:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
return m.state
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
close(m.stopChan)
|
||||
|
||||
m.subMutex.Lock()
|
||||
for _, ch := range m.subscribers {
|
||||
close(ch)
|
||||
}
|
||||
m.subscribers = make(map[string]chan State)
|
||||
for _, ch := range m.updateSubscribers {
|
||||
close(ch)
|
||||
}
|
||||
m.updateSubscribers = make(map[string]chan DeviceUpdate)
|
||||
m.subMutex.Unlock()
|
||||
|
||||
if m.logindBackend != nil {
|
||||
m.logindBackend.Close()
|
||||
}
|
||||
|
||||
if m.ddcBackend != nil {
|
||||
m.ddcBackend.Close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user