use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{ApplyError, QueryError};
use crate::types::{
BrightnessUpdate, Device, DeviceClass, DeviceIdentifier, DeviceMetadata, DeviceState,
DeviceUpdate,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PhysicalDisplayManagerLinuxSys {
sysfs_root: PathBuf,
}
impl Default for PhysicalDisplayManagerLinuxSys {
fn default() -> Self {
Self::new()
}
}
impl PhysicalDisplayManagerLinuxSys {
pub fn new() -> Self {
Self {
sysfs_root: PathBuf::from("/sys/class"),
}
}
pub fn with_sysfs_root(path: impl Into<PathBuf>) -> Self {
Self {
sysfs_root: path.into(),
}
}
pub fn list(&self) -> Result<Vec<Device>, QueryError> {
self.list_by_classes([DeviceClass::Backlight, DeviceClass::Leds])
}
pub fn list_by_classes(
&self,
classes: impl IntoIterator<Item = DeviceClass>,
) -> Result<Vec<Device>, QueryError> {
let mut devices = Vec::new();
for class in classes {
devices.extend(self.list_class(class)?);
}
devices.sort_by(|left, right| left.metadata.cmp(&right.metadata));
Ok(devices)
}
pub fn get(
&self,
ids: BTreeSet<DeviceIdentifier>,
) -> Result<BTreeMap<DeviceIdentifier, Device>, QueryError> {
let devices = self.list()?;
Ok(ids
.into_iter()
.filter_map(|id| {
devices
.iter()
.find(|device| id.is_subset(&device.metadata))
.cloned()
.map(|device| (id, device))
})
.collect())
}
pub fn apply(
&self,
updates: Vec<DeviceUpdate>,
validate: bool,
) -> Result<Vec<DeviceUpdate>, ApplyError> {
if updates.is_empty() {
return Ok(Vec::new());
}
let devices = self.list()?;
let mut remaining = Vec::new();
for update in updates {
let matched_devices: Vec<_> = devices
.iter()
.filter(|device| update.id.is_subset(&device.metadata))
.cloned()
.collect();
if matched_devices.is_empty() {
remaining.push(update);
continue;
}
let Some(brightness_update) = update.brightness.clone() else {
continue;
};
for device in matched_devices {
let target_raw = normalize_brightness_update(&device.state, &brightness_update);
if validate {
continue;
}
self.set_brightness_raw(&device.metadata, target_raw)?;
}
}
Ok(remaining)
}
pub fn update(&self, updates: Vec<DeviceUpdate>) -> Result<Vec<DeviceUpdate>, ApplyError> {
self.apply(updates, false)
}
pub fn validate(&self, updates: Vec<DeviceUpdate>) -> Result<Vec<DeviceUpdate>, ApplyError> {
self.apply(updates, true)
}
fn list_class(&self, class: DeviceClass) -> Result<Vec<Device>, QueryError> {
let class_path = self.sysfs_root.join(class.directory_name());
let entries =
fs::read_dir(&class_path).map_err(|source| QueryError::ReadClassDirectory {
path: class_path.clone(),
source,
})?;
let mut devices = Vec::new();
for entry in entries {
let entry = entry.map_err(|source| QueryError::ReadClassDirectory {
path: class_path.clone(),
source,
})?;
let device_path = entry.path();
let file_type =
entry
.file_type()
.map_err(|source| QueryError::ReadDeviceDirectory {
path: device_path.clone(),
source,
})?;
if !file_type.is_dir() && !file_type.is_symlink() {
continue;
}
let id = entry.file_name().to_string_lossy().into_owned();
let state = read_state(&device_path)?;
devices.push(Device {
metadata: DeviceMetadata {
class,
id,
path: fs::canonicalize(&device_path)
.unwrap_or(device_path.clone())
.to_string_lossy()
.into_owned(),
},
state,
});
}
Ok(devices)
}
pub fn set_brightness_raw(
&self,
metadata: &DeviceMetadata,
value: u32,
) -> Result<(), ApplyError> {
let brightness_path = Path::new(&metadata.path).join("brightness");
fs::write(&brightness_path, value.to_string()).map_err(|source| ApplyError::WriteFile {
path: brightness_path,
source,
})
}
}
fn read_state(device_path: &Path) -> Result<DeviceState, QueryError> {
let brightness_raw = read_u32_file(&device_path.join("brightness"))?;
let max_brightness_raw = read_u32_file(&device_path.join("max_brightness"))?;
let actual_brightness_raw = read_optional_u32_file(&device_path.join("actual_brightness"))?;
Ok(DeviceState {
brightness_raw,
max_brightness_raw,
actual_brightness_raw,
brightness_percent: percent_from_raw(brightness_raw, max_brightness_raw),
})
}
fn read_optional_u32_file(path: &Path) -> Result<Option<u32>, QueryError> {
if !path.exists() {
return Ok(None);
}
read_u32_file(path).map(Some)
}
fn read_u32_file(path: &Path) -> Result<u32, QueryError> {
if !path.exists() {
return Err(QueryError::MissingFile {
path: path.to_path_buf(),
});
}
let content = fs::read_to_string(path).map_err(|source| QueryError::ReadFile {
path: path.to_path_buf(),
source,
})?;
let trimmed = content.trim();
trimmed.parse::<u32>().map_err(|_| QueryError::ParseFile {
path: path.to_path_buf(),
content: trimmed.to_string(),
})
}
pub fn normalize_brightness_update(state: &DeviceState, update: &BrightnessUpdate) -> u32 {
let max = state.max_brightness_raw;
if max == 0 {
return 0;
}
match *update {
BrightnessUpdate::Raw(value) => value.min(max),
BrightnessUpdate::Percent(percent) => raw_from_percent(percent, max),
BrightnessUpdate::RawDelta(delta) => {
clamp_i64(state.brightness_raw as i64 + delta as i64, max)
}
BrightnessUpdate::PercentDelta(delta) => {
let current_percent = state.brightness_raw as f64 / max as f64 * 100.0;
let target_percent = (current_percent + delta as f64).clamp(0.0, 100.0);
raw_from_percent_f64(target_percent, max)
}
}
}
fn clamp_i64(value: i64, max: u32) -> u32 {
value.clamp(0, max as i64) as u32
}
fn percent_from_raw(value: u32, max: u32) -> u8 {
if max == 0 {
return 0;
}
((value as f64 / max as f64) * 100.0)
.round()
.clamp(0.0, 100.0) as u8
}
fn raw_from_percent(percent: u8, max: u32) -> u32 {
let normalized = percent.min(100) as f64 / 100.0;
(normalized * max as f64).round() as u32
}
fn raw_from_percent_f64(percent: f64, max: u32) -> u32 {
let normalized = percent.clamp(0.0, 100.0) / 100.0;
(normalized * max as f64).round() as u32
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use super::PhysicalDisplayManagerLinuxSys;
use crate::{BrightnessUpdate, DeviceClass, DeviceIdentifier, DeviceUpdate};
#[test]
fn list_enumerates_backlights_and_leds() {
let fixture = Fixture::new();
fixture.add_device(
DeviceClass::Backlight,
"intel_backlight",
300,
1200,
Some(280),
);
fixture.add_device(DeviceClass::Leds, "asus::kbd_backlight", 1, 3, None);
let manager = fixture.manager();
let devices = manager.list().unwrap();
assert_eq!(devices.len(), 2);
assert_eq!(devices[0].metadata.class, DeviceClass::Backlight);
assert_eq!(devices[0].metadata.id, "intel_backlight");
assert_eq!(devices[0].state.brightness_percent, 25);
assert_eq!(devices[0].state.actual_brightness_raw, Some(280));
assert_eq!(devices[1].metadata.class, DeviceClass::Leds);
assert_eq!(devices[1].metadata.id, "asus::kbd_backlight");
assert_eq!(devices[1].state.brightness_percent, 33);
}
#[test]
fn list_follows_sysfs_class_symlinks() {
let fixture = Fixture::new();
fixture.add_symlinked_device(DeviceClass::Backlight, "intel_backlight", 300, 1200, None);
let devices = fixture.manager().list().unwrap();
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].metadata.class, DeviceClass::Backlight);
assert_eq!(devices[0].metadata.id, "intel_backlight");
assert_eq!(devices[0].state.brightness_percent, 25);
}
#[test]
fn get_matches_subset_identifiers() {
let fixture = Fixture::new();
fixture.add_device(DeviceClass::Backlight, "intel_backlight", 300, 1200, None);
fixture.add_device(DeviceClass::Leds, "asus::kbd_backlight", 1, 3, None);
let manager = fixture.manager();
let mut ids = BTreeSet::new();
ids.insert(DeviceIdentifier {
class: Some(DeviceClass::Backlight),
id: Some("intel_backlight".to_string()),
path: None,
});
ids.insert(DeviceIdentifier {
class: None,
id: Some("asus::kbd_backlight".to_string()),
path: None,
});
let devices = manager.get(ids).unwrap();
assert_eq!(devices.len(), 2);
assert!(devices
.values()
.any(|device| device.metadata.id == "intel_backlight"));
assert!(devices
.values()
.any(|device| device.metadata.id == "asus::kbd_backlight"));
}
#[test]
fn update_writes_raw_and_percent_values() {
let fixture = Fixture::new();
fixture.add_device(DeviceClass::Backlight, "intel_backlight", 100, 400, None);
let manager = fixture.manager();
let remaining = manager
.update(vec![DeviceUpdate {
id: DeviceIdentifier {
class: Some(DeviceClass::Backlight),
id: Some("intel_backlight".to_string()),
path: None,
},
brightness: Some(BrightnessUpdate::Percent(50)),
}])
.unwrap();
assert!(remaining.is_empty());
assert_eq!(
fixture.read_brightness(DeviceClass::Backlight, "intel_backlight"),
200
);
manager
.update(vec![DeviceUpdate {
id: DeviceIdentifier {
class: None,
id: Some("intel_backlight".to_string()),
path: None,
},
brightness: Some(BrightnessUpdate::Raw(123)),
}])
.unwrap();
assert_eq!(
fixture.read_brightness(DeviceClass::Backlight, "intel_backlight"),
123
);
}
#[test]
fn update_supports_deltas_and_clamps_to_valid_range() {
let fixture = Fixture::new();
fixture.add_device(DeviceClass::Backlight, "intel_backlight", 100, 400, None);
let manager = fixture.manager();
manager
.update(vec![DeviceUpdate {
id: DeviceIdentifier {
class: Some(DeviceClass::Backlight),
id: Some("intel_backlight".to_string()),
path: None,
},
brightness: Some(BrightnessUpdate::RawDelta(50)),
}])
.unwrap();
assert_eq!(
fixture.read_brightness(DeviceClass::Backlight, "intel_backlight"),
150
);
manager
.update(vec![DeviceUpdate {
id: DeviceIdentifier {
class: Some(DeviceClass::Backlight),
id: Some("intel_backlight".to_string()),
path: None,
},
brightness: Some(BrightnessUpdate::PercentDelta(50)),
}])
.unwrap();
assert_eq!(
fixture.read_brightness(DeviceClass::Backlight, "intel_backlight"),
350
);
manager
.update(vec![DeviceUpdate {
id: DeviceIdentifier {
class: Some(DeviceClass::Backlight),
id: Some("intel_backlight".to_string()),
path: None,
},
brightness: Some(BrightnessUpdate::RawDelta(-500)),
}])
.unwrap();
assert_eq!(
fixture.read_brightness(DeviceClass::Backlight, "intel_backlight"),
0
);
}
#[test]
fn update_applies_to_multiple_matching_devices() {
let fixture = Fixture::new();
fixture.add_device(DeviceClass::Leds, "kbd1", 1, 3, None);
fixture.add_device(DeviceClass::Leds, "kbd2", 2, 3, None);
let manager = fixture.manager();
let remaining = manager
.update(vec![DeviceUpdate {
id: DeviceIdentifier {
class: Some(DeviceClass::Leds),
id: None,
path: None,
},
brightness: Some(BrightnessUpdate::Percent(100)),
}])
.unwrap();
assert!(remaining.is_empty());
assert_eq!(fixture.read_brightness(DeviceClass::Leds, "kbd1"), 3);
assert_eq!(fixture.read_brightness(DeviceClass::Leds, "kbd2"), 3);
}
#[test]
fn validate_checks_matches_without_writing() {
let fixture = Fixture::new();
fixture.add_device(DeviceClass::Backlight, "intel_backlight", 100, 400, None);
let manager = fixture.manager();
let remaining = manager
.validate(vec![DeviceUpdate {
id: DeviceIdentifier {
class: Some(DeviceClass::Backlight),
id: Some("intel_backlight".to_string()),
path: None,
},
brightness: Some(BrightnessUpdate::Percent(80)),
}])
.unwrap();
assert!(remaining.is_empty());
assert_eq!(
fixture.read_brightness(DeviceClass::Backlight, "intel_backlight"),
100
);
}
#[test]
fn unmatched_updates_are_returned() {
let fixture = Fixture::new();
fixture.add_device(DeviceClass::Backlight, "intel_backlight", 100, 400, None);
let manager = fixture.manager();
let remaining = manager
.update(vec![DeviceUpdate {
id: DeviceIdentifier {
class: Some(DeviceClass::Backlight),
id: Some("missing".to_string()),
path: None,
},
brightness: Some(BrightnessUpdate::Raw(20)),
}])
.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id.id.as_deref(), Some("missing"));
}
struct Fixture {
tempdir: TempDir,
}
impl Fixture {
fn new() -> Self {
let tempdir = TempDir::new().unwrap();
fs::create_dir_all(tempdir.path().join("backlight")).unwrap();
fs::create_dir_all(tempdir.path().join("leds")).unwrap();
Self { tempdir }
}
fn manager(&self) -> PhysicalDisplayManagerLinuxSys {
PhysicalDisplayManagerLinuxSys::with_sysfs_root(self.tempdir.path())
}
fn add_device(
&self,
class: DeviceClass,
id: &str,
brightness: u32,
max_brightness: u32,
actual_brightness: Option<u32>,
) {
let device_path = self.tempdir.path().join(class.directory_name()).join(id);
fs::create_dir_all(&device_path).unwrap();
fs::write(device_path.join("brightness"), brightness.to_string()).unwrap();
fs::write(
device_path.join("max_brightness"),
max_brightness.to_string(),
)
.unwrap();
if let Some(actual_brightness) = actual_brightness {
fs::write(
device_path.join("actual_brightness"),
actual_brightness.to_string(),
)
.unwrap();
}
}
fn add_symlinked_device(
&self,
class: DeviceClass,
id: &str,
brightness: u32,
max_brightness: u32,
actual_brightness: Option<u32>,
) {
let target_path = self.tempdir.path().join("devices").join(id);
fs::create_dir_all(&target_path).unwrap();
fs::write(target_path.join("brightness"), brightness.to_string()).unwrap();
fs::write(
target_path.join("max_brightness"),
max_brightness.to_string(),
)
.unwrap();
if let Some(actual_brightness) = actual_brightness {
fs::write(
target_path.join("actual_brightness"),
actual_brightness.to_string(),
)
.unwrap();
}
let class_entry_path = self.tempdir.path().join(class.directory_name()).join(id);
std::os::unix::fs::symlink(&target_path, &class_entry_path).unwrap();
}
fn read_brightness(&self, class: DeviceClass, id: &str) -> u32 {
let path = self
.tempdir
.path()
.join(class.directory_name())
.join(id)
.join("brightness");
read_u32(&path)
}
}
fn read_u32(path: &Path) -> u32 {
fs::read_to_string(path)
.unwrap()
.trim()
.parse::<u32>()
.unwrap()
}
}