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
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "asterctl"
version = "0.1.0"
description = "AOOSTAR WTR MAX Screen Control tool"
readme = "../../README.md"
rust-version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
asterctl-lcd = { path = "../asterctl-lcd", version = "0.1.0" }
anyhow = "1.0.98"
clap = { version = "4.5.42", features = ["derive"] }
image = "0.25.6"
imageproc = { version = "0.25.0", default-features = false }
ab_glyph = { version = "0.2.31", default-features = false, features = ["std"] }
log = "0.4.27"
env_logger = "0.11.8"
notify = "8.2.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
serde_repr = "0.1.20"
once_cell = "1.21.3"
[dev-dependencies]
rstest = "0.26"
+1
View File
@@ -0,0 +1 @@
../../LICENSE-APACHE
+1
View File
@@ -0,0 +1 @@
../../LICENSE-MIT
+3
View File
@@ -0,0 +1,3 @@
# Screen control tool for AOOSTAR WTR MAX / GEM12+ PRO
See [README](../../README.md) in root directory for more information.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+262
View File
@@ -0,0 +1,262 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use asterctl::cfg;
use asterctl::font::FontHandler;
use asterctl::render::PanelRenderer;
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
use ab_glyph::PxScale;
use clap::Parser;
use env_logger::Env;
use image::imageops::FilterType;
use image::{ImageReader, Rgb, RgbImage};
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut};
use log::{error, info};
use std::collections::HashMap;
use std::fs;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::thread::sleep;
use std::time::{Duration, Instant};
/// AOOSTAR WTR MAX and GEM12+ PRO screen control demo.
#[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>,
/// 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>,
/// 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>,
/// Switch off display n seconds after loading image.
#[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()?
};
info!("Loading and displaying demo...");
run_demo(
&mut screen,
args.config.as_deref(),
args.config_dir.unwrap_or_else(|| "cfg".into()),
args.font_dir.unwrap_or_else(|| "fonts".into()),
args.save,
)?;
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 run_demo(
screen: &mut AooScreen,
config: Option<&Path>,
config_dir: PathBuf,
font_dir: PathBuf,
save_images: bool,
) -> anyhow::Result<()> {
let rgb_img = demo_image()?;
// fill left and right side of the loaded image with neighboring pixel color
const WIDTH: u32 = 108;
let rgb_img = demo_blinds(screen, &rgb_img, WIDTH, save_images)?;
// print demo text over background image
demo_text(screen, &rgb_img, save_images)?;
if let Some(config) = config {
let mut cfg = if config.is_absolute() {
cfg::load_cfg(config)?
} else {
cfg::load_cfg(config_dir.join(config))?
};
if let Some(panel) = cfg.get_next_active_panel() {
info!("Displaying demo panel...");
// get sensor values from panel configuration
let mut demo_values = HashMap::new();
for sensor in &panel.sensor {
demo_values.insert(
sensor.label.clone(),
sensor.value.clone().unwrap_or_default(),
);
}
let mut renderer = PanelRenderer::new(DISPLAY_SIZE, &font_dir, &config_dir);
renderer.set_save_render_img(save_images);
renderer.set_save_processed_pic(save_images);
renderer.set_save_progress_layer(save_images);
match renderer.render(panel, &demo_values) {
Ok(image) => screen.send_image(&image)?,
Err(e) => error!("Error rendering panel '{}': {e:?}", panel.friendly_name()),
}
} else {
error!("No active panel found");
}
}
Ok(())
}
fn demo_image() -> anyhow::Result<RgbImage> {
let reader = ImageReader::new(Cursor::new(include_bytes!("aybabtu.png")))
.with_guessed_format()
.expect("Cursor io never fails");
Ok(reader
.decode()?
.resize_exact(DISPLAY_SIZE.0, DISPLAY_SIZE.1, FilterType::Lanczos3)
.to_rgb8())
}
fn demo_text(
screen: &mut AooScreen,
background: &RgbImage,
save_images: bool,
) -> anyhow::Result<()> {
let text = "ALL YOUR BASE ARE BELONG TO US.";
let text_upd_delay = Duration::from_millis(0);
let font = FontHandler::default_font();
let height = 36.0;
let scale = PxScale {
x: height,
y: height,
};
if save_images {
fs::create_dir_all("out")?;
}
for text_idx in 0..text.len() {
info!("Printing: {}", &text[0..text_idx + 1]);
let text_upd = Instant::now();
let mut rgb_img = background.clone();
draw_text_mut(
&mut rgb_img,
Rgb([118u8, 118u8, 97u8]),
4 * 47,
300,
scale,
&font,
&text[0..text_idx + 1],
);
if save_images {
rgb_img.save_with_format(
format!("out/demo_text-{text_idx}.png"),
image::ImageFormat::Png,
)?;
}
screen.send_image(&rgb_img)?;
let elapsed = text_upd.elapsed();
if elapsed < text_upd_delay {
sleep(text_upd_delay - elapsed);
}
}
Ok(())
}
// CPU intensive! Release build is ~ 5x faster on M1 Max
fn demo_blinds(
screen: &mut AooScreen,
background: &RgbImage,
width: u32,
save_images: bool,
) -> anyhow::Result<RgbImage> {
let mut rgb_img = background.clone();
info!("Masking {width} pixels of left & right image...");
if save_images {
fs::create_dir_all("out")?;
}
for y in 0..DISPLAY_SIZE.1 {
let color = *rgb_img.get_pixel(width + 1, y);
draw_line_segment_mut(
&mut rgb_img,
(0.0, y as f32),
(width as f32, y as f32),
color,
);
let color = *rgb_img.get_pixel(DISPLAY_SIZE.0 - width - 1, y);
draw_line_segment_mut(
&mut rgb_img,
((DISPLAY_SIZE.0 - width) as f32, y as f32),
(DISPLAY_SIZE.0 as f32, y as f32),
color,
);
if y % 5 == 0 {
screen.send_image(&rgb_img)?;
}
if save_images {
rgb_img
.save_with_format(format!("out/demo_blinds-{y}.png"), image::ImageFormat::Png)?;
}
}
screen.send_image(&rgb_img)?;
Ok(rgb_img)
}
+584
View File
@@ -0,0 +1,584 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! AOOSTAR-X json configuration file format.
//!
//! Derived from the available Monitor3.json file in AOOSTAR-X v1.3.4.
//! Likely not fully compatible with files created with the original editor.
use anyhow::Context;
use image::{Rgb, Rgba};
use imageproc::definitions::HasWhite;
use log::{info, warn};
use serde::de::Visitor;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_repr::{Deserialize_repr, Serialize_repr};
use std::io::BufReader;
use std::num::ParseIntError;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::{fmt, fs};
pub fn load_cfg<P: AsRef<Path>>(path: P) -> anyhow::Result<MonitorConfig> {
let path = path.as_ref();
let file = fs::File::open(path).with_context(|| format!("Failed to load config {path:?}"))?;
let reader = BufReader::new(file);
let config: MonitorConfig = serde_json::from_reader(reader)?;
for active in config.active_panels.clone() {
if active == 0 || active > config.panels.len() as u32 {
warn!("Ignoring invalid active panel {active}");
continue;
}
let panel = &config.panels[active as usize - 1];
println!(
"Panel {active}: {}",
panel.img.as_deref().unwrap_or_default()
);
for sensor in &panel.sensor {
println!(
" {}: {} {} {}",
sensor.label,
sensor
.name
.as_deref()
.or(sensor.item_name.as_deref())
.unwrap_or_default(),
sensor.value.as_deref().unwrap_or_default(),
sensor.unit.as_deref().unwrap_or_default()
);
}
}
Ok(config)
}
/// Load a custom panel configuration.
///
/// The distributed panel ZIP file must be extracted and contain:
/// - `panel.json` configuration file
/// - `img` subdirectory containing the referenced images in panel.json
/// - `fonts` subdirectory containing the referenced fonts in panel.json
///
/// # Arguments
///
/// * `path`: directory path of the extracted custom panel.
///
/// returns: Result<Panel, Error>
pub fn load_custom_panel<P: AsRef<Path>>(path: P) -> anyhow::Result<Panel> {
let path = path.as_ref();
let panel_file = path.join("panel.json");
info!("Loading custom panel {panel_file:?}");
let file = fs::File::open(&panel_file)
.with_context(|| format!("Failed to load custom panel {panel_file:?}"))?;
let reader = BufReader::new(file);
let mut panel: Panel = serde_json::from_reader(reader)?;
// adjust font and image file paths
let img_path = fs::canonicalize(path.join("img"))?;
let font_path = fs::canonicalize(path.join("fonts"))?;
if let Some(img) = &panel.img
&& !Path::new(img).is_absolute()
{
panel.img = Some(img_path.join(img).display().to_string());
}
for sensor in panel.sensor.iter_mut() {
if let Some(pic) = &sensor.pic
&& !Path::new(pic).is_absolute()
{
sensor.pic = Some(img_path.join(pic).display().to_string());
}
if let Some(font_family) = &sensor.font_family
&& !font_family.is_empty()
&& !Path::new(&font_family).is_absolute()
{
sensor.font_family = Some(font_path.join(font_family).display().to_string());
}
}
Ok(panel)
}
/// AOOSTAR-X monitor json configuration file
#[derive(Debug, Serialize, Deserialize)]
pub struct MonitorConfig {
// _Not used_
// pub credentials: Option<Credentials>,
/// Configuration settings.
pub setup: Setup,
/// Panels: 1-based index into `panels`
#[serde(rename = "mianban")]
pub active_panels: Vec<u32>,
/// Custom panels / DIY "Do It Yourself",
#[serde(rename = "diy")]
pub panels: Vec<Panel>,
/// Internal index of the currently active panel. 1-based!
#[serde(skip)]
active_panel_idx: Option<usize>,
}
impl MonitorConfig {
pub fn get_next_active_panel(&mut self) -> Option<&Panel> {
let mut active_panel_idx = self.active_panel_idx.unwrap_or(0) + 1;
if active_panel_idx > self.panels.len() {
active_panel_idx = 1;
}
for (index, active) in self
.active_panels
.iter()
.filter(|&active| *active > 0)
.enumerate()
{
if *active > self.panels.len() as u32 {
warn!("Ignoring invalid active panel {active}");
continue;
}
if index + 1 == active_panel_idx {
self.active_panel_idx = Some(active_panel_idx);
return Some(&self.panels[*active as usize - 1]);
}
}
None
}
pub fn include_custom_panel(&mut self, panel: Panel) {
self.panels.push(panel);
self.active_panels.push(self.panels.len() as u32);
}
}
/// Web-app user login
///
/// Not used, part of AOOSTAR-X json configuration file.
#[derive(Debug, Serialize, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
/// Configuration settings.
///
/// Note: Trimmed down object to include only required fields for `asterctl`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Setup {
/// Switch time between panels in seconds, interpreted as float and converted to milliseconds. Default: 5
pub switch_time: Option<String>, // existed as "30" string
/// Panel redraw interval in seconds. Default: 1
pub refresh: f32,
/*
// The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl`
/// Default: true
pub off_display: bool,
/// Selection of default panels based on theme / control_params / control_disk_temp ?
pub theme: i32,
/// ? Default: true
pub control_params: bool,
/// ? Default: true
pub control_disk_temp: bool,
/// Default: false
pub custom_panel: bool,
/// Language index. Default: 0
pub language: Language,
/// Operation mode: performance, power saving, etc.
pub operation_mode: Option<OperationMode>,
/// Operation type 1 or 2 (?). Default: 1
#[serde(rename = "type")]
pub operation_type: Option<i16>,
/// Default: 300
pub disk_update: i32,
/// Home Assistant URL
#[serde(deserialize_with = "empty_string_as_none")]
#[serde(rename = "ha_url")]
pub ha_url: Option<String>, // "" in JSON ⇒ Option<String>
/// Home Assistant long-lived access token
#[serde(deserialize_with = "empty_string_as_none")]
#[serde(rename = "ha_token")]
pub ha_token: Option<String>, // "" in JSON ⇒ Option<String>
*/
}
/// Language setting.
///
/// Not used, part of AOOSTAR-X json configuration file.
#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)]
#[repr(u8)]
#[allow(dead_code)]
pub enum Language {
Chinese = 0,
English = 1,
Japanese = 2,
}
/// Not used, part of AOOSTAR-X json configuration file.
#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)]
#[repr(i16)]
#[allow(dead_code)]
pub enum OperationMode {
None = -1,
HighPerformance = 0,
Intelligent = 1,
PowerSaving = 2,
Custom30W = 3,
Custom20W = 4,
Custom10W = 5,
}
#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)]
#[repr(u8)]
pub enum SensorDirection {
/// Also used for clockwise in circular/arc progress & rotating pointer/dial indicator
LeftToRight = 1,
/// Also used for counter-clockwise in circular/arc & rotating pointer/dial progress indicator
RightToLeft = 2,
TopToBottom = 3,
BottomToTop = 4,
}
/// Custom DIY panel definition
#[derive(Debug, Serialize, Deserialize)]
pub struct Panel {
/// Custom panel id
pub id: Option<String>,
/// Custom panel name
pub name: Option<String>,
/*
// The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl`
/// TODO
pub checked: Option<bool>,
/// TODO panel type: 5 = built-in? 6 = custom ?
#[serde(rename = "type")]
pub panel_type: i32,
*/
/// Background image filename
pub img: Option<String>,
/// Sensors
pub sensor: Vec<Sensor>,
}
impl Panel {
pub fn friendly_name(&self) -> String {
self.name
.clone()
.or_else(|| self.id.clone())
.or_else(|| {
if let Some(img_file) = &self.img {
let img_file = PathBuf::from(img_file);
img_file
.file_stem()
.map(|s| s.to_string_lossy().to_string())
} else {
None
}
})
.unwrap_or_else(|| "panel".into())
}
}
/// One Data Display Unit
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Sensor {
/// Sensor mode: text, fan, progress, pointer
pub mode: SensorMode,
/// Sensor type, _not used_.
/// - 1 Time / Date Labels
/// - 2 Windows-specific system info
/// - 3 Hardware value
/// - 4 AIDA64 sensor
/// - 5 HA sensor
/// - 6 http fetch from url
/// - 7 system info ?
/// - 8 lm-sensor ?
#[serde(rename = "type")]
pub sensor_type: Option<i32>,
/// Label name for internal panels.
pub name: Option<String>,
/// Label name for custom panels.
pub item_name: Option<String>,
/// Label identifier, also used as data source identifier.
pub label: String,
/// Sensor value. Ignored: value is used from a sensor source
#[serde(deserialize_with = "empty_string_as_none")]
pub value: Option<String>, // "" or numbers, so Option<String>
/// Image for progress, fan and pointer indicators
pub min_value: Option<f32>,
/// Image for progress, fan and pointer indicators
pub max_value: Option<f32>,
/// Optional unit text to print after the value
#[serde(deserialize_with = "empty_string_as_none")]
pub unit: Option<String>,
/// Rounded x-position. Custom panel coordinates are stored as float!
#[serde(deserialize_with = "f32_as_rounded_i32")]
pub x: i32,
/// Rounded y-position. Custom panel coordinates are stored as float!
#[serde(deserialize_with = "f32_as_rounded_i32")]
pub y: i32,
/// Used for pointer type
pub width: Option<u32>,
/// Used for pointer type
pub height: Option<u32>,
/// Sensor graphic orientation
pub direction: Option<SensorDirection>,
/// Font name matching font filename without file extension.
pub font_family: Option<String>,
/// TODO font size unit: points or pixels?
pub font_size: Option<i32>,
/// Font color in `#RRGGBB` notation, or -1 if not set. #ffffff = white, #ff0000 = red
pub font_color: Option<FontColor>,
/// _Not (yet) used_
pub font_weight: Option<FontWeight>,
pub text_align: Option<TextAlign>,
/// Number of integer places for the sensor value.
// -1 ≈ unset ⇒ Option<i32>
#[serde(deserialize_with = "option_none_if_minus_one")]
pub integer_digits: Option<i32>,
/// Number of decimal places for the sensor value.
// -1 ≈ unset ⇒ Option<i32>
#[serde(deserialize_with = "option_none_if_minus_one")]
pub decimal_digits: Option<i32>,
/// Image for progress, fan and pointer indicators
#[serde(deserialize_with = "empty_string_as_none")]
pub pic: Option<String>,
/// Used for fan & pointer sensors
pub min_angle: Option<i32>,
/// Used for fan & pointer sensors
pub max_angle: Option<i32>,
/// Pivot x
#[serde(rename = "xz_x")]
pub xz_x: Option<i32>,
/// Pivot y
#[serde(rename = "xz_y")]
pub xz_y: Option<i32>,
/*
// The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl`
/// _Not (yet) used_
pub text_direction: i32, // layout direction
/// For type = 6
pub url: Option<String>,
/// For type = 6
pub data: Option<String>,
/// For type = 6
pub interval: Option<u32>,
*/
}
/// Sensor element type. Name is based on AOOSTAR-X web configuration
#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, Eq, Hash, PartialEq)]
#[repr(u8)]
pub enum SensorMode {
/// Text element
Text = 1,
/// Circular/arc progress indicator
Fan = 2,
/// Horizontal or vertical progress indicator
Progress = 3,
/// Rotating pointer/dial indicator
Pointer = 4,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum TimeDateLabel {
#[serde(rename = "DATE_year")]
Year,
#[serde(rename = "DATE_month")]
Month,
#[serde(rename = "DATE_day")]
Day,
#[serde(rename = "DATE_hour")]
Hour,
#[serde(rename = "DATE_minute")]
Minute,
#[serde(rename = "DATE_second")]
Second,
#[serde(rename = "DATE_m_d_h_m_1")]
MDHM1,
#[serde(rename = "DATE_m_d_h_m_2")]
MDHM2,
#[serde(rename = "DATE_m_d_1")]
MD1,
#[serde(rename = "DATE_m_d_2")]
MD2,
#[serde(rename = "DATE_y_m_d_1")]
YMD1,
#[serde(rename = "DATE_y_m_d_2")]
YMD2,
#[serde(rename = "DATE_y_m_d_3")]
YMD3,
#[serde(rename = "DATE_y_m_d_4")]
YMD4,
#[serde(rename = "DATE_h_m_s_1")]
HMS1,
#[serde(rename = "DATE_h_m_s_2")]
HMS2,
#[serde(rename = "DATE_h_m_s_3")]
HMS3,
#[serde(rename = "DATE_h_m_1")]
HM1,
#[serde(rename = "DATE_h_m_2")]
HM2,
#[serde(rename = "DATE_h_m_3")]
HM3,
}
#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FontWeight {
#[default]
Normal,
Bold,
}
#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TextAlign {
#[default]
Left,
Center,
Right,
}
fn option_none_if_minus_one<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<i32>::deserialize(deserializer)? {
Some(-1) | None => Ok(None),
Some(other) => Ok(Some(other)),
}
}
/// Special font color type since it is represented either as numeric -1 or as a string :-(
///
/// A good serde programming exercise...
#[derive(Debug, Clone, Copy)]
pub struct FontColor(Rgb<u8>);
impl Default for FontColor {
fn default() -> Self {
FontColor(Rgb::white())
}
}
impl Deref for FontColor {
type Target = Rgb<u8>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl TryFrom<&str> for FontColor {
type Error = ParseIntError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
if value.len() != 7 || !value.starts_with('#') {
warn!("Invalid font color: {value}");
Ok(FontColor::default())
} else {
Ok(FontColor(Rgb([
u8::from_str_radix(&value[1..3], 16)?,
u8::from_str_radix(&value[3..5], 16)?,
u8::from_str_radix(&value[5..7], 16)?,
])))
}
}
}
impl From<Rgb<u8>> for FontColor {
fn from(value: Rgb<u8>) -> Self {
FontColor(value)
}
}
impl From<FontColor> for Rgb<u8> {
fn from(val: FontColor) -> Self {
val.0
}
}
impl From<FontColor> for Rgba<u8> {
fn from(val: FontColor) -> Self {
Rgba([val.0[0], val.0[1], val.0[2], 255])
}
}
impl Serialize for FontColor {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
format!("#{:02x}{:02x}{:02x}", self.0[0], self.0[1], self.0[2]).serialize(serializer)
}
}
impl<'de> Deserialize<'de> for FontColor {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct MyVisitor;
impl<'de> Visitor<'de> for MyVisitor {
type Value = FontColor;
fn expecting(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt.write_str("integer or string")
}
fn visit_i64<E>(self, val: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match val {
-1 => Ok(FontColor::default()),
_ => Err(E::custom("invalid integer value, expected -1")),
}
}
fn visit_str<E>(self, val: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if val.trim().is_empty() {
return Ok(FontColor::default());
}
match val.parse::<i32>() {
Ok(val) => self.visit_i32(val),
Err(_) => val
.try_into()
.map_err(|e| E::custom(format!("invalid font color value: {e}"))),
}
}
}
deserializer.deserialize_any(MyVisitor)
}
}
fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let option = Option::<String>::deserialize(deserializer)?;
Ok(option.and_then(|s| if s.trim().is_empty() { None } else { Some(s) }))
}
fn f32_as_rounded_i32<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
let rounded = f32::deserialize(deserializer).map(f32::round)?;
Ok(rounded as i32)
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Font handling and caching.
use ab_glyph::{FontArc, FontRef, FontVec};
use anyhow::{Context, anyhow};
use log::warn;
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
static DEFAULT_TTF_FONT: Lazy<FontArc> = Lazy::new(|| {
FontArc::new(
FontRef::try_from_slice(include_bytes!("../../../fonts/DejaVuSans.ttf"))
.expect("Failed to load default font"),
)
});
pub struct FontHandler {
ttf_path: PathBuf,
ttf_cache: HashMap<String, FontArc>,
}
impl FontHandler {
pub fn new(ttf_path: impl Into<PathBuf>) -> Self {
Self {
ttf_path: ttf_path.into(),
ttf_cache: Default::default(),
}
}
pub fn default_font() -> FontArc {
DEFAULT_TTF_FONT.clone()
}
pub fn get_ttf_font_or_default(&mut self, name: &str) -> FontArc {
self.get_ttf_font(name).unwrap_or_else(|e| {
warn!("Failed to load font: {e}. Using default");
FontHandler::default_font()
})
}
pub fn get_ttf_font(&mut self, name: &str) -> anyhow::Result<FontArc> {
if let Some(font) = self.ttf_cache.get(name) {
return Ok(font.clone());
}
let mut path = self.ttf_path.join(name);
path.set_extension("ttf");
if !path.exists() {
return Err(anyhow!("{name}.ttf not found"));
}
let data = fs::read(path).with_context(|| format!("Error reading font {name}.ttf"))?;
let font = FontArc::new(
FontVec::try_from_vec(data)
.with_context(|| format!("Error parsing font {name}.ttf"))?,
);
self.ttf_cache.insert(name.to_string(), font.clone());
Ok(font)
}
#[allow(dead_code)]
pub fn clear(&mut self) {
self.ttf_cache.clear();
}
}
+200
View File
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Sensor value format functions based on the AOOSTAR-X application.
#[derive(Debug, Clone)]
pub enum IntegerDigits {
/// Keep all integer digits
Auto, // -1 in Python
/// Only keep the integer part of a decimal number
Zero, // 0 in Python
/// Limit integer part of a decimal number to the given length
Fixed(usize), // positive values
}
impl From<i32> for IntegerDigits {
fn from(value: i32) -> Self {
match value {
-1 => IntegerDigits::Auto,
0 => IntegerDigits::Zero,
n if n > 0 => IntegerDigits::Fixed(n as usize),
_ => IntegerDigits::Auto,
}
}
}
impl From<Option<i32>> for IntegerDigits {
fn from(value: Option<i32>) -> Self {
match value {
None => IntegerDigits::Auto,
Some(digits) => IntegerDigits::from(digits),
}
}
}
/// Format a sensor value in string format to the specified fixed point number.
///
/// # Arguments
///
/// * `value`: decimal number to format
/// * `integer_digits`: number of integer places
/// * `decimal_digits`: fixed point numbers
/// * `unit`: unit suffix to append after the formatted number
///
/// returns: String
///
/// # Examples
///
/// ```
/// let value = asterctl::format_value("123.456", asterctl::IntegerDigits::Auto, 0, "foobar");
/// assert_eq!(value, "123foobar");
/// ```
pub fn format_value(
value: &str,
integer_digits: IntegerDigits,
decimal_digits: usize,
unit: &str,
) -> String {
let num = match value.parse::<f64>() {
Ok(n) => n,
Err(_) => return format!("{}{}", value, unit),
};
// Round number to the specified decimal digits
let factor = 10f64.powi(decimal_digits as i32);
let rounded = if decimal_digits == 0 {
num.round()
} else {
(num * factor).round() / factor
};
// Get integer and decimal parts
// The integer part may increase due to rounding!
let integer_part = rounded.trunc() as i64;
let decimal_part = if decimal_digits > 0 {
let mut dec = (rounded.fract().abs() * factor).round() as u64;
// Handle cases where rounding makes the decimal part equal to factor
if dec == factor as u64 {
// e.g. 9.999 rounded to 1 decimal = 10.0
// We set decimal part to 0
dec = 0;
}
format!("{:0width$}", dec, width = decimal_digits)
} else {
"".to_string()
};
// Format integer part according to padding rules
let integer_str = integer_part.to_string();
let integer_filled = match integer_digits {
IntegerDigits::Auto => integer_str.clone(),
IntegerDigits::Zero => "".to_string(),
IntegerDigits::Fixed(digits) => {
if integer_str.len() > digits {
"9".repeat(digits)
} else {
format!("{:0width$}", integer_part, width = digits)
}
}
};
let formatted = if decimal_digits > 0 {
format!("{}.{}", integer_filled, decimal_part)
} else {
integer_filled
};
format!("{}{}", formatted, unit)
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case(5, 2, "00123.46°C")]
#[case(5, 1, "00123.5°C")]
#[case(5, 0, "00123°C")]
#[case(-1, 2, "123.46°C")]
#[case(-1, 1, "123.5°C")]
#[case(-1, 0, "123°C")]
#[case(2, 0, "99°C")]
fn test_format_value_with_decimal(
#[case] digits: i32,
#[case] decimals: usize,
#[case] output: &str,
) {
let result = format_value("123.456", IntegerDigits::from(digits), decimals, "°C");
assert_eq!(output, result);
}
#[rstest]
#[case(5, 2, "00123.00°C")]
#[case(5, 1, "00123.0°C")]
#[case(5, 0, "00123°C")]
#[case(-1, 2, "123.00°C")]
#[case(-1, 1, "123.0°C")]
#[case(-1, 0, "123°C")]
#[case(2, 0, "99°C")]
fn test_format_value_with_integer(
#[case] digits: i32,
#[case] decimals: usize,
#[case] output: &str,
) {
let result = format_value("123", IntegerDigits::from(digits), decimals, "°C");
assert_eq!(output, result);
}
#[rstest]
#[case(5, 2, "-0123.00°C")]
#[case(5, 0, "-0123°C")]
#[case(-1, 2, "-123.00°C")]
#[case(-1, 1, "-123.0°C")]
#[case(-1, 0, "-123°C")]
#[case(2, 0, "99°C")] // Overflow
fn test_format_value_with_negative_integer(
#[case] digits: i32,
#[case] decimals: usize,
#[case] output: &str,
) {
let result = format_value("-123", IntegerDigits::from(digits), decimals, "°C");
assert_eq!(output, result);
}
#[rstest]
#[case("0", 3, 1, "V", "000.0V")]
#[case("999.99", 2, 1, "%", "99.0%")]
#[case("invalid", 2, 2, "unit", "invalidunit")]
fn test_format_value_edge_cases(
#[case] input: &str,
#[case] digits: i32,
#[case] decimals: usize,
#[case] unit: &str,
#[case] output: &str,
) {
let result = format_value(input, IntegerDigits::from(digits), decimals, unit);
assert_eq!(output, result);
}
#[rstest]
#[case("1.999", 2, 1, "", "02.0")]
#[case("1.999", 2, 0, "", "02")]
#[case("1.999", 1, 1, "", "2.0")]
#[case("1.999", -1, 1, "", "2.0")]
#[case("0.999", 1, 2, "", "1.00")]
#[case("0.999", 1, 1, "", "1.0")]
#[case("0.999", 1, 0, "", "1")]
#[case("123.6", -1, 0, "", "124")]
fn test_format_value_rounding(
#[case] input: &str,
#[case] digits: i32,
#[case] decimals: usize,
#[case] unit: &str,
#[case] output: &str,
) {
let result = format_value(input, IntegerDigits::from(digits), decimals, unit);
assert_eq!(output, result);
}
}
+175
View File
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Image helper functions.
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView, ImageBuffer, ImageReader, Rgba, RgbaImage};
use imageproc::geometric_transformations::{Interpolation, rotate};
use log::{debug, warn};
use std::collections::HashMap;
use std::f32::consts::PI;
use std::path::{Path, PathBuf};
/// Width, height type
pub type Size = (u32, u32);
pub fn load_image<P>(path: P, size: Option<Size>) -> anyhow::Result<DynamicImage>
where
P: AsRef<Path>,
{
let img = ImageReader::open(path)?.decode()?;
debug!(
"Image dimensions: {:?}, {:?}",
img.dimensions(),
img.color()
);
if let Some(size) = size
&& img.dimensions() != size
{
warn!(
"Resizing invalid image dimensions {:?} to expected size {:?}, ignoring aspect ratio",
img.dimensions(),
size
);
Ok(img.resize_exact(size.0, size.1, FilterType::Lanczos3))
} else {
Ok(img)
}
}
/// Cache for loaded images to avoid repeated file I/O
pub struct ImageCache {
img_path: PathBuf,
cache: HashMap<PathBuf, Option<RgbaImage>>,
}
impl ImageCache {
pub fn new(img_path: impl Into<PathBuf>) -> Self {
Self {
img_path: img_path.into(),
cache: HashMap::new(),
}
}
/// Load and cache an image, returns None if loading fails
pub fn get<P: AsRef<Path>>(&mut self, path: P, size: Option<Size>) -> Option<&RgbaImage> {
let path = path.as_ref();
let path = if path.is_absolute() {
path.to_path_buf()
} else {
self.img_path.join(path)
};
if !self.cache.contains_key(&path) {
let image_result = match load_image(&path, size) {
Ok(img) => Some(img.to_rgba8()),
Err(e) => {
warn!("Failed to load image {:?}: {:?}", path, e);
None
}
};
self.cache.insert(path.clone(), image_result);
}
self.cache.get(&path).and_then(|opt| opt.as_ref())
}
#[allow(dead_code)]
pub fn clear(&mut self) {
self.cache.clear();
}
}
/// Quality settings for rotation
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub enum RotationQuality {
/// Nearest neighbor
Fast,
/// Bilinear
Good,
/// Bicubic
Best,
}
/// Rotate image by specified angle in degrees
pub fn rotate_image(image: &RgbaImage, angle_degrees: i32) -> RgbaImage {
match angle_degrees {
0 => image.clone(),
90 => rotate_90_degrees(image, true),
270 => rotate_90_degrees(image, false),
180 => rotate_180_degrees(image),
angle => {
let angle_radians = angle as f32 * PI / 180.0;
// TODO check Bilinear vs Bicubic
rotate_about_center(image, angle_radians, RotationQuality::Good)
}
}
}
/// Rotate image about its center, maintaining original dimensions
fn rotate_about_center(
image: &RgbaImage,
angle_radians: f32,
interpolation: RotationQuality,
) -> RgbaImage {
let (width, height) = image.dimensions();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let interp_method = match interpolation {
RotationQuality::Fast => Interpolation::Nearest,
RotationQuality::Good => Interpolation::Bilinear,
RotationQuality::Best => Interpolation::Bicubic,
};
rotate(
image,
(center_x, center_y),
angle_radians,
interp_method,
Rgba([0, 0, 0, 0]), // Transparent background for areas outside original image
)
}
/// Fast 90-degree rotations (optimized for common cases)
pub fn rotate_90_degrees(image: &RgbaImage, clockwise: bool) -> RgbaImage {
let (width, height) = image.dimensions();
let mut rotated = ImageBuffer::new(height, width); // Swap dimensions
if clockwise {
for y in 0..height {
for x in 0..width {
let pixel = *image.get_pixel(x, y);
rotated.put_pixel(height - 1 - y, x, pixel);
}
}
} else {
for y in 0..height {
for x in 0..width {
let pixel = *image.get_pixel(x, y);
rotated.put_pixel(y, width - 1 - x, pixel);
}
}
}
rotated
}
/// Rotate by 180 degrees (optimized)
pub fn rotate_180_degrees(image: &RgbaImage) -> RgbaImage {
let (width, height) = image.dimensions();
let mut rotated = ImageBuffer::new(width, height);
for y in 0..height {
for x in 0..width {
let pixel = *image.get_pixel(x, y);
rotated.put_pixel(width - 1 - x, height - 1 - y, pixel);
}
}
rotated
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
#![forbid(non_ascii_idents)]
#![deny(unsafe_code)]
pub mod cfg;
pub mod font;
mod format_value;
pub mod img;
pub mod render;
pub mod sensors;
pub use format_value::*;
+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(())
}
+721
View File
@@ -0,0 +1,721 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Sensor panel rendering logic. Create an RGBa image from a panel configuration and sensor values.
use crate::cfg::{Panel, Sensor, SensorDirection, SensorMode, TextAlign};
use crate::font::FontHandler;
use crate::format_value;
use crate::img::{ImageCache, Size, rotate_image};
use ab_glyph::Font;
use image::{ImageBuffer, Rgba, RgbaImage};
use imageproc::drawing::{draw_text_mut, text_size};
use log::{debug, error};
use std::collections::HashMap;
use std::f32::consts::PI;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
/// Error type for image processing operations
#[derive(Debug)]
#[allow(dead_code)]
pub enum ImageProcessingError {
ImageLoadError(String),
InvalidMode(i32),
InvalidDirection(SensorDirection),
MathError(String),
IoError(std::io::Error),
}
impl From<std::io::Error> for ImageProcessingError {
fn from(error: std::io::Error) -> Self {
ImageProcessingError::IoError(error)
}
}
/// Sensor panel renderer.
///
/// Renders a final display image from a sensor panel configuration and current sensor values.
/// All defined fonts and images of a sensor panel are cached after first use.
pub struct PanelRenderer {
size: Size,
composite_layer_map: HashMap<SensorMode, RgbaImage>,
font_handler: FontHandler,
image_cache: ImageCache,
// for debugging: save images for inspection
save_render_img: bool,
save_processed_pic: bool,
save_progress_layer: bool,
img_save_path: PathBuf,
img_suffix: Option<String>,
}
impl PanelRenderer {
/// Create a new image processor instance for a given display size.
///
/// # Arguments
///
/// * `size`: display size, used to render a panel image.
/// * `font_dir`: font directory to load TTF fonts specified in a sensor configuration.
/// * `img_dir`: image directory to load background and sensor images from.
///
/// returns: PanelRenderer
pub fn new(size: Size, font_dir: impl Into<PathBuf>, img_dir: impl Into<PathBuf>) -> Self {
Self {
size,
composite_layer_map: HashMap::new(),
font_handler: FontHandler::new(font_dir),
image_cache: ImageCache::new(img_dir),
save_render_img: false,
save_processed_pic: false,
save_progress_layer: false,
img_save_path: PathBuf::from("out"),
img_suffix: None,
}
}
/// For debugging: save rendered panel image as .PNG graphic for inspection.
pub fn set_save_render_img(&mut self, save: bool) {
self.save_render_img = save;
self.create_img_save_path();
}
/// For debugging: save all processed sensor pic images as .PNG graphics for inspection.
pub fn set_save_processed_pic(&mut self, save: bool) {
self.save_processed_pic = save;
self.create_img_save_path();
}
/// For debugging: save all progress layer images as .PNG graphics for inspection.
pub fn set_save_progress_layer(&mut self, save: bool) {
self.save_progress_layer = save;
self.create_img_save_path();
}
/// Set output directory path for saving images.
///
/// Default output directory is `./out` in the current working directory.
pub fn set_img_save_path(&mut self, img_dir: impl Into<PathBuf>) {
self.img_save_path = img_dir.into();
self.create_img_save_path();
}
/// Set an optional image name suffix for saving a .PNG graphic file.
///
/// This function needs to be called before [render()] if a different suffix should be used for each rendered panel.
pub fn set_img_suffix(&mut self, img_suffix: impl Into<String>) {
self.img_suffix = Some(img_suffix.into());
}
/// Render a sensor panel with the given values and return the final panel image.
///
/// # Arguments
///
/// * `panel`: the panel configuration
/// * `values`: current values for the defined panel sensors in a shared HashMap
///
/// returns: a rendered panel image in [RgbaImage] format, or an [ImageProcessingError] in case of an error.
pub fn render(
&mut self,
panel: &Panel,
values: &HashMap<String, String>,
) -> Result<RgbaImage, ImageProcessingError> {
debug!(
"Rendering panel {}...",
panel
.name
.as_deref()
.unwrap_or_else(|| panel.id.as_deref().unwrap_or_default())
);
let now = Instant::now();
let background = if let Some(img) = &panel.img
&& let Some(background) = self.image_cache.get(img, Some(self.size))
{
background.clone()
} else {
RgbaImage::new(self.size.0, self.size.1)
};
self.composite_layer_map.clear();
let final_image = self.render_all_sensors(panel, values, background)?;
debug!("Rendered panel in {}ms", now.elapsed().as_millis());
if self.save_render_img {
let name = format!(
"render_{}{}.png",
panel.friendly_name(),
self.img_suffix.as_deref().unwrap_or_default()
);
if let Err(e) = final_image.save(self.img_save_path.join(name)) {
error!("Error saving rendered panel image: {e}");
}
}
Ok(final_image)
}
/// Render all panel sensors with the given values on a background image
pub fn render_all_sensors(
&mut self,
panel: &Panel,
values: &HashMap<String, String>,
mut background: RgbaImage,
) -> Result<RgbaImage, ImageProcessingError> {
for sensor in &panel.sensor {
let value = values.get(&sensor.label).cloned();
let unit = values
.get(&format!("{}#unit", sensor.label))
.cloned()
.or_else(|| sensor.unit.clone())
.unwrap_or_default();
if let Some(value) = value {
self.render_sensor(&mut background, sensor, &value, &unit)?;
}
}
// Final compositing
self.composite_layers(&mut background);
Ok(background)
}
/// Render a single sensor element based on its mode
fn render_sensor(
&mut self,
background: &mut RgbaImage,
sensor: &Sensor,
value: &str,
unit: &str,
) -> Result<(), ImageProcessingError> {
let direction = sensor.direction.unwrap_or(SensorDirection::LeftToRight);
match sensor.mode {
SensorMode::Text => self.render_text(background, sensor, value, unit),
SensorMode::Fan => self.render_fan(sensor, value, direction),
SensorMode::Progress => self.render_progress(sensor, value, direction),
SensorMode::Pointer => self.render_pointer(sensor, value, direction),
}
}
/// Mode 1 - Text
fn render_text(
&mut self,
background: &mut RgbaImage,
sensor: &Sensor,
value: &str,
unit: &str,
) -> Result<(), ImageProcessingError> {
let font = if let Some(font_family) = &sensor.font_family {
self.font_handler.get_ttf_font_or_default(font_family)
} else {
FontHandler::default_font()
};
let font_size = sensor.font_size.unwrap_or(14) as f32;
// TODO verify pixel scaling! Is font_size point size or pixel size?
// This is still a bit off compared to the original AOOSTAR-X. Only tested with HarmonyOS_Sans_SC_Bold!
let adjustment_hack = 0.7;
let scale = font.pt_to_px_scale(font_size * adjustment_hack).unwrap();
let text = format_value(
value,
sensor.integer_digits.into(),
sensor.decimal_digits.unwrap_or_default() as usize,
unit,
);
let size = text_size(scale, &font, &text);
// TODO verify x & y-coordinate handling
let x = match sensor.text_align.unwrap_or_default() {
TextAlign::Left => sensor.x,
TextAlign::Center => sensor.x - (size.0 / 2) as i32,
TextAlign::Right => sensor.x - size.0 as i32,
};
let y = (sensor.y as f32 - scale.y / 2f32) as i32;
// let y = sensor.y as i32 - (size.1 / 2) as i32;
debug!(
"Sensor({:03},{:03}), pixel({x:03},{y:03}), size{size:?}: {text}",
sensor.x, sensor.y
);
let font_color = sensor.font_color.unwrap_or_default().into();
draw_text_mut(background, font_color, x, y, scale, &font, &text);
Ok(())
}
/// Mode 2 - Circular/Arc progress indicator
/// TODO needs testing
fn render_fan(
&mut self,
sensor: &Sensor,
value: &str,
direction: SensorDirection,
) -> Result<(), ImageProcessingError> {
if !matches!(
direction,
SensorDirection::LeftToRight | SensorDirection::RightToLeft
) {
return Err(ImageProcessingError::InvalidDirection(direction));
}
let pos_x = sensor.x;
let pos_y = sensor.y;
let pic_path = sensor.pic.as_ref().ok_or_else(|| {
ImageProcessingError::ImageLoadError("No picture specified".to_string())
})?;
let target_image = self
.image_cache
.get(pic_path, None)
.ok_or_else(|| {
ImageProcessingError::ImageLoadError(format!("Failed to load: {:?}", pic_path))
})?
.clone();
let min_angle = sensor.min_angle.unwrap_or(0) as f32;
let max_angle = sensor.max_angle.unwrap_or(180) as f32;
let min_value = sensor.min_value.unwrap_or(0.0);
let max_value = sensor.max_value.unwrap_or(100.0);
let current_value = value
.parse::<f32>()
.map_err(|_| ImageProcessingError::MathError("Invalid value".to_string()))?;
if current_value <= min_value {
return Ok(());
}
let progress = if current_value >= max_value {
1.0
} else {
(current_value - min_value) / (max_value - min_value)
};
let (start_angle, end_angle) = if direction == SensorDirection::LeftToRight {
// Clockwise
let start = min_angle - 90.0;
let end = min_angle + (max_angle - min_angle) * progress - 90.0;
(start, end)
} else {
// Counter-clockwise
// FIXME SensorDirection::RightToLeft does not yet work. Might also be related to certain minAngle / maxAngle values
let start = 360.0 - min_angle - (max_angle - min_angle) * progress - 90.0;
let end = 360.0 - min_angle - 90.0;
(start, end)
};
if let Some(sector_layer) = self.get_layer(SensorMode::Fan) {
PanelRenderer::draw_pie_slice(
sector_layer,
&target_image,
pos_x,
pos_y,
start_angle,
end_angle,
);
}
Ok(())
}
/// Mode 3 - render progress graphic based on percentage value.
///
/// The progress graphic must show the 100% value and is cut based on the actual value.
fn render_progress(
&mut self,
sensor: &Sensor,
value: &str,
direction: SensorDirection,
) -> Result<(), ImageProcessingError> {
let pic_path = sensor.pic.as_ref().ok_or_else(|| {
ImageProcessingError::ImageLoadError("No picture specified".to_string())
})?;
let mut processed_img = self
.image_cache
.get(pic_path, None)
.ok_or_else(|| {
ImageProcessingError::ImageLoadError(format!("Failed to load: {:?}", pic_path))
})?
.clone();
let min_val = sensor.min_value.unwrap_or(0.0);
let max_val = sensor.max_value.unwrap_or(100.0);
let current_value = value
.parse::<f32>()
.map_err(|_| ImageProcessingError::MathError("Invalid value".to_string()))?;
let clamped_value = current_value.clamp(min_val, max_val);
let progress = ((clamped_value - min_val) / (max_val - min_val)).clamp(0.0, 1.0);
let (img_w, img_h) = processed_img.dimensions();
// Create progress mask based on direction
let crop_rect = match direction {
SensorDirection::LeftToRight => {
let crop_w = (img_w as f32 * progress).round() as u32;
(0, 0, crop_w, img_h)
}
SensorDirection::RightToLeft => {
let crop_w = (img_w as f32 * progress).round() as u32;
(img_w - crop_w, 0, img_w, img_h)
}
SensorDirection::TopToBottom => {
let crop_h = (img_h as f32 * progress).round() as u32;
(0, 0, img_w, crop_h)
}
SensorDirection::BottomToTop => {
let crop_h = (img_h as f32 * progress).round() as u32;
(0, img_h - crop_h, img_w, img_h)
}
};
// Apply crop mask to image
self.apply_progress_mask(&mut processed_img, crop_rect, direction);
if self.save_processed_pic {
let name = format!(
"processed_img-{}{}.png",
sensor.label,
self.img_suffix.as_deref().unwrap_or_default()
);
if let Err(e) = processed_img.save(self.img_save_path.join(name)) {
error!("Error saving processed image: {e}");
}
}
let pos_x = sensor.x;
let pos_y = sensor.y;
if let Some(progress_layer) = self.get_layer(SensorMode::Progress) {
PanelRenderer::paste_image(progress_layer, &processed_img, pos_x, pos_y);
if self.save_progress_layer {
let name = format!(
"progress_layer-{}{}.png",
sensor.label,
self.img_suffix.as_deref().unwrap_or_default()
);
if let Err(e) = processed_img.save(self.img_save_path.join(name)) {
error!("Error saving progress layer image: {e}");
}
}
}
Ok(())
}
/// Mode 4 - Rotating pointer/dial indicator
/// TODO needs testing
fn render_pointer(
&mut self,
sensor: &Sensor,
value: &str,
direction: SensorDirection,
) -> Result<(), ImageProcessingError> {
if !matches!(
direction,
SensorDirection::LeftToRight | SensorDirection::RightToLeft
) {
return Err(ImageProcessingError::InvalidDirection(direction));
}
let x_center = sensor.x;
let y_center = sensor.y;
let xz_x = sensor.xz_x.unwrap_or(0);
let xz_y = sensor.xz_y.unwrap_or(0);
let pic_path = sensor.pic.as_ref().ok_or_else(|| {
ImageProcessingError::ImageLoadError("No picture specified".to_string())
})?;
// Resize if dimensions specified
let size = if let (Some(width), Some(height)) = (sensor.width, sensor.height) {
Some((width, height))
} else {
None
};
let pic = self
.image_cache
.get(pic_path, size)
.ok_or_else(|| {
ImageProcessingError::ImageLoadError(format!("Failed to load: {:?}", pic_path))
})?
.clone();
let min_val = sensor.min_value.unwrap_or(0.0);
let max_val = sensor.max_value.unwrap_or(100.0);
let current_value = value
.parse::<f32>()
.map_err(|_| ImageProcessingError::MathError("Invalid value".to_string()))?;
let clamped_value = current_value.clamp(min_val, max_val);
// Calculate progress
let progress = if (max_val - min_val).abs() < f32::EPSILON {
0.0
} else {
(clamped_value - min_val) / (max_val - min_val)
};
let mut min_angle = sensor.min_angle.unwrap_or(0) as f32;
let mut max_angle = sensor.max_angle.unwrap_or(360) as f32;
// Adjust angles for counter-clockwise
if direction == SensorDirection::RightToLeft {
min_angle = -min_angle;
max_angle = -max_angle;
}
let angle = min_angle + progress * (max_angle - min_angle);
let angle_rad = angle.to_radians();
// Calculate offset based on rotation
let offset_x = (xz_x as f32 * angle_rad.cos() - xz_y as f32 * angle_rad.sin()) as i32;
let offset_y = (xz_x as f32 * angle_rad.sin() + xz_y as f32 * angle_rad.cos()) as i32;
// Rotate the image
let angle = angle.round() as i32;
let rotated_pic = rotate_image(&pic, -angle);
// Calculate final position
let final_x = x_center + offset_x - (rotated_pic.width() / 2) as i32;
let final_y = y_center + offset_y - (rotated_pic.height() / 2) as i32;
if let Some(pointer_layer) = self.get_layer(SensorMode::Pointer) {
PanelRenderer::paste_image(pointer_layer, &rotated_pic, final_x, final_y);
}
Ok(())
}
/// Draws a pieslice sector of `source` into `layer`, centered at (center_x, center_y),
/// from `start_deg` to `end_deg` (both in degrees), blending with alpha.
fn draw_pie_slice(
layer: &mut RgbaImage,
source: &RgbaImage,
center_x: i32,
center_y: i32,
start_deg: f32,
end_deg: f32,
) {
let (src_w, src_h) = source.dimensions();
// Radius is half the smaller dimension
let radius = (src_w.min(src_h) as f32) / 2.0;
// Convert angles to radians and normalize
let start = start_deg.to_radians();
let end = end_deg.to_radians();
// Helper: check if angle t is between start and end (clockwise)
let in_sector = |t: f32| {
let mut a = t;
if a < 0.0 {
a += 2.0 * PI;
}
let mut s = start;
let mut e = end;
if s < 0.0 {
s += 2.0 * PI;
}
if e < 0.0 {
e += 2.0 * PI;
}
if e < s {
// wrap
a >= s || a <= e
} else {
a >= s && a <= e
}
};
for sy in 0..src_h {
for sx in 0..src_w {
// Coordinates relative to center of source
let dx = sx as f32 - src_w as f32 / 2.0;
let dy = sy as f32 - src_h as f32 / 2.0;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= radius {
// Polar angle (atan2 returns [-PI, PI], 0 at +x axis)
let angle = dy.atan2(dx);
if in_sector(angle) {
// Pixel is inside the slice: blend it into layer
let dest_x = center_x + sx as i32 - src_w as i32 / 2;
let dest_y = center_y + sy as i32 - src_h as i32 / 2;
if dest_x >= 0 && dest_y >= 0 {
let (lw, lh) = layer.dimensions();
if (dest_x as u32) < lw && (dest_y as u32) < lh {
let src_px = source.get_pixel(sx, sy);
let dst_px = layer.get_pixel_mut(dest_x as u32, dest_y as u32);
// alphablend: out = src.a*src + (1src.a)*dst
let alpha = src_px[3] as f32 / 255.0;
for i in 0..3 {
dst_px[i] = ((src_px[i] as f32 * alpha)
+ (dst_px[i] as f32 * (1.0 - alpha)))
.round()
as u8;
}
for i in 0..4 {
dst_px[i] = ((src_px[i] as f32 * alpha)
+ (dst_px[i] as f32 * (1.0 - alpha)))
.round()
as u8;
}
}
}
}
}
}
}
}
/// Apply progress mask to image based on crop rectangle and direction
fn apply_progress_mask(
&self,
image: &mut RgbaImage,
crop_rect: (u32, u32, u32, u32),
direction: SensorDirection,
) {
let (crop_x, crop_y, crop_w, crop_h) = crop_rect;
let (img_w, img_h) = image.dimensions();
// Create mask - set alpha to 0 outside crop area
for y in 0..img_h {
for x in 0..img_w {
let should_keep = match direction {
SensorDirection::LeftToRight => x < crop_w,
SensorDirection::RightToLeft => x >= crop_x,
SensorDirection::TopToBottom => y < crop_h,
SensorDirection::BottomToTop => y >= crop_y,
};
if !should_keep {
let pixel = image.get_pixel_mut(x, y);
pixel[3] = 0; // Set alpha to 0 (transparent)
}
}
}
}
/// Paste an image onto another image at specified position
fn paste_image(target: &mut RgbaImage, source: &RgbaImage, x: i32, y: i32) {
let (target_w, target_h) = target.dimensions();
let (source_w, source_h) = source.dimensions();
for sy in 0..source_h {
for sx in 0..source_w {
let target_x = x + sx as i32;
let target_y = y + sy as i32;
if target_x >= 0
&& target_y >= 0
&& (target_x as u32) < target_w
&& (target_y as u32) < target_h
{
let source_pixel = *source.get_pixel(sx, sy);
let target_pixel = target.get_pixel_mut(target_x as u32, target_y as u32);
// Alpha blending
let alpha = source_pixel[3] as f32 / 255.0;
let inv_alpha = 1.0 - alpha;
for i in 0..3 {
target_pixel[i] = ((source_pixel[i] as f32 * alpha)
+ (target_pixel[i] as f32 * inv_alpha))
as u8;
}
target_pixel[3] = ((source_pixel[3] as f32 * alpha)
+ (target_pixel[3] as f32 * inv_alpha))
as u8;
}
}
}
}
fn create_img_save_path(&mut self) {
if (self.save_render_img || self.save_processed_pic || self.save_progress_layer)
&& let Err(e) = fs::create_dir_all(&self.img_save_path)
{
error!(
"Error creating image output path {:?}: {e}",
self.img_save_path
);
}
}
fn get_layer(&mut self, mode: SensorMode) -> Option<&mut RgbaImage> {
if !self.composite_layer_map.contains_key(&mode) {
self.composite_layer_map.insert(mode, self.create_layer());
}
self.composite_layer_map.get_mut(&mode)
}
/// Create an overlay image buffer with the same dimensions as the panel
fn create_layer(&self) -> RgbaImage {
ImageBuffer::from_fn(self.size.0, self.size.1, |_, _| Rgba([0, 0, 0, 0]))
}
/// Composite all layers into final image
fn composite_layers(&mut self, background: &mut RgbaImage) {
// quick and dirty, this should be an ordered enum variant list
let modes = [SensorMode::Fan, SensorMode::Progress, SensorMode::Pointer];
for mode in modes {
if let Some(layer) = self.composite_layer_map.get(&mode) {
// Find bounding box of non-transparent pixels
let bbox = PanelRenderer::get_bounding_box(layer);
if let Some((min_x, min_y, max_x, max_y)) = bbox {
// Composite the layer onto final image
for y in min_y..=max_y {
for x in min_x..=max_x {
let layer_pixel = *layer.get_pixel(x, y);
if layer_pixel[3] > 0 {
// If not fully transparent
let final_pixel = background.get_pixel_mut(x, y);
// Alpha compositing
let alpha = layer_pixel[3] as f32 / 255.0;
let inv_alpha = 1.0 - alpha;
for i in 0..4 {
final_pixel[i] = ((layer_pixel[i] as f32 * alpha)
+ (final_pixel[i] as f32 * inv_alpha))
as u8;
}
}
}
}
}
}
}
}
/// Get bounding box of non-transparent pixels
fn get_bounding_box(image: &RgbaImage) -> Option<(u32, u32, u32, u32)> {
let (width, height) = image.dimensions();
let mut min_x = width;
let mut min_y = height;
let mut max_x = 0;
let mut max_y = 0;
let mut found_pixel = false;
for y in 0..height {
for x in 0..width {
let pixel = image.get_pixel(x, y);
if pixel[3] > 0 {
// Non-transparent
found_pixel = true;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
}
if found_pixel {
Some((min_x, min_y, max_x, max_y))
} else {
None
}
}
}
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Sensor value sources.
//!
//! Only implementation is a file-based value provider with simple key-value pairs.
use log::{debug, error, info, warn};
use notify::event::{ModifyKind, RenameMode};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::ops::DerefMut;
use std::path::{Path, PathBuf};
use std::process::exit;
use std::sync::{Arc, RwLock, mpsc};
/// 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
/// source files.
///
/// The source path is monitored for changes in a separate thread.
/// All updated files are automatically read and stored in the shared HashMap.
///
/// # Arguments
///
/// * `source_path`: Single source file path or a directory path.
/// * `values`: a shared, reader-writer lock protected HashMap
///
/// returns: Result<(), Error>
pub fn start_file_slurper<P: Into<PathBuf>>(
source_path: P,
values: Arc<RwLock<HashMap<String, String>>>,
) -> 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())?;
}
let file_values = values.clone();
std::thread::spawn(move || {
// watch sensor file/directory for changes
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
let mut watcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(e) => {
error!("Failed to initialize watcher: {e}");
exit(1);
}
};
info!("Starting sensor file watcher for {dir_path:?}");
if let Err(e) = watcher.watch(&dir_path, RecursiveMode::NonRecursive) {
error!("Failed to start file watcher: {e}");
exit(1);
}
// Block forever, printing out events as they come in
for res in rx {
let event = match res {
Ok(event) => event,
Err(e) => {
warn!("watch error: {e:?}");
continue;
}
};
match event.kind {
EventKind::Modify(kind)
if matches!(kind, ModifyKind::Data(_) | ModifyKind::Name(RenameMode::To)) =>
{
for path in event.paths.iter() {
if path.extension().unwrap_or_default() != "txt" {
continue;
}
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()) {
warn!("Failed to read sensor file {path:?}: {e}");
continue;
}
}
}
_ => {
// just for debugging
debug!("Watch event {:?}: {:?}", event.kind, event.paths);
}
}
}
});
Ok(())
}
/// Read a single key-value-based source file or all source file for a given directory path.
///
/// # Arguments
///
/// * `path`: Single source file path or a directory path.
/// * `values`: HashMap to store all read key-value pairs.
///
/// returns: Result<(), Error>
fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> anyhow::Result<()> {
let path = path.as_ref();
if !path.try_exists()? {
return Ok(());
}
if path.is_file() {
return read_from_file(path, values);
}
for entry in fs::read_dir(path)? {
let path = entry?.path();
if path.is_file()
&& path.extension().unwrap_or_default() == "txt"
&& let Err(e) = read_from_file(&path, values)
{
warn!("Failed to read sensor file {path:?}: {e}");
}
}
Ok(())
}
/// Read a key-value-based sensor source file and store content in the provided hashmap.
///
/// - Empty lines are skipped
/// - Lines starting with # are skipped
/// - Key-value pairs must be separated by `:`
/// - All keys and values are trimmed
///
/// # Arguments
///
/// * `path`: file path to read
/// * `values`: HashMap to store read key-value pairs.
///
/// returns: Result<(), Error>
fn read_from_file<P: AsRef<Path>>(
path: P,
values: &mut HashMap<String, String>,
) -> anyhow::Result<()> {
debug!("Reading sensor file {:?}", path.as_ref());
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;
}
if let Some((key, value)) = line.split_once(':') {
values.insert(key.trim().to_string(), value.trim().to_string());
} else {
warn!("Skipping invalid entry in sensor value file: {line}");
}
}
Ok(())
}