Add native display loop to aster web UI
Rust / Clippy, Rustfmt, Tests (push) Has been cancelled
Rust / Linux-x64 build (push) Has been cancelled
Rust / GitHub release (push) Has been cancelled

This commit is contained in:
2026-06-09 16:07:36 +02:00
parent 1e1d37bfc2
commit b48bbd9b2e
4 changed files with 426 additions and 28 deletions
Generated
+2
View File
@@ -157,6 +157,8 @@ name = "aster-webui"
version = "0.1.0"
dependencies = [
"anyhow",
"asterctl",
"asterctl-lcd",
"axum",
"chrono",
"clap",
+5 -2
View File
@@ -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
+2 -1
View File
@@ -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"] }
+417 -25
View File
@@ -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<String>,
#[arg(long)]
usb: Option<String>,
#[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<RwLock<DisplayStatus>>,
}
#[derive(Serialize)]
#[derive(Clone)]
struct DisplayConfig {
device: Option<String>,
usb: Option<String>,
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<String>,
}
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<String>,
images: Vec<ImageView>,
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<String>,
current_image: Option<String>,
last_error: Option<String>,
updated_at: Option<String>,
}
#[derive(Deserialize)]
struct ActivateRequest {
images: Vec<String>,
@@ -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<Value> {
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<String>) -> Response {
@@ -172,8 +274,13 @@ async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
async fn healthz() -> Json<Value> {
Json(json!({"ok": true}))
async fn healthz(State(state): State<Arc<AppState>>) -> Json<Value> {
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<Arc<AppState>>) -> Response {
@@ -313,7 +420,10 @@ async fn api_delete(
Json(json!({ "ok": true })).into_response()
}
async fn api_image(Path(name): Path<String>, State(state): State<Arc<AppState>>) -> Response {
async fn api_image(
AxumPath(name): AxumPath<String>,
State(state): State<Arc<AppState>>,
) -> 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<String>, State(state): State<Arc<AppState>>)
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<StateResponse> {
},
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<AppState>, config: DisplayConfig) {
thread::spawn(move || run_display_worker(state, config));
}
fn run_display_worker(state: Arc<AppState>, 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<AooScreen> {
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<Value> {
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<RgbImage> {
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<RwLock<DisplayStatus>>) -> DisplayStatus {
status
.read()
.expect("display status lock poisoned")
.clone()
}
fn update_display_status(
status: &Arc<RwLock<DisplayStatus>>,
apply: impl FnOnce(&mut DisplayStatus),
) {
let mut guard = status.write().expect("display status lock poisoned");
apply(&mut guard);
}
fn set_display_error(status: &Arc<RwLock<DisplayStatus>>, 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##"<!DOCTYPE html>
<html lang="en">
<head>
@@ -510,8 +877,9 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
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##"<!DOCTYPE html>
}
.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##"<!DOCTYPE html>
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; }
}
</style>
</head>
@@ -571,7 +939,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<div class="card">
<p class="muted">aoostar-rs fork</p>
<h1>aster-webui</h1>
<p>Eigene Rust-WebUI fuer Panelbilder, Rotation und `Monitor3.json`-Steuerung. Uploads werden auf 960x376 JPG normalisiert; animierte GIFs nutzen aktuell nur das erste Frame.</p>
<p>Eigene Rust-WebUI mit nativer Display-Logik. Uploads werden auf 960x376 JPG normalisiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.</p>
<div class="controls">
<label>
<span>Switch Time</span>
@@ -588,10 +956,13 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
</div>
</div>
</div>
<div class="status">
<div class="card"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
<div class="card"><span class="muted">Active Images</span><strong id="imageCountValue">-</strong></div>
<div class="card"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div>
<div class="status-grid">
<div class="card status"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
<div class="card status"><span class="muted">Active Images</span><strong id="imageCountValue">-</strong></div>
<div class="card status"><span class="muted">Display Status</span><strong id="displayStatusValue">-</strong></div>
<div class="card status"><span class="muted">Current Frame</span><strong id="currentImageValue" style="font-size: 1rem;">-</strong></div>
<div class="card status"><span class="muted">Display Target</span><strong id="displayTargetValue" style="font-size: 1rem;">-</strong></div>
<div class="card status"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div>
</div>
</section>
<section class="content">
@@ -602,18 +973,28 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<div class="card">
<h2>Rotation Order</h2>
<div id="order" class="order"></div>
<p id="displayErrorValue" class="muted"></p>
</div>
</section>
</main>
<div id="toast" class="toast"></div>
<script>
const state = { images: [], activeImages: [], setup: { switch_time: "10", custom_panel: false } };
const state = {
images: [],
activeImages: [],
setup: { switch_time: "10", custom_panel: false },
display: {},
};
const el = {
gallery: document.getElementById("gallery"),
order: document.getElementById("order"),
switchTime: document.getElementById("switchTime"),
customPanelValue: document.getElementById("customPanelValue"),
imageCountValue: document.getElementById("imageCountValue"),
displayStatusValue: document.getElementById("displayStatusValue"),
currentImageValue: document.getElementById("currentImageValue"),
displayTargetValue: document.getElementById("displayTargetValue"),
displayErrorValue: document.getElementById("displayErrorValue"),
configPathValue: document.getElementById("configPathValue"),
upload: document.getElementById("upload"),
apply: document.getElementById("apply"),
@@ -666,9 +1047,16 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
state.images = data.images;
state.activeImages = data.active_images;
state.setup = data.setup;
state.display = data.display || {};
el.switchTime.value = data.setup.switch_time || "10";
el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled";
el.imageCountValue.textContent = String(data.active_images.length);
el.displayStatusValue.textContent = state.display.connected
? `Connected (${state.display.mode || "native"})`
: (state.display.native_enabled ? "Disconnected" : "Disabled");
el.currentImageValue.textContent = state.display.current_image || "-";
el.displayTargetValue.textContent = state.display.device || "-";
el.displayErrorValue.textContent = state.display.last_error || "";
el.configPathValue.textContent = data.monitor_path;
render();
}
@@ -746,7 +1134,10 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
row.className = "order-item";
row.innerHTML = `
<div class="index">${index + 1}</div>
<div>${name}</div>
<div class="meta">
<div>${name}</div>
<div class="muted">${state.display.current_image === name ? "Currently shown" : ""}</div>
</div>
<div class="mini">
<button class="ghost" type="button">Up</button>
<button class="ghost" type="button">Down</button>
@@ -767,6 +1158,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
el.refresh.addEventListener("click", () => load().catch((err) => toast(err.message)));
load().catch((err) => toast(err.message));
setInterval(() => load().catch(() => {}), 5000);
</script>
</body>
</html>