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,
pub standby: bool,
}
const STANDBY_EXIT: i32 = 3;
#[derive(Debug)]
enum Poll {
Read(SmartTick),
Standby,
Failed,
}
#[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.poll(devices);
}
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.poll(devices);
}
fn poll(&mut self, devices: &[crate::collect::DeviceTick]) {
self.last_refresh = Instant::now();
self.last_refresh_at = Some(self.last_refresh);
for d in devices {
self.apply(&d.name, query_device(&d.name));
}
}
fn apply(&mut self, name: &str, poll: Poll) {
match poll {
Poll::Read(tick) => {
self.by_device.insert(name.to_string(), tick);
}
Poll::Standby => {
let tick = self
.by_device
.entry(name.to_string())
.or_insert_with(|| SmartTick {
device: name.to_string(),
..Default::default()
});
tick.standby = true;
}
Poll::Failed => {}
}
}
}
fn probe_smartctl() -> bool {
Command::new("smartctl")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn query_device(name: &str) -> Poll {
let dev = format!("/dev/{}", name);
let standby = format!("standby,{STANDBY_EXIT}");
match Command::new("smartctl")
.args(["-n", &standby, "-A", "--json", &dev])
.output()
{
Ok(out) => classify(name, out.status.code(), &out.stdout),
Err(_) => Poll::Failed,
}
}
fn classify(name: &str, status: Option<i32>, stdout: &[u8]) -> Poll {
if status == Some(STANDBY_EXIT) {
return Poll::Standby;
}
match serde_json::from_slice::<serde_json::Value>(stdout) {
Ok(v) => Poll::Read(parse_smartctl(name, &v)),
Err(_) => Poll::Failed,
}
}
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::*;
#[test]
fn standby_is_its_own_exit_status_and_keeps_the_last_reading() {
assert!(matches!(classify("sda", Some(3), b""), Poll::Standby));
assert!(matches!(
classify("sda", Some(2), b"not json"),
Poll::Failed
));
let mut c = SmartCollector::new();
c.apply(
"sda",
Poll::Read(SmartTick {
temperature_c: Some(38),
..Default::default()
}),
);
c.apply("sda", Poll::Standby);
let t = &c.by_device["sda"];
assert!(t.standby);
assert_eq!(t.temperature_c, Some(38), "the last reading is kept");
c.apply(
"sda",
Poll::Read(SmartTick {
temperature_c: Some(40),
..Default::default()
}),
);
assert!(!c.by_device["sda"].standby, "awake again");
c.apply("sdb", Poll::Standby);
assert!(c.by_device["sdb"].standby && c.by_device["sdb"].temperature_c.is_none());
}
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);
}
}