fix(brightness): use actual_brightness and prefer internal panel device

Switch from reading `brightness` (software-requested) to `actual_brightness`
(hardware-reported), which is the correct file on AMD GPU backlights where
the two values can diverge.

Replace the first-found device selection with a scored preference so that
when multiple backlight devices are present (e.g. amdgpu_bl2 and nvidia_0)
the internal laptop panel is chosen rather than a dGPU output that may
report a fixed ceiling value. Priority: amdgpu/intel (3) > acpi/platform
(2) > unknown (1) > nvidia (0).
This commit is contained in:
2026-04-10 14:48:47 -04:00
parent d00c4aa371
commit aadb0a4a57
+47 -14
View File
@@ -3,19 +3,21 @@
//! Screen brightness monitor.
//!
//! Reads from `/sys/class/backlight/<device>/brightness` and
//! Reads from `/sys/class/backlight/<device>/actual_brightness` and
//! `/sys/class/backlight/<device>/max_brightness`, polling every 2 seconds
//! and sending `CMD_BRIGHTNESS` packets to all keyboards when the value
//! changes.
//!
//! On a KDE6 / Wayland desktop the backlight sysfs path is typically one of:
//! - `/sys/class/backlight/intel_backlight`
//! - `/sys/class/backlight/amdgpu_bl0`
//! - `/sys/class/backlight/acpi_video0`
//! `actual_brightness` is preferred over `brightness` because it reflects the
//! real hardware state; on AMD GPU panels these can diverge.
//!
//! When multiple backlight devices are present (e.g. `amdgpu_bl2` and
//! `nvidia_0`), the device most likely to represent the internal laptop panel
//! is selected automatically (amdgpu/intel > acpi/platform > other > nvidia).
//!
//! # Future improvement
//! Replace polling with an inotify watch on the `brightness` file so changes
//! are reflected immediately.
//! Replace polling with an inotify watch on the `actual_brightness` file so
//! changes are reflected immediately.
use std::fs;
use std::path::{Path, PathBuf};
@@ -65,23 +67,54 @@ pub fn monitor(keyboards: Vec<Keyboard>) {
// Helpers
// ---------------------------------------------------------------------------
/// Return the sysfs path to the first available backlight device.
/// Preference score for a backlight device name (higher = more preferred).
///
/// Internal GPU panels (amdgpu, intel) are preferred over discrete GPU
/// outputs (nvidia), which often report a fixed ceiling value rather than the
/// actual adjustable panel brightness.
fn backlight_score(name: &str) -> u8 {
if name.starts_with("amdgpu_") || name.starts_with("intel_") {
3
} else if name.starts_with("acpi_") || name.starts_with("platform_") {
2
} else if name.starts_with("nvidia_") {
0
} else {
1
}
}
/// Return the sysfs path to the best available backlight device.
///
/// Scores each candidate by name prefix and returns the highest-scoring one.
/// Requires both `actual_brightness` and `max_brightness` to be present.
fn find_backlight() -> Option<PathBuf> {
let read_dir = fs::read_dir("/sys/class/backlight").ok()?;
let mut best: Option<(u8, PathBuf)> = None;
for entry in read_dir.flatten() {
let path = entry.path();
// Verify the required files exist before committing to this device.
if path.join("brightness").exists() && path.join("max_brightness").exists() {
return Some(path);
if !path.join("actual_brightness").exists() || !path.join("max_brightness").exists() {
continue;
}
let name = path.file_name()?.to_string_lossy().into_owned();
let score = backlight_score(&name);
if best.as_ref().map_or(true, |(best_score, _)| score > *best_score) {
best = Some((score, path));
}
}
None
best.map(|(_, path)| path)
}
/// Read the current brightness as a percentage (0-100) from a sysfs backlight
/// directory, or `None` if the files cannot be read / parsed.
/// directory, or `None` if the files cannot be read or parsed.
///
/// Uses `actual_brightness` (hardware-reported) rather than `brightness`
/// (software-requested) for accuracy on AMD GPU panels.
fn read_brightness(backlight: &Path) -> Option<u8> {
let current: u64 = fs::read_to_string(backlight.join("brightness"))
let current: u64 = fs::read_to_string(backlight.join("actual_brightness"))
.ok()?
.trim()
.parse()