Files
lightbar/src/modules/sysfs.rs
2026-09-29 13:01:10 +02:00

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")
);
}
}