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
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "asterctl-lcd"
version = "0.1.0"
description = "AOOSTAR WTR MAX / GEM12+ PRO screen protocol"
rust-version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
anyhow = "1.0.98"
bytes = "1.10.1"
# TODO make image an optional feature
image = "0.25.6"
log = "0.4.27"
serialport = "4.7.3"
+1
View File
@@ -0,0 +1 @@
../../LICENSE-APACHE
+1
View File
@@ -0,0 +1 @@
../../LICENSE-MIT
+17
View File
@@ -0,0 +1,17 @@
# AOOSTAR WTR MAX / GEM12+ PRO UART Screen Protocol
Reverse engineered [AOOSTAR WTR MAX](https://aoostar.com/products/aoostar-wtr-max-amd-r7-pro-8845hs-11-bays-mini-pc)
UART display protocol, written in Rust.
This project should also support the GEM12+ PRO device.
- [LCD Protocol](../../docs/lcd_protocol.md)
- See [README](../../README.md) for more information about the `asterctl` screen control tool.
## Display Information
- **Resolution:** 960 × 376
- **Manufacturer:** Synwit
- **Connected over USB UART** with a proprietary serial communication protocol:
- **USB device ID:** `416:90A1` (as shown by `lsusb`)
- **Linux device (example on Debian):** `/dev/ttyACM0`
- **1,500,000 baud**, 8N1 (likely ignored; actual USB transfer speed is much higher)
+302
View File
@@ -0,0 +1,302 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use crate::FakeSerialPort;
use crate::ToRgb565;
use anyhow::{Context, anyhow};
use bytes::{BufMut, BytesMut};
use log::{debug, error, info, warn};
use serialport::{SerialPort, SerialPortType};
use std::io::{Read, Write};
use std::thread::sleep;
use std::time::{Duration, Instant};
pub const DISPLAY_SIZE: (u32, u32) = (960, 376);
const SERIAL_RETRY: u8 = 3;
const UART_BAUDRATE: u32 = 1_500_000;
const USB_UART_VID: u16 = 0x416;
const USB_UART_PID: u16 = 0x90A1;
const IMG_CHUNK_SIZE: usize = 47;
static DISPLAY_OFF: [u8; 8] = [0xAA, 0x55, 0xAA, 0x55, 0x0A, 0x00, 0x00, 0x00];
static DISPLAY_ON: [u8; 8] = [0xAA, 0x55, 0xAA, 0x55, 0x0B, 0x00, 0x00, 0x00];
static HEADER_START: [u8; 16] = [
0xAA, 0x55, 0xAA, 0x55, 0x05, 0x00, 0x00, 0x00, 0x04, 0x00, 0x0F, 0x2F, 0x00, 0x04, 0x0B, 0x00,
];
static HEADER_END: [u8; 8] = [0xAA, 0x55, 0xAA, 0x55, 0x06, 0x00, 0x00, 0x00];
static HEADER: [u8; 8] = [0xAA, 0x55, 0xAA, 0x55, 0x08, 0x00, 0x00, 0x00];
#[derive(Default)]
pub struct AooScreenBuilder {
timeout: Option<Duration>,
enable_cache: Option<bool>,
no_init_check: Option<bool>,
}
#[allow(dead_code)]
impl AooScreenBuilder {
pub fn new() -> Self {
Self::default()
}
/// Set the amount of time to wait to receive data before timing out. Defaults to 1 sec.
pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
self.timeout = Some(timeout);
self
}
/// Cache previous frame sent to display for future diff updates. Enabled by default.
pub fn enable_cache(&mut self, enable: bool) -> &mut Self {
self.enable_cache = Some(enable);
self
}
/// Disable LCD initialization check and only write data to the display. Defaults to false.
pub fn no_init_check(&mut self, no_check: bool) -> &mut Self {
self.no_init_check = Some(no_check);
self
}
/// Open the default AOOSTAR LCD USB UART device 416:90A1.
pub fn open_default(self) -> anyhow::Result<AooScreen> {
self.open_usb(USB_UART_VID, USB_UART_PID)
}
/// Simulate the LCD device. No real device or serial port is required.
pub fn simulate(self) -> anyhow::Result<AooScreen> {
Ok(AooScreen {
port: Some(Box::new(FakeSerialPort::new())),
enable_cache: self.enable_cache.unwrap_or(true),
prev_frame: None,
no_init_check: self.no_init_check.unwrap_or(false),
})
}
/// Open the specified USB UART device id. Format: vid:pid
pub fn open_usb_id(self, id: &str) -> anyhow::Result<AooScreen> {
let (vid, pid) = id
.split_once(':')
.with_context(|| "Error parsing serial port ID. Expected `vid:pid` format.")?;
self.open_usb(u16::from_str_radix(vid, 16)?, u16::from_str_radix(pid, 16)?)
}
/// Open the specified USB UART
pub fn open_usb(self, vid: u16, pid: u16) -> anyhow::Result<AooScreen> {
let serial_dev = find_usb_serial_port(vid, pid)?;
self.open_device(&serial_dev)
}
/// Open the specified serial device
pub fn open_device(self, device: &str) -> anyhow::Result<AooScreen> {
let port = serialport::new(device, UART_BAUDRATE)
.timeout(self.timeout.unwrap_or(Duration::from_millis(1000)))
.open()
.with_context(|| format!("Error opening serial port: {device}"))?;
info!(
"Opened serial port {device}: baud={}, {}:{}:{}",
port.baud_rate()?,
port.data_bits()?,
port.parity()?,
port.stop_bits()?
);
Ok(AooScreen {
port: Some(port),
enable_cache: self.enable_cache.unwrap_or(true),
prev_frame: None,
no_init_check: self.no_init_check.unwrap_or(false),
})
}
}
pub struct AooScreen {
port: Option<Box<dyn SerialPort>>,
enable_cache: bool,
prev_frame: Option<BytesMut>,
no_init_check: bool,
}
#[allow(dead_code)]
impl AooScreen {
pub fn init(&mut self) -> anyhow::Result<()> {
let port = self.port.as_mut().ok_or(anyhow!("LCD port not open"))?;
port.write(&DISPLAY_ON)
.with_context(|| "Error sending display on command")?;
if self.no_init_check {
warn!("Test mode: only writing to the display");
} else {
// quick and dirty response check as in the original app
sleep(Duration::from_secs(1));
let available = port
.bytes_to_read()
.with_context(|| "Failed to get available bytes from serial port")?;
if available == 0 {
return Err(anyhow!("Initialization failed, no response received"));
}
let mut serial_buf: Vec<u8> = vec![0; available as usize];
port.read(serial_buf.as_mut_slice())
.with_context(|| "Failed to read from serial port")?;
let marker = b'A';
if !serial_buf.contains(&marker) {
return Err(anyhow!(
"Initialization failed, received: {}",
String::from_utf8_lossy(&serial_buf)
));
}
}
info!("Display initialized!");
Ok(())
}
pub fn close(&mut self) {
if self.port.is_some() {
if let Err(e) = self.off() {
warn!("Failed to close display: {e}");
}
self.port = None;
}
}
pub fn on(&mut self) -> anyhow::Result<()> {
self.send(&DISPLAY_ON)
.with_context(|| "Failed to send display on")
}
pub fn off(&mut self) -> anyhow::Result<()> {
self.send(&DISPLAY_OFF)
.with_context(|| "Failed to send display off")
}
pub fn send_image(&mut self, image: impl ToRgb565) -> anyhow::Result<()> {
let img_rgb565 = image.to_rgb565_le();
debug!(
"Start sending image (size {}) {} cache... ",
img_rgb565.len(),
if self.enable_cache && self.prev_frame.is_some() {
"with"
} else {
"without"
}
);
let start_time = Instant::now();
self.send(&HEADER_START)
.with_context(|| "Failed to send header start")?;
let mut buf = BytesMut::with_capacity(HEADER.len() + 4 + IMG_CHUNK_SIZE);
let mut sent_chunks = 0;
for (idx, chunk) in img_rgb565.chunks(IMG_CHUNK_SIZE).enumerate() {
let offset = idx * IMG_CHUNK_SIZE;
if self.enable_cache
&& let Some(cache) = self.prev_frame.as_mut()
{
let offset = idx * IMG_CHUNK_SIZE;
if offset + IMG_CHUNK_SIZE <= cache.len()
&& cache[offset..offset + IMG_CHUNK_SIZE].eq(chunk)
{
// Block is unchanged from the previous frame; skip sending
continue;
}
}
buf.clear();
buf.extend(&HEADER);
buf.put_u32_le(offset as u32);
buf.extend(chunk);
self.send(&buf)
.with_context(|| format!("Failed to send image data chunk {idx}"))?;
sent_chunks += 1;
}
self.send(&HEADER_END)
.with_context(|| "Failed to send header end")?;
if self.enable_cache {
self.prev_frame.replace(img_rgb565);
}
debug!(
"Image sent: {}ms, {sent_chunks} chunks",
start_time.elapsed().as_millis()
);
Ok(())
}
pub fn enable_cache(&mut self, enable: bool) {
self.enable_cache = enable;
if !enable {
self.clear_cache();
}
}
pub fn is_cache_enabled(&self) -> bool {
self.enable_cache
}
pub fn clear_cache(&mut self) {
self.prev_frame = None;
}
fn send(&mut self, data: &[u8]) -> anyhow::Result<()> {
// TODO not sure if retry logic is required. Need a real device to test...
let mut retry = 0;
let port = self.port.as_mut().ok_or(anyhow!("LCD port not open"))?;
loop {
return match port.write_all(data) {
Ok(()) => {
port.flush()?;
Ok(())
}
Err(e) => {
debug!(
"Bytes queued to send: {}",
port.bytes_to_write()
.with_context(|| "Error calling bytes_to_write")?
);
if retry < SERIAL_RETRY {
warn!("Failed to write to display, retrying! Error: {e}");
retry += 1;
continue;
}
error!("Failed to write to display: {e}");
Err(e.into())
}
};
}
}
}
pub fn find_usb_serial_port(vid: u16, pid: u16) -> serialport::Result<String> {
info!("Looking for USB serial port {vid:x}:{pid:x}");
let ports = serialport::available_ports()?;
for p in ports {
debug!("Found serial port: {}", p.port_name);
if let SerialPortType::UsbPort(info) = p.port_type
&& info.pid == pid
&& info.vid == vid
{
return Ok(p.port_name);
}
}
Err(serialport::Error::new(
serialport::ErrorKind::NoDevice,
format!("USB serial port {vid:x}:{pid:x} not found"),
))
}
+164
View File
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use serialport::{ClearBuffer, DataBits, FlowControl, Parity, SerialPort, StopBits};
use std::thread::sleep;
use std::time::Duration;
pub struct FakeSerialPort {
baud_rate: u32,
data_bits: DataBits,
flow_control: FlowControl,
parity: Parity,
stop_bits: StopBits,
timeout: Duration,
}
impl Default for FakeSerialPort {
fn default() -> Self {
Self::new()
}
}
impl FakeSerialPort {
pub fn new() -> FakeSerialPort {
Self {
baud_rate: 1_500_000,
data_bits: DataBits::Eight,
flow_control: FlowControl::None,
parity: Parity::None,
stop_bits: StopBits::One,
timeout: Default::default(),
}
}
}
impl std::io::Read for FakeSerialPort {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
buf[0] = b'A';
Ok(1)
}
}
impl std::io::Write for FakeSerialPort {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
// just some approximation, additional overhead like flushing etc is not considered
let byte_rate =
self.baud_rate / (1 + u8::from(self.data_bits) + u8::from(self.stop_bits)) as u32;
let delay = Duration::from_micros((buf.len() * 1000 * 1000 / byte_rate as usize) as u64);
sleep(delay);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl SerialPort for FakeSerialPort {
fn name(&self) -> Option<String> {
Some("Dummy Serial".into())
}
fn baud_rate(&self) -> serialport::Result<u32> {
Ok(self.baud_rate)
}
fn data_bits(&self) -> serialport::Result<DataBits> {
Ok(self.data_bits)
}
fn flow_control(&self) -> serialport::Result<FlowControl> {
Ok(self.flow_control)
}
fn parity(&self) -> serialport::Result<Parity> {
Ok(self.parity)
}
fn stop_bits(&self) -> serialport::Result<StopBits> {
Ok(self.stop_bits)
}
fn timeout(&self) -> Duration {
self.timeout
}
fn set_baud_rate(&mut self, baud_rate: u32) -> serialport::Result<()> {
self.baud_rate = baud_rate;
Ok(())
}
fn set_data_bits(&mut self, data_bits: DataBits) -> serialport::Result<()> {
self.data_bits = data_bits;
Ok(())
}
fn set_flow_control(&mut self, flow_control: FlowControl) -> serialport::Result<()> {
self.flow_control = flow_control;
Ok(())
}
fn set_parity(&mut self, parity: Parity) -> serialport::Result<()> {
self.parity = parity;
Ok(())
}
fn set_stop_bits(&mut self, stop_bits: StopBits) -> serialport::Result<()> {
self.stop_bits = stop_bits;
Ok(())
}
fn set_timeout(&mut self, timeout: Duration) -> serialport::Result<()> {
self.timeout = timeout;
Ok(())
}
fn write_request_to_send(&mut self, _level: bool) -> serialport::Result<()> {
Ok(())
}
fn write_data_terminal_ready(&mut self, _level: bool) -> serialport::Result<()> {
Ok(())
}
fn read_clear_to_send(&mut self) -> serialport::Result<bool> {
Ok(true)
}
fn read_data_set_ready(&mut self) -> serialport::Result<bool> {
Ok(true)
}
fn read_ring_indicator(&mut self) -> serialport::Result<bool> {
Ok(false)
}
fn read_carrier_detect(&mut self) -> serialport::Result<bool> {
Ok(false)
}
fn bytes_to_read(&self) -> serialport::Result<u32> {
Ok(1)
}
fn bytes_to_write(&self) -> serialport::Result<u32> {
Ok(0)
}
fn clear(&self, _buffer_to_clear: ClearBuffer) -> serialport::Result<()> {
Ok(())
}
fn try_clone(&self) -> serialport::Result<Box<dyn SerialPort>> {
todo!()
}
fn set_break(&self) -> serialport::Result<()> {
Ok(())
}
fn clear_break(&self) -> serialport::Result<()> {
Ok(())
}
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
#![forbid(non_ascii_idents)]
#![deny(unsafe_code)]
use bytes::{BufMut, BytesMut};
use image::{RgbImage, RgbaImage};
mod aoo_screen;
mod fake_serialport;
pub use aoo_screen::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
pub use fake_serialport::FakeSerialPort;
/// Trait definition to get a RGB 565 representation from a source image.
pub trait ToRgb565 {
/// Get an RGB 565 representation of the image in little endian format.
fn to_rgb565_le(&self) -> BytesMut;
/// Convert a single RGB 888 pixel to 16 bit RGB 565 format.
fn convert_rgb(&self, r: u8, g: u8, b: u8) -> u16 {
((r & 248) as u16) << 8 | ((g & 252) as u16) << 3 | ((b as u16) >> 3)
}
}
// TODO quick & dirty approach for converting RgbImage & RgbaImage to RGB 565.
// There should be a more generic way, maybe with PixelEnumerator...
impl ToRgb565 for &RgbImage {
fn to_rgb565_le(&self) -> BytesMut {
let mut img_rgb565 =
BytesMut::with_capacity(self.width() as usize * self.height() as usize * 2);
for (_x, _y, pixel) in self.enumerate_pixels() {
img_rgb565.put_u16_le(self.convert_rgb(pixel.0[0], pixel.0[1], pixel.0[2]));
}
img_rgb565
}
}
impl ToRgb565 for &RgbaImage {
fn to_rgb565_le(&self) -> BytesMut {
let mut img_rgb565 =
BytesMut::with_capacity(self.width() as usize * self.height() as usize * 2);
for (_x, _y, pixel) in self.enumerate_pixels() {
img_rgb565.put_u16_le(self.convert_rgb(pixel.0[0], pixel.0[1], pixel.0[2]));
}
img_rgb565
}
}
+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(())
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "sysinfo"
version = "0.1.0"
description = "System sensor provider for asterctl"
rust-version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
clap = { version = "4.5.42", features = ["derive"] }
sysinfo = "0.37.0"
itertools = "0.14"
tempfile = "3"
log = "0.4.27"
env_logger = "0.11.8"
regex = "1.11"
+1
View File
@@ -0,0 +1 @@
../../LICENSE-APACHE
+1
View File
@@ -0,0 +1 @@
../../LICENSE-MIT
+47
View File
@@ -0,0 +1,47 @@
# System Sensor Provider for asterctl
This tool gathers system sensor values with the help of the [sysinfo](https://github.com/GuillaumeGomez/sysinfo) crate
and writes them into a text file.
See [README](../../README.md) in root directory for more information.
```
Proof of concept sensor value collection for the asterctl screen control tool
Usage: sysinfo [OPTIONS]
Options:
-o, --out <OUT>
Output sensor file
-t, --temp-dir <TEMP_DIR>
Temporary directory for preparing the output sensor file.
The system temp directory is used if not specified.
The temp directory must be on the same file system for atomic rename operation!
--console
Print values in console
-r, --refresh <REFRESH>
System sensor refresh interval in seconds
--disk-refresh <DISK_REFRESH>
Enable individual disk refresh logic as used in AOOSTAR-X. Refresh interval in seconds
--smartctl
Retrieve drive temperature if `disk-update` option is enabled.
Requires smartctl and password-less sudo!
```
Single test run with printing all sensors in the console:
```shell
sysinfo --console
```
Normal mode providing sensor values for `asterctl` in `/tmp/sensors/sysinfo.txt` every 3 seconds:
```shell
sysinfo --refresh 3 --out /tmp/sensors/sysinfo.txt
```
+768
View File
@@ -0,0 +1,768 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
#![forbid(non_ascii_idents)]
#![deny(unsafe_code)]
use clap::Parser;
use env_logger::Env;
use itertools::Itertools;
use log::{debug, error, info};
use regex::Regex;
use std::cmp::PartialEq;
use std::collections::HashMap;
use std::fmt::Display;
use std::fs;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, exit};
use std::thread::sleep;
use std::time::{Duration, Instant};
use sysinfo::{Components, DiskKind, Disks, Networks, System};
use tempfile::NamedTempFile;
/// Proof of concept sensor value collection for the asterctl screen control tool.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Output sensor file.
#[arg(short, long)]
out: Option<PathBuf>,
/// Temporary directory for preparing the output sensor file.
///
/// The system temp directory is used if not specified.
/// The temp directory must be on the same file system for atomic rename operation!
#[arg(short, long)]
temp_dir: Option<PathBuf>,
/// Print values in console
#[arg(long)]
console: bool,
/// System sensor refresh interval in seconds
#[arg(short, long)]
refresh: Option<u16>,
/// Enable individual disk refresh logic as used in AOOSTAR-X. Refresh interval in seconds.
#[arg(long)]
disk_refresh: Option<u16>,
/// Retrieve drive temperature if `disk-update` option is enabled.
///
/// Requires smartctl and password-less sudo!
#[cfg(target_os = "linux")]
#[arg(long)]
smartctl: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let args = Args::parse();
#[cfg(target_os = "linux")]
let use_smartctl = args.smartctl;
#[cfg(not(target_os = "linux"))]
let use_smartctl = false;
let mut sensors = HashMap::with_capacity(64);
let mut sysinfo_source = SysinfoSource::new();
let refresh = Duration::from_secs(args.refresh.unwrap_or_default() as u64);
let disk_refresh = Duration::from_secs(args.disk_refresh.unwrap_or_default() as u64);
let mut disk_refresh_time = Instant::now();
if !disk_refresh.is_zero() {
update_linux_storage_sensors(&mut sensors, use_smartctl)?;
}
loop {
let upd_start_time = Instant::now();
sysinfo_source.refresh();
sysinfo_source.update_sensors(&mut sensors)?;
if !disk_refresh.is_zero() && disk_refresh_time.elapsed() > disk_refresh {
info!("Refreshing individual disks");
update_linux_storage_sensors(&mut sensors, use_smartctl)?;
disk_refresh_time = Instant::now();
}
if let Some(out_file) = &args.out {
write_sensor_file(out_file, args.temp_dir.as_deref(), &sensors)?;
}
if args.console {
// pretty print console output with sorted keys
for (label, value) in sensors.iter().sorted() {
println!("{}: {}", label, value);
}
println!();
}
if refresh.is_zero() {
break;
}
let elapsed = upd_start_time.elapsed();
if refresh > elapsed {
sleep(refresh - elapsed);
}
}
Ok(())
}
fn write_sensor_file(
out_file: &Path,
temp_dir: Option<&Path>,
sensors: &HashMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> {
if out_file.is_dir() {
error!("Output cannot be a directory: {}", out_file.display());
exit(1);
}
let tmp_file = if let Some(temp_path) = temp_dir {
fs::create_dir_all(temp_path)?;
NamedTempFile::new_in(temp_path)?
} else {
NamedTempFile::new()?
};
let mut stream = BufWriter::new(&tmp_file);
for (label, value) in sensors.iter() {
writeln!(stream, "{label}: {value}")?;
}
stream.flush()?;
drop(stream);
tmp_file.persist(out_file)?;
Ok(())
}
pub struct SysinfoSource {
sys: System,
disks: Disks,
components: Components,
networks: Networks,
last_refresh: Option<Instant>,
refresh_duration: Option<Duration>,
}
impl Default for SysinfoSource {
fn default() -> Self {
Self::new()
}
}
impl SysinfoSource {
pub fn new() -> Self {
Self {
sys: System::new_all(),
disks: Disks::new(),
components: Components::new(),
networks: Networks::new(),
last_refresh: None,
refresh_duration: None,
}
}
pub fn refresh(&mut self) {
self.sys.refresh_all();
// TODO research "remove_not_listed_###" refresh parameter
self.disks.refresh(false);
self.components.refresh(false);
self.networks.refresh(false);
if let Some(last_refresh) = self.last_refresh {
self.refresh_duration = Some(last_refresh.elapsed());
}
self.last_refresh = Some(Instant::now());
}
fn update_sensors(
&self,
sensors: &mut HashMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> {
for cpu in self.sys.cpus() {
add_sensor(
sensors,
format!("cpu_{}_frequency", cpu.name()),
cpu.frequency(),
);
add_sensor(
sensors,
format!("cpu_{}_usage", cpu.name()),
format!("{:.2}", cpu.cpu_usage()),
);
}
let load_avg = System::load_average();
add_sensor(sensors, "load_avg_one", format!("{:.2}", load_avg.one));
add_sensor(sensors, "load_avg_five", format!("{:.2}", load_avg.five));
add_sensor(
sensors,
"load_avg_fifteen",
format!("{:.2}", load_avg.fifteen),
);
// RAM and swap information:
add_sensor(sensors, "mem_free_bytes", self.sys.free_memory());
add_sensor(sensors, "mem_free", format_bytes(self.sys.free_memory()));
add_sensor(sensors, "mem_total_bytes", self.sys.total_memory());
add_sensor(sensors, "mem_total", format_bytes(self.sys.total_memory()));
add_sensor(sensors, "mem_used_bytes", self.sys.used_memory());
add_sensor(sensors, "mem_used", format_bytes(self.sys.used_memory()));
add_sensor(
sensors,
"mem_usage_percent",
format!(
"{:.1}",
(self.sys.used_memory() * 100) as f64 / self.sys.total_memory() as f64
),
);
add_sensor(sensors, "swap_free_bytes", self.sys.free_swap());
add_sensor(sensors, "swap_free", format_bytes(self.sys.free_swap()));
add_sensor(sensors, "swap_total_bytes", self.sys.total_swap());
add_sensor(sensors, "swap_total", format_bytes(self.sys.total_swap()));
add_sensor(sensors, "swap_used_bytes", self.sys.used_swap());
add_sensor(sensors, "swap_used", format_bytes(self.sys.used_swap()));
add_sensor(
sensors,
"swap_usage_percent",
format!(
"{:.1}",
(self.sys.used_swap() * 100) as f64 / self.sys.total_swap() as f64
),
);
// System information:
if let Some(name) = System::name() {
add_sensor(sensors, "system_name", name);
}
if let Some(kernel_version) = System::kernel_version() {
add_sensor(sensors, "system_kernel_version", kernel_version);
}
if let Some(os_version) = System::os_version() {
add_sensor(sensors, "system_os_version", os_version);
}
if let Some(host_name) = System::host_name() {
add_sensor(sensors, "system_hostname", host_name);
}
add_sensor(sensors, "cpu_count", self.sys.cpus().len());
add_sensor(sensors, "total_processes", self.sys.processes().len());
// disks' information:
let mut ssd_idx = 0;
let mut hdd_idx = 0;
for disk in &self.disks {
let label;
match disk.kind() {
DiskKind::SSD => {
label = format!("storage_ssd[{}]", ssd_idx);
ssd_idx += 1;
}
DiskKind::HDD => {
label = format!("storage_hdd[{}]", hdd_idx);
hdd_idx += 1;
}
_ => continue,
}
// special label for AOOSTAR-X system panel
add_sensor(
sensors,
format!("{label}_usage_percent"),
(disk.total_space() - disk.available_space()) * 100 / disk.total_space(),
);
// using similar labels as AOOSTAR-X, but combining `{label2}_{label}`
let device = disk.name().to_string_lossy().replace(' ', "_");
add_sensor(
sensors,
format!("disk_{device}_total_bytes"),
disk.total_space(),
);
add_sensor(
sensors,
format!("disk_{device}_total"),
format_bytes(disk.total_space()),
);
let used = disk.total_space() - disk.available_space();
add_sensor(sensors, format!("disk_{device}_used_bytes"), used);
add_sensor(sensors, format!("disk_{device}_used"), format_bytes(used));
add_sensor(
sensors,
format!("disk_{device}_free_bytes"),
disk.available_space(),
);
add_sensor(
sensors,
format!("disk_{device}_free"),
format_bytes(disk.available_space()),
);
add_sensor(
sensors,
format!("disk_{device}_usage_percent"),
format!(
"{:.1}",
(disk.total_space() - disk.available_space()) as f64 * 100.0
/ disk.total_space() as f64
),
);
}
// Components temperature:
for component in &self.components {
if let Some(temperature) = component.temperature() {
let label;
if component.label().contains("spd5118") {
label = "temperature_memory".to_string();
} else if component.label().contains("amdgpu") {
label = "temperature_gpu".to_string();
} else if component.label().contains("Tctl") {
label = "temperature_cpu".to_string();
} else if component.label().contains("Composite")
&& !component.label().contains("nvme")
{
// just a guess...
label = "temperature_motherboard".to_string();
} else {
label = format!("temperature_{}", component.label().replace(' ', "_"));
// println!("label={}, type_id={:?}, id={:?}, {component:?}",
// component.label(), component.type_id(), component.id());
}
// TODO add unit as a separate sensor?
add_sensor(sensors, label, format!("{temperature:.1} °C"));
}
}
// Network interfaces name, total data received and total data transmitted:
for (interface_name, data) in &self.networks {
// only consider specific interfaces
let if_name = interface_name.to_lowercase();
if !["eth", "en", "em", "wlan", "wlp", "wlo"]
.iter()
.any(|i| if_name.starts_with(*i))
{
continue;
}
// Sort by address to avoid random order in refreshes
for (idx, addr) in data
.ip_networks()
.iter()
.map(|net| net.addr)
.sorted()
.enumerate()
{
add_sensor(
sensors,
format!("network_{interface_name}_address{idx}"),
addr,
);
}
if let Some(refresh) = self.refresh_duration {
let interval = refresh.as_millis() as u64;
if interval > 0 {
add_sensor(
sensors,
format!("network_{interface_name}_download_speed"),
format!("{}/s", format_bytes(1000 * data.received() / interval)),
);
add_sensor(
sensors,
format!("network_{interface_name}_upload_speed"),
format!("{}/s", format_bytes(1000 * data.transmitted() / interval)),
);
}
}
add_sensor(
sensors,
format!("network_{interface_name}_total_received_bytes"),
data.total_received(),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_received"),
format_bytes(data.total_received()),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_transmitted_bytes"),
data.total_transmitted(),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_transmitted"),
format_bytes(data.total_transmitted()),
);
}
Ok(())
}
}
fn add_sensor(
sensors: &mut HashMap<String, String>,
label: impl Into<String>,
value: impl Display,
) {
sensors.insert(label.into(), value.to_string());
}
fn update_linux_storage_sensors(
sensors: &mut HashMap<String, String>,
use_smartctl: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Note: AOOSTAR-X only considered spinning Rust. Too bad if you're using SSDs in the HD bays...
if let Ok(hdd_devices) = get_storage_devices(StorageDevice::HddOrSsd) {
debug!("HDD devices : {:?}", hdd_devices);
for (idx, device) in hdd_devices.iter().enumerate() {
let usage = get_disk_usage(device)?;
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_size_bytes"),
usage.total_size,
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_size"),
format_bytes(usage.total_size),
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_used_bytes"),
usage.total_used,
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_used"),
format_bytes(usage.total_used),
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_usage_percent"),
usage.usage_percent,
);
if use_smartctl && let Some(temperature) = get_smartctl_disk_temperature(device)? {
add_sensor(
sensors,
format!("storage_hdd[{idx}]_temperature"),
temperature,
);
}
}
}
// AOOSTAR-X: ssd == nvme
if let Ok(nvme_devices) = get_storage_devices(StorageDevice::Nvme) {
debug!("NVME devices: {:?}", nvme_devices);
for (idx, device) in nvme_devices.iter().enumerate() {
let usage = get_disk_usage(device)?;
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_size_bytes"),
usage.total_size,
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_size"),
format_bytes(usage.total_size),
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_used_bytes"),
usage.total_used,
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_used"),
format_bytes(usage.total_used),
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_usage_percent"),
usage.usage_percent,
);
if use_smartctl && let Some(temperature) = get_smartctl_disk_temperature(device)? {
add_sensor(
sensors,
format!("storage_ssd[{idx}]_temperature"),
temperature,
);
}
}
}
Ok(())
}
#[derive(Debug)]
pub struct DiskInfo {
pub device: String,
pub temperature: i32,
pub used: f64,
pub total_used: u64,
pub total_size: u64,
}
#[derive(Debug)]
pub struct DiskUsage {
pub usage_percent: f64,
pub total_used: u64,
pub total_size: u64,
}
#[derive(Debug, PartialEq)]
pub enum StorageDevice {
All,
Hdd,
Ssd,
HddOrSsd,
Nvme,
}
pub type DiskResult = Result<Vec<DiskInfo>, Box<dyn std::error::Error>>;
/// Get storage devices of the given type: NVME, SSD or HD
///
/// Storage devices are identified from /sys/block attributes.
/// Removable devices are excluded.
///
/// # Arguments
///
/// * `kind`: type of storage device
///
/// returns: sorted list of found device names (`sd*` and `nvme*`)
pub fn get_storage_devices(kind: StorageDevice) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut devices = Vec::new();
let sys_block = Path::new("/sys/block");
if !sys_block.exists() {
info!("No storage device found");
return Ok(devices);
}
let device_regex = Regex::new(r"^sd[a-z]+$")?;
let nvme_regex = Regex::new(r"^nvme[0-9]+n[0-9]+$")?;
for entry in fs::read_dir(sys_block)? {
let entry = entry?;
let dev_name = entry.file_name();
let dev_str = dev_name.to_string_lossy();
// filter out all non sd* and nvme* devices
let is_nvme = nvme_regex.is_match(&dev_str);
let is_storage = device_regex.is_match(&dev_str);
if !(is_nvme || is_storage) {
continue;
}
match kind {
StorageDevice::All => {}
StorageDevice::Hdd | StorageDevice::Ssd | StorageDevice::HddOrSsd => {
if !is_storage {
continue;
}
}
StorageDevice::Nvme => {
if !is_nvme {
continue;
}
}
};
if is_nvme {
let dev_name = entry.file_name();
let dev_str = dev_name.to_string_lossy();
devices.push(dev_str.to_string());
continue;
}
let rotational_path = sys_block.join(dev_str.as_ref()).join("queue/rotational");
let removable_path = sys_block.join(dev_str.as_ref()).join("removable");
match (
fs::read_to_string(&rotational_path),
fs::read_to_string(&removable_path),
) {
(Ok(rotational), Ok(removable)) => {
let rotational = rotational.trim();
let removable = removable.trim();
// ignore removable
if removable == "1" {
continue;
}
if kind == StorageDevice::Hdd && rotational == "1"
|| kind == StorageDevice::Ssd && rotational == "0"
|| kind == StorageDevice::HddOrSsd
{
devices.push(dev_str.to_string());
}
}
(Err(e), _) | (_, Err(e)) => {
error!("Unable to read device {dev_str} attributes: {e}");
}
}
}
devices.sort();
Ok(devices)
}
/// Retrieve temperature from NVMe or SDD/HDD with smartctl
pub fn get_smartctl_disk_temperature(dev: &str) -> Result<Option<i32>, Box<dyn std::error::Error>> {
let temp_regex =
Regex::new(r"194\s+Temperature_Celsius\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+-\s+(\d+)")?;
let nvme_temp_regex = Regex::new(r"Temperature:\s+(\d+)\s")?;
let dev = format!("/dev/{}", dev);
match Command::new("sudo")
.arg("-n")
.arg("smartctl")
.arg("-A")
.arg(&dev)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(temp_captures) = temp_regex
.captures(&stdout)
.or_else(|| nvme_temp_regex.captures(&stdout))
&& let Some(temp_match) = temp_captures.get(1)
{
let temperature = temp_match.as_str().parse::<i32>()?;
return Ok(Some(temperature));
}
}
Err(e) => {
error!("Device {dev} acquisition failed, error: {e}");
}
}
Ok(None)
}
/// Calculate actual filesystem usage rate of hard disk (based on df command)
pub fn get_disk_usage(dev: &str) -> Result<DiskUsage, Box<dyn std::error::Error>> {
let mut tmp = DiskUsage {
usage_percent: 0.0,
total_used: 0,
total_size: 0,
};
// Get mounted partitions for this device
let cmd = format!(
"df -h --output=source,target,pcent | grep '/dev/{}[0-9]*'",
dev
);
match Command::new("sh").arg("-c").arg(&cmd).output() {
Ok(output) => {
if !output.status.success() {
return Ok(tmp);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut total_used: u64 = 0;
let mut total_size: u64 = 0;
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
continue;
}
let mountpoint = parts[1];
// Get size in bytes
let size_cmd = format!(
"df --block-size=1 {} | awk 'NR==2 {{print $2}}'",
mountpoint
);
if let Ok(size_output) = Command::new("sh").arg("-c").arg(&size_cmd).output()
&& let Ok(size_str) = String::from_utf8(size_output.stdout)
&& let Ok(size) = size_str.trim().parse::<u64>()
{
total_size += size;
}
// Get used space in bytes
let used_cmd = format!(
"df --block-size=1 {} | awk 'NR==2 {{print $3}}'",
mountpoint
);
if let Ok(used_output) = Command::new("sh").arg("-c").arg(&used_cmd).output()
&& let Ok(used_str) = String::from_utf8(used_output.stdout)
&& let Ok(used) = used_str.trim().parse::<u64>()
{
total_used += used;
}
}
if total_size != 0 {
tmp.usage_percent =
((total_used as f64 / total_size as f64) * 100.0 * 100.0).round() / 100.0;
tmp.total_used = total_used;
tmp.total_size = total_size;
}
Ok(tmp)
}
Err(_) => Ok(tmp),
}
}
/// Format bytes into human-readable string
pub fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
const THRESHOLD: f64 = 1024.0;
if bytes == 0 {
return "0 B".to_string();
}
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= THRESHOLD && unit_index < UNITS.len() - 1 {
size /= THRESHOLD;
unit_index += 1;
}
if unit_index > 0 {
format!("{:.2} {}", size, UNITS[unit_index])
} else {
format!("{} {}", size, UNITS[unit_index])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(1024), "1.00 KB");
assert_eq!(format_bytes(1048576), "1.00 MB");
assert_eq!(format_bytes(1073741824), "1.00 GB");
}
}