refactor: project structure (#9)
Split up project into multiple crates and use a Cargo workspace.
This commit is contained in:
@@ -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"
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-APACHE
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-MIT
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 pie‐slice 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);
|
||||
// alpha‐blend: out = src.a*src + (1−src.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user