diff --git a/Cargo.lock b/Cargo.lock index df082b3..1e2bcfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,8 @@ name = "aster-webui" version = "0.1.0" dependencies = [ "anyhow", + "asterctl", + "asterctl-lcd", "axum", "chrono", "clap", diff --git a/README.md b/README.md index a80d77b..1e190a3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Current scope: - normalize them to AOOSTAR-safe `960x376` JPG files - manage rotation order and switch interval - enable or disable custom panels without the vendor web UI +- drive the AOOSTAR LCD directly through the native `aoostar-rs` serial implementation Start it from the workspace root: @@ -40,8 +41,10 @@ Important: - the current web UI writes compatible `Monitor3.json` and `/config/img/*` assets - animated GIF uploads currently use the first frame only -- native screen driving is still handled separately; the next step is wiring the web UI directly - into a full `aoostar-rs`-based display daemon +- the native display loop runs automatically and uses the default AOOSTAR USB UART unless + `--device`, `--usb`, `--simulate`, or `--disable-display` is specified +- the native path currently targets custom image panels; built-in vendor sensor themes are not yet + reimplemented in the web UI ## Disclaimer diff --git a/crates/aster-webui/Cargo.toml b/crates/aster-webui/Cargo.toml index 8875335..479f7cb 100644 --- a/crates/aster-webui/Cargo.toml +++ b/crates/aster-webui/Cargo.toml @@ -11,6 +11,8 @@ repository.workspace = true [dependencies] anyhow = "1.0.98" +asterctl = { path = "../asterctl", version = "0.2.0" } +asterctl-lcd = { path = "../asterctl-lcd", version = "0.2.0" } axum = { version = "0.8.4", features = ["multipart"] } chrono = "0.4" clap = { version = "4.5.42", features = ["derive"] } @@ -21,4 +23,3 @@ serde_json = "1.0.142" tokio = { version = "1.47.1", features = ["fs", "macros", "net", "rt-multi-thread"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } - diff --git a/crates/aster-webui/src/main.rs b/crates/aster-webui/src/main.rs index 0d7f410..bacc79e 100644 --- a/crates/aster-webui/src/main.rs +++ b/crates/aster-webui/src/main.rs @@ -1,21 +1,29 @@ -use std::{net::SocketAddr, path::PathBuf, sync::Arc}; +use std::{ + net::SocketAddr, + path::PathBuf, + sync::{Arc, RwLock}, + thread, + time::{Duration, Instant}, +}; use anyhow::{Context, Result}; +use asterctl::img; +use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE}; use axum::{ Json, Router, body::Body, - extract::{DefaultBodyLimit, Multipart, Path, State}, + extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State}, http::{HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Response}, routing::{get, post}, }; -use chrono::Local; +use chrono::{Local, Utc}; use clap::Parser; use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::fs; -use tracing::info; +use tracing::{error, info, warn}; const DISPLAY_WIDTH: u32 = 960; const DISPLAY_HEIGHT: u32 = 376; @@ -27,36 +35,85 @@ struct Cli { 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(Serialize)] +#[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, + active_images: Vec, +} + +impl RotationSnapshot { + fn rotation_active(&self) -> bool { + self.custom_panel && !self.active_images.is_empty() + } +} + +#[derive(Clone, Serialize)] struct StateResponse { monitor_path: String, image_dir: String, setup: SetupView, active_images: Vec, images: Vec, + display: DisplayStatus, } -#[derive(Serialize)] +#[derive(Clone, Serialize)] struct SetupView { custom_panel: bool, switch_time: String, } -#[derive(Serialize)] +#[derive(Clone, Serialize)] struct ImageView { name: String, size: u64, url: String, } +#[derive(Clone, Debug, Default, Serialize)] +struct DisplayStatus { + native_enabled: bool, + connected: bool, + mode: String, + device: String, + custom_panel: bool, + rotation_active: bool, + switch_time: String, + active_images: Vec, + current_image: Option, + last_error: Option, + updated_at: Option, +} + #[derive(Deserialize)] struct ActivateRequest { images: Vec, @@ -88,12 +145,22 @@ async fn main() -> Result<()> { .parse() .with_context(|| format!("Invalid bind address: {}", cli.bind))?; + let display_config = DisplayConfig { + device: cli.device, + usb: cli.usb, + simulate: cli.simulate, + write_only: cli.write_only, + native_enabled: !cli.disable_display, + }; + let state = Arc::new(AppState { monitor_path: cli.config_dir.join("Monitor3.json"), image_dir: cli.config_dir.join("img"), + display_status: Arc::new(RwLock::new(initial_display_status(&display_config))), }); ensure_layout(&state).await?; + spawn_display_worker(state.clone(), display_config); let app = Router::new() .route("/", get(index)) @@ -113,6 +180,28 @@ async fn main() -> Result<()> { 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, + 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?; @@ -156,9 +245,22 @@ async fn load_monitor_json(state: &AppState) -> Result { async fn save_monitor_json(state: &AppState, value: &Value) -> Result<()> { let payload = serde_json::to_string_pretty(value)?; - fs::write(&state.monitor_path, payload) + let tmp_path = temp_monitor_path(&state.monitor_path); + + fs::write(&tmp_path, payload) .await - .with_context(|| format!("Failed to write {:?}", state.monitor_path)) + .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 { @@ -172,8 +274,13 @@ async fn index() -> Html<&'static str> { Html(INDEX_HTML) } -async fn healthz() -> Json { - Json(json!({"ok": true})) +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 { @@ -313,7 +420,10 @@ async fn api_delete( Json(json!({ "ok": true })).into_response() } -async fn api_image(Path(name): Path, State(state): State>) -> 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"); } @@ -331,7 +441,8 @@ async fn api_image(Path(name): Path, State(state): State>) 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")), + HeaderValue::from_str(mime.as_ref()) + .unwrap_or(HeaderValue::from_static("application/octet-stream")), ); response } @@ -363,6 +474,7 @@ async fn build_state_response(state: &AppState) -> Result { }, active_images: current_active_images(&monitor), images: list_images(state).await?, + display: read_display_status(&state.display_status), }) } @@ -411,6 +523,23 @@ fn current_switch_time(monitor: &Value) -> u32 { .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 rotation_snapshot(monitor: &Value) -> RotationSnapshot { + RotationSnapshot { + custom_panel: current_custom_panel_enabled(monitor), + switch_time: current_switch_time(monitor), + active_images: current_active_images(monitor), + } +} + fn set_custom_panels(monitor: &mut Value, enabled: bool, switch_time: u32, images: &[String]) { let setup = monitor .as_object_mut() @@ -482,6 +611,244 @@ fn make_image_name(file_name: &str) -> String { format!("panel-{timestamp}-{clean}.jpg") } +fn spawn_display_worker(state: Arc, config: DisplayConfig) { + thread::spawn(move || run_display_worker(state, config)); +} + +fn run_display_worker(state: Arc, config: DisplayConfig) { + if !config.native_enabled { + info!("Native display loop disabled"); + return; + } + + loop { + match open_screen(&config) { + Ok(mut screen) => { + info!("Display target opened: {}", display_target(&config)); + if let Err(err) = screen.init() { + warn!("Display init failed: {err}"); + set_display_error( + &state.display_status, + false, + format!("Display init failed: {err}"), + ); + thread::sleep(Duration::from_secs(3)); + continue; + } + + update_display_status(&state.display_status, |status| { + status.connected = true; + status.last_error = None; + status.updated_at = Some(stamp_now()); + }); + + if let Err(err) = run_display_session(&state, &mut screen) { + error!("Display loop error: {err}"); + set_display_error( + &state.display_status, + false, + format!("Display loop error: {err}"), + ); + } + } + Err(err) => { + warn!("Failed to open display target: {err}"); + set_display_error( + &state.display_status, + false, + format!("Failed to open display target: {err}"), + ); + } + } + + thread::sleep(Duration::from_secs(3)); + } +} + +fn open_screen(config: &DisplayConfig) -> Result { + let mut builder = AooScreenBuilder::new(); + builder.no_init_check(config.write_only); + + if config.simulate { + builder.simulate() + } else if let Some(device) = &config.device { + builder.open_device(device) + } else if let Some(usb) = &config.usb { + builder.open_usb_id(usb) + } else { + builder.open_default() + } +} + +fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { + let mut snapshot = RotationSnapshot::default(); + let mut next_index = 0usize; + let mut last_switch_at = Instant::now(); + let mut force_send = true; + + 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; + next_index = 0; + force_send = true; + } + + update_display_status(&state.display_status, |status| { + status.custom_panel = snapshot.custom_panel; + status.rotation_active = snapshot.rotation_active(); + status.switch_time = snapshot.switch_time.to_string(); + status.active_images = snapshot.active_images.clone(); + if !snapshot.rotation_active() { + status.current_image = None; + } + status.connected = true; + status.updated_at = Some(stamp_now()); + }); + + if !snapshot.rotation_active() { + thread::sleep(Duration::from_millis(750)); + continue; + } + + let switch_after = Duration::from_secs(snapshot.switch_time as u64); + let send_due = force_send || last_switch_at.elapsed() >= switch_after; + + if send_due { + let image_name = snapshot.active_images[next_index].clone(); + match load_panel_rgb(&state.image_dir, &image_name) { + Ok(rgb_img) => { + screen + .send_image(&rgb_img) + .with_context(|| format!("Failed to send panel {image_name}"))?; + + update_display_status(&state.display_status, |status| { + status.current_image = Some(image_name.clone()); + status.last_error = None; + status.connected = true; + status.updated_at = Some(stamp_now()); + }); + } + Err(err) => { + warn!("Skipping panel {image_name}: {err}"); + update_display_status(&state.display_status, |status| { + status.current_image = Some(image_name.clone()); + status.last_error = + Some(format!("Panel {image_name} could not be rendered: {err}")); + status.connected = true; + status.updated_at = Some(stamp_now()); + }); + } + } + + last_switch_at = Instant::now(); + force_send = false; + next_index = (next_index + 1) % snapshot.active_images.len(); + } + + thread::sleep(rotation_sleep(snapshot.active_images.len(), switch_after, last_switch_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(image_count: usize, switch_after: Duration, last_switch_at: Instant) -> Duration { + if image_count <= 1 { + return Duration::from_millis(750); + } + + let remaining = switch_after.saturating_sub(last_switch_at.elapsed()); + if remaining > Duration::from_millis(750) { + Duration::from_millis(750) + } else if remaining < Duration::from_millis(200) { + Duration::from_millis(200) + } else { + remaining + } +} + +fn read_display_status(status: &Arc>) -> DisplayStatus { + status + .read() + .expect("display status lock poisoned") + .clone() +} + +fn update_display_status( + status: &Arc>, + apply: impl FnOnce(&mut DisplayStatus), +) { + let mut guard = status.write().expect("display status lock poisoned"); + apply(&mut guard); +} + +fn set_display_error(status: &Arc>, connected: bool, message: String) { + update_display_status(status, |current| { + current.connected = connected; + current.last_error = Some(message); + current.updated_at = Some(stamp_now()); + if !connected { + current.current_image = None; + } + }); +} + +fn display_mode(config: &DisplayConfig) -> String { + if !config.native_enabled { + "disabled".into() + } else if config.simulate { + "simulate".into() + } else if config.device.is_some() { + "device".into() + } else if config.usb.is_some() { + "usb-id".into() + } else { + "usb-default".into() + } +} + +fn display_target(config: &DisplayConfig) -> String { + if !config.native_enabled { + "native loop disabled".into() + } else if config.simulate { + "simulated LCD".into() + } else if let Some(device) = &config.device { + device.clone() + } else if let Some(usb) = &config.usb { + format!("USB {usb}") + } else { + "default AOOSTAR USB UART 0416:90A1".into() + } +} + +fn stamp_now() -> String { + Utc::now().to_rfc3339() +} + const INDEX_HTML: &str = r##" @@ -510,8 +877,9 @@ const INDEX_HTML: &str = r##" font-family: "IBM Plex Sans", sans-serif; } main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; } - .hero, .content { display: grid; gap: 18px; } - .hero { grid-template-columns: 1.5fr 1fr; margin-bottom: 18px; } + .hero, .content, .status-grid { display: grid; gap: 18px; } + .hero { grid-template-columns: 1.4fr 1fr; margin-bottom: 18px; } + .status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .card { background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9)); border: 1px solid var(--line); @@ -520,8 +888,7 @@ const INDEX_HTML: &str = r##" } .hero h1 { margin: 0 0 8px; font-size: clamp(2rem, 3vw, 3.4rem); line-height: 1; } .hero p, .muted { color: var(--muted); } - .status { display: grid; gap: 12px; } - .status strong { display: block; margin-top: 8px; font-size: 1.5rem; } + .status strong { display: block; margin-top: 8px; font-size: 1.3rem; } .controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; } label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; } input, button { @@ -555,13 +922,14 @@ const INDEX_HTML: &str = r##" border: 1px solid var(--line); border-radius: 18px; padding: 12px 14px; background: rgba(8,12,18,.78); } .index { color: var(--accent-2); font-weight: 700; } + .meta { display: grid; gap: 4px; } .toast { position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line); background: rgba(10,14,20,.94); display: none; } @media (max-width: 1024px) { - .hero, .content, .controls { grid-template-columns: 1fr; } + .hero, .content, .controls, .status-grid { grid-template-columns: 1fr; } } @@ -571,7 +939,7 @@ const INDEX_HTML: &str = r##"

aoostar-rs fork

aster-webui

-

Eigene Rust-WebUI fuer Panelbilder, Rotation und `Monitor3.json`-Steuerung. Uploads werden auf 960x376 JPG normalisiert; animierte GIFs nutzen aktuell nur das erste Frame.

+

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

-
-
Custom Panel-
-
Active Images-
-
Config Path-
+
+
Custom Panel-
+
Active Images-
+
Display Status-
+
Current Frame-
+
Display Target-
+
Config Path-
@@ -602,18 +973,28 @@ const INDEX_HTML: &str = r##"

Rotation Order

+