428 lines
13 KiB
Rust
428 lines
13 KiB
Rust
use std::{
|
|
collections::BTreeMap,
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
thread::{self, JoinHandle},
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use calloop::channel::Sender;
|
|
|
|
use crate::{
|
|
config::{BacklightModule, BatteryModule, TemperatureModule},
|
|
format::{Value, expand},
|
|
model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow, SliderModel},
|
|
};
|
|
|
|
pub fn spawn_temperature(
|
|
name: String,
|
|
settings: TemperatureModule,
|
|
sender: Sender<ModuleEvent>,
|
|
stop: Arc<AtomicBool>,
|
|
) -> JoinHandle<()> {
|
|
spawn_polling(name, settings.interval.0, sender, stop, move || {
|
|
temperature_snapshot(&settings)
|
|
})
|
|
}
|
|
|
|
pub fn spawn_backlight(
|
|
name: String,
|
|
settings: BacklightModule,
|
|
sender: Sender<ModuleEvent>,
|
|
stop: Arc<AtomicBool>,
|
|
) -> JoinHandle<()> {
|
|
spawn_polling(name, Duration::from_secs(5), sender, stop, move || {
|
|
backlight_snapshot(&settings)
|
|
})
|
|
}
|
|
|
|
pub fn spawn_battery(
|
|
name: String,
|
|
settings: BatteryModule,
|
|
sender: Sender<ModuleEvent>,
|
|
stop: Arc<AtomicBool>,
|
|
) -> JoinHandle<()> {
|
|
spawn_polling(name, Duration::from_secs(30), sender, stop, move || {
|
|
battery_snapshot(&settings)
|
|
})
|
|
}
|
|
|
|
fn spawn_polling<F>(
|
|
name: String,
|
|
interval: Duration,
|
|
sender: Sender<ModuleEvent>,
|
|
stop: Arc<AtomicBool>,
|
|
mut read: F,
|
|
) -> JoinHandle<()>
|
|
where
|
|
F: FnMut() -> Result<ModuleSnapshot> + Send + 'static,
|
|
{
|
|
thread::Builder::new()
|
|
.name(format!("lightbar-sysfs-{name}"))
|
|
.spawn(move || {
|
|
let mut last_good: Option<ModuleSnapshot> = None;
|
|
let mut failures = 0_u32;
|
|
while !stop.load(Ordering::Acquire) {
|
|
let snapshot = match read() {
|
|
Ok(snapshot) => {
|
|
failures = 0;
|
|
last_good = Some(snapshot.clone());
|
|
snapshot
|
|
}
|
|
Err(error) => {
|
|
failures = failures.saturating_add(1);
|
|
tracing::debug!(module = %name, %error, "sysfs module update failed");
|
|
let mut stale = last_good.clone().unwrap_or_default();
|
|
stale.stale = last_good.is_some();
|
|
stale
|
|
}
|
|
};
|
|
if sender
|
|
.send(ModuleEvent {
|
|
module: name.clone(),
|
|
snapshot,
|
|
})
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
let retry = if failures == 0 {
|
|
interval
|
|
} else {
|
|
super::process::backoff(failures).min(interval)
|
|
};
|
|
thread::park_timeout(retry);
|
|
}
|
|
})
|
|
.expect("sysfs worker thread")
|
|
}
|
|
|
|
fn temperature_snapshot(settings: &TemperatureModule) -> Result<ModuleSnapshot> {
|
|
let sensor = settings
|
|
.sensor
|
|
.clone()
|
|
.map_or_else(|| discover_temperature(settings.label.as_deref()), Ok)?;
|
|
let raw = read_number(&sensor)?;
|
|
let temperature = if raw.abs() > 1_000.0 {
|
|
raw / 1_000.0
|
|
} else {
|
|
raw
|
|
};
|
|
let text = expand(
|
|
&settings.format,
|
|
&BTreeMap::from([("temperature_c", Value::from(temperature))]),
|
|
);
|
|
let state = if temperature >= settings.critical_c {
|
|
"critical"
|
|
} else if temperature >= settings.warning_c {
|
|
"warning"
|
|
} else {
|
|
"normal"
|
|
};
|
|
let mut snapshot = ModuleSnapshot::text(text).with_state(state);
|
|
snapshot.tooltip = Some(format!("{}: {temperature:.1}°C", sensor.display()));
|
|
snapshot.popup = Some(PopupModel {
|
|
title: "Temperature".to_owned(),
|
|
content: PopupContent::Rows(vec![PopupRow {
|
|
id: None,
|
|
label: sensor.display().to_string(),
|
|
detail: Some(format!("{temperature:.1}°C")),
|
|
state: state.to_owned(),
|
|
enabled: false,
|
|
}]),
|
|
});
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn discover_temperature(label: Option<&str>) -> Result<PathBuf> {
|
|
let mut candidates = Vec::new();
|
|
for hwmon in read_dirs(Path::new("/sys/class/hwmon"))? {
|
|
let chip = read_trimmed(&hwmon.join("name")).unwrap_or_default();
|
|
for input in read_dirs(&hwmon)?.into_iter().filter(|path| {
|
|
path.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_some_and(|name| name.starts_with("temp") && name.ends_with("_input"))
|
|
}) {
|
|
let base = input
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.and_then(|name| name.strip_suffix("_input"))
|
|
.unwrap_or_default();
|
|
let sensor_label =
|
|
read_trimmed(&hwmon.join(format!("{base}_label"))).unwrap_or_default();
|
|
if label.is_some_and(|wanted| {
|
|
!sensor_label.eq_ignore_ascii_case(wanted) && !chip.eq_ignore_ascii_case(wanted)
|
|
}) {
|
|
continue;
|
|
}
|
|
let score = temperature_score(&chip, &sensor_label);
|
|
candidates.push((score, input));
|
|
}
|
|
}
|
|
candidates
|
|
.into_iter()
|
|
.max_by_key(|(score, _)| *score)
|
|
.map(|(_, path)| path)
|
|
.context("no hwmon temperature sensor found")
|
|
}
|
|
|
|
fn temperature_score(chip: &str, label: &str) -> u8 {
|
|
let combined = format!("{chip} {label}").to_ascii_lowercase();
|
|
if combined.contains("package") || combined.contains("tctl") {
|
|
3
|
|
} else if combined.contains("coretemp") || combined.contains("k10temp") {
|
|
2
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
|
|
fn backlight_snapshot(settings: &BacklightModule) -> Result<ModuleSnapshot> {
|
|
let device = discover_backlight(settings.device.as_deref())?;
|
|
let current = read_number(&device.join("actual_brightness"))
|
|
.or_else(|_| read_number(&device.join("brightness")))?;
|
|
let maximum = read_number(&device.join("max_brightness"))?;
|
|
if maximum <= 0.0 {
|
|
bail!("backlight maximum is zero");
|
|
}
|
|
let percent = (current / maximum * 100.0).round().clamp(0.0, 100.0) as i64;
|
|
let text = expand(
|
|
&settings.format,
|
|
&BTreeMap::from([("percent", Value::from(percent))]),
|
|
);
|
|
let mut snapshot = ModuleSnapshot::text(text);
|
|
snapshot.tooltip = Some(format!("Backlight: {percent}%"));
|
|
snapshot.popup = Some(PopupModel {
|
|
title: "Backlight".to_owned(),
|
|
content: PopupContent::Slider(SliderModel {
|
|
label: device
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("Backlight")
|
|
.to_owned(),
|
|
value: f64::from(percent as i32),
|
|
minimum: 0.0,
|
|
maximum: 100.0,
|
|
muted: false,
|
|
detail_rows: Vec::new(),
|
|
}),
|
|
});
|
|
Ok(snapshot)
|
|
}
|
|
|
|
pub fn adjust_backlight(settings: &BacklightModule, delta_percent: i16) -> Result<()> {
|
|
let device = discover_backlight(settings.device.as_deref())?;
|
|
let current = read_number(&device.join("brightness"))?;
|
|
let maximum = read_number(&device.join("max_brightness"))?;
|
|
let delta = maximum * f64::from(delta_percent) / 100.0;
|
|
let value = (current + delta).round().clamp(1.0, maximum) as u64;
|
|
set_backlight_value(&device, value)
|
|
}
|
|
|
|
pub fn set_backlight_percent(settings: &BacklightModule, percent: f64) -> Result<()> {
|
|
let device = discover_backlight(settings.device.as_deref())?;
|
|
let maximum = read_number(&device.join("max_brightness"))?;
|
|
let value = (maximum * percent.clamp(0.0, 100.0) / 100.0)
|
|
.round()
|
|
.clamp(1.0, maximum) as u64;
|
|
set_backlight_value(&device, value)
|
|
}
|
|
|
|
fn set_backlight_value(device: &Path, value: u64) -> Result<()> {
|
|
let brightness = device.join("brightness");
|
|
if fs::write(&brightness, value.to_string()).is_ok() {
|
|
return Ok(());
|
|
}
|
|
|
|
let device_name = device
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.context("backlight device has no UTF-8 name")?;
|
|
let status = Command::new("busctl")
|
|
.args([
|
|
"--system",
|
|
"call",
|
|
"org.freedesktop.login1",
|
|
"/org/freedesktop/login1/session/self",
|
|
"org.freedesktop.login1.Session",
|
|
"SetBrightness",
|
|
"ssu",
|
|
"backlight",
|
|
device_name,
|
|
&value.to_string(),
|
|
])
|
|
.status()
|
|
.context("could not invoke logind SetBrightness")?;
|
|
if !status.success() {
|
|
bail!("logind SetBrightness exited with {status}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn discover_backlight(device: Option<&str>) -> Result<PathBuf> {
|
|
let root = Path::new("/sys/class/backlight");
|
|
if let Some(device) = device {
|
|
let path = root.join(device);
|
|
if path.is_dir() {
|
|
return Ok(path);
|
|
}
|
|
bail!(
|
|
"configured backlight device {} does not exist",
|
|
path.display()
|
|
);
|
|
}
|
|
read_dirs(root)?
|
|
.into_iter()
|
|
.max_by(|left, right| {
|
|
read_number(&left.join("max_brightness"))
|
|
.unwrap_or_default()
|
|
.total_cmp(&read_number(&right.join("max_brightness")).unwrap_or_default())
|
|
})
|
|
.context("no backlight device found")
|
|
}
|
|
|
|
fn battery_snapshot(settings: &BatteryModule) -> Result<ModuleSnapshot> {
|
|
let battery = discover_battery()?;
|
|
let percent = read_number(&battery.join("capacity"))?
|
|
.round()
|
|
.clamp(0.0, 100.0) as u8;
|
|
let status = read_trimmed(&battery.join("status")).unwrap_or_else(|_| "Unknown".to_owned());
|
|
let icon = battery_icon(percent);
|
|
let values = BTreeMap::from([
|
|
("percent", Value::from(percent)),
|
|
("status", Value::from(status.clone())),
|
|
("icon", Value::from(icon)),
|
|
]);
|
|
let format = if percent >= 99 {
|
|
&settings.full_format
|
|
} else {
|
|
&settings.format
|
|
};
|
|
let state = if percent <= settings.critical_percent {
|
|
"critical"
|
|
} else if percent <= settings.warning_percent {
|
|
"warning"
|
|
} else {
|
|
"normal"
|
|
};
|
|
let mut snapshot = ModuleSnapshot::text(expand(format, &values)).with_state(state);
|
|
let time = estimated_battery_time(&battery, &status);
|
|
snapshot.tooltip = Some(match time.as_deref() {
|
|
Some(time) => format!("{status}: {percent}% · {time}"),
|
|
None => format!("{status}: {percent}%"),
|
|
});
|
|
snapshot.popup = Some(PopupModel {
|
|
title: "Battery".to_owned(),
|
|
content: PopupContent::Rows(vec![
|
|
PopupRow {
|
|
id: None,
|
|
label: "Charge".to_owned(),
|
|
detail: Some(format!("{percent}%")),
|
|
state: state.to_owned(),
|
|
enabled: false,
|
|
},
|
|
PopupRow {
|
|
id: None,
|
|
label: "Status".to_owned(),
|
|
detail: Some(status),
|
|
state: "normal".to_owned(),
|
|
enabled: false,
|
|
},
|
|
PopupRow {
|
|
id: None,
|
|
label: "Estimate".to_owned(),
|
|
detail: time,
|
|
state: "normal".to_owned(),
|
|
enabled: false,
|
|
},
|
|
]),
|
|
});
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn discover_battery() -> Result<PathBuf> {
|
|
read_dirs(Path::new("/sys/class/power_supply"))?
|
|
.into_iter()
|
|
.find(|path| {
|
|
read_trimmed(&path.join("type")).is_ok_and(|kind| kind.eq_ignore_ascii_case("battery"))
|
|
})
|
|
.context("no battery power supply found")
|
|
}
|
|
|
|
fn battery_icon(percent: u8) -> &'static str {
|
|
match percent {
|
|
0..=10 => "",
|
|
11..=35 => "",
|
|
36..=60 => "",
|
|
61..=85 => "",
|
|
_ => "",
|
|
}
|
|
}
|
|
|
|
fn estimated_battery_time(battery: &Path, status: &str) -> Option<String> {
|
|
let power = read_number(&battery.join("power_now")).ok()?;
|
|
if power <= 0.0 {
|
|
return None;
|
|
}
|
|
let energy = if status.eq_ignore_ascii_case("charging") {
|
|
read_number(&battery.join("energy_full")).ok()?
|
|
- read_number(&battery.join("energy_now")).ok()?
|
|
} else {
|
|
read_number(&battery.join("energy_now")).ok()?
|
|
};
|
|
let hours = (energy / power).max(0.0);
|
|
let total_minutes = (hours * 60.0).round() as u64;
|
|
Some(format!(
|
|
"{}h {:02}m",
|
|
total_minutes / 60,
|
|
total_minutes % 60
|
|
))
|
|
}
|
|
|
|
fn read_dirs(path: &Path) -> Result<Vec<PathBuf>> {
|
|
let mut paths = Vec::new();
|
|
for entry in fs::read_dir(path).with_context(|| format!("cannot read {}", path.display()))? {
|
|
let entry = entry?;
|
|
paths.push(entry.path());
|
|
}
|
|
Ok(paths)
|
|
}
|
|
|
|
fn read_trimmed(path: &Path) -> Result<String> {
|
|
Ok(fs::read_to_string(path)
|
|
.with_context(|| format!("cannot read {}", path.display()))?
|
|
.trim()
|
|
.to_owned())
|
|
}
|
|
|
|
fn read_number(path: &Path) -> Result<f64> {
|
|
read_trimmed(path)?
|
|
.parse()
|
|
.with_context(|| format!("{} does not contain a number", path.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn chooses_expected_battery_icons() {
|
|
assert_eq!(battery_icon(5), "");
|
|
assert_eq!(battery_icon(50), "");
|
|
assert_eq!(battery_icon(100), "");
|
|
}
|
|
|
|
#[test]
|
|
fn scores_cpu_package_sensors_first() {
|
|
assert!(
|
|
temperature_score("coretemp", "Package id 0") > temperature_score("acpitz", "temp1")
|
|
);
|
|
}
|
|
}
|