Files
aoostar-rs/crates/asterctl/src/main.rs
T
Markus Zehnder 9af5deb204 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.
2025-09-17 21:29:36 +02:00

325 lines
9.8 KiB
Rust

// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
#![forbid(non_ascii_idents)]
#![deny(unsafe_code)]
use asterctl::cfg::{MonitorConfig, Panel, load_custom_panel};
use asterctl::render::PanelRenderer;
use asterctl::sensors::{read_filter_file, read_key_value_file, start_file_slurper};
use asterctl::{cfg, img};
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
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};
use std::str::FromStr;
use std::sync::{Arc, RwLock};
use std::thread::sleep;
use std::time::{Duration, Instant};
/// AOOSTAR WTR MAX and GEM12+ PRO screen control.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Serial device, for example, "/dev/cu.usbserial-AB0KOHLS". Takes priority over --usb option.
#[arg(short, long)]
device: Option<String>,
/// USB serial UART "vid:pid" in hex notation (lsusb output). Default: 416:90A1
#[arg(short, long)]
usb: Option<String>,
/// Switch display on and exit. This will show the last displayed image.
#[arg(long)]
on: bool,
/// Switch display off and exit.
#[arg(long)]
off: bool,
/// Image to display, other sizes than 960x376 will be scaled.
#[arg(short, long)]
image: Option<String>,
/// AOOSTAR-X json configuration file to parse.
///
/// The configuration file will be loaded from the `config_dir` directory if no full path is
/// specified.
#[arg(short, long)]
config: Option<PathBuf>,
/// Include one or more additional custom panels into the base configuration.
///
/// Specify the path to the panel directory containing panel.json and fonts / img subdirectories.
#[arg(short, long)]
panels: Option<Vec<PathBuf>>,
/// Configuration directory containing configuration files and background images
/// 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.
#[arg(long, default_value_t = String::from("fonts"))]
font_dir: String,
/// Single sensor value input file or directory for multiple sensor input files.
#[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)]
off_after: Option<u32>,
/// Test mode: only write to the display without checking response.
#[arg(short, long)]
write_only: bool,
/// Test mode: save changed images in ./out folder.
#[arg(short, long)]
save: bool,
/// Simulate serial port for testing and development, `--device` and `--usb` options are ignored.
#[arg(long)]
simulate: bool,
}
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let args = Args::parse();
// initialize display with given UART port parameter
let mut builder = AooScreenBuilder::new();
builder.no_init_check(args.write_only);
let mut screen = if args.simulate {
builder.simulate()?
} else if let Some(device) = args.device {
builder.open_device(&device)?
} else if let Some(usb) = args.usb {
builder.open_usb_id(&usb)?
} else {
builder.open_default()?
};
// process simple commands
if args.off {
screen.off()?;
return Ok(());
} else if args.on {
screen.on()?;
return Ok(());
}
// switch on screen for remaining commands
screen.init()?;
if let Some(config) = args.config {
info!("Starting sensor panel mode");
let img_save_path = if args.save {
let img_save_path = PathBuf::from("out");
fs::create_dir_all(&img_save_path)?;
Some(img_save_path)
} else {
None
};
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,
font_dir,
sensor_path,
img_save_path,
)?;
return Ok(());
}
if let Some(image) = args.image {
info!("Loading and displaying background image {image}...");
let rgb_img = img::load_image(&image, Some(DISPLAY_SIZE))?.to_rgb8();
let timestamp = Instant::now();
screen.send_image(&rgb_img)?;
debug!("Image sent in {}ms", timestamp.elapsed().as_millis());
}
if let Some(off) = args.off_after {
info!("Switching off display in {off}s");
sleep(Duration::from_secs(off as u64));
screen.off()?;
}
info!("Bye bye!");
Ok(())
}
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();
let mut cfg = if config.is_absolute() {
cfg::load_cfg(config)?
} else {
cfg::load_cfg(config_dir.join(config))?
};
if let Some(panels) = panels {
for panel in panels {
cfg.include_custom_panel(load_custom_panel(panel)?);
}
}
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,
config_dir: B,
font_dir: B,
sensor_path: B,
img_save_path: Option<B>,
) -> anyhow::Result<()> {
let font_dir = font_dir.into();
let config_dir = config_dir.into();
let img_save_path = img_save_path.map(|p| p.into());
let mut renderer = PanelRenderer::new(DISPLAY_SIZE, &font_dir, &config_dir);
if let Some(img_save_path) = &img_save_path {
renderer.set_img_save_path(img_save_path);
renderer.set_save_render_img(true);
// renderer.set_save_processed_pic(true);
// renderer.set_save_progress_layer(true);
}
let sensor_values: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new()));
start_file_slurper(
sensor_path,
sensor_values.clone(),
cfg.sensor_filter.clone(),
)?;
let refresh = Duration::from_millis((cfg.setup.refresh * 1000f32) as u64);
let switch_time = cfg
.setup
.switch_time
.as_deref()
.and_then(|v| f32::from_str(v).ok())
.map(|v| Duration::from_millis((v * 1000.0) as u64))
.unwrap_or(Duration::from_secs(5));
// panel switching loop
loop {
let panel = cfg
.get_next_active_panel()
.ok_or(anyhow!("No active panel"))?;
info!("Switching panel: {}", panel.friendly_name());
let panel_switch_time = Instant::now();
// active panel refresh loop
let mut refresh_count = 1;
loop {
let upd_start_time = Instant::now();
if img_save_path.is_some() {
renderer.set_img_suffix(format!("-{refresh_count:02}"));
}
// Keeping the read lock during panel rendering should be ok, otherwise we could always clone the HashMap
let values = sensor_values.read().expect("RwLock is poisoned");
update_panel(screen, &mut renderer, panel, &values)?;
drop(values);
let elapsed = upd_start_time.elapsed();
if refresh > elapsed {
sleep(refresh - elapsed);
}
if panel_switch_time.elapsed() >= switch_time {
break;
}
refresh_count += 1;
}
}
}
fn update_panel(
screen: &mut AooScreen,
renderer: &mut PanelRenderer,
panel: &Panel,
values: &HashMap<String, String>,
) -> anyhow::Result<()> {
debug!("Displaying panel '{}'...", panel.friendly_name());
match renderer.render(panel, values) {
Ok(image) => screen.send_image(&image)?,
Err(e) => error!("Error rendering panel '{}': {e:?}", panel.friendly_name()),
}
Ok(())
}