feat: sensor filter option

Filter out sensors based on regex matches.
This allows removing all unit text suffixes if, for example, the panel
image already contains the unit text.
This commit is contained in:
Markus Zehnder
2025-09-16 23:35:41 +02:00
parent 1e2616848a
commit 9af5deb204
7 changed files with 196 additions and 9 deletions
Generated
+1
View File
@@ -167,6 +167,7 @@ dependencies = [
"log", "log",
"notify", "notify",
"once_cell", "once_cell",
"regex",
"rstest", "rstest",
"serde", "serde",
"serde_json", "serde_json",
@@ -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
+1
View File
@@ -26,6 +26,7 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142" serde_json = "1.0.142"
serde_repr = "0.1.20" serde_repr = "0.1.20"
once_cell = "1.21.3" once_cell = "1.21.3"
regex = "1.11.2"
[dev-dependencies] [dev-dependencies]
rstest = "0.26" rstest = "0.26"
+4
View File
@@ -10,6 +10,7 @@ use anyhow::Context;
use image::{Rgb, Rgba}; use image::{Rgb, Rgba};
use imageproc::definitions::HasWhite; use imageproc::definitions::HasWhite;
use log::{info, warn}; use log::{info, warn};
use regex::Regex;
use serde::de::Visitor; use serde::de::Visitor;
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_repr::{Deserialize_repr, Serialize_repr};
@@ -122,6 +123,9 @@ pub struct MonitorConfig {
/// Internal sensor label mapping /// Internal sensor label mapping
#[serde(skip)] #[serde(skip)]
sensor_mapping: Option<HashMap<String, String>>, sensor_mapping: Option<HashMap<String, String>>,
/// Internal sensor filter
#[serde(skip)]
pub sensor_filter: Option<Vec<Regex>>,
} }
impl MonitorConfig { impl MonitorConfig {
+30 -3
View File
@@ -6,7 +6,7 @@
use asterctl::cfg::{MonitorConfig, Panel, load_custom_panel}; use asterctl::cfg::{MonitorConfig, Panel, load_custom_panel};
use asterctl::render::PanelRenderer; 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::{cfg, img};
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE}; use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
@@ -14,6 +14,7 @@ use anyhow::anyhow;
use clap::Parser; use clap::Parser;
use env_logger::Env; use env_logger::Env;
use log::{debug, error, info}; use log::{debug, error, info};
use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -200,15 +201,37 @@ fn load_configuration<P: AsRef<Path>>(
}; };
if mapping_cfg.is_file() { if mapping_cfg.is_file() {
let mut mapping = HashMap::new(); 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); cfg.set_sensor_mapping(mapping);
} else { } else {
info!("Sensor mapping file {mapping_cfg:?} not found"); info!("Sensor mapping file {mapping_cfg:?} not found");
} }
cfg.sensor_filter = load_sensor_filter(&mapping_cfg)?;
Ok(cfg) Ok(cfg)
} }
fn load_sensor_filter(mapping_cfg: &Path) -> anyhow::Result<Option<Vec<Regex>>> {
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<B: Into<PathBuf>>( fn run_sensor_panel<B: Into<PathBuf>>(
screen: &mut AooScreen, screen: &mut AooScreen,
mut cfg: MonitorConfig, mut cfg: MonitorConfig,
@@ -231,7 +254,11 @@ fn run_sensor_panel<B: Into<PathBuf>>(
let sensor_values: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new())); let sensor_values: Arc<RwLock<HashMap<String, String>>> = 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); let refresh = Duration::from_millis((cfg.setup.refresh * 1000f32) as u64);
+130 -6
View File
@@ -11,6 +11,7 @@ use chrono::{DateTime, Datelike, Local, Timelike};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use notify::event::{ModifyKind, RenameMode}; use notify::event::{ModifyKind, RenameMode};
use notify::{Event, EventKind, RecursiveMode, Watcher}; use notify::{Event, EventKind, RecursiveMode, Watcher};
use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
@@ -71,17 +72,19 @@ pub fn get_date_time_value(label: &str, now: &DateTime<Local>) -> Option<String>
/// ///
/// * `source_path`: Single source file path or a directory path. /// * `source_path`: Single source file path or a directory path.
/// * `values`: a shared, reader-writer lock protected HashMap /// * `values`: a shared, reader-writer lock protected HashMap
/// * `sensor_filter`: Optional list of regex filters to filter out matching sensor keys.
/// ///
/// returns: Result<(), Error> /// returns: Result<(), Error>
pub fn start_file_slurper<P: Into<PathBuf>>( pub fn start_file_slurper<P: Into<PathBuf>>(
source_path: P, source_path: P,
values: Arc<RwLock<HashMap<String, String>>>, values: Arc<RwLock<HashMap<String, String>>>,
sensor_filter: Option<Vec<Regex>>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let dir_path = source_path.into(); let dir_path = source_path.into();
// read existing file(s) // read existing file(s)
{ {
let mut val = values.write().expect("Failed to lock values"); 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(); let file_values = values.clone();
@@ -97,7 +100,7 @@ pub fn start_file_slurper<P: Into<PathBuf>>(
} }
}; };
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) { if let Err(e) = watcher.watch(&dir_path, RecursiveMode::NonRecursive) {
error!("Failed to start file watcher: {e}"); error!("Failed to start file watcher: {e}");
exit(1); exit(1);
@@ -123,7 +126,9 @@ pub fn start_file_slurper<P: Into<PathBuf>>(
debug!("Modified sensor file ({kind:?}): {path:?}"); debug!("Modified sensor file ({kind:?}): {path:?}");
let mut val = file_values.write().expect("Poisoned sensor RwLock"); 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}"); warn!("Failed to read sensor file {path:?}: {e}");
continue; continue;
} }
@@ -146,9 +151,14 @@ pub fn start_file_slurper<P: Into<PathBuf>>(
/// ///
/// * `path`: Single source file path or a directory path. /// * `path`: Single source file path or a directory path.
/// * `values`: HashMap to store all read key-value pairs. /// * `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> /// returns: Result<(), Error>
fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> anyhow::Result<()> { fn read_path<P: AsRef<Path>>(
path: P,
values: &mut HashMap<String, String>,
sensor_filter: Option<&[Regex]>,
) -> anyhow::Result<()> {
let path = path.as_ref(); let path = path.as_ref();
if !path.try_exists()? { if !path.try_exists()? {
@@ -156,7 +166,7 @@ fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> a
} }
if path.is_file() { 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)? { for entry in fs::read_dir(path)? {
@@ -164,7 +174,7 @@ fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> a
if path.is_file() if path.is_file()
&& path.extension().unwrap_or_default() == "txt" && 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}"); warn!("Failed to read sensor file {path:?}: {e}");
} }
@@ -184,11 +194,13 @@ fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> a
/// ///
/// * `path`: file path to read. /// * `path`: file path to read.
/// * `values`: HashMap to insert key-value pairs from the file. /// * `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> /// returns: Result<(), Error>
pub fn read_key_value_file<P: AsRef<Path>>( pub fn read_key_value_file<P: AsRef<Path>>(
path: P, path: P,
values: &mut HashMap<String, String>, values: &mut HashMap<String, String>,
sensor_filter: Option<&[Regex]>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
debug!("Reading sensor file {:?}", path.as_ref()); debug!("Reading sensor file {:?}", path.as_ref());
@@ -202,6 +214,13 @@ pub fn read_key_value_file<P: AsRef<Path>>(
continue; continue;
} }
if let Some((key, value)) = line.split_once(':') { 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()); values.insert(key.trim().to_string(), value.trim().to_string());
} else { } else {
warn!("Skipping invalid entry in sensor value file: {line}"); warn!("Skipping invalid entry in sensor value file: {line}");
@@ -210,3 +229,108 @@ pub fn read_key_value_file<P: AsRef<Path>>(
Ok(()) 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<P: AsRef<Path>>(path: P) -> anyhow::Result<Option<Vec<Regex>>> {
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<Regex> = 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<Regex> = filters
.iter()
.map(|f| Regex::new(f).expect("Invalid regex"))
.collect();
assert!(
is_filtered(key, &filters),
"Filter {filters:?} match match {key}"
);
}
}
+21
View File
@@ -53,3 +53,24 @@ Usage example:
```shell ```shell
asterctl --config monitor.json --sensor-mapping sensor-mapping/sysinfo-to-aoostar.cfg 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.