//! 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 std::sync::atomic::{AtomicU16, Ordering}; 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, pub temp_backend: TempBackend, } static LAST_TEMP: AtomicU16 = AtomicU16::new(0); static LAST_GAMMA: AtomicU16 = AtomicU16::new(0); #[derive(Debug, PartialEq, Copy, Clone)] pub enum GammaBackend { Hyprctl, Ddcutil, Xsct, Redshift, Gammastep, None, } #[derive(Debug, PartialEq, Copy, Clone)] pub enum TempBackend { Hyprctl, Redshift, Xsct, Gammastep, None, } 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, temp_backend: TempBackend::Hyprctl, } } } #[derive(Debug)] pub enum ConfigError { IoError(std::io::Error), ParseIntError(std::num::ParseIntError), InvalidTemperature(String), InvalidTime(String), InvalidGamma(String), InvalidGammaBackend(String), InvalidTempBackend(String), } impl From for ConfigError { fn from(err: std::io::Error) -> Self { ConfigError::IoError(err) } } impl From for ConfigError { fn from(err: std::num::ParseIntError) -> Self { ConfigError::ParseIntError(err) } } impl Config { pub fn load + std::fmt::Debug>(path: P) -> Result { 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::()?; } "gamma_backend" => match value.to_lowercase().as_str() { "hyprctl" => config.gamma_backend = GammaBackend::Hyprctl, "ddcutil" => config.gamma_backend = GammaBackend::Ddcutil, "gammastep" => config.gamma_backend = GammaBackend::Gammastep, "xsct" => config.gamma_backend = GammaBackend::Xsct, "redshift" => config.gamma_backend = GammaBackend::Redshift, "none" => config.gamma_backend = GammaBackend::None, _ => return Err(ConfigError::InvalidGammaBackend(value.to_string())), }, "temp_backend" => match value.to_lowercase().as_str() { "hyprctl" => config.temp_backend = TempBackend::Hyprctl, "gammastep" => config.temp_backend = TempBackend::Gammastep, "xsct" => config.temp_backend = TempBackend::Xsct, "redshift" => config.temp_backend = TempBackend::Redshift, "none" => config.temp_backend = TempBackend::None, _ => return Err(ConfigError::InvalidTempBackend(value.to_string())), }, _ => {} }, "gamma" => match key_trimmed { "day" => { let parsed = value.parse::()?; if 0 < parsed && parsed <= 100 { config.gamma_day = parsed } else { return Err(ConfigError::InvalidGamma(value.to_string())); } } "night" => { let parsed = value.parse::()?; if 0 < parsed && parsed <= 100 { config.gamma_night = parsed } else { return Err(ConfigError::InvalidGamma(value.to_string())); } } _ => {} }, "temp" => match key_trimmed { "day" => { let parsed = value.parse::()?; if (1000..=20000).contains(&parsed) { config.temp_day = parsed } else { return Err(ConfigError::InvalidTemperature(value.to_string())); } } "night" => { let parsed = value.parse::()?; 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::()?; if (0..=23).contains(&parsed) { config.sunset_start = parsed } else { return Err(ConfigError::InvalidTime(value.to_string())); } } "sunset_end" => { let parsed = value.parse::()?; if (0..=23).contains(&parsed) { config.sunset_end = parsed } else { return Err(ConfigError::InvalidTime(value.to_string())); } } "sunrise_start" => { let parsed = value.parse::()?; if (0..=23).contains(&parsed) { config.sunrise_start = parsed } else { return Err(ConfigError::InvalidTime(value.to_string())); } } "sunrise_end" => { let parsed = value.parse::()?; if (0..=23).contains(&parsed) { config.sunrise_end = parsed } else { return Err(ConfigError::InvalidTime(value.to_string())); } } _ => {} }, _ => {} } } } if config.sunset_start >= config.sunset_end { return Err(ConfigError::InvalidTime(format!( "sunset_start ({0}) is greater than sunset_end ({1})", config.sunset_start, config.sunset_end ))); } if config.sunrise_start >= config.sunrise_end { return Err(ConfigError::InvalidTime(format!( "sunrise_start ({0}) is greater than sunrise_end ({1})", config.sunrise_start, config.sunrise_end ))); } 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, config: &Config) { trace!("apply_settings({temp}, {gamma})"); let last_temp = LAST_TEMP.load(Ordering::SeqCst); let last_gamma = LAST_GAMMA.load(Ordering::SeqCst); if last_temp == temp && last_gamma == gamma { trace!("Settings unchanged, skipping application"); return; } debug!("applying temperature: {temp}"); debug!("applying gamma: {gamma}"); if temp != last_temp { match config.temp_backend { TempBackend::Hyprctl => { let _ = Command::new("hyprctl") .args(["hyprsunset", "temperature", &temp.to_string()]) .output(); trace!("hyprctl hyprsunset temperature {temp}"); } TempBackend::Redshift => { let _ = Command::new("redshift") .args(["-o", "-t", &temp.to_string()]) .output(); trace!("redshift -o -t {temp}"); } TempBackend::Xsct => { let _ = Command::new("xsct").args([&temp.to_string()]).output(); trace!("xsct {temp}"); } TempBackend::None => {} TempBackend::Gammastep => { let _ = Command::new("gammastep") .args(["-O", &temp.to_string()]) .output(); } } LAST_TEMP.store(temp, Ordering::SeqCst); } if gamma != last_gamma { match config.gamma_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}"); } GammaBackend::Gammastep => { let _ = Command::new("gammastep") .args(["-O", "6500", "-g", &(&gamma / 100).to_string()]) .output(); trace!("gammastep -O 6500 -g {gamma}"); } GammaBackend::Xsct => { let output = Command::new("xsct").output().expect("xsct failed"); let stdout = String::from_utf8_lossy(&output.stdout); let mut ttemp = 6000; for line in stdout.lines() { if line.contains("temperature") { // example: "Screen 0: temperature ~ 6000 0.598234" let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 6 { ttemp = parts[4].parse::().unwrap(); } } } let _ = Command::new("xsct") .args([&ttemp.to_string(), &(&gamma / 100).to_string()]) .output(); trace!("xsct {ttemp} {gamma}"); } GammaBackend::Redshift => { let _ = Command::new("redshift") .args(["-O", "6500", "-g", &(&gamma / 100).to_string()]) .output(); trace!("redshift -O 6500 -g {gamma}"); } GammaBackend::None => {} } LAST_GAMMA.store(gamma, Ordering::SeqCst); } } pub fn reset_cache() { LAST_TEMP.store(0, Ordering::SeqCst); LAST_GAMMA.store(0, Ordering::SeqCst); }