hinoirisetr 0.3.0

A daemon to dim the screen at night
Documentation
//! hinoirisetr library
//! Contains core logic for computing temperature and gamma and applying settings.

use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::Command;

use time::Time;

pub mod log;
pub mod notify;
pub mod time;

#[derive(Debug, Copy, Clone)]
pub struct Config {
    pub temp_day: u16,
    pub temp_night: u16,

    pub gamma_day: u16,
    pub gamma_night: u16,

    pub sunset_start: u8,
    pub sunset_end: u8,
    pub sunrise_start: u8,
    pub sunrise_end: u8,

    pub notification_timeout: u32,
    pub gamma_backend: GammaBackend,
}

#[derive(Debug, Copy, Clone)]
pub enum GammaBackend {
    Hyprctl,
    Ddcutil,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            temp_day: 6500,
            temp_night: 2500,
            gamma_day: 100,
            gamma_night: 95,
            sunset_start: 19,
            sunset_end: 22,
            sunrise_start: 4,
            sunrise_end: 7,
            notification_timeout: 5000,
            gamma_backend: GammaBackend::Hyprctl,
        }
    }
}

#[derive(Debug)]
pub enum ConfigError {
    IoError(std::io::Error),
    ParseIntError(std::num::ParseIntError),
    InvalidTemperature(String),
    InvalidTime(String),
    InvalidGamma(String),
    InvalidGammaBackend(String),
}

impl From<std::io::Error> for ConfigError {
    fn from(err: std::io::Error) -> Self {
        ConfigError::IoError(err)
    }
}

impl From<std::num::ParseIntError> for ConfigError {
    fn from(err: std::num::ParseIntError) -> Self {
        ConfigError::ParseIntError(err)
    }
}

impl Config {
    pub fn load<P: AsRef<Path> + std::fmt::Debug>(path: P) -> Result<Self, ConfigError> {
        trace!("Config::load({path:?})");
        let mut config = Self::default(); // Start with default values

        let config_file = File::open(path)?;
        let reader = BufReader::new(config_file);
        let mut current_section = String::new();

        for line in reader
            .lines()
            .map_while(Result::ok)
            .map(|l| String::from(l.trim()))
            .filter(|l| !l.is_empty())
            .filter(|l| !l.starts_with('#'))
        {
            trace!("line: {line}");
            if line.starts_with('[') && line.contains(']') {
                current_section = line[1..line.find(']').unwrap()].to_string();
                trace!("current_section: {current_section}");
            } else if let Some((key, value)) = line.split_once('=') {
                trace!("key: {key}, value: {value}");
                let key_trimmed_string = key.trim().replace('"', "");
                let key_trimmed = key_trimmed_string.as_str();

                let value = value.trim().replace('"', "");
                match current_section.as_str() {
                    "" => match key_trimmed {
                        "notification_timeout" => {
                            config.notification_timeout = value.parse::<u32>()?;
                        }
                        "gamma_backend" => match value.to_lowercase().as_str() {
                            "hyprctl" => config.gamma_backend = GammaBackend::Hyprctl,
                            "ddcutil" => config.gamma_backend = GammaBackend::Ddcutil,
                            _ => return Err(ConfigError::InvalidGammaBackend(value.to_string())),
                        },
                        _ => {}
                    },
                    "gamma" => match key_trimmed {
                        "day" => {
                            let parsed = value.parse::<u16>()?;
                            if parsed <= 100 {
                                config.gamma_day = parsed
                            } else {
                                return Err(ConfigError::InvalidGamma(value.to_string()));
                            }
                        }
                        "night" => {
                            let parsed = value.parse::<u16>()?;
                            if parsed <= 100 {
                                config.gamma_night = parsed
                            } else {
                                return Err(ConfigError::InvalidGamma(value.to_string()));
                            }
                        }
                        _ => {}
                    },
                    "temp" => match key_trimmed {
                        "day" => {
                            let parsed = value.parse::<u16>()?;
                            if (1000..=20000).contains(&parsed) {
                                config.temp_day = parsed
                            } else {
                                return Err(ConfigError::InvalidTemperature(value.to_string()));
                            }
                        }
                        "night" => {
                            let parsed = value.parse::<u16>()?;
                            if (1000..=20000).contains(&parsed) {
                                config.temp_night = parsed
                            } else {
                                return Err(ConfigError::InvalidTemperature(value.to_string()));
                            }
                        }
                        _ => {}
                    },
                    "time" => match key_trimmed {
                        "sunset_start" => {
                            let parsed = value.parse::<u8>()?;
                            if (0..=23).contains(&parsed) {
                                config.sunset_start = parsed
                            } else {
                                return Err(ConfigError::InvalidTime(value.to_string()));
                            }
                        }
                        "sunset_end" => {
                            let parsed = value.parse::<u8>()?;
                            if (0..=23).contains(&parsed) {
                                config.sunset_end = parsed
                            } else {
                                return Err(ConfigError::InvalidTime(value.to_string()));
                            }
                        }
                        "sunrise_start" => {
                            let parsed = value.parse::<u8>()?;
                            if (0..=23).contains(&parsed) {
                                config.sunrise_start = parsed
                            } else {
                                return Err(ConfigError::InvalidTime(value.to_string()));
                            }
                        }
                        "sunrise_end" => {
                            let parsed = value.parse::<u8>()?;
                            if (0..=23).contains(&parsed) {
                                config.sunrise_end = parsed
                            } else {
                                return Err(ConfigError::InvalidTime(value.to_string()));
                            }
                        }
                        _ => {}
                    },
                    _ => {}
                }
            }
        }

        Ok(config)
    }
}

/// Linearly interpolate between start and end by factor [0.0, 1.0]
pub fn interpolate(start: u16, end: u16, factor: f64) -> u16 {
    trace!("interpolate({start}, {end}, {factor})");
    if end < start {
        (end as f64 + (start - end) as f64 * (1.0 - factor)).round() as u16
    } else {
        (start as f64 + (end - start) as f64 * factor).round() as u16
    }
}

/// Compute current temperature and gamma based on provided time
pub fn compute_settings(now: Time, config: &Config) -> (u16, u16) {
    trace!("compute_settings({now:?})");
    let time_in_hours = now.hour() as f64 + now.minute() as f64 / 60.0;
    trace!("time_in_hours: {time_in_hours}");

    if (time_in_hours >= config.sunset_start as f64) && (time_in_hours <= config.sunset_end as f64)
    {
        trace!("time_in_hours is within sunset");
        let factor = ((time_in_hours - config.sunset_start as f64)
            / (config.sunset_end - config.sunset_start) as f64)
            .clamp(0.0, 1.0);
        (
            interpolate(config.temp_day, config.temp_night, factor),
            interpolate(config.gamma_day, config.gamma_night, factor),
        )
    } else if (time_in_hours >= config.sunrise_start as f64)
        && (time_in_hours <= config.sunrise_end as f64)
    {
        trace!("time_in_hours is within sunrise");
        let factor = 1.0
            - ((time_in_hours - config.sunrise_start as f64)
                / (config.sunrise_end - config.sunrise_start) as f64)
                .clamp(0.0, 1.0);
        (
            interpolate(config.temp_day, config.temp_night, factor),
            interpolate(config.gamma_day, config.gamma_night, factor),
        )
    } else if time_in_hours > config.sunset_end as f64
        || time_in_hours < config.sunrise_start as f64
    {
        trace!("time_in_hours is within night");
        (config.temp_night, config.gamma_night)
    } else {
        trace!("time_in_hours is within day");
        (config.temp_day, config.gamma_day)
    }
}

/// Apply given temperature (Kelvin) and gamma (%) via hyprctl commands
pub fn apply_settings(temp: u16, gamma: u16, backend: GammaBackend) {
    trace!("apply_settings({temp}, {gamma})");
    debug!("applying temperature: {temp}");
    debug!("applying gamma: {gamma}");

    let _ = Command::new("hyprctl")
        .args(["hyprsunset", "temperature", &temp.to_string()])
        .output();
    trace!("hyprctl hyprsunset temperature {temp}");

    match backend {
        GammaBackend::Hyprctl => {
            let _ = Command::new("hyprctl")
                .args(["hyprsunset", "gamma", &gamma.to_string()])
                .output();
            trace!("hyprctl hyprsunset gamma {gamma}");
        }
        GammaBackend::Ddcutil => {
            let _ = Command::new("ddcutil")
                .args(["setvcp", "10", &gamma.to_string()])
                .output();
            trace!("ddcutil setvcp 10 {gamma}");
        }
    }
}