refactor: project structure (#9)

Split up project into multiple crates and use a Cargo workspace.
This commit is contained in:
Markus Zehnder
2025-08-28 09:03:30 +02:00
committed by GitHub
parent f0128197d9
commit d98cd89c48
71 changed files with 672 additions and 401 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "sysinfo"
version = "0.1.0"
description = "System sensor provider for asterctl"
rust-version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
clap = { version = "4.5.42", features = ["derive"] }
sysinfo = "0.37.0"
itertools = "0.14"
tempfile = "3"
log = "0.4.27"
env_logger = "0.11.8"
regex = "1.11"
+1
View File
@@ -0,0 +1 @@
../../LICENSE-APACHE
+1
View File
@@ -0,0 +1 @@
../../LICENSE-MIT
+47
View File
@@ -0,0 +1,47 @@
# System Sensor Provider for asterctl
This tool gathers system sensor values with the help of the [sysinfo](https://github.com/GuillaumeGomez/sysinfo) crate
and writes them into a text file.
See [README](../../README.md) in root directory for more information.
```
Proof of concept sensor value collection for the asterctl screen control tool
Usage: sysinfo [OPTIONS]
Options:
-o, --out <OUT>
Output sensor file
-t, --temp-dir <TEMP_DIR>
Temporary directory for preparing the output sensor file.
The system temp directory is used if not specified.
The temp directory must be on the same file system for atomic rename operation!
--console
Print values in console
-r, --refresh <REFRESH>
System sensor refresh interval in seconds
--disk-refresh <DISK_REFRESH>
Enable individual disk refresh logic as used in AOOSTAR-X. Refresh interval in seconds
--smartctl
Retrieve drive temperature if `disk-update` option is enabled.
Requires smartctl and password-less sudo!
```
Single test run with printing all sensors in the console:
```shell
sysinfo --console
```
Normal mode providing sensor values for `asterctl` in `/tmp/sensors/sysinfo.txt` every 3 seconds:
```shell
sysinfo --refresh 3 --out /tmp/sensors/sysinfo.txt
```
+768
View File
@@ -0,0 +1,768 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
#![forbid(non_ascii_idents)]
#![deny(unsafe_code)]
use clap::Parser;
use env_logger::Env;
use itertools::Itertools;
use log::{debug, error, info};
use regex::Regex;
use std::cmp::PartialEq;
use std::collections::HashMap;
use std::fmt::Display;
use std::fs;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, exit};
use std::thread::sleep;
use std::time::{Duration, Instant};
use sysinfo::{Components, DiskKind, Disks, Networks, System};
use tempfile::NamedTempFile;
/// Proof of concept sensor value collection for the asterctl screen control tool.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Output sensor file.
#[arg(short, long)]
out: Option<PathBuf>,
/// Temporary directory for preparing the output sensor file.
///
/// The system temp directory is used if not specified.
/// The temp directory must be on the same file system for atomic rename operation!
#[arg(short, long)]
temp_dir: Option<PathBuf>,
/// Print values in console
#[arg(long)]
console: bool,
/// System sensor refresh interval in seconds
#[arg(short, long)]
refresh: Option<u16>,
/// Enable individual disk refresh logic as used in AOOSTAR-X. Refresh interval in seconds.
#[arg(long)]
disk_refresh: Option<u16>,
/// Retrieve drive temperature if `disk-update` option is enabled.
///
/// Requires smartctl and password-less sudo!
#[cfg(target_os = "linux")]
#[arg(long)]
smartctl: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let args = Args::parse();
#[cfg(target_os = "linux")]
let use_smartctl = args.smartctl;
#[cfg(not(target_os = "linux"))]
let use_smartctl = false;
let mut sensors = HashMap::with_capacity(64);
let mut sysinfo_source = SysinfoSource::new();
let refresh = Duration::from_secs(args.refresh.unwrap_or_default() as u64);
let disk_refresh = Duration::from_secs(args.disk_refresh.unwrap_or_default() as u64);
let mut disk_refresh_time = Instant::now();
if !disk_refresh.is_zero() {
update_linux_storage_sensors(&mut sensors, use_smartctl)?;
}
loop {
let upd_start_time = Instant::now();
sysinfo_source.refresh();
sysinfo_source.update_sensors(&mut sensors)?;
if !disk_refresh.is_zero() && disk_refresh_time.elapsed() > disk_refresh {
info!("Refreshing individual disks");
update_linux_storage_sensors(&mut sensors, use_smartctl)?;
disk_refresh_time = Instant::now();
}
if let Some(out_file) = &args.out {
write_sensor_file(out_file, args.temp_dir.as_deref(), &sensors)?;
}
if args.console {
// pretty print console output with sorted keys
for (label, value) in sensors.iter().sorted() {
println!("{}: {}", label, value);
}
println!();
}
if refresh.is_zero() {
break;
}
let elapsed = upd_start_time.elapsed();
if refresh > elapsed {
sleep(refresh - elapsed);
}
}
Ok(())
}
fn write_sensor_file(
out_file: &Path,
temp_dir: Option<&Path>,
sensors: &HashMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> {
if out_file.is_dir() {
error!("Output cannot be a directory: {}", out_file.display());
exit(1);
}
let tmp_file = if let Some(temp_path) = temp_dir {
fs::create_dir_all(temp_path)?;
NamedTempFile::new_in(temp_path)?
} else {
NamedTempFile::new()?
};
let mut stream = BufWriter::new(&tmp_file);
for (label, value) in sensors.iter() {
writeln!(stream, "{label}: {value}")?;
}
stream.flush()?;
drop(stream);
tmp_file.persist(out_file)?;
Ok(())
}
pub struct SysinfoSource {
sys: System,
disks: Disks,
components: Components,
networks: Networks,
last_refresh: Option<Instant>,
refresh_duration: Option<Duration>,
}
impl Default for SysinfoSource {
fn default() -> Self {
Self::new()
}
}
impl SysinfoSource {
pub fn new() -> Self {
Self {
sys: System::new_all(),
disks: Disks::new(),
components: Components::new(),
networks: Networks::new(),
last_refresh: None,
refresh_duration: None,
}
}
pub fn refresh(&mut self) {
self.sys.refresh_all();
// TODO research "remove_not_listed_###" refresh parameter
self.disks.refresh(false);
self.components.refresh(false);
self.networks.refresh(false);
if let Some(last_refresh) = self.last_refresh {
self.refresh_duration = Some(last_refresh.elapsed());
}
self.last_refresh = Some(Instant::now());
}
fn update_sensors(
&self,
sensors: &mut HashMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> {
for cpu in self.sys.cpus() {
add_sensor(
sensors,
format!("cpu_{}_frequency", cpu.name()),
cpu.frequency(),
);
add_sensor(
sensors,
format!("cpu_{}_usage", cpu.name()),
format!("{:.2}", cpu.cpu_usage()),
);
}
let load_avg = System::load_average();
add_sensor(sensors, "load_avg_one", format!("{:.2}", load_avg.one));
add_sensor(sensors, "load_avg_five", format!("{:.2}", load_avg.five));
add_sensor(
sensors,
"load_avg_fifteen",
format!("{:.2}", load_avg.fifteen),
);
// RAM and swap information:
add_sensor(sensors, "mem_free_bytes", self.sys.free_memory());
add_sensor(sensors, "mem_free", format_bytes(self.sys.free_memory()));
add_sensor(sensors, "mem_total_bytes", self.sys.total_memory());
add_sensor(sensors, "mem_total", format_bytes(self.sys.total_memory()));
add_sensor(sensors, "mem_used_bytes", self.sys.used_memory());
add_sensor(sensors, "mem_used", format_bytes(self.sys.used_memory()));
add_sensor(
sensors,
"mem_usage_percent",
format!(
"{:.1}",
(self.sys.used_memory() * 100) as f64 / self.sys.total_memory() as f64
),
);
add_sensor(sensors, "swap_free_bytes", self.sys.free_swap());
add_sensor(sensors, "swap_free", format_bytes(self.sys.free_swap()));
add_sensor(sensors, "swap_total_bytes", self.sys.total_swap());
add_sensor(sensors, "swap_total", format_bytes(self.sys.total_swap()));
add_sensor(sensors, "swap_used_bytes", self.sys.used_swap());
add_sensor(sensors, "swap_used", format_bytes(self.sys.used_swap()));
add_sensor(
sensors,
"swap_usage_percent",
format!(
"{:.1}",
(self.sys.used_swap() * 100) as f64 / self.sys.total_swap() as f64
),
);
// System information:
if let Some(name) = System::name() {
add_sensor(sensors, "system_name", name);
}
if let Some(kernel_version) = System::kernel_version() {
add_sensor(sensors, "system_kernel_version", kernel_version);
}
if let Some(os_version) = System::os_version() {
add_sensor(sensors, "system_os_version", os_version);
}
if let Some(host_name) = System::host_name() {
add_sensor(sensors, "system_hostname", host_name);
}
add_sensor(sensors, "cpu_count", self.sys.cpus().len());
add_sensor(sensors, "total_processes", self.sys.processes().len());
// disks' information:
let mut ssd_idx = 0;
let mut hdd_idx = 0;
for disk in &self.disks {
let label;
match disk.kind() {
DiskKind::SSD => {
label = format!("storage_ssd[{}]", ssd_idx);
ssd_idx += 1;
}
DiskKind::HDD => {
label = format!("storage_hdd[{}]", hdd_idx);
hdd_idx += 1;
}
_ => continue,
}
// special label for AOOSTAR-X system panel
add_sensor(
sensors,
format!("{label}_usage_percent"),
(disk.total_space() - disk.available_space()) * 100 / disk.total_space(),
);
// using similar labels as AOOSTAR-X, but combining `{label2}_{label}`
let device = disk.name().to_string_lossy().replace(' ', "_");
add_sensor(
sensors,
format!("disk_{device}_total_bytes"),
disk.total_space(),
);
add_sensor(
sensors,
format!("disk_{device}_total"),
format_bytes(disk.total_space()),
);
let used = disk.total_space() - disk.available_space();
add_sensor(sensors, format!("disk_{device}_used_bytes"), used);
add_sensor(sensors, format!("disk_{device}_used"), format_bytes(used));
add_sensor(
sensors,
format!("disk_{device}_free_bytes"),
disk.available_space(),
);
add_sensor(
sensors,
format!("disk_{device}_free"),
format_bytes(disk.available_space()),
);
add_sensor(
sensors,
format!("disk_{device}_usage_percent"),
format!(
"{:.1}",
(disk.total_space() - disk.available_space()) as f64 * 100.0
/ disk.total_space() as f64
),
);
}
// Components temperature:
for component in &self.components {
if let Some(temperature) = component.temperature() {
let label;
if component.label().contains("spd5118") {
label = "temperature_memory".to_string();
} else if component.label().contains("amdgpu") {
label = "temperature_gpu".to_string();
} else if component.label().contains("Tctl") {
label = "temperature_cpu".to_string();
} else if component.label().contains("Composite")
&& !component.label().contains("nvme")
{
// just a guess...
label = "temperature_motherboard".to_string();
} else {
label = format!("temperature_{}", component.label().replace(' ', "_"));
// println!("label={}, type_id={:?}, id={:?}, {component:?}",
// component.label(), component.type_id(), component.id());
}
// TODO add unit as a separate sensor?
add_sensor(sensors, label, format!("{temperature:.1} °C"));
}
}
// Network interfaces name, total data received and total data transmitted:
for (interface_name, data) in &self.networks {
// only consider specific interfaces
let if_name = interface_name.to_lowercase();
if !["eth", "en", "em", "wlan", "wlp", "wlo"]
.iter()
.any(|i| if_name.starts_with(*i))
{
continue;
}
// Sort by address to avoid random order in refreshes
for (idx, addr) in data
.ip_networks()
.iter()
.map(|net| net.addr)
.sorted()
.enumerate()
{
add_sensor(
sensors,
format!("network_{interface_name}_address{idx}"),
addr,
);
}
if let Some(refresh) = self.refresh_duration {
let interval = refresh.as_millis() as u64;
if interval > 0 {
add_sensor(
sensors,
format!("network_{interface_name}_download_speed"),
format!("{}/s", format_bytes(1000 * data.received() / interval)),
);
add_sensor(
sensors,
format!("network_{interface_name}_upload_speed"),
format!("{}/s", format_bytes(1000 * data.transmitted() / interval)),
);
}
}
add_sensor(
sensors,
format!("network_{interface_name}_total_received_bytes"),
data.total_received(),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_received"),
format_bytes(data.total_received()),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_transmitted_bytes"),
data.total_transmitted(),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_transmitted"),
format_bytes(data.total_transmitted()),
);
}
Ok(())
}
}
fn add_sensor(
sensors: &mut HashMap<String, String>,
label: impl Into<String>,
value: impl Display,
) {
sensors.insert(label.into(), value.to_string());
}
fn update_linux_storage_sensors(
sensors: &mut HashMap<String, String>,
use_smartctl: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Note: AOOSTAR-X only considered spinning Rust. Too bad if you're using SSDs in the HD bays...
if let Ok(hdd_devices) = get_storage_devices(StorageDevice::HddOrSsd) {
debug!("HDD devices : {:?}", hdd_devices);
for (idx, device) in hdd_devices.iter().enumerate() {
let usage = get_disk_usage(device)?;
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_size_bytes"),
usage.total_size,
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_size"),
format_bytes(usage.total_size),
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_used_bytes"),
usage.total_used,
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_used"),
format_bytes(usage.total_used),
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_usage_percent"),
usage.usage_percent,
);
if use_smartctl && let Some(temperature) = get_smartctl_disk_temperature(device)? {
add_sensor(
sensors,
format!("storage_hdd[{idx}]_temperature"),
temperature,
);
}
}
}
// AOOSTAR-X: ssd == nvme
if let Ok(nvme_devices) = get_storage_devices(StorageDevice::Nvme) {
debug!("NVME devices: {:?}", nvme_devices);
for (idx, device) in nvme_devices.iter().enumerate() {
let usage = get_disk_usage(device)?;
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_size_bytes"),
usage.total_size,
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_size"),
format_bytes(usage.total_size),
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_used_bytes"),
usage.total_used,
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_used"),
format_bytes(usage.total_used),
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_usage_percent"),
usage.usage_percent,
);
if use_smartctl && let Some(temperature) = get_smartctl_disk_temperature(device)? {
add_sensor(
sensors,
format!("storage_ssd[{idx}]_temperature"),
temperature,
);
}
}
}
Ok(())
}
#[derive(Debug)]
pub struct DiskInfo {
pub device: String,
pub temperature: i32,
pub used: f64,
pub total_used: u64,
pub total_size: u64,
}
#[derive(Debug)]
pub struct DiskUsage {
pub usage_percent: f64,
pub total_used: u64,
pub total_size: u64,
}
#[derive(Debug, PartialEq)]
pub enum StorageDevice {
All,
Hdd,
Ssd,
HddOrSsd,
Nvme,
}
pub type DiskResult = Result<Vec<DiskInfo>, Box<dyn std::error::Error>>;
/// Get storage devices of the given type: NVME, SSD or HD
///
/// Storage devices are identified from /sys/block attributes.
/// Removable devices are excluded.
///
/// # Arguments
///
/// * `kind`: type of storage device
///
/// returns: sorted list of found device names (`sd*` and `nvme*`)
pub fn get_storage_devices(kind: StorageDevice) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut devices = Vec::new();
let sys_block = Path::new("/sys/block");
if !sys_block.exists() {
info!("No storage device found");
return Ok(devices);
}
let device_regex = Regex::new(r"^sd[a-z]+$")?;
let nvme_regex = Regex::new(r"^nvme[0-9]+n[0-9]+$")?;
for entry in fs::read_dir(sys_block)? {
let entry = entry?;
let dev_name = entry.file_name();
let dev_str = dev_name.to_string_lossy();
// filter out all non sd* and nvme* devices
let is_nvme = nvme_regex.is_match(&dev_str);
let is_storage = device_regex.is_match(&dev_str);
if !(is_nvme || is_storage) {
continue;
}
match kind {
StorageDevice::All => {}
StorageDevice::Hdd | StorageDevice::Ssd | StorageDevice::HddOrSsd => {
if !is_storage {
continue;
}
}
StorageDevice::Nvme => {
if !is_nvme {
continue;
}
}
};
if is_nvme {
let dev_name = entry.file_name();
let dev_str = dev_name.to_string_lossy();
devices.push(dev_str.to_string());
continue;
}
let rotational_path = sys_block.join(dev_str.as_ref()).join("queue/rotational");
let removable_path = sys_block.join(dev_str.as_ref()).join("removable");
match (
fs::read_to_string(&rotational_path),
fs::read_to_string(&removable_path),
) {
(Ok(rotational), Ok(removable)) => {
let rotational = rotational.trim();
let removable = removable.trim();
// ignore removable
if removable == "1" {
continue;
}
if kind == StorageDevice::Hdd && rotational == "1"
|| kind == StorageDevice::Ssd && rotational == "0"
|| kind == StorageDevice::HddOrSsd
{
devices.push(dev_str.to_string());
}
}
(Err(e), _) | (_, Err(e)) => {
error!("Unable to read device {dev_str} attributes: {e}");
}
}
}
devices.sort();
Ok(devices)
}
/// Retrieve temperature from NVMe or SDD/HDD with smartctl
pub fn get_smartctl_disk_temperature(dev: &str) -> Result<Option<i32>, Box<dyn std::error::Error>> {
let temp_regex =
Regex::new(r"194\s+Temperature_Celsius\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+-\s+(\d+)")?;
let nvme_temp_regex = Regex::new(r"Temperature:\s+(\d+)\s")?;
let dev = format!("/dev/{}", dev);
match Command::new("sudo")
.arg("-n")
.arg("smartctl")
.arg("-A")
.arg(&dev)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(temp_captures) = temp_regex
.captures(&stdout)
.or_else(|| nvme_temp_regex.captures(&stdout))
&& let Some(temp_match) = temp_captures.get(1)
{
let temperature = temp_match.as_str().parse::<i32>()?;
return Ok(Some(temperature));
}
}
Err(e) => {
error!("Device {dev} acquisition failed, error: {e}");
}
}
Ok(None)
}
/// Calculate actual filesystem usage rate of hard disk (based on df command)
pub fn get_disk_usage(dev: &str) -> Result<DiskUsage, Box<dyn std::error::Error>> {
let mut tmp = DiskUsage {
usage_percent: 0.0,
total_used: 0,
total_size: 0,
};
// Get mounted partitions for this device
let cmd = format!(
"df -h --output=source,target,pcent | grep '/dev/{}[0-9]*'",
dev
);
match Command::new("sh").arg("-c").arg(&cmd).output() {
Ok(output) => {
if !output.status.success() {
return Ok(tmp);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut total_used: u64 = 0;
let mut total_size: u64 = 0;
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
continue;
}
let mountpoint = parts[1];
// Get size in bytes
let size_cmd = format!(
"df --block-size=1 {} | awk 'NR==2 {{print $2}}'",
mountpoint
);
if let Ok(size_output) = Command::new("sh").arg("-c").arg(&size_cmd).output()
&& let Ok(size_str) = String::from_utf8(size_output.stdout)
&& let Ok(size) = size_str.trim().parse::<u64>()
{
total_size += size;
}
// Get used space in bytes
let used_cmd = format!(
"df --block-size=1 {} | awk 'NR==2 {{print $3}}'",
mountpoint
);
if let Ok(used_output) = Command::new("sh").arg("-c").arg(&used_cmd).output()
&& let Ok(used_str) = String::from_utf8(used_output.stdout)
&& let Ok(used) = used_str.trim().parse::<u64>()
{
total_used += used;
}
}
if total_size != 0 {
tmp.usage_percent =
((total_used as f64 / total_size as f64) * 100.0 * 100.0).round() / 100.0;
tmp.total_used = total_used;
tmp.total_size = total_size;
}
Ok(tmp)
}
Err(_) => Ok(tmp),
}
}
/// Format bytes into human-readable string
pub fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
const THRESHOLD: f64 = 1024.0;
if bytes == 0 {
return "0 B".to_string();
}
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= THRESHOLD && unit_index < UNITS.len() - 1 {
size /= THRESHOLD;
unit_index += 1;
}
if unit_index > 0 {
format!("{:.2} {}", size, UNITS[unit_index])
} else {
format!("{} {}", size, UNITS[unit_index])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(1024), "1.00 KB");
assert_eq!(format_bytes(1048576), "1.00 MB");
assert_eq!(format_bytes(1073741824), "1.00 GB");
}
}