Merge pull request #20 from zehnm/feat/18-sensor-id-mapping

Sensor identifier mapping, sensor filters, internal date time sensors
This commit is contained in:
Markus Zehnder
2025-09-17 21:34:00 +02:00
committed by GitHub
17 changed files with 551 additions and 42 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run demo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="dev" />
<option name="command" value="run --bin asterctl -- --demo -c monitor.json" />
<option name="command" value="run --bin demo -- -c monitor.json" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
+1 -1
View File
@@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run sysinfo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="dev" />
q <option name="command" value="run --bin sysinfo -- --console --out ./cfg/sensors/sysinfo.txt" />
<option name="command" value="run --bin aster-sysinfo -- --console --out ./cfg/sensors/sysinfo.txt" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
+1 -1
View File
@@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run sysinfo repeat" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="dev" />
<option name="command" value="run --bin sysinfo -- --console --out ./cfg/sensors/sysinfo.txt --refresh 3" />
<option name="command" value="run --bin aster-sysinfo -- --console --out ./cfg/sensors/sysinfo.txt --refresh 3" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
Generated
+62 -8
View File
@@ -42,6 +42,15 @@ dependencies = [
"equator",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.20"
@@ -150,6 +159,7 @@ dependencies = [
"ab_glyph",
"anyhow",
"asterctl-lcd",
"chrono",
"clap",
"env_logger",
"image",
@@ -157,6 +167,7 @@ dependencies = [
"log",
"notify",
"once_cell",
"regex",
"rstest",
"serde",
"serde_json",
@@ -284,6 +295,19 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "chrono"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link 0.2.0",
]
[[package]]
name = "clap"
version = "4.5.46"
@@ -612,6 +636,30 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "image"
version = "0.25.6"
@@ -1989,7 +2037,7 @@ dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-link",
"windows-link 0.1.3",
"windows-numerics",
]
@@ -2010,7 +2058,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-link 0.1.3",
"windows-result",
"windows-strings",
]
@@ -2022,7 +2070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core",
"windows-link",
"windows-link 0.1.3",
"windows-threading",
]
@@ -2054,6 +2102,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
[[package]]
name = "windows-numerics"
version = "0.2.0"
@@ -2061,7 +2115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core",
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -2070,7 +2124,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -2079,7 +2133,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -2122,7 +2176,7 @@ version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
dependencies = [
"windows-link",
"windows-link 0.1.3",
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
@@ -2139,7 +2193,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -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
+46
View File
@@ -0,0 +1,46 @@
# Mapping sensor labels from sensor value providers to panel definition labels.
#
# Mapping definition for: aster-sysinfo
#
# Key = label identifier used in panel definition
# Value = label identifier used in sensor providers
cpu_percent: cpu_usage_percent
cpu_temperature: temperature_cpu
memory_usage: mem_usage_percent
memory_Temperature: temperature_memory
gpu_temperature: temperature_gpu
# replace network_###_ with the desired interface: enp100s0f0np0 / enp100s0f1np1 / enp102s0 / enp103s0 etc
net_upload_speed: network_enp100s0f1np1_upload_speed
net_download_speed: network_enp100s0f1np1_download_speed
net_ip_address: network_enp100s0f1np1_address0
# Individual storage_ssd / hdd[x] disk info doesn't seem very useful for a NAS.
# Usually there are different arrays / fs mounts. But that's easy to map now!
storage_ssd[0]['temperature']: temperature_nvme_Composite_KINGSTON_OM8PGP41024Q-A0
storage_ssd[0]['used']: storage_ssd[0]_usage_percent
storage_ssd[1]['temperature']: temperature_nvme_Composite_Samsung_SSD_990_EVO_Plus_2TB
storage_ssd[1]['used']: storage_ssd[1]_usage_percent
# storage_ssd[2]['temperature']:
storage_ssd[2]['used']: storage_ssd[2]_usage_percent
# storage_ssd[3]['temperature']:
storage_ssd[3]['used']: storage_ssd[3]_usage_percent
# storage_ssd[4]['temperature']:
storage_ssd[4]['used']: storage_ssd[4]_usage_percent
# storage_hdd[0]['temperature']:
storage_hdd[0]['used']: storage_hdd[0]_usage_percent
# storage_hdd[1]['temperature']:
storage_hdd[1]['used']: storage_hdd[1]_usage_percent
# storage_hdd[2]['temperature']:
storage_hdd[2]['used']: storage_hdd[2]_usage_percent
# storage_hdd[3]['temperature']:
storage_hdd[3]['used']: storage_hdd[3]_usage_percent
# storage_hdd[4]['temperature']:
storage_hdd[4]['used']: storage_hdd[4]_usage_percent
# storage_hdd[5]['temperature']:
storage_hdd[5]['used']: storage_hdd[5]_usage_percent
# TODO not (yet) available in aster-sysinfo
# gpu_core:
# motherboard_temperature:
+42 -2
View File
@@ -224,6 +224,12 @@ impl SysinfoSource {
);
}
add_sensor(
sensors,
"cpu_usage_percent".to_string(),
format!("{:.2}", self.sys.global_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));
@@ -265,6 +271,40 @@ impl SysinfoSource {
);
// System information:
let up_secs = System::uptime();
let up_days = up_secs / 86400;
let up_hours = (up_secs - (up_days * 86400)) / 3600;
let up_mins = (up_secs - (up_days * 86400) - (up_hours * 3600)) / 60;
add_sensor(sensors, "system_uptime_sec", up_secs);
/*
Time to look into ftl for i18n
The coreutils project did a lot of work that could be used:
https://github.com/uutils/coreutils/blob/main/src/uucore/src/lib/mods/locale.rs
Then this would be the easy way to format the time, just uses a lot of setup code:
uptime-format = { $days ->
[0] { $time }
[one] { $days } day, { $time }
*[other] { $days } days { $time }
}
translate!(
"uptime-format",
"days" => up_days,
"time" => format!("{up_hours:02}:{up_mins:02}")
)
*/
let day_string = match up_days {
0 => "",
1 => "1 day, ",
n => &format!("{n} days "),
};
add_sensor(
sensors,
"system_uptime",
format!("{day_string}{up_hours:02}:{up_mins:02}"),
);
if let Some(name) = System::name() {
add_sensor(sensors, "system_name", name);
}
@@ -361,8 +401,8 @@ impl SysinfoSource {
// component.label(), component.type_id(), component.id());
}
// TODO add unit as a separate sensor?
add_sensor(sensors, label, format!("{temperature:.1} °C"));
add_sensor(sensors, format!("{label}#unit"), "°C");
add_sensor(sensors, label, format!("{temperature:.1}"));
}
}
+2
View File
@@ -15,6 +15,7 @@ asterctl-lcd = { path = "../asterctl-lcd", version = "0.2.0" }
anyhow = "1.0.98"
clap = { version = "4.5.42", features = ["derive"] }
chrono = "0.4"
image = "0.25.6"
imageproc = { version = "0.25.0", default-features = false }
ab_glyph = { version = "0.2.31", default-features = false, features = ["std"] }
@@ -25,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"
+40 -1
View File
@@ -10,9 +10,11 @@ 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};
use std::collections::HashMap;
use std::io::BufReader;
use std::num::ParseIntError;
use std::ops::Deref;
@@ -118,6 +120,12 @@ pub struct MonitorConfig {
/// Internal index of the currently active panel. 1-based!
#[serde(skip)]
active_panel_idx: Option<usize>,
/// Internal sensor label mapping
#[serde(skip)]
sensor_mapping: Option<HashMap<String, String>>,
/// Internal sensor filter
#[serde(skip)]
pub sensor_filter: Option<Vec<Regex>>,
}
impl MonitorConfig {
@@ -146,10 +154,33 @@ impl MonitorConfig {
None
}
pub fn include_custom_panel(&mut self, panel: Panel) {
/// Adds a custom panel to the application and maps sensor labels if applicable.
///
/// The panel is marked active and will be returned with [get_next_active_panel] when it is its turn.
///
/// # Arguments
///
/// * `panel` - the Panel to include in the active panels.
pub fn include_custom_panel(&mut self, mut panel: Panel) {
if let Some(mapping) = &self.sensor_mapping {
panel.map_sensor_labels(mapping);
}
self.panels.push(panel);
self.active_panels.push(self.panels.len() as u32);
}
/// Apply a sensor label mapping on the included panels.
///
/// The mapping will also be applied on any custom panel added in the future with [include_custom_panel].
///
/// **Attention**: this method may only be called once at startup.
/// Dynamically changing mappings are not supported, and the original sensor labels are not preserved.
pub fn set_sensor_mapping(&mut self, mapping: HashMap<String, String>) {
for panel in self.panels.iter_mut() {
panel.map_sensor_labels(&mapping);
}
self.sensor_mapping = Some(mapping);
}
}
/// Web-app user login
@@ -278,6 +309,14 @@ impl Panel {
})
.unwrap_or_else(|| "panel".into())
}
fn map_sensor_labels(&mut self, mapping: &HashMap<String, String>) {
for sensor in self.sensor.iter_mut() {
if let Some(new_label) = mapping.get(&sensor.label) {
sensor.label = new_label.clone();
}
}
}
}
/// One Data Display Unit
+67 -16
View File
@@ -6,7 +6,7 @@
use asterctl::cfg::{MonitorConfig, Panel, load_custom_panel};
use asterctl::render::PanelRenderer;
use asterctl::sensors::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};
@@ -26,7 +27,7 @@ use std::time::{Duration, Instant};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Serial device, for example "/dev/cu.usbserial-AB0KOHLS". Takes priority over --usb option.
/// Serial device, for example, "/dev/cu.usbserial-AB0KOHLS". Takes priority over --usb option.
#[arg(short, long)]
device: Option<String>,
@@ -60,18 +61,24 @@ struct Args {
panels: Option<Vec<PathBuf>>,
/// Configuration directory containing configuration files and background images
/// specified in the `config` file. Default: `./cfg`
#[arg(long)]
config_dir: Option<PathBuf>,
/// specified in the `config` file.
#[arg(long, default_value_t = String::from("cfg"))]
config_dir: String, // default_value_t requires Display trait which PathBuf does not implement
/// Font directory for fonts specified in the `config` file. Default: `./fonts`
#[arg(long)]
font_dir: Option<PathBuf>,
/// Font directory for fonts specified in the `config` file.
#[arg(long, default_value_t = String::from("fonts"))]
font_dir: String,
/// Single sensor value input file or directory for multiple sensor input files.
/// Default: `./cfg/sensors`
#[arg(long)]
sensor_path: Option<PathBuf>,
#[arg(long, default_value_t = String::from("cfg/sensors"))]
sensor_path: String,
/// Sensor identifier mapping file. Ignored if the file does not exist.
///
/// The configuration file will be loaded from the `config_dir` directory if no full path is
/// specified.
#[arg(long, default_value_t = String::from("sensor-mapping.cfg"))]
sensor_mapping: String,
/// Switch off display n seconds after loading image or running demo.
#[arg(short, long)]
@@ -130,14 +137,17 @@ fn main() -> anyhow::Result<()> {
None
};
let cfg_dir = args.config_dir.unwrap_or_else(|| "cfg".into());
let cfg = load_configuration(&config, &cfg_dir, args.panels)?;
let cfg_dir = PathBuf::from(args.config_dir);
let font_dir = PathBuf::from(args.font_dir);
let sensor_path = PathBuf::from(args.sensor_path);
let mapping_cfg = PathBuf::from(args.sensor_mapping);
let cfg = load_configuration(&config, &cfg_dir, args.panels, &mapping_cfg)?;
run_sensor_panel(
&mut screen,
cfg,
cfg_dir,
args.font_dir.unwrap_or_else(|| "fonts".into()),
args.sensor_path.unwrap_or_else(|| "cfg/sensors".into()),
font_dir,
sensor_path,
img_save_path,
)?;
return Ok(());
@@ -166,6 +176,7 @@ fn load_configuration<P: AsRef<Path>>(
config: P,
config_dir: P,
panels: Option<Vec<PathBuf>>,
sensor_mapping: P,
) -> anyhow::Result<MonitorConfig> {
let config = config.as_ref();
let config_dir = config_dir.as_ref();
@@ -182,9 +193,45 @@ fn load_configuration<P: AsRef<Path>>(
}
}
let sensor_mapping = sensor_mapping.as_ref();
let mapping_cfg = if sensor_mapping.is_absolute() {
sensor_mapping.to_path_buf()
} else {
config_dir.join(sensor_mapping)
};
if mapping_cfg.is_file() {
let mut mapping = HashMap::new();
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<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>>(
screen: &mut AooScreen,
mut cfg: MonitorConfig,
@@ -207,7 +254,11 @@ fn run_sensor_panel<B: Into<PathBuf>>(
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);
+6
View File
@@ -7,7 +7,9 @@ use crate::cfg::{Panel, Sensor, SensorDirection, SensorMode, TextAlign};
use crate::font::FontHandler;
use crate::format_value;
use crate::img::{ImageCache, Size, rotate_image};
use crate::sensors::get_date_time_value;
use ab_glyph::Font;
use chrono::{DateTime, Local};
use image::{ImageBuffer, Rgba, RgbaImage};
use imageproc::drawing::{draw_text_mut, text_size};
use log::{debug, error};
@@ -160,6 +162,8 @@ impl PanelRenderer {
values: &HashMap<String, String>,
mut background: RgbaImage,
) -> Result<RgbaImage, ImageProcessingError> {
let now: DateTime<Local> = Local::now();
for sensor in &panel.sensor {
let value = values.get(&sensor.label).cloned();
let unit = values
@@ -170,6 +174,8 @@ impl PanelRenderer {
if let Some(value) = value {
self.render_sensor(&mut background, sensor, &value, &unit)?;
} else if let Some(value) = get_date_time_value(&sensor.label, &now) {
self.render_sensor(&mut background, sensor, &value, &unit)?;
}
}
+177 -10
View File
@@ -3,11 +3,15 @@
//! Sensor value sources.
//!
//! Only implementation is a file-based value provider with simple key-value pairs.
//! Implementations:
//! - internal date time sensors
//! - file-based value provider with simple key-value pairs.
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};
@@ -16,6 +20,46 @@ use std::path::{Path, PathBuf};
use std::process::exit;
use std::sync::{Arc, RwLock, mpsc};
pub fn get_date_time_value(label: &str, now: &DateTime<Local>) -> Option<String> {
if !label.starts_with("DATE_") {
return None;
}
let year = now.year();
let month = format!("{:02}", now.month());
let day = format!("{:02}", now.day());
let hour = format!("{:02}", now.hour());
let minute = format!("{:02}", now.minute());
let second = format!("{:02}", now.second());
// same formatting logic as in AOOSTAR-X
let value = match label {
"DATE_year" => year.to_string(),
"DATE_month" => month,
"DATE_day" => day,
"DATE_hour" => hour,
"DATE_minute" => minute,
"DATE_second" => second,
"DATE_m_d_h_m_1" => format!("{month}{day}{hour}:{minute}"),
"DATE_m_d_h_m_2" => format!("{month}/{day} {hour}:{minute}"),
"DATE_m_d_1" => format!("{month}{day}"),
"DATE_m_d_2" => format!("{month}-{day}"),
"DATE_y_m_d_1" => format!("{year}{month}{day}"),
"DATE_y_m_d_2" => format!("{year}-{month}-{day}"),
"DATE_y_m_d_3" => format!("{year}/{month}/{day}"),
"DATE_y_m_d_4" => format!("{year} {month} {day}"),
"DATE_h_m_s_1" => format!("{hour}:{minute}:{second}"),
"DATE_h_m_s_2" => format!("{hour}{minute}{second}"),
"DATE_h_m_s_3" => format!("{hour} {minute} {second}"),
"DATE_h_m_1" => format!("{hour}{minute}"),
"DATE_h_m_2" => format!("{hour} : {minute}"),
"DATE_h_m_3" => format!("{hour}:{minute}"),
_ => return None,
};
Some(value)
}
/// Read all sensor value source files from the given path and stort monitoring for changes.
///
/// The source path is either a single sensor source file or a directory containing multiple sensor
@@ -28,17 +72,19 @@ use std::sync::{Arc, RwLock, mpsc};
///
/// * `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<P: Into<PathBuf>>(
source_path: P,
values: Arc<RwLock<HashMap<String, String>>>,
sensor_filter: Option<Vec<Regex>>,
) -> 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();
@@ -54,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) {
error!("Failed to start file watcher: {e}");
exit(1);
@@ -80,7 +126,9 @@ pub fn start_file_slurper<P: Into<PathBuf>>(
debug!("Modified sensor file ({kind:?}): {path:?}");
let mut val = file_values.write().expect("Poisoned sensor RwLock");
if let Err(e) = read_from_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;
}
@@ -103,9 +151,14 @@ pub fn start_file_slurper<P: Into<PathBuf>>(
///
/// * `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<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();
if !path.try_exists()? {
@@ -113,7 +166,7 @@ fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> a
}
if path.is_file() {
return read_from_file(path, values);
return read_key_value_file(path, values, sensor_filter);
}
for entry in fs::read_dir(path)? {
@@ -121,7 +174,7 @@ fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> a
if path.is_file()
&& path.extension().unwrap_or_default() == "txt"
&& let Err(e) = read_from_file(&path, values)
&& let Err(e) = read_key_value_file(&path, values, sensor_filter)
{
warn!("Failed to read sensor file {path:?}: {e}");
}
@@ -139,13 +192,15 @@ fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> a
///
/// # Arguments
///
/// * `path`: file path to read
/// * `values`: HashMap to store read key-value pairs.
/// * `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>
fn read_from_file<P: AsRef<Path>>(
pub fn read_key_value_file<P: AsRef<Path>>(
path: P,
values: &mut HashMap<String, String>,
sensor_filter: Option<&[Regex]>,
) -> anyhow::Result<()> {
debug!("Reading sensor file {:?}", path.as_ref());
@@ -159,6 +214,13 @@ fn read_from_file<P: AsRef<Path>>(
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}");
@@ -167,3 +229,108 @@ fn read_from_file<P: AsRef<Path>>(
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}"
);
}
}
+1
View File
@@ -19,6 +19,7 @@
- [Progress Sensor](sensor/cfg/mode3_progress.md)
- [Pointer Sensor](sensor/cfg/mode4_pointer.md)
- [Sensor Value Provider](sensor/provider/README.md)
- [Internal Date Time](sensor/provider/internal_date_time.md)
- [Text File Data Source](sensor/provider/text_file.md)
- [Shell Scripts](sensor/provider/shell_scripts.md)
- [aster-sysinfo Tool](sensor/provider/sysinfo.md)
+7
View File
@@ -53,6 +53,13 @@ Options:
Single sensor value input file or directory for multiple sensor input files.
Default: `./cfg/sensors`
--sensor-mapping <SENSOR_MAPPING>
Sensor identifier mapping file. Ignored if the file does not exist.
The configuration file will be loaded from the `config_dir` directory if no full path is specified.
[default: sensor-mapping.cfg]
-o, --off-after <OFF_AFTER>
Switch off display n seconds after loading image or running demo
+50 -2
View File
@@ -15,10 +15,12 @@ Different sensor modes are supported:
## Sensor Data Sources
The sensor value reading is separated from the `asterctl` tool.
The sensor value reading is separated from the `asterctl` tool, with the exception of some internal sensors:
- Internal [date time sensors](provider/internal_date_time.md)
Sensor values are provided in separate text files and are automatically read when the file changes.
Only the file data source is supported at the moment, other sources like pipes, sockets etc. might be supported later.
Only the file data source is supported at the moment; other sources like pipes, sockets, etc. might be supported later.
- [Text file data source](provider/text_file.md)
@@ -26,3 +28,49 @@ Only the file data source is supported at the moment, other sources like pipes,
- Proof of concept [Linux shell scripts](provider/shell_scripts.md)
- [aster-sysinfo tool](provider/sysinfo.md)
### Sensor Identifier Mapping
The original AOOSTAR-X software uses very weird label identifiers (actually sometimes even a composite key depending on
the data source), which are likely based on an internal JSON structure.
To easily use original custom sensor panels with various sensor data sources, a sensor identifier mapping file can be used.
The mapping file is a simple text file with one identifier mapping per line:
- Key = label identifier used in panel definition
- Value = label identifier used in sensor providers
Example:
```
cpu_temperature: temperature_cpu
```
This maps the `temperature_cpu` sensor from the `aster-sysinfo` tool to the `cpu_temperature` sensor used in the
AOOSTAR-X panel definitions.
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.
+5
View File
@@ -1 +1,6 @@
# Sensor Value Provider
- Internal [date time sensors](internal_date_time.md)
- Proof of concept [Linux shell scripts](shell_scripts.md)
- [aster-sysinfo tool](sysinfo.md)
@@ -0,0 +1,34 @@
# Internal Date Time Sensors
## Individual Components
- `DATE_year`: `{year}`
- `DATE_month`: `{month}` with leading zero
- `DATE_day`: `{day}` with leading zero
- `DATE_hour`: `{hour}` 24h format with leading zero
- `DATE_minute`: `{minute}` with leading zero
- `DATE_second`: `{second}` with leading zero
## Month/Day with Hour/Minute
- `DATE_m_d_h_m_1`: `{month}月{day}日 {hour}:{minute}`
- `DATE_m_d_h_m_2`: `{month}/{day} {hour}:{minute}`
## Month/Day Only
- `DATE_m_d_1`: `{month}月{day}日`
- `DATE_m_d_2`: `{month}-{day}`
## Year/Month/Day
- `DATE_y_m_d_1`: `{year}年{month}月{day}日`
- `DATE_y_m_d_2`: `{year}-{month}-{day}`
- `DATE_y_m_d_3`: `{year}/{month}/{day}`
- `DATE_y_m_d_4`: `{year} {month} {day}`
## Hour/Minute/Second
- `DATE_h_m_s_1`: `{hour}:{minute}:{second}`
- `DATE_h_m_s_2`: `{hour}时{minute}分{second}秒`
- `DATE_h_m_s_3`: `{hour} {minute} {second}`
## Hour/Minute Only
- `DATE_h_m_1`: `{hour}时{minute}分`
- `DATE_h_m_2`: `{hour} : {minute}`
- `DATE_h_m_3`: `{hour}:{minute}`