From a302d3270e21ba3e0fb04f6e092f8bdb1d8dfcfa Mon Sep 17 00:00:00 2001 From: magges Date: Tue, 30 Jun 2026 19:21:44 +0200 Subject: [PATCH] refactor: split aster-webui main into modules --- crates/aster-webui/src/display.rs | 616 ++++++++ crates/aster-webui/src/images.rs | 230 +++ crates/aster-webui/src/main.rs | 2257 +---------------------------- crates/aster-webui/src/monitor.rs | 225 +++ crates/aster-webui/src/routes.rs | 270 ++++ crates/aster-webui/src/system.rs | 348 +++++ crates/aster-webui/src/types.rs | 185 +++ crates/aster-webui/src/ui.rs | 503 +++++++ 8 files changed, 2392 insertions(+), 2242 deletions(-) create mode 100644 crates/aster-webui/src/display.rs create mode 100644 crates/aster-webui/src/images.rs create mode 100644 crates/aster-webui/src/monitor.rs create mode 100644 crates/aster-webui/src/routes.rs create mode 100644 crates/aster-webui/src/system.rs create mode 100644 crates/aster-webui/src/types.rs create mode 100644 crates/aster-webui/src/ui.rs diff --git a/crates/aster-webui/src/display.rs b/crates/aster-webui/src/display.rs new file mode 100644 index 0000000..4cbda30 --- /dev/null +++ b/crates/aster-webui/src/display.rs @@ -0,0 +1,616 @@ +use std::{ + sync::{Arc, RwLock}, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use asterctl_lcd::{AooScreen, AooScreenBuilder}; +use chrono::Utc; +use tracing::{error, info, warn}; + +use crate::{ + images::{is_gif_manifest_name, load_gif_manifest, load_panel_rgb}, + monitor::{load_monitor_json_sync, rotation_snapshot}, + system::{overlay_system_specs, render_system_panel}, + types::{ActiveFrame, AppState, DisplayConfig, DisplayStatus, RotationSlot, RotationSnapshot}, +}; + +const SYSTEM_FRAME_NAME: &str = "System Specs"; + +pub(crate) 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, + gifs_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 +} + +pub(crate) 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_key: Option = None; + let mut slots: Vec = Vec::new(); + + 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_key = None; + slots = build_rotation_slots(&snapshot, &state.image_dir); + } + + 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.gifs_enabled = snapshot.gifs_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 specs_only = snapshot.specs_enabled && slots.is_empty(); + if let Some((_slot_idx, active_frame)) = + current_slot_frame(&slots, specs_only, snapshot.switch_time, cycle_started_at) + { + let send_due = current_frame_key.as_deref() != Some(active_frame.key.as_str()); + if send_due { + if active_frame.image_name == SYSTEM_FRAME_NAME { + let panel_name = SYSTEM_FRAME_NAME.to_string(); + let rgb_img = render_system_panel(); + prepare_screen_for_frame(screen, &active_frame); + 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()); + }); + } else { + match load_panel_rgb(&state.image_dir, &active_frame.image_name) { + Ok(mut rgb_img) => { + if snapshot.specs_enabled { + overlay_system_specs(&mut rgb_img); + } + prepare_screen_for_frame(screen, &active_frame); + screen.send_image(&rgb_img).with_context(|| { + format!("Failed to send panel {}", active_frame.image_name) + })?; + + update_display_status(&state.display_status, |status| { + status.current_image = Some(active_frame.display_name.clone()); + status.last_error = None; + status.connected = true; + status.updated_at = Some(stamp_now()); + }); + } + Err(err) => { + warn!("Skipping panel {}: {err}", active_frame.image_name); + update_display_status(&state.display_status, |status| { + status.current_image = Some(active_frame.display_name.clone()); + status.last_error = Some(format!( + "Panel {} could not be rendered: {err}", + active_frame.display_name + )); + status.connected = true; + status.updated_at = Some(stamp_now()); + }); + } + } + } + current_frame_key = Some(active_frame.key); + } + } + + thread::sleep(rotation_sleep( + snapshot.rotation_active(), + &slots, + cycle_started_at, + )); + } +} + +fn build_rotation_slots( + snapshot: &RotationSnapshot, + image_dir: &std::path::PathBuf, +) -> Vec { + let mut slots = Vec::new(); + if snapshot.memes_enabled || snapshot.gifs_enabled { + for name in &snapshot.active_images { + if is_gif_manifest_name(name) { + if !snapshot.gifs_enabled { + continue; + } + match load_gif_manifest(image_dir, name) { + Ok(manifest) if !manifest.frames.is_empty() => { + let mut frames = Vec::new(); + for (idx, frame) in manifest.frames.iter().enumerate() { + frames.push(( + ActiveFrame { + key: format!("{name}#{idx}"), + image_name: frame.name.clone(), + display_name: format!("{} [{}]", manifest.label, idx + 1), + animated: true, + }, + Duration::from_millis(frame.delay_ms.max(100) as u64), + )); + } + slots.push(RotationSlot { + total_duration: Duration::from_secs(snapshot.switch_time as u64), + frames, + }); + } + Ok(_) => {} + Err(err) => warn!("Skipping GIF set {name}: {err}"), + } + } else if snapshot.memes_enabled { + slots.push(RotationSlot { + total_duration: Duration::from_secs(snapshot.switch_time as u64), + frames: vec![( + ActiveFrame { + key: name.clone(), + image_name: name.clone(), + display_name: name.clone(), + animated: false, + }, + Duration::from_secs(snapshot.switch_time as u64), + )], + }); + } + } + } + if slots.is_empty() && snapshot.specs_enabled { + slots.push(RotationSlot { + total_duration: Duration::from_secs(snapshot.switch_time as u64), + frames: vec![( + ActiveFrame { + key: SYSTEM_FRAME_NAME.into(), + image_name: SYSTEM_FRAME_NAME.into(), + display_name: SYSTEM_FRAME_NAME.into(), + animated: false, + }, + Duration::from_secs(snapshot.switch_time as u64), + )], + }); + } + slots +} + +fn current_slot_frame( + slots: &[RotationSlot], + specs_only: bool, + switch_time: u32, + cycle_started_at: Instant, +) -> Option<(usize, ActiveFrame)> { + if specs_only { + return Some(( + 0, + ActiveFrame { + key: SYSTEM_FRAME_NAME.into(), + image_name: SYSTEM_FRAME_NAME.into(), + display_name: SYSTEM_FRAME_NAME.into(), + animated: false, + }, + )); + } + if slots.is_empty() { + return None; + } + + let cycle_ms: u64 = slots + .iter() + .map(|slot| slot.total_duration.as_millis() as u64) + .sum::() + .max((switch_time as u64).max(1) * 1000); + let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; + let mut cursor = 0u64; + + for (slot_idx, slot) in slots.iter().enumerate() { + let slot_ms = slot.total_duration.as_millis() as u64; + if elapsed_ms < cursor + slot_ms { + let local_ms = elapsed_ms - cursor; + return slot_frame_at(slot, slot_idx, local_ms); + } + cursor += slot_ms; + } + + slots + .last() + .and_then(|slot| slot.frames.last().map(|(frame, _)| frame.clone())) + .map(|frame| (slots.len() - 1, frame)) +} + +fn slot_frame_at( + slot: &RotationSlot, + slot_idx: usize, + local_ms: u64, +) -> Option<(usize, ActiveFrame)> { + if slot.frames.len() == 1 { + return Some((slot_idx, slot.frames[0].0.clone())); + } + + let frame_cycle = frame_cycle_ms(slot); + let frame_elapsed_ms = if frame_cycle == 0 { + 0 + } else { + local_ms % frame_cycle + }; + let mut frame_cursor = 0u64; + + for (frame, delay) in &slot.frames { + let delay_ms = delay.as_millis() as u64; + if frame_elapsed_ms < frame_cursor + delay_ms { + return Some((slot_idx, frame.clone())); + } + frame_cursor += delay_ms; + } + + slot.frames + .last() + .map(|(frame, _)| (slot_idx, frame.clone())) +} + +fn frame_cycle_ms(slot: &RotationSlot) -> u64 { + slot.frames + .iter() + .map(|(_, delay)| delay.as_millis() as u64) + .sum::() + .max(100) +} + +fn prepare_screen_for_frame(screen: &mut AooScreen, frame: &ActiveFrame) { + let use_cache = !frame.animated; + if screen.is_cache_enabled() != use_cache { + screen.enable_cache(use_cache); + } +} + +fn rotation_sleep( + rotation_active: bool, + slots: &[RotationSlot], + cycle_started_at: Instant, +) -> Duration { + if !rotation_active || slots.is_empty() { + return Duration::from_millis(750); + } + let total_frames: usize = slots.iter().map(|slot| slot.frames.len()).sum(); + if total_frames <= 1 { + return Duration::from_millis(750); + } + + let cycle_ms: u64 = slots + .iter() + .map(|slot| slot.total_duration.as_millis() as u64) + .sum::() + .max(100); + let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; + let mut cursor = 0u64; + + for slot in slots { + let slot_ms = slot.total_duration.as_millis() as u64; + if elapsed_ms < cursor + slot_ms { + if slot.frames.len() <= 1 { + return Duration::from_millis(750); + } + let local_ms = elapsed_ms - cursor; + let frame_elapsed_ms = local_ms % frame_cycle_ms(slot); + let mut frame_cursor = 0u64; + for (_, delay) in &slot.frames { + let delay_ms = delay.as_millis() as u64; + if frame_elapsed_ms < frame_cursor + delay_ms { + let frame_remaining = frame_cursor + delay_ms - frame_elapsed_ms; + let slot_remaining = slot_ms.saturating_sub(local_ms); + return Duration::from_millis( + frame_remaining.min(slot_remaining).clamp(100, 750), + ); + } + frame_cursor += delay_ms; + } + } + cursor += slot_ms; + } + + Duration::from_millis(250) +} + +pub(crate) 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() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_frame(key: &str) -> ActiveFrame { + ActiveFrame { + key: key.into(), + image_name: format!("{key}.jpg"), + display_name: "gif".into(), + animated: true, + } + } + + #[test] + fn prepare_screen_for_frame_disables_cache_for_animated_frames() { + let mut screen = AooScreenBuilder::new().simulate().unwrap(); + assert!(screen.is_cache_enabled()); + + prepare_screen_for_frame(&mut screen, &test_frame("gif#0")); + assert!(!screen.is_cache_enabled()); + + let still = ActiveFrame { + key: "still".into(), + image_name: "still.jpg".into(), + display_name: "still".into(), + animated: false, + }; + prepare_screen_for_frame(&mut screen, &still); + assert!(screen.is_cache_enabled()); + } + + #[test] + fn slot_frame_at_loops_animated_frames_for_full_slot() { + let slot = RotationSlot { + total_duration: Duration::from_secs(10), + frames: vec![ + (test_frame("gif#0"), Duration::from_millis(100)), + (test_frame("gif#1"), Duration::from_millis(100)), + (test_frame("gif#2"), Duration::from_millis(100)), + ], + }; + + assert_eq!(slot_frame_at(&slot, 0, 50).unwrap().1.key, "gif#0"); + assert_eq!(slot_frame_at(&slot, 0, 150).unwrap().1.key, "gif#1"); + assert_eq!(slot_frame_at(&slot, 0, 250).unwrap().1.key, "gif#2"); + assert_eq!(slot_frame_at(&slot, 0, 350).unwrap().1.key, "gif#0"); + assert_eq!(slot_frame_at(&slot, 0, 9_950).unwrap().1.key, "gif#0"); + } + + #[test] + fn build_rotation_slots_uses_switch_time_for_gif_slot_duration() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let image_dir = std::env::temp_dir().join(format!("aster-webui-gif-test-{unique}")); + fs::create_dir_all(&image_dir).unwrap(); + let manifest_name = "panel-test.gifset.json"; + fs::write( + image_dir.join(manifest_name), + r#"{ + "kind": "gif_set", + "version": 1, + "label": "panel-test.gif", + "preview": ".gifframe-test-000.jpg", + "frames": [ + { "name": ".gifframe-test-000.jpg", "delay_ms": 100 }, + { "name": ".gifframe-test-001.jpg", "delay_ms": 100 } + ] + }"#, + ) + .unwrap(); + + let snapshot = RotationSnapshot { + custom_panel: true, + switch_time: 7, + specs_enabled: false, + memes_enabled: false, + gifs_enabled: true, + active_images: vec![manifest_name.into()], + }; + + let slots = build_rotation_slots(&snapshot, &image_dir); + fs::remove_dir_all(&image_dir).unwrap(); + + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].total_duration, Duration::from_secs(7)); + assert_eq!(slots[0].frames.len(), 2); + } + + #[test] + fn build_rotation_slots_skips_gif_when_gifs_are_disabled() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let image_dir = + std::env::temp_dir().join(format!("aster-webui-gif-disabled-test-{unique}")); + fs::create_dir_all(&image_dir).unwrap(); + let manifest_name = "panel-test.gifset.json"; + fs::write( + image_dir.join(manifest_name), + r#"{ + "kind": "gif_set", + "version": 1, + "label": "panel-test.gif", + "preview": ".gifframe-test-000.jpg", + "frames": [ + { "name": ".gifframe-test-000.jpg", "delay_ms": 100 }, + { "name": ".gifframe-test-001.jpg", "delay_ms": 100 } + ] + }"#, + ) + .unwrap(); + + let snapshot = RotationSnapshot { + custom_panel: true, + switch_time: 7, + specs_enabled: false, + memes_enabled: false, + gifs_enabled: false, + active_images: vec![manifest_name.into()], + }; + + let slots = build_rotation_slots(&snapshot, &image_dir); + fs::remove_dir_all(&image_dir).unwrap(); + + assert!(slots.is_empty()); + } +} diff --git a/crates/aster-webui/src/images.rs b/crates/aster-webui/src/images.rs new file mode 100644 index 0000000..b5f44ae --- /dev/null +++ b/crates/aster-webui/src/images.rs @@ -0,0 +1,230 @@ +use std::{io::Cursor, path::PathBuf}; + +use anyhow::{Context, Result}; +use asterctl::img; +use chrono::Local; +use image::{ + codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage, Frame, + ImageFormat, RgbImage, +}; +use tokio::fs; + +use crate::{ + system::{DISPLAY_HEIGHT, DISPLAY_WIDTH}, + types::{AppState, GifFrameMeta, GifSetManifest, ImageView}, +}; + +pub(crate) const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif"); +const GIF_FRAME_PREFIX: &str = ".gifframe-"; +const GIF_MANIFEST_SUFFIX: &str = ".gifset.json"; + +pub(crate) 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; + }; + if is_gif_frame_name(&name) { + continue; + } + let meta = entry.metadata().await?; + if is_gif_manifest_name(&name) { + let raw = fs::read_to_string(&path).await?; + let manifest: GifSetManifest = serde_json::from_str(&raw) + .with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?; + out.push(ImageView { + url: format!("/api/images/{}", manifest.preview), + name, + label: manifest.label, + size: meta.len(), + animated: true, + frame_count: manifest.frames.len(), + }); + } else { + out.push(ImageView { + url: format!("/api/images/{name}"), + label: name.clone(), + name, + size: meta.len(), + animated: false, + frame_count: 1, + }); + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +pub(crate) async fn convert_and_store_image( + state: &AppState, + file_name: &str, + bytes: &[u8], +) -> Result { + if is_gif_file(file_name, bytes) { + return convert_and_store_gif(state, file_name, bytes).await; + } + + 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) +} + +async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8]) -> Result { + let decoder = GifDecoder::new(Cursor::new(bytes)) + .with_context(|| format!("Unsupported GIF format: {file_name}"))?; + let frames = decoder + .into_frames() + .collect_frames() + .context("Failed to decode GIF frames")?; + if frames.is_empty() { + anyhow::bail!("GIF contains no frames"); + } + + let base = make_image_stem(file_name); + let timestamp = Local::now().format("%Y%m%d-%H%M%S"); + let manifest_name = format!("panel-{timestamp}-{base}{GIF_MANIFEST_SUFFIX}"); + let mut stored_frames = Vec::new(); + + for (idx, frame) in frames.into_iter().enumerate() { + let delay_ms = frame_delay_ms(&frame); + let frame_name = format!("{GIF_FRAME_PREFIX}{timestamp}-{base}-{idx:03}.jpg"); + let frame_path = state.image_dir.join(&frame_name); + let rendered = render_display_panel(&DynamicImage::ImageRgba8(frame.into_buffer())); + rendered + .save_with_format(&frame_path, ImageFormat::Jpeg) + .with_context(|| format!("Failed to save {}", frame_path.display()))?; + stored_frames.push(GifFrameMeta { + name: frame_name, + delay_ms, + }); + } + + let manifest = GifSetManifest { + kind: "gif_set".into(), + version: 1, + label: file_name.to_string(), + preview: stored_frames + .first() + .map(|frame| frame.name.clone()) + .unwrap_or_default(), + frames: stored_frames, + }; + let manifest_path = state.image_dir.join(&manifest_name); + fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?) + .await + .with_context(|| format!("Failed to save {}", manifest_path.display()))?; + Ok(manifest_name) +} + +pub(crate) 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 make_image_name(file_name: &str) -> String { + let clean = make_image_stem(file_name); + let timestamp = Local::now().format("%Y%m%d-%H%M%S"); + format!("panel-{timestamp}-{clean}.jpg") +} + +fn make_image_stem(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('-'); + if clean.is_empty() { + "panel".into() + } else { + clean.to_string() + } +} + +fn is_gif_file(file_name: &str, bytes: &[u8]) -> bool { + file_name.to_ascii_lowercase().ends_with(".gif") + || bytes.starts_with(b"GIF87a") + || bytes.starts_with(b"GIF89a") +} + +pub(crate) fn is_gif_manifest_name(name: &str) -> bool { + name.ends_with(GIF_MANIFEST_SUFFIX) +} + +fn is_gif_frame_name(name: &str) -> bool { + name.starts_with(GIF_FRAME_PREFIX) +} + +fn frame_delay_ms(frame: &Frame) -> u32 { + let (numer, denom) = frame.delay().numer_denom_ms(); + let delay = if denom == 0 { + numer + } else { + ((numer as f64) / (denom as f64)).round() as u32 + }; + delay.max(100) +} + +pub(crate) async fn delete_image_asset(state: &AppState, name: &str) -> Result<()> { + let path = state.image_dir.join(name); + if is_gif_manifest_name(name) { + let raw = fs::read_to_string(&path) + .await + .with_context(|| format!("Failed to read {}", path.display()))?; + let manifest: GifSetManifest = serde_json::from_str(&raw) + .with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?; + for frame in manifest.frames { + let frame_path = state.image_dir.join(frame.name); + match fs::remove_file(&frame_path).await { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + } + } + + fs::remove_file(&path) + .await + .with_context(|| format!("Failed to remove {}", path.display()))?; + Ok(()) +} + +pub(crate) fn load_gif_manifest(image_dir: &PathBuf, name: &str) -> Result { + let path = image_dir.join(name); + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read GIF manifest {}", path.display()))?; + serde_json::from_str(&raw) + .with_context(|| format!("Failed to parse GIF manifest {}", path.display())) +} + +pub(crate) 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(asterctl_lcd::DISPLAY_SIZE)) + .with_context(|| format!("Failed to load panel image {}", path.display()))?; + Ok(image.to_rgb8()) +} diff --git a/crates/aster-webui/src/main.rs b/crates/aster-webui/src/main.rs index bcb8208..041880e 100644 --- a/crates/aster-webui/src/main.rs +++ b/crates/aster-webui/src/main.rs @@ -1,222 +1,25 @@ use std::{ - io::Cursor, net::SocketAddr, - path::PathBuf, - sync::{Arc, OnceLock, RwLock}, - thread, - time::{Duration, Instant}, + sync::{Arc, RwLock}, }; -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 axum::extract::DefaultBodyLimit; use clap::Parser; -use image::{ - AnimationDecoder, DynamicImage, Frame, ImageFormat, Rgb, RgbImage, codecs::gif::GifDecoder, - 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}; +use tracing::info; -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"); -const GIF_FRAME_PREFIX: &str = ".gifframe-"; -const GIF_MANIFEST_SUFFIX: &str = ".gifset.json"; +mod display; +mod images; +mod monitor; +mod routes; +mod system; +mod types; +mod ui; -#[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, - gifs_enabled: bool, - active_images: Vec, -} - -impl RotationSnapshot { - fn rotation_active(&self) -> bool { - self.custom_panel - && (self.specs_enabled - || ((self.memes_enabled || self.gifs_enabled) && !self.active_images.is_empty())) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct GifFrameMeta { - name: String, - delay_ms: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct GifSetManifest { - kind: String, - version: u32, - label: String, - preview: String, - frames: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ActiveFrame { - key: String, - image_name: String, - display_name: String, -} - -#[derive(Clone, Debug)] -struct RotationSlot { - total_duration: Duration, - frames: Vec<(ActiveFrame, Duration)>, -} - -#[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, - gifs_enabled: bool, -} - -#[derive(Clone, Serialize)] -struct ImageView { - name: String, - label: String, - size: u64, - url: String, - animated: bool, - frame_count: usize, -} - -#[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, - gifs_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, - gifs_enabled: Option, -} - -#[derive(Deserialize)] -struct DeleteRequest { - name: String, -} - -#[derive(Serialize)] -struct ErrorResponse { - error: String, -} +use display::{initial_display_status, spawn_display_worker}; +use monitor::ensure_layout; +use routes::router; +use types::{AppState, Cli, DisplayConfig}; #[tokio::main] async fn main() -> Result<()> { @@ -250,2040 +53,10 @@ async fn main() -> Result<()> { 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); + let app = router(state).layer(DefaultBodyLimit::max(64 * 1024 * 1024)); 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, - gifs_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, - "nativeGifs": 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 has_static_selection = valid.iter().any(|name| !is_gif_manifest_name(name)); - let has_gif_selection = valid.iter().any(|name| is_gif_manifest_name(name)); - let memes_enabled = payload.memes_enabled.unwrap_or(has_static_selection); - let gifs_enabled = payload.gifs_enabled.unwrap_or(has_gif_selection); - - if !specs_enabled - && !(memes_enabled && has_static_selection) - && !(gifs_enabled && has_gif_selection) - { - return error_response( - StatusCode::BAD_REQUEST, - "Enable specs, memes with a still image, or GIFs with an animated 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, - gifs_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, - "gifsEnabled": gifs_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, 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"); - } - - if let Err(err) = delete_image_asset(&state, &payload.name).await { - return match err.downcast_ref::() { - Some(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => { - error_response(StatusCode::NOT_FOUND, "Image not found") - } - _ => 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 gifs_enabled = current_gifs_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, - gifs_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); - let gifs_enabled = current_gifs_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, - gifs_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; - }; - if is_gif_frame_name(&name) { - continue; - } - let meta = entry.metadata().await?; - if is_gif_manifest_name(&name) { - let raw = fs::read_to_string(&path).await?; - let manifest: GifSetManifest = serde_json::from_str(&raw) - .with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?; - out.push(ImageView { - url: format!("/api/images/{}", manifest.preview), - name, - label: manifest.label, - size: meta.len(), - animated: true, - frame_count: manifest.frames.len(), - }); - } else { - out.push(ImageView { - url: format!("/api/images/{name}"), - label: name.clone(), - name, - size: meta.len(), - animated: false, - frame_count: 1, - }); - } - } - 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 current_gifs_enabled(monitor: &Value) -> bool { - monitor - .get("setup") - .and_then(Value::as_object) - .and_then(|setup| setup.get("nativeGifs")) - .and_then(Value::as_bool) - .unwrap_or(true) -} - -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), - gifs_enabled: current_gifs_enabled(monitor), - active_images: current_active_images(monitor), - } -} - -fn set_custom_panels( - monitor: &mut Value, - switch_time: u32, - specs_enabled: bool, - memes_enabled: bool, - gifs_enabled: bool, - images: &[String], -) { - let enabled = specs_enabled || ((memes_enabled || gifs_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)); - setup.insert("nativeGifs".into(), Value::Bool(gifs_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 { - if is_gif_file(file_name, bytes) { - return convert_and_store_gif(state, file_name, bytes).await; - } - - 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) -} - -async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8]) -> Result { - let decoder = GifDecoder::new(Cursor::new(bytes)) - .with_context(|| format!("Unsupported GIF format: {file_name}"))?; - let frames = decoder - .into_frames() - .collect_frames() - .context("Failed to decode GIF frames")?; - if frames.is_empty() { - anyhow::bail!("GIF contains no frames"); - } - - let base = make_image_stem(file_name); - let timestamp = Local::now().format("%Y%m%d-%H%M%S"); - let manifest_name = format!("panel-{timestamp}-{base}{GIF_MANIFEST_SUFFIX}"); - let mut stored_frames = Vec::new(); - - for (idx, frame) in frames.into_iter().enumerate() { - let delay_ms = frame_delay_ms(&frame); - let frame_name = format!("{GIF_FRAME_PREFIX}{timestamp}-{base}-{idx:03}.jpg"); - let frame_path = state.image_dir.join(&frame_name); - let rendered = render_display_panel(&DynamicImage::ImageRgba8(frame.into_buffer())); - rendered - .save_with_format(&frame_path, ImageFormat::Jpeg) - .with_context(|| format!("Failed to save {}", frame_path.display()))?; - stored_frames.push(GifFrameMeta { - name: frame_name, - delay_ms, - }); - } - - let manifest = GifSetManifest { - kind: "gif_set".into(), - version: 1, - label: file_name.to_string(), - preview: stored_frames - .first() - .map(|frame| frame.name.clone()) - .unwrap_or_default(), - frames: stored_frames, - }; - let manifest_path = state.image_dir.join(&manifest_name); - fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?) - .await - .with_context(|| format!("Failed to save {}", manifest_path.display()))?; - Ok(manifest_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 clean = make_image_stem(file_name); - let timestamp = Local::now().format("%Y%m%d-%H%M%S"); - format!("panel-{timestamp}-{clean}.jpg") -} - -fn make_image_stem(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('-'); - if clean.is_empty() { - "panel".into() - } else { - clean.to_string() - } -} - -fn is_gif_file(file_name: &str, bytes: &[u8]) -> bool { - file_name.to_ascii_lowercase().ends_with(".gif") || bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") -} - -fn is_gif_manifest_name(name: &str) -> bool { - name.ends_with(GIF_MANIFEST_SUFFIX) -} - -fn is_gif_frame_name(name: &str) -> bool { - name.starts_with(GIF_FRAME_PREFIX) -} - -fn frame_delay_ms(frame: &Frame) -> u32 { - let (numer, denom) = frame.delay().numer_denom_ms(); - let delay = if denom == 0 { - numer - } else { - ((numer as f64) / (denom as f64)).round() as u32 - }; - delay.max(100) -} - -async fn delete_image_asset(state: &AppState, name: &str) -> Result<()> { - let path = state.image_dir.join(name); - if is_gif_manifest_name(name) { - let raw = fs::read_to_string(&path) - .await - .with_context(|| format!("Failed to read {}", path.display()))?; - let manifest: GifSetManifest = serde_json::from_str(&raw) - .with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?; - for frame in manifest.frames { - let frame_path = state.image_dir.join(frame.name); - match fs::remove_file(&frame_path).await { - Ok(_) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(err.into()), - } - } - } - - fs::remove_file(&path) - .await - .with_context(|| format!("Failed to remove {}", path.display()))?; - Ok(()) -} - -fn load_gif_manifest(image_dir: &PathBuf, name: &str) -> Result { - let path = image_dir.join(name); - let raw = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read GIF manifest {}", path.display()))?; - serde_json::from_str(&raw) - .with_context(|| format!("Failed to parse GIF manifest {}", path.display())) -} - -fn build_rotation_slots(snapshot: &RotationSnapshot, image_dir: &PathBuf) -> Vec { - let mut slots = Vec::new(); - if snapshot.memes_enabled || snapshot.gifs_enabled { - for name in &snapshot.active_images { - if is_gif_manifest_name(name) { - if !snapshot.gifs_enabled { - continue; - } - match load_gif_manifest(image_dir, name) { - Ok(manifest) if !manifest.frames.is_empty() => { - let mut frames = Vec::new(); - for (idx, frame) in manifest.frames.iter().enumerate() { - frames.push(( - ActiveFrame { - key: format!("{name}#{idx}"), - image_name: frame.name.clone(), - display_name: manifest.label.clone(), - }, - Duration::from_millis(frame.delay_ms.max(100) as u64), - )); - } - slots.push(RotationSlot { - total_duration: Duration::from_secs(snapshot.switch_time as u64), - frames, - }); - } - Ok(_) => {} - Err(err) => warn!("Skipping GIF set {name}: {err}"), - } - } else if snapshot.memes_enabled { - slots.push(RotationSlot { - total_duration: Duration::from_secs(snapshot.switch_time as u64), - frames: vec![( - ActiveFrame { - key: name.clone(), - image_name: name.clone(), - display_name: name.clone(), - }, - Duration::from_secs(snapshot.switch_time as u64), - )], - }); - } - } - } - if slots.is_empty() && snapshot.specs_enabled { - slots.push(RotationSlot { - total_duration: Duration::from_secs(snapshot.switch_time as u64), - frames: vec![( - ActiveFrame { - key: SYSTEM_FRAME_NAME.into(), - image_name: SYSTEM_FRAME_NAME.into(), - display_name: SYSTEM_FRAME_NAME.into(), - }, - Duration::from_secs(snapshot.switch_time as u64), - )], - }); - } - slots -} - -fn current_slot_frame( - slots: &[RotationSlot], - specs_only: bool, - switch_time: u32, - cycle_started_at: Instant, -) -> Option<(usize, ActiveFrame)> { - if specs_only { - return Some(( - 0, - ActiveFrame { - key: SYSTEM_FRAME_NAME.into(), - image_name: SYSTEM_FRAME_NAME.into(), - display_name: SYSTEM_FRAME_NAME.into(), - }, - )); - } - if slots.is_empty() { - return None; - } - - let cycle_ms: u64 = slots - .iter() - .map(|slot| slot.total_duration.as_millis() as u64) - .sum::() - .max((switch_time as u64).max(1) * 1000); - let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; - let mut cursor = 0u64; - - for (slot_idx, slot) in slots.iter().enumerate() { - let slot_ms = slot.total_duration.as_millis() as u64; - if elapsed_ms < cursor + slot_ms { - let local_ms = elapsed_ms - cursor; - return slot_frame_at(slot, slot_idx, local_ms); - } - cursor += slot_ms; - } - - slots - .last() - .and_then(|slot| slot.frames.last().map(|(frame, _)| frame.clone())) - .map(|frame| (slots.len() - 1, frame)) -} - -fn slot_frame_at(slot: &RotationSlot, slot_idx: usize, local_ms: u64) -> Option<(usize, ActiveFrame)> { - if slot.frames.len() == 1 { - return Some((slot_idx, slot.frames[0].0.clone())); - } - - let frame_cycle_ms = frame_cycle_ms(slot); - let frame_elapsed_ms = if frame_cycle_ms == 0 { - 0 - } else { - local_ms % frame_cycle_ms - }; - let mut frame_cursor = 0u64; - - for (frame, delay) in &slot.frames { - let delay_ms = delay.as_millis() as u64; - if frame_elapsed_ms < frame_cursor + delay_ms { - return Some((slot_idx, frame.clone())); - } - frame_cursor += delay_ms; - } - - slot.frames - .last() - .map(|(frame, _)| (slot_idx, frame.clone())) -} - -fn frame_cycle_ms(slot: &RotationSlot) -> u64 { - slot.frames - .iter() - .map(|(_, delay)| delay.as_millis() as u64) - .sum::() - .max(100) -} - -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_key: Option = None; - let mut slots: Vec = Vec::new(); - - 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_key = None; - slots = build_rotation_slots(&snapshot, &state.image_dir); - } - - 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.gifs_enabled = snapshot.gifs_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 specs_only = snapshot.specs_enabled && slots.is_empty(); - if let Some((_slot_idx, active_frame)) = - current_slot_frame(&slots, specs_only, snapshot.switch_time, cycle_started_at) - { - let send_due = current_frame_key.as_deref() != Some(active_frame.key.as_str()); - if send_due { - if active_frame.image_name == SYSTEM_FRAME_NAME { - 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()); - }); - } else { - match load_panel_rgb(&state.image_dir, &active_frame.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 {}", active_frame.image_name) - })?; - - update_display_status(&state.display_status, |status| { - status.current_image = Some(active_frame.display_name.clone()); - status.last_error = None; - status.connected = true; - status.updated_at = Some(stamp_now()); - }); - } - Err(err) => { - warn!("Skipping panel {}: {err}", active_frame.image_name); - update_display_status(&state.display_status, |status| { - status.current_image = Some(active_frame.display_name.clone()); - status.last_error = Some(format!( - "Panel {} could not be rendered: {err}", - active_frame.display_name - )); - status.connected = true; - status.updated_at = Some(stamp_now()); - }); - } - } - } - current_frame_key = Some(active_frame.key); - } - } - - thread::sleep(rotation_sleep(snapshot.rotation_active(), &slots, 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(rotation_active: bool, slots: &[RotationSlot], cycle_started_at: Instant) -> Duration { - if !rotation_active || slots.is_empty() { - return Duration::from_millis(750); - } - let total_frames: usize = slots.iter().map(|slot| slot.frames.len()).sum(); - if total_frames <= 1 { - return Duration::from_millis(750); - } - - let cycle_ms: u64 = slots - .iter() - .map(|slot| slot.total_duration.as_millis() as u64) - .sum::() - .max(100); - let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; - let mut cursor = 0u64; - - for slot in slots { - let slot_ms = slot.total_duration.as_millis() as u64; - if elapsed_ms < cursor + slot_ms { - if slot.frames.len() <= 1 { - return Duration::from_millis(750); - } - let local_ms = elapsed_ms - cursor; - let frame_elapsed_ms = local_ms % frame_cycle_ms(slot); - let mut frame_cursor = 0u64; - for (_, delay) in &slot.frames { - let delay_ms = delay.as_millis() as u64; - if frame_elapsed_ms < frame_cursor + delay_ms { - let frame_remaining = frame_cursor + delay_ms - frame_elapsed_ms; - let slot_remaining = slot_ms.saturating_sub(local_ms); - return Duration::from_millis(frame_remaining.min(slot_remaining).clamp(100, 750)); - } - frame_cursor += delay_ms; - } - } - cursor += slot_ms; - } - - Duration::from_millis(250) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn test_frame(key: &str) -> ActiveFrame { - ActiveFrame { - key: key.into(), - image_name: format!("{key}.jpg"), - display_name: "gif".into(), - } - } - - #[test] - fn slot_frame_at_loops_animated_frames_for_full_slot() { - let slot = RotationSlot { - total_duration: Duration::from_secs(10), - frames: vec![ - (test_frame("gif#0"), Duration::from_millis(100)), - (test_frame("gif#1"), Duration::from_millis(100)), - (test_frame("gif#2"), Duration::from_millis(100)), - ], - }; - - assert_eq!(slot_frame_at(&slot, 0, 50).unwrap().1.key, "gif#0"); - assert_eq!(slot_frame_at(&slot, 0, 150).unwrap().1.key, "gif#1"); - assert_eq!(slot_frame_at(&slot, 0, 250).unwrap().1.key, "gif#2"); - assert_eq!(slot_frame_at(&slot, 0, 350).unwrap().1.key, "gif#0"); - assert_eq!(slot_frame_at(&slot, 0, 9_950).unwrap().1.key, "gif#0"); - } - - #[test] - fn build_rotation_slots_uses_switch_time_for_gif_slot_duration() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let image_dir = std::env::temp_dir().join(format!("aster-webui-gif-test-{unique}")); - fs::create_dir_all(&image_dir).unwrap(); - let manifest_name = "panel-test.gifset.json"; - fs::write( - image_dir.join(manifest_name), - r#"{ - "kind": "gif_set", - "version": 1, - "label": "panel-test.gif", - "preview": ".gifframe-test-000.jpg", - "frames": [ - { "name": ".gifframe-test-000.jpg", "delay_ms": 100 }, - { "name": ".gifframe-test-001.jpg", "delay_ms": 100 } - ] - }"#, - ) - .unwrap(); - - let snapshot = RotationSnapshot { - custom_panel: true, - switch_time: 7, - specs_enabled: false, - memes_enabled: false, - gifs_enabled: true, - active_images: vec![manifest_name.into()], - }; - - let slots = build_rotation_slots(&snapshot, &image_dir); - fs::remove_dir_all(&image_dir).unwrap(); - - assert_eq!(slots.len(), 1); - assert_eq!(slots[0].total_duration, Duration::from_secs(7)); - assert_eq!(slots[0].frames.len(), 2); - } - - #[test] - fn build_rotation_slots_skips_gif_when_gifs_are_disabled() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let image_dir = std::env::temp_dir().join(format!("aster-webui-gif-disabled-test-{unique}")); - fs::create_dir_all(&image_dir).unwrap(); - let manifest_name = "panel-test.gifset.json"; - fs::write( - image_dir.join(manifest_name), - r#"{ - "kind": "gif_set", - "version": 1, - "label": "panel-test.gif", - "preview": ".gifframe-test-000.jpg", - "frames": [ - { "name": ".gifframe-test-000.jpg", "delay_ms": 100 }, - { "name": ".gifframe-test-001.jpg", "delay_ms": 100 } - ] - }"#, - ) - .unwrap(); - - let snapshot = RotationSnapshot { - custom_panel: true, - switch_time: 7, - specs_enabled: false, - memes_enabled: false, - gifs_enabled: false, - active_images: vec![manifest_name.into()], - }; - - let slots = build_rotation_slots(&snapshot, &image_dir); - fs::remove_dir_all(&image_dir).unwrap(); - - assert!(slots.is_empty()); - } -} - -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. Bilder werden auf 960x376 normalisiert, GIFs als animierte Frame-Sets importiert; 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

-
-

-
-
-
-
- - - - - -"##; diff --git a/crates/aster-webui/src/monitor.rs b/crates/aster-webui/src/monitor.rs new file mode 100644 index 0000000..11a2632 --- /dev/null +++ b/crates/aster-webui/src/monitor.rs @@ -0,0 +1,225 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use tokio::fs; + +use crate::{ + display::read_display_status, + images::list_images, + system::collect_system_view, + types::{AppState, RotationSnapshot, SetupView, StateResponse}, +}; + +pub(crate) 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, + "nativeGifs": true, + "operationMode": 0, + "theme": 1, + "diskUpdate": 300, + "ha_url": "", + "ha_token": "", + "refresh": 1 + }, + "mianban": [], + "diy": [] + }); + + save_monitor_json(state, &default).await +} + +pub(crate) 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") +} + +pub(crate) 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) +} + +pub(crate) 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); + let gifs_enabled = current_gifs_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, + gifs_enabled, + }, + active_images: current_active_images(&monitor), + images: list_images(state).await?, + display: read_display_status(&state.display_status), + system: collect_system_view(), + }) +} + +pub(crate) 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() +} + +pub(crate) 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) +} + +pub(crate) 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) +} + +pub(crate) 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) +} + +pub(crate) 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()) +} + +pub(crate) fn current_gifs_enabled(monitor: &Value) -> bool { + monitor + .get("setup") + .and_then(Value::as_object) + .and_then(|setup| setup.get("nativeGifs")) + .and_then(Value::as_bool) + .unwrap_or(true) +} + +pub(crate) 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), + gifs_enabled: current_gifs_enabled(monitor), + active_images: current_active_images(monitor), + } +} + +pub(crate) fn set_custom_panels( + monitor: &mut Value, + switch_time: u32, + specs_enabled: bool, + memes_enabled: bool, + gifs_enabled: bool, + images: &[String], +) { + let enabled = specs_enabled || ((memes_enabled || gifs_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)); + setup.insert("nativeGifs".into(), Value::Bool(gifs_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(), + ); +} + +pub(crate) 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") +} diff --git a/crates/aster-webui/src/routes.rs b/crates/aster-webui/src/routes.rs new file mode 100644 index 0000000..a487782 --- /dev/null +++ b/crates/aster-webui/src/routes.rs @@ -0,0 +1,270 @@ +use std::sync::Arc; + +use axum::{ + body::Body, + extract::{Multipart, Path as AxumPath, State}, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde_json::{json, Value}; +use tokio::fs; + +use crate::{ + images::{ + convert_and_store_image, delete_image_asset, is_gif_manifest_name, list_images, + NYAN_CAT_GIF, + }, + monitor::{ + build_state_response, current_active_images, current_gifs_enabled, current_memes_enabled, + current_specs_enabled, current_switch_time, load_monitor_json, save_monitor_json, + set_custom_panels, + }, + types::{ActivateRequest, AppState, DeleteRequest, ErrorResponse}, + ui::index_response, +}; + +pub(crate) fn router(state: Arc) -> Router { + 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)) + .with_state(state) +} + +fn error_response(status: StatusCode, message: impl Into) -> Response { + let body = Json(ErrorResponse { + error: message.into(), + }); + (status, body).into_response() +} + +async fn index() -> Response { + index_response() +} + +async fn healthz(State(state): State>) -> Json { + let display = crate::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 has_static_selection = valid.iter().any(|name| !is_gif_manifest_name(name)); + let has_gif_selection = valid.iter().any(|name| is_gif_manifest_name(name)); + let memes_enabled = payload.memes_enabled.unwrap_or(has_static_selection); + let gifs_enabled = payload.gifs_enabled.unwrap_or(has_gif_selection); + + if !specs_enabled + && !(memes_enabled && has_static_selection) + && !(gifs_enabled && has_gif_selection) + { + return error_response( + StatusCode::BAD_REQUEST, + "Enable specs, memes with a still image, or GIFs with an animated 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, + gifs_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, + "gifsEnabled": gifs_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, + 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"); + } + + if let Err(err) = delete_image_asset(&state, &payload.name).await { + return match err.downcast_ref::() { + Some(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => { + error_response(StatusCode::NOT_FOUND, "Image not found") + } + _ => 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 gifs_enabled = current_gifs_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, + gifs_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 +} diff --git a/crates/aster-webui/src/system.rs b/crates/aster-webui/src/system.rs new file mode 100644 index 0000000..c552e18 --- /dev/null +++ b/crates/aster-webui/src/system.rs @@ -0,0 +1,348 @@ +use std::sync::OnceLock; + +use ab_glyph::{FontArc, PxScale}; +use chrono::Local; +use image::{Rgb, RgbImage}; +use imageproc::{ + drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut}, + rect::Rect, +}; +use sysinfo::{Components, Disks, System}; + +use crate::types::SystemView; + +pub(crate) const DISPLAY_WIDTH: u32 = 960; +pub(crate) const DISPLAY_HEIGHT: u32 = 376; + +pub(crate) 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]) + } +} + +pub(crate) 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 +} + +pub(crate) 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") + }) +} diff --git a/crates/aster-webui/src/types.rs b/crates/aster-webui/src/types.rs new file mode 100644 index 0000000..75d8fde --- /dev/null +++ b/crates/aster-webui/src/types.rs @@ -0,0 +1,185 @@ +use std::{ + path::PathBuf, + sync::{Arc, RwLock}, + time::Duration, +}; + +use clap::Parser; +use serde::{Deserialize, Serialize}; + +#[derive(Parser, Debug)] +#[command(author, version, about)] +pub(crate) struct Cli { + #[arg(long, default_value = "0.0.0.0:8080")] + pub(crate) bind: String, + #[arg(long, default_value = "/config")] + pub(crate) config_dir: PathBuf, + #[arg(long)] + pub(crate) device: Option, + #[arg(long)] + pub(crate) usb: Option, + #[arg(long)] + pub(crate) simulate: bool, + #[arg(long)] + pub(crate) write_only: bool, + #[arg(long)] + pub(crate) disable_display: bool, +} + +#[derive(Clone)] +pub(crate) struct AppState { + pub(crate) monitor_path: PathBuf, + pub(crate) image_dir: PathBuf, + pub(crate) display_status: Arc>, +} + +#[derive(Clone)] +pub(crate) struct DisplayConfig { + pub(crate) device: Option, + pub(crate) usb: Option, + pub(crate) simulate: bool, + pub(crate) write_only: bool, + pub(crate) native_enabled: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct RotationSnapshot { + pub(crate) custom_panel: bool, + pub(crate) switch_time: u32, + pub(crate) specs_enabled: bool, + pub(crate) memes_enabled: bool, + pub(crate) gifs_enabled: bool, + pub(crate) active_images: Vec, +} + +impl RotationSnapshot { + pub(crate) fn rotation_active(&self) -> bool { + self.custom_panel + && (self.specs_enabled + || ((self.memes_enabled || self.gifs_enabled) && !self.active_images.is_empty())) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct GifFrameMeta { + pub(crate) name: String, + pub(crate) delay_ms: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct GifSetManifest { + pub(crate) kind: String, + pub(crate) version: u32, + pub(crate) label: String, + pub(crate) preview: String, + pub(crate) frames: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ActiveFrame { + pub(crate) key: String, + pub(crate) image_name: String, + pub(crate) display_name: String, + pub(crate) animated: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct RotationSlot { + pub(crate) total_duration: Duration, + pub(crate) frames: Vec<(ActiveFrame, Duration)>, +} + +#[derive(Clone, Serialize)] +pub(crate) struct StateResponse { + pub(crate) monitor_path: String, + pub(crate) image_dir: String, + pub(crate) setup: SetupView, + pub(crate) active_images: Vec, + pub(crate) images: Vec, + pub(crate) display: DisplayStatus, + pub(crate) system: SystemView, +} + +#[derive(Clone, Serialize)] +pub(crate) struct SetupView { + pub(crate) custom_panel: bool, + pub(crate) switch_time: String, + pub(crate) specs_enabled: bool, + pub(crate) memes_enabled: bool, + pub(crate) gifs_enabled: bool, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ImageView { + pub(crate) name: String, + pub(crate) label: String, + pub(crate) size: u64, + pub(crate) url: String, + pub(crate) animated: bool, + pub(crate) frame_count: usize, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct DisplayStatus { + pub(crate) native_enabled: bool, + pub(crate) connected: bool, + pub(crate) mode: String, + pub(crate) device: String, + pub(crate) custom_panel: bool, + pub(crate) specs_enabled: bool, + pub(crate) memes_enabled: bool, + pub(crate) gifs_enabled: bool, + pub(crate) rotation_active: bool, + pub(crate) switch_time: String, + pub(crate) active_images: Vec, + pub(crate) current_image: Option, + pub(crate) last_error: Option, + pub(crate) updated_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct SystemView { + pub(crate) cpu_usage_percent: String, + pub(crate) load_avg_one: String, + pub(crate) load_avg_five: String, + pub(crate) load_avg_fifteen: String, + pub(crate) mem_usage_percent: String, + pub(crate) mem_used: String, + pub(crate) mem_total: String, + pub(crate) swap_usage_percent: String, + pub(crate) swap_used: String, + pub(crate) swap_total: String, + pub(crate) disk_usage_percent: String, + pub(crate) disk_used: String, + pub(crate) disk_total: String, + pub(crate) cache_usage_percent: String, + pub(crate) cache_used: String, + pub(crate) cache_total: String, + pub(crate) user_usage_percent: String, + pub(crate) user_used: String, + pub(crate) user_total: String, + pub(crate) cpu_count: usize, + pub(crate) process_count: usize, + pub(crate) uptime: String, + pub(crate) temperature_cpu: Option, + pub(crate) temperature_gpu: Option, +} + +#[derive(Deserialize)] +pub(crate) struct ActivateRequest { + pub(crate) images: Vec, + pub(crate) switch_time: Option, + pub(crate) specs_enabled: Option, + pub(crate) memes_enabled: Option, + pub(crate) gifs_enabled: Option, +} + +#[derive(Deserialize)] +pub(crate) struct DeleteRequest { + pub(crate) name: String, +} + +#[derive(Serialize)] +pub(crate) struct ErrorResponse { + pub(crate) error: String, +} diff --git a/crates/aster-webui/src/ui.rs b/crates/aster-webui/src/ui.rs new file mode 100644 index 0000000..6d67216 --- /dev/null +++ b/crates/aster-webui/src/ui.rs @@ -0,0 +1,503 @@ +use axum::{ + http::header, + response::{Html, IntoResponse, Response}, +}; + +pub(crate) fn index_response() -> 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() +} + +const INDEX_HTML: &str = r##" + + + + + aster-webui + + + +
+
+
+

aoostar-rs fork

+

aster-webui

+

Eigene Rust-WebUI mit nativer Display-Logik. Bilder werden auf 960x376 normalisiert, GIFs als animierte Frame-Sets importiert; 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

+
+

+
+
+
+
+ + + + + +"##;