#![warn(clippy::pedantic)]
#[cfg(not(target_os = "linux"))]
compile_error!("blight is only supported on linux");
use std::{
borrow::Cow,
fmt::{Debug, Display},
fs::{self, File},
io::prelude::*,
ops::Deref,
path::{Path, PathBuf},
thread,
time::Duration,
};
pub mod err;
pub mod led;
pub use err::{Error, ErrorKind, Result};
pub const BLDIR: &str = "/sys/class/backlight";
const CURRENT_FILE: &str = "brightness";
const MAX_FILE: &str = "max_brightness";
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Direction {
Inc,
Dec,
}
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Change {
#[default]
Regular,
Sweep,
}
#[derive(Debug, Clone, Copy)]
pub struct Delay(Duration);
impl From<Duration> for Delay {
fn from(value: Duration) -> Self {
Self(value)
}
}
impl Deref for Delay {
type Target = Duration;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Default for Delay {
fn default() -> Self {
Self(Duration::from_millis(25))
}
}
impl Delay {
pub fn from_millis(millis: u64) -> Self {
Self(Duration::from_millis(millis))
}
}
#[derive(Debug)]
pub struct Device {
name: String,
current: u32,
max: u32,
path: PathBuf,
brightness: File,
}
impl Device {
pub fn new(name: Option<Cow<str>>) -> Result<Device> {
let name = match name {
Some(val) => val,
None => Self::detect_device(BLDIR)?.into(),
};
let info = utils::read_info(BLDIR, &name, None)?;
Ok(Device {
current: info.current,
max: info.max,
path: info.path,
name: name.into_owned(),
brightness: info.brightness,
})
}
#[cfg(feature = "locking")]
pub fn new_locked(name: Option<Cow<str>>, blocking: bool) -> Result<Device> {
let name = match name {
Some(val) => val,
None => Self::detect_device(BLDIR)?.into(),
};
let info = utils::read_info(
BLDIR,
&name,
Some(if blocking {
utils::Lock::Blocking
} else {
utils::Lock::NonBlocking
}),
)?;
Ok(Device {
current: info.current,
max: info.max,
path: info.path,
name: name.into_owned(),
brightness: info.brightness,
})
}
fn detect_device(bldir: &str) -> Result<String> {
let dirs: Vec<_> = fs::read_dir(bldir)
.map_err(|err| Error::from(ErrorKind::ReadDir { dir: BLDIR }).with_source(err))?
.filter_map(|d| d.ok().map(|d| d.file_name()))
.collect();
let (mut nv, mut ac): (Option<usize>, Option<usize>) = (None, None);
for (i, entry) in dirs.iter().enumerate() {
let name = entry.to_string_lossy();
if name.contains("amd") || name.contains("intel") {
return Ok(name.into_owned());
} else if nv.is_none() && (name.contains("nvidia") | name.contains("nv")) {
nv = Some(i);
} else if ac.is_none() && name.contains("acpi") {
ac = Some(i);
}
}
let to_str = |i: usize| Ok(dirs[i].to_string_lossy().into_owned());
if let Some(nv) = nv {
to_str(nv)
} else if let Some(ac) = ac {
to_str(ac)
} else if !dirs.is_empty() {
to_str(0)
} else {
Err(ErrorKind::NotFound.into())
}
}
}
impl private::Sealed for Device {}
impl Light for Device {
type Value = u32;
fn name(&self) -> &str {
&self.name
}
fn current(&self) -> Self::Value {
self.current
}
fn max(&self) -> Self::Value {
self.max
}
fn set_current(&mut self, _: private::Internal, current: Self::Value) {
self.current = current;
}
fn brightness_file(&mut self, _: private::Internal) -> &mut File {
&mut self.brightness
}
fn device_path(&self) -> &Path {
&self.path
}
}
impl Dimmable for Device {}
impl Toggleable for Device {}
mod private {
#[doc(hidden)]
pub struct Internal;
#[doc(hidden)]
pub trait Sealed {}
}
pub trait Dimmable: Toggleable + private::Sealed {}
pub trait Toggleable: private::Sealed {}
pub trait Light: private::Sealed {
type Value: Into<u32> + TryFrom<u32> + PartialEq + Copy + Default + Display + Debug;
fn name(&self) -> &str;
fn current(&self) -> Self::Value;
fn max(&self) -> Self::Value;
fn device_path(&self) -> &Path;
#[doc(hidden)]
fn set_current(&mut self, _: private::Internal, current: Self::Value);
#[doc(hidden)]
fn brightness_file(&mut self, _: private::Internal) -> &mut File;
fn current_percent(&self) -> f64
where
Self: Dimmable,
{
let (current, max): (u32, u32) = (self.current().into(), self.max().into());
(f64::from(current) / f64::from(max)) * 100.
}
fn reload(&mut self) {
self.try_reload()
.expect("Failed to read current brightness value");
}
fn try_reload(&mut self) -> Result<()> {
let current = utils::read_ascii_u32(self.brightness_file(private::Internal))
.map_err(|err| Error::from(ErrorKind::ReadCurrent).with_source(err))?;
self.set_current(
private::Internal,
Self::Value::try_from(current).unwrap_or_default(),
);
Ok(())
}
fn write_value(&mut self, value: Self::Value) -> Result<()> {
let (val, max): (u32, u32) = (value.into(), self.max().into());
if val > max {
return Err(ErrorKind::ValueTooLarge {
given: val,
supported: max,
}
.into());
}
let name = self.name().into();
let convert = |err| Error::from(ErrorKind::WriteValue { device: name }).with_source(err);
let file = self.brightness_file(private::Internal);
write!(file, "{val}",).map_err(convert.clone())?;
file.rewind().map_err(convert)?;
self.set_current(private::Internal, value);
Ok(())
}
fn sweep_write(&mut self, value: Self::Value, delay: Delay) -> Result<()>
where
Self: Dimmable,
{
let (mut current, val, max): (u32, u32, u32) =
(self.current().into(), value.into(), self.max().into());
if val > max {
return Err(ErrorKind::ValueTooLarge {
given: val,
supported: max,
}
.into());
}
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let mut rate = (f64::from(max) * 0.01) as u32;
let dir = if val > current {
Direction::Inc
} else {
Direction::Dec
};
let bfile = self.brightness_file(private::Internal);
let map_err = |err| Error::from(ErrorKind::SweepError).with_source(err);
while current != val {
match dir {
Direction::Inc => {
if (current + rate) > val {
rate = val - current;
}
current += rate;
}
Direction::Dec => {
if rate > current {
rate = current;
} else if (current - rate) < val {
rate = current - val;
}
current -= rate;
}
}
bfile.rewind().map_err(map_err)?;
write!(bfile, "{current}").map_err(map_err)?;
thread::sleep(*delay);
}
bfile.rewind().map_err(map_err)?;
self.set_current(private::Internal, value);
Ok(())
}
fn calculate_change(&self, step_size: Self::Value, dir: Direction) -> Self::Value
where
Self: Dimmable,
{
let (current, max, step_size): (u32, u32, u32) =
(self.current().into(), self.max().into(), step_size.into());
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let step: u32 = (f64::from(max) * (f64::from(step_size) / 100.0)) as u32;
let change = match dir {
Direction::Inc => current.saturating_add(step),
Direction::Dec => current.saturating_sub(step),
}
.min(max); Self::Value::try_from(change).unwrap_or_default()
}
fn toggle(&mut self) -> Result<()>
where
Self: Toggleable,
{
let value = if self.current() == self.max() {
Self::Value::default()
} else {
self.max()
};
self.write_value(value)
}
}
pub fn change_bl(
step_size: u32,
ch: Change,
dir: Direction,
device_name: Option<Cow<str>>,
) -> crate::Result<()> {
let mut device = Device::new(device_name)?;
let change = device.calculate_change(step_size, dir);
if change != device.current {
match ch {
Change::Sweep => device.sweep_write(change, Delay::default())?,
Change::Regular => device.write_value(change)?,
}
}
Ok(())
}
pub fn set_bl(val: u32, device_name: Option<Cow<str>>) -> Result<()> {
let mut device = Device::new(device_name)?;
if val != device.current {
device.write_value(val)?;
}
Ok(())
}
mod utils {
use super::{Error, ErrorKind, Result};
use std::{
fs::File,
io::{Read, Seek},
path::PathBuf,
};
use crate::{CURRENT_FILE, MAX_FILE};
pub(crate) struct Info {
pub(crate) current: u32,
pub(crate) max: u32,
pub(crate) brightness: File,
pub(crate) path: PathBuf,
}
#[allow(unused)]
#[derive(Clone, Copy)]
pub(crate) enum Lock {
NonBlocking,
Blocking,
}
pub(crate) fn read_info(dir: &str, interface: &str, _lock: Option<Lock>) -> Result<Info> {
let mut path = construct_path(dir, interface);
if !path.is_dir() {
return Err(ErrorKind::NotFound.into());
}
let map_err = |kind| |err| Error::from(kind).with_source(err);
let max = {
let err = map_err(ErrorKind::ReadMax);
path.push(MAX_FILE);
let mut max_file = File::open(&path).map_err(err.clone())?;
read_ascii_u32(&mut max_file).map_err(err)?
};
let (current, brightness) = {
let err = map_err(ErrorKind::ReadCurrent);
path.set_file_name(CURRENT_FILE);
let mut current_file = File::options()
.read(true)
.write(true)
.open(&path)
.map_err(err.clone())?;
#[cfg(feature = "locking")]
if let Some(lock) = _lock {
acquire_lock(&mut current_file, lock)?;
}
let current = read_ascii_u32(&mut current_file).map_err(err)?;
(current, current_file)
};
path.pop();
Ok(Info {
current,
max,
brightness,
path,
})
}
#[cfg(feature = "locking")]
fn acquire_lock(file: &mut File, lock: Lock) -> Result<()> {
let lock_err = |blocked, src: Option<_>| {
let err = Error::from(ErrorKind::LockError { blocked });
if let Some(src) = src {
err.with_source(src)
} else {
err
}
};
match lock {
Lock::NonBlocking => match file.try_lock() {
Ok(_) => Ok(()),
Err(std::fs::TryLockError::WouldBlock) => Err(lock_err(true, None)),
Err(std::fs::TryLockError::Error(src)) => Err(lock_err(false, Some(src))),
},
Lock::Blocking => file.lock().map_err(|err| lock_err(false, Some(err))),
}
}
pub(crate) fn read_ascii_u32<S: Read + Seek>(mut source: S) -> std::io::Result<u32> {
let mut buf = [0; 10]; let read = source.read(&mut buf)?;
source.rewind()?;
if read == 0 || read > buf.len() {
return Err(std::io::Error::other(format!(
"read too few or too many bytes: {read} bytes into buf of len {}",
buf.len()
)));
}
let mut readi = read - 1;
if buf[readi] as char == '\n' {
readi -= 1;
}
#[allow(clippy::cast_possible_truncation)]
let (mut value, mut place) = (0, 10u32.pow(readi as _));
#[allow(clippy::char_lit_as_u8)]
for v in &buf[..=readi] {
value += u32::from(v - '0' as u8) * place;
place /= 10;
}
Ok(value)
}
pub(crate) fn construct_path(dir: &str, device_name: &str) -> PathBuf {
let mut buf = PathBuf::with_capacity(dir.len() + device_name.len() + 1);
buf.push(dir);
buf.push(device_name);
buf
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) const BLDIR: &str = "testbldir";
struct MockInterface(utils::Info);
impl MockInterface {
fn new(name: &str) -> Self {
Self(utils::read_info(BLDIR, name, None).expect("failed to initialize mock interface"))
}
fn dummy(current: u32, max: u32) -> Self {
Self(utils::Info {
current,
max,
brightness: File::create("/tmp/dummy.blight").expect("failed to open file"),
path: PathBuf::new(),
})
}
}
impl private::Sealed for MockInterface {}
impl Toggleable for MockInterface {}
impl Dimmable for MockInterface {}
impl Light for MockInterface {
type Value = u32;
fn name(&self) -> &str {
self.0.path.file_name().unwrap().to_str().unwrap()
}
fn device_path(&self) -> &Path {
&self.0.path
}
fn current(&self) -> Self::Value {
self.0.current
}
fn max(&self) -> Self::Value {
self.0.max
}
fn set_current(&mut self, _: private::Internal, current: Self::Value) {
self.0.current = current;
}
fn brightness_file(&mut self, _: private::Internal) -> &mut File {
&mut self.0.brightness
}
}
#[test]
fn reading_info() {
let name = "generic";
let test = || {
let utils::Info {
current, max, path, ..
} = utils::read_info(BLDIR, name, None).expect("failed to read info");
assert_eq!(current, 50, "incorrect current value");
assert_eq!(max, 100, "incorrect max value");
assert_eq!(
&path,
<str as AsRef<Path>>::as_ref(&format!("{BLDIR}/{name}")),
"incorrect interface dir path"
);
};
with_test_env(&[name], test);
}
#[test]
fn ascii_conversion() {
let cases: [(&[u8], u32); 4] = [
(b"123\n".as_slice(), 123),
(b"999\n".as_slice(), 999),
(b"0".as_slice(), 0),
(b"4294967295".as_slice(), u32::MAX),
];
for (i, (case, expected)) in cases.into_iter().enumerate() {
let value = utils::read_ascii_u32(std::io::Cursor::new(case))
.expect("failed to convert ASCII bytes to u32");
assert_eq!(value, expected, "case {i} failed");
}
}
#[test]
fn path_construction() {
assert_eq!(
utils::construct_path(BLDIR, "generic"),
PathBuf::from(&format!("{BLDIR}/generic"))
);
}
#[test]
fn detecting_device_nvidia() {
let interfaces = ["nvidia_0", "generic"];
let test = || {
let name = Device::detect_device(BLDIR);
assert!(name.is_ok());
assert_eq!(name.unwrap(), "nvidia_0");
};
with_test_env(&interfaces, test);
}
#[test]
fn detecting_device_amd() {
let interfaces = ["nvidia_0", "generic", "amdgpu_x"];
let test = || {
let name = Device::detect_device(BLDIR);
assert!(name.is_ok());
assert_eq!(name.unwrap(), "amdgpu_x");
};
with_test_env(&interfaces, test);
}
#[test]
fn detecting_device_acpi() {
let interfaces = ["acpi_video0", "generic"];
let test = || {
let name = Device::detect_device(BLDIR);
assert!(name.is_ok());
assert_eq!(name.unwrap(), "acpi_video0");
};
with_test_env(&interfaces, test);
}
#[test]
fn detecting_device_fallback() {
let expected = "generic";
let test = || {
let name = Device::detect_device(BLDIR);
assert!(name.is_ok());
assert_eq!(name.unwrap(), expected);
};
with_test_env(&[expected], test);
}
#[test]
fn toggle() {
let name = "generic";
let test = || {
let mut d = MockInterface::new(name);
d.write_value(0).expect("failed to write value");
d.reload();
assert_ne!(d.current(), d.max());
d.toggle().expect("failed to toggle on/off");
d.reload();
assert_eq!(d.current(), d.max());
d.toggle().expect("failed to toggle on/off");
d.reload();
assert_eq!(d.current(), <MockInterface as Light>::Value::default());
};
with_test_env(&[name], test);
}
#[test]
fn reload() {
let name = "generic";
let test = || {
let mut d = MockInterface::new(name);
let test_value = 12345;
assert_ne!(d.current(), test_value);
write!(&mut d.0.brightness, "{test_value}")
.expect("failed to write value to brightness file");
d.0.brightness
.rewind()
.expect("failed to reset brightness file cursor");
d.reload();
assert_eq!(d.current(), test_value);
};
with_test_env(&[name], test);
}
#[test]
fn write_value() {
let name = "generic";
let test = || {
let mut d = MockInterface::new(name);
d.write_value(100).unwrap();
let res = fs::read_to_string(format!("{BLDIR}/generic/brightness"))
.expect("failed to read test backlight value");
assert_eq!(res.trim(), "100", "Result was {res}");
};
with_test_env(&[name], test);
}
#[test]
fn read_value() {
let name = "generic";
let test = || {
let (_, mut file) = open_current_file(name);
assert_eq!(50, utils::read_ascii_u32(&mut file).unwrap());
};
with_test_env(&[name], test);
}
#[test]
fn current_percent() {
let percent = MockInterface::dummy(5, 255).current_percent().round();
assert_eq!(percent, 2.0);
}
#[test]
fn inc_calculation() {
let d = MockInterface::dummy(10, 100);
let ch = d.calculate_change(10, Direction::Inc);
assert_eq!(ch, 20);
}
#[test]
fn dec_calculation() {
let d = MockInterface::dummy(30, 100);
let ch = d.calculate_change(10, Direction::Dec);
assert_eq!(ch, 20);
}
#[test]
fn inc_calculation_max() {
let d = MockInterface::dummy(90, 100);
let ch = d.calculate_change(20, Direction::Inc);
assert_eq!(ch, 100);
}
#[test]
fn dec_calculation_max() {
let d = MockInterface::dummy(10, 100);
let ch = d.calculate_change(20, Direction::Dec);
assert_eq!(ch, 0);
}
#[test]
fn sweeping() {
let name = "generic";
let test = || {
let mut d = MockInterface::new(name);
d.sweep_write(100, Delay::default()).unwrap();
d.reload();
assert_eq!(d.current(), 100);
d.sweep_write(0, Delay::default()).unwrap();
d.reload();
assert_eq!(d.current(), 0);
};
with_test_env(&[name], test);
}
#[test]
fn sweep_bounds() {
let name = "generic";
let test = || {
let mut d = MockInterface::new(name);
d.write_value(0).unwrap();
let err = d.sweep_write(u32::MAX, Delay::default());
assert_eq!(
err.unwrap_err().kind(),
&ErrorKind::ValueTooLarge {
given: u32::MAX,
supported: d.max()
}
);
d.reload();
assert_eq!(d.current(), 0);
};
with_test_env(&[name], test);
}
pub(crate) fn with_test_env(dirs: &[&str], test: impl FnOnce()) {
clean_up();
setup_test_env(dirs, 50, 100);
test();
clean_up();
}
pub(crate) fn setup_test_env(dirs: &[&str], current: u32, max: u32) {
let inner = || -> std::io::Result<()> {
fs::create_dir(BLDIR)?;
for dir in dirs {
fs::create_dir(format!("{BLDIR}/{dir}"))?;
fs::write(format!("{BLDIR}/{dir}/brightness"), current.to_string())?;
fs::write(format!("{BLDIR}/{dir}/max_brightness"), max.to_string())?;
}
Ok(())
};
inner().expect("failed to set up test env");
}
fn open_current_file(name: &str) -> (PathBuf, File) {
let mut path = utils::construct_path(BLDIR, name);
path.push(CURRENT_FILE);
let file = File::open(&path).expect("failed to open test current brightness file");
(path, file)
}
pub(crate) fn clean_up() {
if <str as AsRef<Path>>::as_ref(BLDIR).is_dir() {
fs::remove_dir_all(BLDIR).expect("Failed to clean up testing backlight directory.");
}
}
}