diff --git a/Cargo.lock b/Cargo.lock index 7cf14a1..466c5fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,7 @@ dependencies = [ "log", "notify", "once_cell", + "regex", "rstest", "serde", "serde_json", diff --git a/cfg/sensor-mapping/sysinfo-to-aoostar-filter.cfg b/cfg/sensor-mapping/sysinfo-to-aoostar-filter.cfg new file mode 100644 index 0000000..9fb35ba --- /dev/null +++ b/cfg/sensor-mapping/sysinfo-to-aoostar-filter.cfg @@ -0,0 +1,9 @@ +# Filter out specified sensor keys of the corresponding sensor file without the `-filter` suffix. +# +# Sensor filter for: aster-sysinfo +# +# Format: one RegEx entry per line. +# Empty lines and lines starting with # are filtered out. + +# remove all temperature sensor units +^temperature_.*#unit diff --git a/crates/asterctl/Cargo.toml b/crates/asterctl/Cargo.toml index 1d81807..9102bd1 100644 --- a/crates/asterctl/Cargo.toml +++ b/crates/asterctl/Cargo.toml @@ -26,6 +26,7 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" serde_repr = "0.1.20" once_cell = "1.21.3" +regex = "1.11.2" [dev-dependencies] rstest = "0.26" diff --git a/crates/asterctl/src/cfg.rs b/crates/asterctl/src/cfg.rs index 2077e4c..71c2536 100644 --- a/crates/asterctl/src/cfg.rs +++ b/crates/asterctl/src/cfg.rs @@ -10,6 +10,7 @@ use anyhow::Context; use image::{Rgb, Rgba}; use imageproc::definitions::HasWhite; use log::{info, warn}; +use regex::Regex; use serde::de::Visitor; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_repr::{Deserialize_repr, Serialize_repr}; @@ -122,6 +123,9 @@ pub struct MonitorConfig { /// Internal sensor label mapping #[serde(skip)] sensor_mapping: Option>, + /// Internal sensor filter + #[serde(skip)] + pub sensor_filter: Option>, } impl MonitorConfig { diff --git a/crates/asterctl/src/main.rs b/crates/asterctl/src/main.rs index 53bd2b4..c855cf2 100644 --- a/crates/asterctl/src/main.rs +++ b/crates/asterctl/src/main.rs @@ -6,7 +6,7 @@ use asterctl::cfg::{MonitorConfig, Panel, load_custom_panel}; use asterctl::render::PanelRenderer; -use asterctl::sensors::{read_key_value_file, start_file_slurper}; +use asterctl::sensors::{read_filter_file, read_key_value_file, start_file_slurper}; use asterctl::{cfg, img}; use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE}; @@ -14,6 +14,7 @@ use anyhow::anyhow; use clap::Parser; use env_logger::Env; use log::{debug, error, info}; +use regex::Regex; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; @@ -200,15 +201,37 @@ fn load_configuration>( }; if mapping_cfg.is_file() { let mut mapping = HashMap::new(); - read_key_value_file(&mapping_cfg, &mut mapping)?; + read_key_value_file(&mapping_cfg, &mut mapping, None)?; cfg.set_sensor_mapping(mapping); } else { info!("Sensor mapping file {mapping_cfg:?} not found"); } + cfg.sensor_filter = load_sensor_filter(&mapping_cfg)?; + Ok(cfg) } +fn load_sensor_filter(mapping_cfg: &Path) -> anyhow::Result>> { + if let Some(parent) = mapping_cfg.parent() + && let Some(file_stem) = mapping_cfg.file_stem() + && let Some(extension) = mapping_cfg.extension() + { + let filter_file = parent + .join(format!("{}-filter", file_stem.to_string_lossy())) + .with_extension(extension); + + if filter_file.is_file() { + info!("Loading sensor filter file {filter_file:?}"); + return read_filter_file(filter_file); + } else { + info!("No sensor filter file {filter_file:?} available"); + } + } + + Ok(None) +} + fn run_sensor_panel>( screen: &mut AooScreen, mut cfg: MonitorConfig, @@ -231,7 +254,11 @@ fn run_sensor_panel>( let sensor_values: Arc>> = Arc::new(RwLock::new(HashMap::new())); - start_file_slurper(sensor_path, sensor_values.clone())?; + start_file_slurper( + sensor_path, + sensor_values.clone(), + cfg.sensor_filter.clone(), + )?; let refresh = Duration::from_millis((cfg.setup.refresh * 1000f32) as u64); diff --git a/crates/asterctl/src/sensors.rs b/crates/asterctl/src/sensors.rs index 9f18b8f..f390482 100644 --- a/crates/asterctl/src/sensors.rs +++ b/crates/asterctl/src/sensors.rs @@ -11,6 +11,7 @@ use chrono::{DateTime, Datelike, Local, Timelike}; use log::{debug, error, info, warn}; use notify::event::{ModifyKind, RenameMode}; use notify::{Event, EventKind, RecursiveMode, Watcher}; +use regex::Regex; use std::collections::HashMap; use std::fs; use std::io::{BufRead, BufReader}; @@ -71,17 +72,19 @@ pub fn get_date_time_value(label: &str, now: &DateTime) -> Option /// /// * `source_path`: Single source file path or a directory path. /// * `values`: a shared, reader-writer lock protected HashMap +/// * `sensor_filter`: Optional list of regex filters to filter out matching sensor keys. /// /// returns: Result<(), Error> pub fn start_file_slurper>( source_path: P, values: Arc>>, + sensor_filter: Option>, ) -> anyhow::Result<()> { let dir_path = source_path.into(); // read existing file(s) { let mut val = values.write().expect("Failed to lock values"); - read_path(&dir_path, val.deref_mut())?; + read_path(&dir_path, val.deref_mut(), sensor_filter.as_deref())?; } let file_values = values.clone(); @@ -97,7 +100,7 @@ pub fn start_file_slurper>( } }; - info!("Starting sensor file watcher for {dir_path:?}"); + info!("Starting sensor file watcher for {dir_path:?} with filter {sensor_filter:?}"); if let Err(e) = watcher.watch(&dir_path, RecursiveMode::NonRecursive) { error!("Failed to start file watcher: {e}"); exit(1); @@ -123,7 +126,9 @@ pub fn start_file_slurper>( debug!("Modified sensor file ({kind:?}): {path:?}"); let mut val = file_values.write().expect("Poisoned sensor RwLock"); - if let Err(e) = read_key_value_file(path, val.deref_mut()) { + if let Err(e) = + read_key_value_file(path, val.deref_mut(), sensor_filter.as_deref()) + { warn!("Failed to read sensor file {path:?}: {e}"); continue; } @@ -146,9 +151,14 @@ pub fn start_file_slurper>( /// /// * `path`: Single source file path or a directory path. /// * `values`: HashMap to store all read key-value pairs. +/// * `sensor_filter`: Optional list of regex filters to filter out matching sensor keys. /// /// returns: Result<(), Error> -fn read_path>(path: P, values: &mut HashMap) -> anyhow::Result<()> { +fn read_path>( + path: P, + values: &mut HashMap, + sensor_filter: Option<&[Regex]>, +) -> anyhow::Result<()> { let path = path.as_ref(); if !path.try_exists()? { @@ -156,7 +166,7 @@ fn read_path>(path: P, values: &mut HashMap) -> a } if path.is_file() { - return read_key_value_file(path, values); + return read_key_value_file(path, values, sensor_filter); } for entry in fs::read_dir(path)? { @@ -164,7 +174,7 @@ fn read_path>(path: P, values: &mut HashMap) -> a if path.is_file() && path.extension().unwrap_or_default() == "txt" - && let Err(e) = read_key_value_file(&path, values) + && let Err(e) = read_key_value_file(&path, values, sensor_filter) { warn!("Failed to read sensor file {path:?}: {e}"); } @@ -184,11 +194,13 @@ fn read_path>(path: P, values: &mut HashMap) -> a /// /// * `path`: file path to read. /// * `values`: HashMap to insert key-value pairs from the file. +/// * `sensor_filter`: Optional list of regex filters to filter out matching sensor keys. /// /// returns: Result<(), Error> pub fn read_key_value_file>( path: P, values: &mut HashMap, + sensor_filter: Option<&[Regex]>, ) -> anyhow::Result<()> { debug!("Reading sensor file {:?}", path.as_ref()); @@ -202,6 +214,13 @@ pub fn read_key_value_file>( continue; } if let Some((key, value)) = line.split_once(':') { + if let Some(filter) = sensor_filter + && is_filtered(key, filter) + { + debug!("Filtered: {key}"); + continue; + } + values.insert(key.trim().to_string(), value.trim().to_string()); } else { warn!("Skipping invalid entry in sensor value file: {line}"); @@ -210,3 +229,108 @@ pub fn read_key_value_file>( Ok(()) } + +fn is_filtered(key: &str, filters: &[Regex]) -> bool { + filters.iter().any(|re| re.is_match(key)) +} + +/// Read the sensor filter configuration file. +/// +/// This is a simple text file containing multiple RegEx expressions. +/// - one RegEx per line +/// - Empty lines are skipped +/// - Lines starting with # are skipped +/// +/// # Arguments +/// +/// * `path`: file path to read. +/// +/// returns: None if the file is empty or contains no valid RegEx expressions. +/// +pub fn read_filter_file>(path: P) -> anyhow::Result>> { + debug!("Reading sensor filter file {:?}", path.as_ref()); + + let mut filters = Vec::new(); + let file = fs::File::open(path)?; + let reader = BufReader::new(file); + + for line in reader.lines() { + let line = line?; + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + match Regex::new(line) { + Ok(re) => { + filters.push(re); + } + Err(e) => { + warn!("Skipping invalid filter in sensor filter file: {line}: {e}"); + } + } + } + + if filters.is_empty() { + Ok(None) + } else { + Ok(Some(filters)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn is_filtered_does_not_filter_without_filters() { + let key = "foobar"; + let filters = Vec::new(); + assert!(!is_filtered(key, &filters)); + } + + #[test] + fn test_unit_extension_filter() { + let key = "temperature_cpu#unit"; + let filters = vec![Regex::new("^temperature_.*#unit").unwrap()]; + assert!(is_filtered(key, &filters)); + } + + #[rstest] + #[case(vec!["^foo$"])] + #[case(vec!["^bar"])] + #[case(vec!["other"])] + #[case(vec!["123", "bla", "other"])] + fn is_filtered_does_not_filter_without_a_match(#[case] filters: Vec<&str>) { + let key = "foobar"; + let filters: Vec = filters + .iter() + .map(|f| Regex::new(f).expect("Invalid regex")) + .collect(); + assert!( + !is_filtered(key, &filters), + "Filter {filters:?} should not match {key}" + ); + // + } + + #[rstest] + #[case(vec!["foo"])] + #[case(vec!["bar"])] + #[case(vec!["^.+bar"])] + #[case(vec!["123", "foo", "other"])] + #[case(vec!["bar", "123"])] + #[case(vec!["^.+bar", "other"])] + fn is_filtered_matches_filters(#[case] filters: Vec<&str>) { + let key = "foobar"; + let filters: Vec = filters + .iter() + .map(|f| Regex::new(f).expect("Invalid regex")) + .collect(); + assert!( + is_filtered(key, &filters), + "Filter {filters:?} match match {key}" + ); + } +} diff --git a/docs/sensor/README.md b/docs/sensor/README.md index ba3e95d..9363df2 100644 --- a/docs/sensor/README.md +++ b/docs/sensor/README.md @@ -53,3 +53,24 @@ Usage example: ```shell asterctl --config monitor.json --sensor-mapping sensor-mapping/sysinfo-to-aoostar.cfg ``` + +### Sensor Filter + +Sensor entries in the text file can be filtered by regular expressions defined in the sensor filter file having the +same name as the sensor identifier mapping file, but with the `-filter` suffix in the file name. + +Example: +- Sensor identifier mapping file: `sensor-mapping/sysinfo-to-aoostar.cfg` +- Sensor filter file: `sensor-mapping/sysinfo-to-aoostar-filter.cfg` + +The filter file is a simple text file with one regular expression per line: + +Example: + +``` +# remove all temperature sensor units +temperature_.*#unit +``` + +This removes all sensors starting with `temperature_` and ending with `#unit`, which will make sure that all the +temperature sensors will be rendered without the unit text suffix on the display panel.