refactor: project structure (#9)

Split up project into multiple crates and use a Cargo workspace.
This commit is contained in:
Markus Zehnder
2025-08-28 09:03:30 +02:00
committed by GitHub
parent f0128197d9
commit d98cd89c48
71 changed files with 672 additions and 401 deletions
+273
View File
@@ -0,0 +1,273 @@
// 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::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 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. Default: `./cfg`
#[arg(long)]
config_dir: Option<PathBuf>,
/// Font directory for fonts specified in the `config` file. Default: `./fonts`
#[arg(long)]
font_dir: Option<PathBuf>,
/// Single sensor value input file or directory for multiple sensor input files.
/// Default: `./cfg/sensors`
#[arg(long)]
sensor_path: Option<PathBuf>,
/// 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 = args.config_dir.unwrap_or_else(|| "cfg".into());
let cfg = load_configuration(&config, &cfg_dir, args.panels)?;
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()),
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>>,
) -> 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)?);
}
}
Ok(cfg)
}
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())?;
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(())
}