// 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, /// USB serial UART "vid:pid" in hex notation (lsusb output). Default: 416:90A1 #[arg(short, long)] usb: Option, /// 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, /// 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, /// 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>, /// 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, /// 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>( config: P, config_dir: P, panels: Option>, sensor_mapping: P, ) -> anyhow::Result { 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>> { 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, config_dir: B, font_dir: B, sensor_path: B, img_save_path: Option, ) -> 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>> = 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, ) -> 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(()) }