lightctl 0.1.0

A backlight control utility that does smooth transitions
use clap::{Parser, Subcommand};
use std::{
    ffi::OsString,
    fs::{self, read_to_string},
    io::{self, Error},
    num::ParseIntError,
    path::PathBuf,
    time::{Duration, Instant},
};

use dbus::blocking::{Connection, Proxy};
use generated::OrgFreedesktopLogin1Session;

mod generated;

fn parse_duration(s: &str) -> Result<Duration, ParseIntError> {
    s.parse().map(Duration::from_millis)
}

fn parse_percent(s: &str) -> Result<u8, &'static str> {
    if let Ok(i) = s.parse() {
        if i <= 100 {
            return Ok(i);
        }
    };

    Err("value must be between 0 and 100")
}

// TODO: Maybe support a comma-separated list of devices, and return a Vec<Device> here?
//       That should also enable us to use __all__.
fn parse_device(s: &str) -> io::Result<Device> {
    if s == "__default__" {
        match find_device_names() {
            Ok(device_names) if device_names.len() == 1 => Device::new(String::from(
                device_names[0]
                    .to_str()
                    .expect("device name is valid string"),
            )),
            Ok(_) => Err(Error::new(
                io::ErrorKind::InvalidInput,
                "more than one device available",
            )),
            Err(err) => {
                // TODO: check error output in this case.
                Err(err)
            }
        }
    } else {
        Device::new(String::from(s))
    }
}

#[derive(Parser)]
#[clap(author, version, about)]
struct Cli {
    #[clap(long, parse(try_from_str = parse_duration), default_value = "200")]
    duration: Duration,

    #[clap(long, default_value = "60")]
    fps: u8,

    #[clap(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Print current brightness and exit.
    Get {
        #[clap(parse(try_from_str = parse_device))]
        device: Device,
    },
    /// Set the backlight brightness to a given value
    Set {
        #[clap(parse(try_from_str = parse_device))]
        device: Device,

        #[clap(parse(try_from_str = parse_percent))]
        value: u8,
    },
    /// Change brightness to 100%
    Max {
        #[clap(parse(try_from_str = parse_device))]
        device: Device,
    },
    /// Change brightness to 1%
    Min {
        #[clap(parse(try_from_str = parse_device))]
        device: Device,
    },
    /// Turn backlight completely off
    Off {
        #[clap(parse(try_from_str = parse_device))]
        device: Device,
    },
    /// List local devices and exit
    List,
    /// Increase brightness, using a non-linear scale.
    Inc {
        #[clap(parse(try_from_str = parse_device))]
        device: Device,
    },
    /// Decrease brightness, using a non-linear scale.
    Dec {
        #[clap(parse(try_from_str = parse_device))]
        device: Device,
    },
}

struct Transition {
    start: u32,
    end: u32,
    sleep_time: Duration, // Estimated time between frames.
    stop_condition: Box<dyn Fn(u32) -> bool>,
}

impl Transition {
    fn new(start: u32, end: u32, fps: u8, total_time: Duration) -> Transition {
        // Rounding can results in us not hitting the exact target, and simply
        // comparing equality sometimes fails.
        let stop_condition: Box<dyn Fn(u32) -> bool> = if start > end {
            Box::new(move |value| -> bool { value <= end })
        } else {
            Box::new(move |value| -> bool { value >= end })
        };

        Transition {
            start,
            end,
            sleep_time: total_time / fps.into(),
            stop_condition,
        }
    }
}

/// Read an integer from a file. Panics on failure.
fn read_int_from_path(path: PathBuf) -> u32 {
    read_to_string(path)
        .expect("max brightness from sysfs")
        .trim()
        .parse()
        .expect("max brightness as a valid u32")
}

#[derive(Debug)]
struct Device {
    name: String,
    base_path: PathBuf,
    // device_class? (backlight OR led)
}

impl Device {
    fn new(name: String) -> io::Result<Device> {
        let base_path = PathBuf::from("/sys/class/backlight/").join(name.clone());
        if !base_path.is_dir() {
            Err(Error::new(io::ErrorKind::NotFound, "device node not found"))
        } else {
            Ok(Device { name, base_path })
        }
    }

    fn get_maximum_brightness(&self) -> u32 {
        let path = self.base_path.join("max_brightness");
        read_int_from_path(path)
    }

    fn get_brightness(&self) -> u32 {
        let path = self.base_path.join("brightness");
        read_int_from_path(path)
    }

    fn get_percentage(&self) -> u8 {
        let max = self.get_maximum_brightness();
        let cur = self.get_brightness();
        ((cur * 100) / max).try_into().expect("calculate current percentage")
    }

    /// Sets the brightness to a given value.
    /// Returns the amount of time the operation took to complete.
    fn set_brightness(&self, proxy: &Proxy<&Connection>, value: u32) -> Duration {
        let timer = Instant::now();
        proxy
            .set_brightness("backlight", &self.name, value)
            .unwrap();
        timer.elapsed()
    }

    /// Smooth-transition brightness to a given value.
    fn transition_brightness(
        &self,
        value: u8,
        fps: u8,
        duration: Duration,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let conn = Connection::new_system()?;
        let proxy = conn.with_proxy(
            "org.freedesktop.login1",
            "/org/freedesktop/login1/session/auto",
            Duration::from_millis(100),
        );

        let from = self.get_brightness();
        let to = (value as u32) * self.get_maximum_brightness() / 100;

        println!(
            "current: {}, target:{}, max: {}",
            self.get_brightness(),
            to,
            self.get_maximum_brightness()
        );

        let transition = Transition::new(from, to, fps, duration);
        let mut current = self.get_brightness();
        let start_value = self.get_brightness();
        let start_time = Instant::now();

        loop {
            let elapsed = start_time.elapsed();

            // TODO: extract linear function (so we can also use non-linear?).

            // Note: Use unsinged here since delta _can_ be negative.
            // TODO: Maybe I can use less than 128bits? Not my highest priority right now.
            let next_value = ((transition.end as i128 - transition.start as i128)
                * elapsed.as_millis() as i128)
                / (duration.as_millis() as i128);
            let next_value = (start_value as i128 + next_value) as u32;

            // Measure delay, so we don't over-sleep before the next frame.
            // If the adjustment takes too long, don't sleep at all.
            let delay = self.set_brightness(&proxy, next_value);

            // TODO: the Transition should continue running in its own loop, and indicated that it
            // must stop when the timer expires. This is the next natural step in trying to make
            // this a service.

            if transition.sleep_time > delay {
                std::thread::sleep(transition.sleep_time - delay);
            }

            if (transition.stop_condition)(current) {
                break;
            }

            if start_time.elapsed() > duration {
                self.set_brightness(&proxy, transition.end);
                break;
            }

            println!(
                "delay: {:?}, sleep_time: {:?}, current: {}, target:{}, max: {}",
                delay,
                transition.sleep_time,
                self.get_brightness(),
                value,
                self.get_maximum_brightness(),
            );
            current = self.get_brightness();
        }

        Ok(())
    }
}

fn find_device_names() -> io::Result<Vec<OsString>> {
    let paths = fs::read_dir("/sys/class/backlight/")?;
    paths.map(|r| r.map(|d| d.file_name())).collect()
}

fn delta_for_range(current: u8) -> u8 {
    // TODO: A logarithmic scale probably makes sense here, but we need to make
    // sure the minimum change is always greater than zero.
    match current {
        0..=14 => 1,
        15..=29 => 5,
        30.. => 10,
    }
}

fn main() {
    let cli = Cli::parse();

    let (device, value) = match cli.command {
        Commands::Get { device } => {
            println!("{}", device.get_maximum_brightness());
            return;
        }
        Commands::Set { device, value } => (device, value),
        Commands::Max { device } => (device, 100),
        Commands::Min { device } => (device, 1),
        Commands::Off { device } => (device, 0),
        Commands::List => {
            match find_device_names() {
                Ok(devices) => {
                    for device in devices {
                        println!("{:?}", device);
                    }
                }
                Err(err) => {
                    println!("Error finding device names: {}.", err);
                }
            }
            return;
        }
        Commands::Inc { device } => {
            let current = device.get_percentage();
            (device, current + delta_for_range(current))
        }
        Commands::Dec { device } => {
            let current = device.get_percentage();
            (device, current - delta_for_range(current))
        }
    };

    if let Err(e) = device.transition_brightness(value, cli.fps, cli.duration) {
        println!("Transition failed: {}", e);
    };
}

// TODO: run as a d-bus service than can be triggered to dim the screen.
// TODO: when told to exit, reset to original, then exit.
// TODO: it's fine to exit cleanly, but tell systemd to NOT restart us
// TODO: It's probably best if it works via d-bus activation.
// TODO: hover, keep in ind that this is just for DIMMING, not regular user control. That happens
// without any deamon
// Keep in mind that multiple dims may happen, but we always return to original value.
// Q: How is using a lockfile not simpler and better?

// The dbus service can also run a start-increase and stop-decrease.
// This can be triggered when pressing and releasing the brightness key respectively.