use std::{ net::SocketAddr, path::PathBuf, sync::{Arc, OnceLock, RwLock}, thread, time::{Duration, Instant}, }; use ab_glyph::{FontArc, PxScale}; use anyhow::{Context, Result}; use asterctl::img; use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE}; use axum::{ Json, Router, body::Body, extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State}, http::{HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Response}, routing::{get, post}, }; use chrono::{Local, Utc}; use clap::Parser; use image::{DynamicImage, ImageFormat, Rgb, RgbImage, imageops::FilterType}; use imageproc::{ drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut}, rect::Rect, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sysinfo::{Components, Disks, System}; use tokio::fs; use tracing::{error, info, warn}; const DISPLAY_WIDTH: u32 = 960; const DISPLAY_HEIGHT: u32 = 376; const SYSTEM_FRAME_NAME: &str = "System Specs"; const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif"); #[derive(Parser, Debug)] #[command(author, version, about)] struct Cli { #[arg(long, default_value = "0.0.0.0:8080")] bind: String, #[arg(long, default_value = "/config")] config_dir: PathBuf, #[arg(long)] device: Option, #[arg(long)] usb: Option, #[arg(long)] simulate: bool, #[arg(long)] write_only: bool, #[arg(long)] disable_display: bool, } #[derive(Clone)] struct AppState { monitor_path: PathBuf, image_dir: PathBuf, display_status: Arc>, } #[derive(Clone)] struct DisplayConfig { device: Option, usb: Option, simulate: bool, write_only: bool, native_enabled: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq)] struct RotationSnapshot { custom_panel: bool, switch_time: u32, specs_enabled: bool, memes_enabled: bool, active_images: Vec, } impl RotationSnapshot { fn rotation_active(&self) -> bool { self.custom_panel && !self.frames().is_empty() } fn frames(&self) -> Vec { let mut frames = Vec::new(); if self.memes_enabled { frames.extend(self.active_images.iter().cloned().map(RotationFrame::Image)); } if frames.is_empty() && self.specs_enabled { frames.push(RotationFrame::SystemSpecs); } frames } } #[derive(Clone, Debug, PartialEq, Eq)] enum RotationFrame { SystemSpecs, Image(String), } #[derive(Clone, Serialize)] struct StateResponse { monitor_path: String, image_dir: String, setup: SetupView, active_images: Vec, images: Vec, display: DisplayStatus, system: SystemView, } #[derive(Clone, Serialize)] struct SetupView { custom_panel: bool, switch_time: String, specs_enabled: bool, memes_enabled: bool, } #[derive(Clone, Serialize)] struct ImageView { name: String, size: u64, url: String, } #[derive(Clone, Debug, Default, Serialize)] struct DisplayStatus { native_enabled: bool, connected: bool, mode: String, device: String, custom_panel: bool, specs_enabled: bool, memes_enabled: bool, rotation_active: bool, switch_time: String, active_images: Vec, current_image: Option, last_error: Option, updated_at: Option, } #[derive(Clone, Debug, Serialize)] struct SystemView { cpu_usage_percent: String, load_avg_one: String, load_avg_five: String, load_avg_fifteen: String, mem_usage_percent: String, mem_used: String, mem_total: String, swap_usage_percent: String, swap_used: String, swap_total: String, disk_usage_percent: String, disk_used: String, disk_total: String, cache_usage_percent: String, cache_used: String, cache_total: String, user_usage_percent: String, user_used: String, user_total: String, cpu_count: usize, process_count: usize, uptime: String, temperature_cpu: Option, temperature_gpu: Option, } #[derive(Deserialize)] struct ActivateRequest { images: Vec, switch_time: Option, specs_enabled: Option, memes_enabled: Option, } #[derive(Deserialize)] struct DeleteRequest { name: String, } #[derive(Serialize)] struct ErrorResponse { error: String, } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,aster_webui=debug".into()), ) .init(); let cli = Cli::parse(); let addr: SocketAddr = cli .bind .parse() .with_context(|| format!("Invalid bind address: {}", cli.bind))?; let display_config = DisplayConfig { device: cli.device, usb: cli.usb, simulate: cli.simulate, write_only: cli.write_only, native_enabled: !cli.disable_display, }; let state = Arc::new(AppState { monitor_path: cli.config_dir.join("Monitor3.json"), image_dir: cli.config_dir.join("img"), display_status: Arc::new(RwLock::new(initial_display_status(&display_config))), }); ensure_layout(&state).await?; spawn_display_worker(state.clone(), display_config); let app = Router::new() .route("/", get(index)) .route("/healthz", get(healthz)) .route("/api/state", get(api_state)) .route("/api/upload", post(api_upload)) .route("/api/panels/activate", post(api_activate)) .route("/api/panels/disable", post(api_disable)) .route("/api/images/delete", post(api_delete)) .route("/api/assets/nyan-cat.gif", get(api_nyan_cat)) .route("/api/images/{name}", get(api_image)) .layer(DefaultBodyLimit::max(64 * 1024 * 1024)) .with_state(state); info!("Starting aster-webui on {addr}"); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app).await?; Ok(()) } fn initial_display_status(config: &DisplayConfig) -> DisplayStatus { let mut status = DisplayStatus { native_enabled: config.native_enabled, connected: false, mode: display_mode(config), device: display_target(config), custom_panel: false, specs_enabled: false, memes_enabled: false, rotation_active: false, switch_time: "10".into(), active_images: Vec::new(), current_image: None, last_error: None, updated_at: Some(stamp_now()), }; if !config.native_enabled { status.last_error = Some("Native display loop disabled via --disable-display".into()); } status } async fn ensure_layout(state: &AppState) -> Result<()> { fs::create_dir_all(&state.image_dir).await?; if fs::try_exists(&state.monitor_path).await? { return Ok(()); } let default = json!({ "credentials": { "username": "admin", "password": "123456" }, "setup": { "type": 1, "offDisplay": true, "controlParams": true, "controlDiskTemp": true, "customPanel": false, "language": 1, "switchTime": "10", "nativeSpecs": false, "nativeMemes": true, "operationMode": 0, "theme": 1, "diskUpdate": 300, "ha_url": "", "ha_token": "", "refresh": 1 }, "mianban": [], "diy": [] }); save_monitor_json(state, &default).await } async fn load_monitor_json(state: &AppState) -> Result { let raw = fs::read_to_string(&state.monitor_path) .await .with_context(|| format!("Failed to read {:?}", state.monitor_path))?; serde_json::from_str(&raw).context("Failed to parse Monitor3.json") } async fn save_monitor_json(state: &AppState, value: &Value) -> Result<()> { let payload = serde_json::to_string_pretty(value)?; let tmp_path = temp_monitor_path(&state.monitor_path); fs::write(&tmp_path, payload) .await .with_context(|| format!("Failed to write {:?}", tmp_path))?; fs::rename(&tmp_path, &state.monitor_path) .await .with_context(|| format!("Failed to replace {:?}", state.monitor_path)) } fn temp_monitor_path(path: &PathBuf) -> PathBuf { let file_name = path .file_name() .map(|name| format!("{}.tmp", name.to_string_lossy())) .unwrap_or_else(|| "Monitor3.json.tmp".into()); path.with_file_name(file_name) } fn error_response(status: StatusCode, message: impl Into) -> Response { let body = Json(ErrorResponse { error: message.into(), }); (status, body).into_response() } async fn index() -> Response { ( [ (header::CACHE_CONTROL, "no-store, no-cache, must-revalidate, max-age=0"), (header::PRAGMA, "no-cache"), (header::EXPIRES, "0"), ], Html(INDEX_HTML), ) .into_response() } async fn healthz(State(state): State>) -> Json { let display = read_display_status(&state.display_status); Json(json!({ "ok": true, "nativeDisplay": display.native_enabled, "displayConnected": display.connected, })) } async fn api_state(State(state): State>) -> Response { match build_state_response(&state).await { Ok(payload) => Json(payload).into_response(), Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), } } async fn api_upload(State(state): State>, mut multipart: Multipart) -> Response { let mut stored = None; while let Ok(Some(field)) = multipart.next_field().await { if field.name() != Some("file") { continue; } let file_name = field .file_name() .map(ToString::to_string) .unwrap_or_else(|| "panel".into()); let bytes = match field.bytes().await { Ok(bytes) => bytes, Err(err) => { return error_response(StatusCode::BAD_REQUEST, format!("Upload failed: {err}")); } }; match convert_and_store_image(&state, &file_name, &bytes).await { Ok(name) => stored = Some(name), Err(err) => return error_response(StatusCode::BAD_REQUEST, err.to_string()), } } match stored { Some(name) => Json(json!({ "ok": true, "name": name })).into_response(), None => error_response(StatusCode::BAD_REQUEST, "No file field provided"), } } async fn api_activate( State(state): State>, Json(payload): Json, ) -> Response { let switch_time = payload.switch_time.unwrap_or(10).clamp(1, 600); let specs_enabled = payload.specs_enabled.unwrap_or(false); let available = match list_images(&state).await { Ok(items) => items, Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), }; let available_names: Vec = available.into_iter().map(|item| item.name).collect(); let mut valid = Vec::new(); for name in payload.images { if available_names.iter().any(|candidate| candidate == &name) && !valid.contains(&name) { valid.push(name); } } let memes_enabled = payload .memes_enabled .unwrap_or(!valid.is_empty()); if !specs_enabled && (!memes_enabled || valid.is_empty()) { return error_response( StatusCode::BAD_REQUEST, "Enable specs or select at least one meme image", ); } let mut monitor = match load_monitor_json(&state).await { Ok(value) => value, Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), }; set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &valid); if let Err(err) = save_monitor_json(&state, &monitor).await { return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } Json(json!({ "ok": true, "switchTime": switch_time, "specsEnabled": specs_enabled, "memesEnabled": memes_enabled, "activeImages": valid, })) .into_response() } async fn api_disable(State(state): State>) -> Response { let mut monitor = match load_monitor_json(&state).await { Ok(value) => value, Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), }; let switch_time = current_switch_time(&monitor); let active_images = current_active_images(&monitor); set_custom_panels(&mut monitor, switch_time, false, false, &active_images); if let Err(err) = save_monitor_json(&state, &monitor).await { return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } Json(json!({ "ok": true })).into_response() } async fn api_delete( State(state): State>, Json(payload): Json, ) -> Response { if payload.name.contains('/') || payload.name.contains('\\') { return error_response(StatusCode::BAD_REQUEST, "Invalid file name"); } let path = state.image_dir.join(&payload.name); match fs::remove_file(&path).await { Ok(_) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return error_response(StatusCode::NOT_FOUND, "Image not found"); } Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), } let mut monitor = match load_monitor_json(&state).await { Ok(value) => value, Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), }; let mut active_images = current_active_images(&monitor); let switch_time = current_switch_time(&monitor); let specs_enabled = current_specs_enabled(&monitor); let memes_enabled = current_memes_enabled(&monitor); let before = active_images.len(); active_images.retain(|name| name != &payload.name); if active_images.len() != before { set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &active_images); if let Err(err) = save_monitor_json(&state, &monitor).await { return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } } Json(json!({ "ok": true })).into_response() } async fn api_image( AxumPath(name): AxumPath, State(state): State>, ) -> Response { if name.contains('/') || name.contains('\\') { return error_response(StatusCode::BAD_REQUEST, "Invalid file name"); } let path = state.image_dir.join(&name); let bytes = match fs::read(&path).await { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return error_response(StatusCode::NOT_FOUND, "Image not found"); } Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), }; let mime = mime_guess::from_path(&path).first_or_octet_stream(); let mut response = Response::new(Body::from(bytes)); response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_str(mime.as_ref()) .unwrap_or(HeaderValue::from_static("application/octet-stream")), ); response } async fn api_nyan_cat() -> Response { let mut response = Response::new(Body::from(NYAN_CAT_GIF)); response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static("image/gif"), ); response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400"), ); response } async fn build_state_response(state: &AppState) -> Result { let monitor = load_monitor_json(state).await?; let setup = monitor .get("setup") .and_then(Value::as_object) .cloned() .unwrap_or_default(); let custom_panel = setup .get("customPanel") .and_then(Value::as_bool) .unwrap_or(false); let switch_time = setup .get("switchTime") .and_then(Value::as_str) .unwrap_or("10") .to_string(); let specs_enabled = current_specs_enabled(&monitor); let memes_enabled = current_memes_enabled(&monitor); Ok(StateResponse { monitor_path: state.monitor_path.display().to_string(), image_dir: state.image_dir.display().to_string(), setup: SetupView { custom_panel, switch_time, specs_enabled, memes_enabled, }, active_images: current_active_images(&monitor), images: list_images(state).await?, display: read_display_status(&state.display_status), system: collect_system_view(), }) } fn collect_system_view() -> SystemView { fn mount_usage(disks: &Disks, mount_point: &str) -> (String, String, String) { disks .iter() .find(|disk| disk.mount_point() == std::path::Path::new(mount_point)) .map(|disk| { let total = disk.total_space(); let used = total.saturating_sub(disk.available_space()); let usage_percent = if total == 0 { 0.0 } else { used as f64 / total as f64 * 100.0 }; ( format!("{usage_percent:.0}"), format_bytes(used), format_bytes(total), ) }) .unwrap_or_else(|| ("n/a".into(), "n/a".into(), "n/a".into())) } let mut sys = System::new_all(); sys.refresh_all(); let load_avg = System::load_average(); let total_memory = sys.total_memory(); let used_memory = sys.used_memory(); let total_swap = sys.total_swap(); let used_swap = sys.used_swap(); let mut disks = Disks::new(); disks.refresh(false); let disk_total: u64 = disks.iter().map(|disk| disk.total_space()).sum(); let disk_used: u64 = disks .iter() .map(|disk| disk.total_space().saturating_sub(disk.available_space())) .sum(); let mut components = Components::new(); components.refresh(false); let (cache_usage_percent, cache_used, cache_total) = mount_usage(&disks, "/mnt/cache"); let (user_usage_percent, user_used, user_total) = mount_usage(&disks, "/mnt/user"); SystemView { cache_usage_percent, cache_used, cache_total, user_usage_percent, user_used, user_total, cpu_usage_percent: format!("{:.1}", sys.global_cpu_usage()), load_avg_one: format!("{:.2}", load_avg.one), load_avg_five: format!("{:.2}", load_avg.five), load_avg_fifteen: format!("{:.2}", load_avg.fifteen), mem_usage_percent: format!("{:.1}", percentage(used_memory, total_memory)), mem_used: format_bytes(used_memory), mem_total: format_bytes(total_memory), swap_usage_percent: format!("{:.1}", percentage(used_swap, total_swap)), swap_used: format_bytes(used_swap), swap_total: format_bytes(total_swap), disk_usage_percent: format!("{:.1}", percentage(disk_used, disk_total)), disk_used: format_bytes(disk_used), disk_total: format_bytes(disk_total), cpu_count: sys.cpus().len(), process_count: sys.processes().len(), uptime: format_uptime(System::uptime()), temperature_cpu: component_temperature(&components, "Tctl"), temperature_gpu: component_temperature(&components, "amdgpu"), } } fn component_temperature(components: &Components, needle: &str) -> Option { components .iter() .find(|component| component.label().contains(needle)) .and_then(|component| component.temperature()) .map(|value| format!("{value:.1} °C")) } fn percentage(used: u64, total: u64) -> f64 { if total == 0 { 0.0 } else { used as f64 * 100.0 / total as f64 } } fn format_uptime(seconds: u64) -> String { let days = seconds / 86_400; let hours = (seconds % 86_400) / 3_600; let minutes = (seconds % 3_600) / 60; if days == 0 { format!("{hours:02}:{minutes:02}") } else if days == 1 { format!("1 day, {hours:02}:{minutes:02}") } else { format!("{days} days, {hours:02}:{minutes:02}") } } 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!("{size:.2} {}", UNITS[unit_index]) } else { format!("{} {}", size, UNITS[unit_index]) } } async fn list_images(state: &AppState) -> Result> { let mut out = Vec::new(); let mut dir = fs::read_dir(&state.image_dir).await?; while let Some(entry) = dir.next_entry().await? { let path = entry.path(); let file_type = entry.file_type().await?; if !file_type.is_file() { continue; } let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else { continue; }; let meta = entry.metadata().await?; out.push(ImageView { url: format!("/api/images/{name}"), name, size: meta.len(), }); } out.sort_by(|a, b| a.name.cmp(&b.name)); Ok(out) } fn current_active_images(monitor: &Value) -> Vec { monitor .get("diy") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|panel| panel.get("img").and_then(Value::as_str)) .map(ToString::to_string) .collect() } fn current_switch_time(monitor: &Value) -> u32 { monitor .get("setup") .and_then(Value::as_object) .and_then(|setup| setup.get("switchTime")) .and_then(Value::as_str) .and_then(|value| value.parse::().ok()) .filter(|value| (1..=600).contains(value)) .unwrap_or(10) } fn current_custom_panel_enabled(monitor: &Value) -> bool { monitor .get("setup") .and_then(Value::as_object) .and_then(|setup| setup.get("customPanel")) .and_then(Value::as_bool) .unwrap_or(false) } fn current_specs_enabled(monitor: &Value) -> bool { monitor .get("setup") .and_then(Value::as_object) .and_then(|setup| setup.get("nativeSpecs")) .and_then(Value::as_bool) .unwrap_or(false) } fn current_memes_enabled(monitor: &Value) -> bool { monitor .get("setup") .and_then(Value::as_object) .and_then(|setup| setup.get("nativeMemes")) .and_then(Value::as_bool) .unwrap_or_else(|| !current_active_images(monitor).is_empty()) } fn rotation_snapshot(monitor: &Value) -> RotationSnapshot { RotationSnapshot { custom_panel: current_custom_panel_enabled(monitor), switch_time: current_switch_time(monitor), specs_enabled: current_specs_enabled(monitor), memes_enabled: current_memes_enabled(monitor), active_images: current_active_images(monitor), } } fn set_custom_panels( monitor: &mut Value, switch_time: u32, specs_enabled: bool, memes_enabled: bool, images: &[String], ) { let enabled = specs_enabled || (memes_enabled && !images.is_empty()); let setup = monitor .as_object_mut() .expect("monitor config must be an object") .entry("setup") .or_insert_with(|| Value::Object(Default::default())); if let Some(setup) = setup.as_object_mut() { setup.insert("customPanel".into(), Value::Bool(enabled)); setup.insert("switchTime".into(), Value::String(switch_time.to_string())); setup.insert("nativeSpecs".into(), Value::Bool(specs_enabled)); setup.insert("nativeMemes".into(), Value::Bool(memes_enabled)); } monitor["mianban"] = Value::Array( (1..=images.len()) .map(|index| Value::Number((index as u64).into())) .collect(), ); monitor["diy"] = Value::Array( images .iter() .map(|name| json!({"type": 5, "img": name, "sensor": []})) .collect(), ); } async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8]) -> Result { let image = image::load_from_memory(bytes) .with_context(|| format!("Unsupported image format: {file_name}"))?; let target_name = make_image_name(file_name); let path = state.image_dir.join(&target_name); let rendered = render_display_panel(&image); rendered .save_with_format(&path, ImageFormat::Jpeg) .with_context(|| format!("Failed to save {}", path.display()))?; Ok(target_name) } fn render_display_panel(image: &DynamicImage) -> RgbImage { let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3); let rgb = resized.to_rgb8(); let mut canvas = RgbImage::from_pixel( DISPLAY_WIDTH, DISPLAY_HEIGHT, image::Rgb([8, 10, 12]), ); let offset_x = ((DISPLAY_WIDTH - rgb.width()) / 2) as i64; let offset_y = ((DISPLAY_HEIGHT - rgb.height()) / 2) as i64; image::imageops::overlay(&mut canvas, &rgb, offset_x, offset_y); canvas } fn render_system_panel() -> RgbImage { let system = collect_system_view(); let mut canvas = RgbImage::from_pixel(DISPLAY_WIDTH, DISPLAY_HEIGHT, Rgb([10, 14, 18])); let host_name = System::host_name().unwrap_or_else(|| "Unraid".into()); let timestamp = Local::now().format("%d.%m.%Y %H:%M:%S").to_string(); draw_filled_rect_mut( &mut canvas, Rect::at(0, 0).of_size(DISPLAY_WIDTH, 84), Rgb([18, 27, 36]), ); draw_filled_rect_mut( &mut canvas, Rect::at(0, 84).of_size(DISPLAY_WIDTH, DISPLAY_HEIGHT - 84), Rgb([8, 11, 15]), ); draw_text_line( &mut canvas, Rgb([216, 253, 114]), 28, 20, 36.0, "AOOSTAR Native Specs", ); draw_text_line( &mut canvas, Rgb([146, 163, 181]), 30, 58, 20.0, &format!("{host_name} | {timestamp}"), ); let cards = [ ( 24, 106, "CPU", format!("{} %", system.cpu_usage_percent), format!( "Load {} / {} / {}", system.load_avg_one, system.load_avg_five, system.load_avg_fifteen ), Rgb([124, 231, 191]), ), ( 332, 106, "Memory", format!("{} %", system.mem_usage_percent), format!("{} / {}", system.mem_used, system.mem_total), Rgb([125, 196, 255]), ), ( 640, 106, "Storage", format!("C {}% U {}%", system.cache_usage_percent, system.user_usage_percent), format!( "/mnt/cache {} / {} | /mnt/user {} / {}", system.cache_used, system.cache_total, system.user_used, system.user_total ), Rgb([255, 199, 115]), ), ( 24, 236, "Swap", format!("{} %", system.swap_usage_percent), format!("{} / {}", system.swap_used, system.swap_total), Rgb([194, 167, 255]), ), ( 332, 236, "Temperatures", format!("CPU {}", system.temperature_cpu.as_deref().unwrap_or("-")), format!("GPU {}", system.temperature_gpu.as_deref().unwrap_or("-")), Rgb([255, 127, 115]), ), ( 640, 236, "System", system.uptime.clone(), format!("{} CPU / {} proc", system.cpu_count, system.process_count), Rgb([216, 253, 114]), ), ]; for (x, y, title, value, meta, accent) in cards { draw_metric_card(&mut canvas, x, y, title, &value, &meta, accent); } canvas } fn overlay_system_specs(canvas: &mut RgbImage) { let system = collect_system_view(); let host_name = System::host_name().unwrap_or_else(|| "Unraid".into()); let timestamp = Local::now().format("%H:%M:%S").to_string(); draw_filled_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([12, 18, 24])); draw_hollow_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([36, 50, 67])); draw_text_line( canvas, Rgb([216, 253, 114]), 34, 28, 22.0, &format!( "{host_name} CPU {}% RAM {}% C {}% U {}%", system.cpu_usage_percent, system.mem_usage_percent, system.cache_usage_percent, system.user_usage_percent ), ); draw_text_line( canvas, Rgb([146, 163, 181]), 34, 52, 16.0, &format!( "Load {} / {} / {} Temp {} / {} Uptime {} {}", system.load_avg_one, system.load_avg_five, system.load_avg_fifteen, system.temperature_cpu.as_deref().unwrap_or("-"), system.temperature_gpu.as_deref().unwrap_or("-"), system.uptime, timestamp ), ); draw_filled_rect_mut(canvas, Rect::at(18, 306).of_size(924, 52), Rgb([12, 18, 24])); draw_hollow_rect_mut(canvas, Rect::at(18, 306).of_size(924, 52), Rgb([36, 50, 67])); draw_text_line( canvas, Rgb([237, 242, 247]), 34, 320, 18.0, &format!( "Cache {} / {} User {} / {} Proc {} CPU {} GPU {}", system.cache_used, system.cache_total, system.user_used, system.user_total, system.process_count, system.temperature_cpu.as_deref().unwrap_or("-"), system.temperature_gpu.as_deref().unwrap_or("-"), ), ); } fn draw_metric_card( canvas: &mut RgbImage, x: i32, y: i32, title: &str, value: &str, meta: &str, accent: Rgb, ) { draw_filled_rect_mut(canvas, Rect::at(x, y).of_size(284, 112), Rgb([16, 22, 30])); draw_hollow_rect_mut(canvas, Rect::at(x, y).of_size(284, 112), Rgb([36, 50, 67])); draw_filled_rect_mut(canvas, Rect::at(x + 1, y + 1).of_size(6, 110), accent); draw_text_line(canvas, accent, x + 20, y + 16, 19.0, title); draw_text_line(canvas, Rgb([237, 242, 247]), x + 20, y + 44, 28.0, value); draw_text_line(canvas, Rgb([146, 163, 181]), x + 20, y + 82, 18.0, meta); } fn draw_text_line( canvas: &mut RgbImage, color: Rgb, x: i32, y: i32, size: f32, text: &str, ) { draw_text_mut( canvas, color, x, y, PxScale { x: size, y: size }, display_font(), text, ); } fn display_font() -> &'static FontArc { static FONT: OnceLock = OnceLock::new(); FONT.get_or_init(|| { FontArc::try_from_slice(include_bytes!("../../../fonts/DejaVuSans.ttf")) .expect("embedded display font must be valid") }) } fn make_image_name(file_name: &str) -> String { let base = file_name .rsplit_once('.') .map(|(name, _)| name) .unwrap_or(file_name); let clean: String = base .chars() .map(|ch| match ch { 'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(), _ => '-', }) .collect(); let clean = clean.trim_matches('-'); let clean = if clean.is_empty() { "panel" } else { clean }; let timestamp = Local::now().format("%Y%m%d-%H%M%S"); format!("panel-{timestamp}-{clean}.jpg") } fn spawn_display_worker(state: Arc, config: DisplayConfig) { thread::spawn(move || run_display_worker(state, config)); } fn run_display_worker(state: Arc, config: DisplayConfig) { if !config.native_enabled { info!("Native display loop disabled"); return; } loop { match open_screen(&config) { Ok(mut screen) => { info!("Display target opened: {}", display_target(&config)); if let Err(err) = screen.init() { warn!("Display init failed: {err}"); set_display_error( &state.display_status, false, format!("Display init failed: {err}"), ); thread::sleep(Duration::from_secs(3)); continue; } update_display_status(&state.display_status, |status| { status.connected = true; status.last_error = None; status.updated_at = Some(stamp_now()); }); if let Err(err) = run_display_session(&state, &mut screen) { error!("Display loop error: {err}"); set_display_error( &state.display_status, false, format!("Display loop error: {err}"), ); } } Err(err) => { warn!("Failed to open display target: {err}"); set_display_error( &state.display_status, false, format!("Failed to open display target: {err}"), ); } } thread::sleep(Duration::from_secs(3)); } } fn open_screen(config: &DisplayConfig) -> Result { let mut builder = AooScreenBuilder::new(); builder.no_init_check(config.write_only); if config.simulate { builder.simulate() } else if let Some(device) = &config.device { builder.open_device(device) } else if let Some(usb) = &config.usb { builder.open_usb_id(usb) } else { builder.open_default() } } fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { let mut snapshot = RotationSnapshot::default(); let mut cycle_started_at = Instant::now(); let mut current_frame_index = None; loop { let monitor = match load_monitor_json_sync(&state.monitor_path) { Ok(monitor) => monitor, Err(err) => { update_display_status(&state.display_status, |status| { status.last_error = Some(format!("Config read failed: {err}")); status.connected = true; status.updated_at = Some(stamp_now()); }); thread::sleep(Duration::from_secs(1)); continue; } }; let new_snapshot = rotation_snapshot(&monitor); if new_snapshot != snapshot { snapshot = new_snapshot; cycle_started_at = Instant::now(); current_frame_index = None; } let frames = snapshot.frames(); update_display_status(&state.display_status, |status| { status.custom_panel = snapshot.custom_panel; status.specs_enabled = snapshot.specs_enabled; status.memes_enabled = snapshot.memes_enabled; status.rotation_active = snapshot.rotation_active(); status.switch_time = snapshot.switch_time.to_string(); status.active_images = snapshot.active_images.clone(); if !snapshot.rotation_active() { status.current_image = None; } status.connected = true; status.updated_at = Some(stamp_now()); }); if !snapshot.rotation_active() { thread::sleep(Duration::from_millis(750)); continue; } let switch_after = Duration::from_secs(snapshot.switch_time as u64); let elapsed = cycle_started_at.elapsed(); let frame_slot = elapsed.as_secs() / snapshot.switch_time as u64; let next_index = (frame_slot as usize) % frames.len(); let send_due = current_frame_index != Some(next_index); if send_due { let frame = frames[next_index].clone(); match frame { RotationFrame::SystemSpecs => { let panel_name = SYSTEM_FRAME_NAME.to_string(); let rgb_img = render_system_panel(); screen .send_image(&rgb_img) .context("Failed to send system specs panel")?; update_display_status(&state.display_status, |status| { status.current_image = Some(panel_name); status.last_error = None; status.connected = true; status.updated_at = Some(stamp_now()); }); } RotationFrame::Image(image_name) => match load_panel_rgb(&state.image_dir, &image_name) { Ok(mut rgb_img) => { if snapshot.specs_enabled { overlay_system_specs(&mut rgb_img); } screen .send_image(&rgb_img) .with_context(|| format!("Failed to send panel {image_name}"))?; update_display_status(&state.display_status, |status| { status.current_image = Some(image_name.clone()); status.last_error = None; status.connected = true; status.updated_at = Some(stamp_now()); }); } Err(err) => { warn!("Skipping panel {image_name}: {err}"); update_display_status(&state.display_status, |status| { status.current_image = Some(image_name.clone()); status.last_error = Some(format!("Panel {image_name} could not be rendered: {err}")); status.connected = true; status.updated_at = Some(stamp_now()); }); } }, } current_frame_index = Some(next_index); } thread::sleep(rotation_sleep(frames.len(), switch_after, cycle_started_at)); } } fn load_monitor_json_sync(path: &PathBuf) -> Result { let raw = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; serde_json::from_str(&raw).context("Failed to parse Monitor3.json") } fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result { let path = image_dir.join(image_name); let image = img::load_image(&path, Some(DISPLAY_SIZE)) .with_context(|| format!("Failed to load panel image {}", path.display()))?; Ok(image.to_rgb8()) } fn rotation_sleep(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration { if frame_count <= 1 { return Duration::from_millis(750); } let elapsed = cycle_started_at.elapsed(); let switch_secs = switch_after.as_secs().max(1); let next_boundary_secs = ((elapsed.as_secs() / switch_secs) + 1) * switch_secs; let remaining = Duration::from_secs(next_boundary_secs).saturating_sub(elapsed); if remaining > Duration::from_millis(750) { Duration::from_millis(750) } else if remaining < Duration::from_millis(200) { Duration::from_millis(200) } else { remaining } } fn read_display_status(status: &Arc>) -> DisplayStatus { status .read() .expect("display status lock poisoned") .clone() } fn update_display_status( status: &Arc>, apply: impl FnOnce(&mut DisplayStatus), ) { let mut guard = status.write().expect("display status lock poisoned"); apply(&mut guard); } fn set_display_error(status: &Arc>, connected: bool, message: String) { update_display_status(status, |current| { current.connected = connected; current.last_error = Some(message); current.updated_at = Some(stamp_now()); if !connected { current.current_image = None; } }); } fn display_mode(config: &DisplayConfig) -> String { if !config.native_enabled { "disabled".into() } else if config.simulate { "simulate".into() } else if config.device.is_some() { "device".into() } else if config.usb.is_some() { "usb-id".into() } else { "usb-default".into() } } fn display_target(config: &DisplayConfig) -> String { if !config.native_enabled { "native loop disabled".into() } else if config.simulate { "simulated LCD".into() } else if let Some(device) = &config.device { device.clone() } else if let Some(usb) = &config.usb { format!("USB {usb}") } else { "default AOOSTAR USB UART 0416:90A1".into() } } fn stamp_now() -> String { Utc::now().to_rfc3339() } const INDEX_HTML: &str = r##" aster-webui

aoostar-rs fork

aster-webui

Eigene Rust-WebUI mit nativer Display-Logik. Uploads werden auf 960x376 JPG normalisiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.

Custom Panel-
Active Images-
Display Status-
Current Frame-
Display Target-
Config Path-
CPU--
Memory--
Storage--
Swap--
Load Avg--
Temperatures--

Available Images

Rotation Order

"##;