use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::Child;
use std::process::Command;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU16, Ordering};
use time::OffsetDateTime;
use rayon::prelude::*;
pub mod log;
pub mod notify;
static LAST_TEMP: AtomicU16 = AtomicU16::new(0);
static LAST_GAMMA: AtomicU16 = AtomicU16::new(0);
static GAMMASTEP: LazyLock<Arc<Mutex<Option<Child>>>> =
LazyLock::new(|| Arc::new(Mutex::new(None)));
#[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_start_minutes: u8,
pub sunset_end: u8,
pub sunset_end_minutes: u8,
pub sunrise_start: u8,
pub sunrise_start_minutes: u8,
pub sunrise_end: u8,
pub sunrise_end_minutes: u8,
pub notification_timeout: u32,
pub disable_timeout: u32,
pub gamma_backend: GammaBackend,
pub temp_backend: TempBackend,
pub interpolation_temp: Interpolation,
pub interpolation_gamma: Interpolation,
}
#[derive(Debug, PartialEq, Copy, Clone)]
pub enum Interpolation {
Linear,
CubicEaseInOut,
Cosine,
Exponential,
}
#[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_start_minutes: 0,
sunset_end: 22,
sunset_end_minutes: 0,
sunrise_start: 4,
sunrise_start_minutes: 0,
sunrise_end: 7,
sunrise_end_minutes: 0,
disable_timeout: 300,
notification_timeout: 5000,
gamma_backend: GammaBackend::Hyprctl,
temp_backend: TempBackend::Hyprctl,
interpolation_temp: Interpolation::Linear,
interpolation_gamma: Interpolation::Linear,
}
}
}
#[derive(Debug)]
pub enum ConfigError {
IoError(std::io::Error),
ParseIntError(std::num::ParseIntError),
InvalidTemperature(String),
InvalidTime(String),
InvalidGamma(String),
InvalidGammaBackend(String),
InvalidTempBackend(String),
InvalidInterpolation(String),
DuplicateKey(String),
UnavailableCommand(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();
let mut seen_keys = HashSet::new();
let mut seen_time_keys = HashSet::new();
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('#'))
{
if line.starts_with('[') && line.contains(']') {
current_section = line[1..line.find(']').unwrap()].to_string();
seen_keys.clear();
} else if let Some((key, value)) = line.split_once('=') {
let key_trimmed_string = key.trim().replace('"', "");
let key_trimmed = key_trimmed_string.as_str();
if seen_keys.contains(key_trimmed) {
return Err(ConfigError::DuplicateKey(format!(
"Duplicate key '{key_trimmed}' in section '{current_section}'",
)));
}
seen_keys.insert(key_trimmed.to_string());
let value = value.trim().replace('"', "");
match current_section.as_str() {
"" => match key_trimmed {
"notification_timeout" => {
config.notification_timeout = value.parse::<u32>()?;
}
"disable_timeout" => {
config.disable_timeout = value.parse::<u32>()?;
}
"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::<u16>()?;
if 0 < parsed && parsed <= 100 {
config.gamma_day = parsed
} else {
return Err(ConfigError::InvalidGamma(value.to_string()));
}
}
"night" => {
let parsed = value.parse::<u16>()?;
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::<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" => {
fn parse_time(value: &str, key: &str) -> Result<(u8, u8), ConfigError> {
let parts: Vec<&str> = value.split(':').collect();
match parts.len() {
1 => {
warn!("missing minutes in {}, assuming 00", key);
let hour = value.parse::<u8>()?;
if (0..=23).contains(&hour) {
Ok((hour, 0))
} else {
Err(ConfigError::InvalidTime(value.to_string()))
}
}
2 => {
let hour = parts[0].parse::<u8>()?;
let minute = parts[1].parse::<u8>()?;
if (0..=23).contains(&hour) && (0..=59).contains(&minute) {
Ok((hour, minute))
} else {
Err(ConfigError::InvalidTime(value.to_string()))
}
}
_ => Err(ConfigError::InvalidTime(value.to_string())),
}
}
seen_time_keys.insert(key_trimmed.to_string());
match key_trimmed {
"sunset_start" => {
(config.sunset_start, config.sunset_start_minutes) =
parse_time(&value, "sunset_start")?
}
"sunset_end" => {
(config.sunset_end, config.sunset_end_minutes) =
parse_time(&value, "sunset_end")?
}
"sunrise_start" => {
(config.sunrise_start, config.sunrise_start_minutes) =
parse_time(&value, "sunrise_start")?
}
"sunrise_end" => {
(config.sunrise_end, config.sunrise_end_minutes) =
parse_time(&value, "sunrise_end")?
}
"interpolation_temp" => match value.to_lowercase().as_str() {
"linear" => config.interpolation_temp = Interpolation::Linear,
"cubic" | "cubiceaseinout" => {
config.interpolation_temp = Interpolation::CubicEaseInOut
}
"cosine" => config.interpolation_temp = Interpolation::Cosine,
"exponential" => {
config.interpolation_temp = Interpolation::Exponential
}
_ => {
return Err(ConfigError::InvalidInterpolation(format!(
"invalid value for interpolation_temp: {value}. Valid values are: linear, cubic, cubiceaseinout, cosine, exponential"
)));
}
},
"interpolation_gamma" => match value.to_lowercase().as_str() {
"linear" => config.interpolation_gamma = Interpolation::Linear,
"cubic" | "cubiceaseinout" => {
config.interpolation_gamma = Interpolation::CubicEaseInOut
}
"cosine" => config.interpolation_gamma = Interpolation::Cosine,
"exponential" => {
config.interpolation_gamma = Interpolation::Exponential
}
_ => {
return Err(ConfigError::InvalidInterpolation(format!(
"invalid value for interpolation_gamma: {value}. Valid values are: linear, cubic, cubiceaseinout, cosine, exponential"
)));
}
},
_ => {}
}
}
_ => {}
}
}
}
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
)));
}
if !seen_time_keys.contains("sunset_start")
|| !seen_time_keys.contains("sunset_end")
|| !seen_time_keys.contains("sunrise_start")
|| !seen_time_keys.contains("sunrise_end")
{
return Err(ConfigError::InvalidTime(
"sunset_start, sunset_end, sunrise_start, and sunrise_end are all required"
.to_string(),
));
}
Ok(config)
}
}
pub fn interpolate_linear(start: u16, end: u16, factor: f64) -> u16 {
trace!("interpolate_linear({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
}
}
fn interpolate_cubic(start: u16, end: u16, factor: f64) -> u16 {
trace!("interpolate_cubic({start}, {end}, {factor})");
let factor = factor.clamp(0.0, 1.0);
let start_f = start as f64;
let end_f = end as f64;
let t = factor;
let smooth_t = (3.0 * t * t) - (2.0 * t * t * t);
let result = start_f + smooth_t * (end_f - start_f);
result.round().max(0.0).min(u16::MAX as f64) as u16
}
fn interpolate_cosine(start: u16, end: u16, factor: f64) -> u16 {
trace!("interpolate_cosine({start}, {end}, {factor})");
let t = (1.0 - (factor.clamp(0.0, 1.0) * std::f64::consts::PI).cos()) / 2.0;
interpolate_linear(start, end, t)
}
fn interpolate_exponential(start: u16, end: u16, factor: f64) -> u16 {
trace!("interpolate_exponential({start}, {end}, {factor})");
let t = factor.clamp(0.0, 1.0);
let t = if t < 0.5 {
0.5 * (2.0 * t).powf(3.0)
} else {
0.5 * (1.0 - (2.0 * (1.0 - t)).powf(3.0)) + 0.5
};
interpolate_linear(start, end, t)
}
fn interpolate_value(start: u16, end: u16, factor: f64, interpolation: &Interpolation) -> u16 {
trace!("interpolate_value({start}, {end}, {factor}, {interpolation:?})");
match interpolation {
Interpolation::Linear => interpolate_linear(start, end, factor),
Interpolation::CubicEaseInOut => interpolate_cubic(start, end, factor),
Interpolation::Cosine => interpolate_cosine(start, end, factor),
Interpolation::Exponential => interpolate_exponential(start, end, factor),
}
}
pub fn compute_settings(now: OffsetDateTime, config: &Config) -> (u16, u16) {
trace!("compute_settings({now:?})");
let tod = now.time();
let time_in_hours = tod.hour() as f64 + tod.minute() as f64 / 60.0;
trace!("time_in_hours: {time_in_hours}");
let sunset_start = config.sunset_start as f64 + config.sunset_start_minutes as f64 / 60.0;
let sunset_end = config.sunset_end as f64 + config.sunset_end_minutes as f64 / 60.0;
let sunrise_start = config.sunrise_start as f64 + config.sunrise_start_minutes as f64 / 60.0;
let sunrise_end = config.sunrise_end as f64 + config.sunrise_end_minutes as f64 / 60.0;
trace!("sunset_start: {sunset_start}, sunset_end: {sunset_end}");
trace!("sunrise_start: {sunrise_start}, sunrise_end: {sunrise_end}");
if (time_in_hours >= sunset_start) && (time_in_hours <= sunset_end) {
trace!("time_in_hours is within sunset");
let factor = ((time_in_hours - sunset_start) / (sunset_end - sunset_start)).clamp(0.0, 1.0);
(
interpolate_value(
config.temp_day,
config.temp_night,
factor,
&config.interpolation_temp,
),
interpolate_value(
config.gamma_day,
config.gamma_night,
factor,
&config.interpolation_gamma,
),
)
} else if (time_in_hours >= sunrise_start) && (time_in_hours <= sunrise_end) {
trace!("time_in_hours is within sunrise");
let factor =
1.0 - ((time_in_hours - sunrise_start) / (sunrise_end - sunrise_start)).clamp(0.0, 1.0);
(
interpolate_value(
config.temp_day,
config.temp_night,
factor,
&config.interpolation_temp,
),
interpolate_value(
config.gamma_day,
config.gamma_night,
factor,
&config.interpolation_gamma,
),
)
} else if time_in_hours > sunset_end || time_in_hours < sunrise_start {
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)
}
}
pub fn apply_temp(temp: u16, config: &Config) {
trace!("apply_temp({temp})");
let last_temp = LAST_TEMP.load(Ordering::SeqCst);
if last_temp == temp {
trace!("Settings unchanged, skipping application");
return;
}
debug!("applying temperature: {temp}");
if temp != last_temp {
match config.temp_backend {
TempBackend::Hyprctl => match ensure_hyprsunset_running() {
Ok(_) => {
let _ = Command::new("hyprctl")
.args(["hyprsunset", "temperature", &temp.to_string()])
.output();
trace!("hyprctl hyprsunset temperature {temp}");
}
Err(err) => {
error!("Error while starting hyprsunset: {err}");
}
},
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 mut guard = GAMMASTEP.lock().unwrap();
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
let child = match Command::new("gammastep")
.args(["-O", &temp.to_string()])
.spawn()
{
Ok(c) => Some(c),
Err(e) => {
error!("{}", e);
None
}
};
trace!("gammastep -O {temp}");
*guard = child;
}
}
LAST_TEMP.store(temp, Ordering::SeqCst);
}
}
pub fn apply_gamma(gamma: u16, config: &Config) {
trace!("apply_gamma({gamma})");
let last_gamma = LAST_GAMMA.load(Ordering::SeqCst);
if last_gamma == gamma {
trace!("Settings unchanged, skipping application");
return;
}
debug!("applying gamma: {gamma}");
if gamma != last_gamma {
match config.gamma_backend {
GammaBackend::Hyprctl => match ensure_hyprsunset_running() {
Ok(_) => {
let _ = Command::new("hyprctl")
.args(["hyprsunset", "gamma", &gamma.to_string()])
.output();
trace!("hyprctl hyprsunset gamma {gamma}");
}
Err(err) => {
error!("Error while starting hyprsunset: {err}");
}
},
GammaBackend::Ddcutil => {
let display_count = match Command::new("ddcutil").args(["detect"]).output() {
Ok(output) => String::from_utf8_lossy(&output.stdout)
.trim()
.to_string()
.lines()
.filter(|l| l.contains("Display"))
.count(),
Err(e) => {
error!("Failed to detect the number of displays with error: {e}");
1
}
};
(1..=display_count).into_par_iter().for_each(|display| {
let _ = Command::new("ddcutil")
.args([
"-d",
&display.to_string(),
"setvcp",
"10",
&gamma.to_string(),
])
.output();
trace!("ddcutil -d {display} 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") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 6 {
ttemp = parts[4].parse::<u16>().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);
}
fn ensure_hyprsunset_running() -> Result<(), String> {
trace!("Ensuring hyprsunset is running...");
if !is_process_running("hyprsunset") {
info!("hyprsunset is not running. Attempting to start it...");
start_process("hyprsunset 2>/dev/null")?;
}
Ok(())
}
fn start_process(command: &str) -> Result<(), String> {
Command::new("sh")
.arg("-c")
.arg(command)
.spawn()
.map_err(|e| format!("Failed to start {command}: {e}"))?;
std::thread::sleep(std::time::Duration::from_secs(1));
if is_process_running(command.split_whitespace().next().unwrap_or(command)) {
Ok(())
} else {
Err(format!("Failed to start {command}"))
}
}
fn is_process_running(process_name: &str) -> bool {
Command::new("pgrep")
.arg("-x")
.arg(process_name)
.status()
.map(|status| status.success())
.unwrap_or(false)
}