use std::collections::HashMap;
use std::process::Command;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Default)]
pub struct SmartTick {
#[allow(dead_code)]
pub device: String,
pub temperature_c: Option<i16>,
pub power_on_hours: Option<u64>,
pub power_cycles: Option<u64>,
pub percentage_used: Option<u8>,
pub available_spare: Option<u8>,
pub data_units_read: Option<u64>,
pub data_units_written: Option<u64>,
pub ata_attrs: Vec<AtaAttr>,
pub needs_root: bool,
}
#[derive(Debug, Clone, Default)]
pub struct AtaAttr {
pub id: u8,
pub name: String,
pub value: u32,
pub worst: u32,
pub thresh: Option<u32>,
pub raw: String,
}
pub struct SmartCollector {
have_smartctl: Option<bool>,
pub by_device: HashMap<String, SmartTick>,
last_refresh: Instant,
interval: Duration,
pub last_refresh_at: Option<Instant>,
}
impl SmartCollector {
pub fn new() -> Self {
Self {
have_smartctl: None,
by_device: HashMap::new(),
last_refresh: Instant::now() - Duration::from_secs(3600),
interval: Duration::from_secs(300),
last_refresh_at: None,
}
}
pub fn smartctl_available(&self) -> bool {
matches!(self.have_smartctl, Some(true))
}
pub fn needs_root(&self, device: &str) -> bool {
self.by_device.get(device).is_some_and(|t| t.needs_root)
}
#[cfg(test)]
pub fn assume_smartctl(&mut self) {
self.have_smartctl = Some(true);
}
pub fn current_interval(&self) -> Duration {
self.interval
}
pub fn set_interval(&mut self, d: Duration) {
self.interval = d;
}
pub fn secs_until_next_refresh(&self) -> u64 {
let elapsed = self.last_refresh.elapsed();
if elapsed >= self.interval {
0
} else {
(self.interval - elapsed).as_secs()
}
}
pub fn force_refresh(&mut self, devices: &[crate::collect::DeviceTick]) {
if self.have_smartctl.is_none() {
self.have_smartctl = Some(probe_smartctl());
}
if !matches!(self.have_smartctl, Some(true)) {
return;
}
self.last_refresh = Instant::now();
self.last_refresh_at = Some(self.last_refresh);
for d in devices {
if let Some(tick) = query_device(&d.name) {
self.by_device.insert(d.name.clone(), tick);
}
}
}
pub fn refresh_if_due(&mut self, devices: &[crate::collect::DeviceTick]) {
if self.have_smartctl.is_none() {
self.have_smartctl = Some(probe_smartctl());
}
if !matches!(self.have_smartctl, Some(true)) {
return;
}
if self.last_refresh.elapsed() < self.interval {
return;
}
self.last_refresh = Instant::now();
self.last_refresh_at = Some(self.last_refresh);
for d in devices {
if let Some(tick) = query_device(&d.name) {
self.by_device.insert(d.name.clone(), tick);
}
}
}
}
fn probe_smartctl() -> bool {
Command::new("smartctl")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn query_device(name: &str) -> Option<SmartTick> {
let dev = format!("/dev/{}", name);
let out = Command::new("smartctl")
.args(["-A", "--json", &dev])
.output()
.ok()?;
let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
Some(parse_smartctl(name, &v))
}
fn refused(v: &serde_json::Value) -> bool {
let Some(messages) = v.pointer("/smartctl/messages").and_then(|m| m.as_array()) else {
return false;
};
messages.iter().any(|m| {
m.get("string").and_then(|s| s.as_str()).is_some_and(|s| {
s.contains("Permission denied") || s.contains("Operation not permitted")
})
})
}
fn parse_smartctl(name: &str, v: &serde_json::Value) -> SmartTick {
let mut tick = SmartTick {
device: name.to_string(),
needs_root: refused(v),
..Default::default()
};
if let Some(t) = v
.get("temperature")
.and_then(|x| x.get("current"))
.and_then(|x| x.as_i64())
{
tick.temperature_c = Some(t as i16);
}
if let Some(t) = v.get("temperature").and_then(|x| x.as_i64()) {
if tick.temperature_c.is_none() {
tick.temperature_c = Some(t as i16);
}
}
if let Some(log) = v.get("nvme_smart_health_information_log") {
if let Some(t) = log.get("temperature").and_then(|x| x.as_i64()) {
tick.temperature_c = Some(t as i16);
}
tick.power_on_hours = log.get("power_on_hours").and_then(|x| x.as_u64());
tick.power_cycles = log.get("power_cycles").and_then(|x| x.as_u64());
tick.percentage_used = log
.get("percentage_used")
.and_then(|x| x.as_u64())
.map(|n| n as u8);
tick.available_spare = log
.get("available_spare")
.and_then(|x| x.as_u64())
.map(|n| n as u8);
tick.data_units_read = log.get("data_units_read").and_then(|x| x.as_u64());
tick.data_units_written = log.get("data_units_written").and_then(|x| x.as_u64());
}
if let Some(attrs) = v
.get("ata_smart_attributes")
.and_then(|x| x.get("table"))
.and_then(|x| x.as_array())
{
for a in attrs {
let Some(id) = a.get("id").and_then(|x| x.as_u64()) else {
continue;
};
let name = a
.get("name")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let value = a.get("value").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let worst = a.get("worst").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let thresh = a.get("thresh").and_then(|x| x.as_u64()).map(|n| n as u32);
let raw = a
.get("raw")
.and_then(|x| x.get("string"))
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
tick.ata_attrs.push(AtaAttr {
id: id as u8,
name,
value,
worst,
thresh,
raw,
});
let raw_int: Option<u64> = a
.get("raw")
.and_then(|x| x.get("value"))
.and_then(|x| x.as_u64());
match id as u8 {
0x09 if tick.power_on_hours.is_none() => {
tick.power_on_hours = raw_int;
}
0x0C if tick.power_cycles.is_none() => {
tick.power_cycles = raw_int;
}
_ => {}
}
}
}
tick
}
#[cfg(test)]
mod tests {
use super::*;
const REFUSED: &str = r#"{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
5
],
"pre_release": false,
"svn_revision": "5714",
"platform_info": "x86_64-linux-7.1.13-200.fc44.x86_64",
"build_info": "(local build)",
"argv": [
"smartctl",
"-A",
"--json",
"/dev/nvme0n1"
],
"messages": [
{
"string": "Smartctl open device: /dev/nvme0n1 failed: Permission denied",
"severity": "error"
}
],
"exit_status": 2
},
"local_time": {
"time_t": 1791358805,
"asctime": "Wed Oct 7 18:40:05 2026 AEDT"
}
}"#;
const NVME: &str = r#"{
"smartctl": { "version": [7, 4], "exit_status": 0 },
"device": { "name": "/dev/nvme0n1", "type": "nvme", "protocol": "NVMe" },
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 41,
"available_spare": 100,
"percentage_used": 2,
"data_units_read": 19446553,
"data_units_written": 25771349,
"power_cycles": 412,
"power_on_hours": 3150
},
"temperature": { "current": 41 },
"power_cycle_count": 412,
"power_on_time": { "hours": 3150 }
}"#;
fn parse(text: &str) -> SmartTick {
parse_smartctl("nvme0n1", &serde_json::from_str(text).unwrap())
}
#[test]
fn a_permission_refusal_is_told_apart_from_no_data() {
let t = parse(REFUSED);
assert!(t.needs_root);
assert_eq!(t.temperature_c, None);
assert_eq!(t.power_on_hours, None);
}
#[test]
fn a_successful_read_does_not_need_root() {
let t = parse(NVME);
assert!(!t.needs_root);
assert_eq!(t.temperature_c, Some(41));
assert_eq!(t.percentage_used, Some(2));
assert_eq!(t.power_on_hours, Some(3150));
}
#[test]
fn other_failures_are_not_blamed_on_root() {
let t = parse(
r#"{"smartctl": {"messages": [{"string": "/dev/zram0: Unable to detect device type", "severity": "error"}], "exit_status": 1}}"#,
);
assert!(!t.needs_root);
}
}