1
0
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:
bbedward
2025-11-12 17:18:45 -05:00
parent 6013c994a6
commit 24e800501a
768 changed files with 76284 additions and 221 deletions

View 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() {
}

View 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)
}

View 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)
}
})
}
}

View 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)
}
})
}
}

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

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

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

View 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:
}
}
}

View 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")
}

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

View 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)
}
}

View 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")
}
}

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