Add native display loop to aster web UI
This commit is contained in:
Generated
+2
@@ -157,6 +157,8 @@ name = "aster-webui"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"asterctl",
|
||||||
|
"asterctl-lcd",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Current scope:
|
|||||||
- normalize them to AOOSTAR-safe `960x376` JPG files
|
- normalize them to AOOSTAR-safe `960x376` JPG files
|
||||||
- manage rotation order and switch interval
|
- manage rotation order and switch interval
|
||||||
- enable or disable custom panels without the vendor web UI
|
- 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:
|
Start it from the workspace root:
|
||||||
|
|
||||||
@@ -40,8 +41,10 @@ Important:
|
|||||||
|
|
||||||
- the current web UI writes compatible `Monitor3.json` and `/config/img/*` assets
|
- the current web UI writes compatible `Monitor3.json` and `/config/img/*` assets
|
||||||
- animated GIF uploads currently use the first frame only
|
- 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
|
- the native display loop runs automatically and uses the default AOOSTAR USB UART unless
|
||||||
into a full `aoostar-rs`-based display daemon
|
`--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
|
## Disclaimer
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.98"
|
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"] }
|
axum = { version = "0.8.4", features = ["multipart"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = { version = "4.5.42", features = ["derive"] }
|
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"] }
|
tokio = { version = "1.47.1", features = ["fs", "macros", "net", "rt-multi-thread"] }
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||||
|
|
||||||
|
|||||||
+416
-24
@@ -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 anyhow::{Context, Result};
|
||||||
|
use asterctl::img;
|
||||||
|
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{DefaultBodyLimit, Multipart, Path, State},
|
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
|
||||||
http::{HeaderValue, StatusCode, header},
|
http::{HeaderValue, StatusCode, header},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use chrono::Local;
|
use chrono::{Local, Utc};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType};
|
use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::info;
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
const DISPLAY_WIDTH: u32 = 960;
|
const DISPLAY_WIDTH: u32 = 960;
|
||||||
const DISPLAY_HEIGHT: u32 = 376;
|
const DISPLAY_HEIGHT: u32 = 376;
|
||||||
@@ -27,36 +35,85 @@ struct Cli {
|
|||||||
bind: String,
|
bind: String,
|
||||||
#[arg(long, default_value = "/config")]
|
#[arg(long, default_value = "/config")]
|
||||||
config_dir: PathBuf,
|
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)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
monitor_path: PathBuf,
|
monitor_path: PathBuf,
|
||||||
image_dir: 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 {
|
struct StateResponse {
|
||||||
monitor_path: String,
|
monitor_path: String,
|
||||||
image_dir: String,
|
image_dir: String,
|
||||||
setup: SetupView,
|
setup: SetupView,
|
||||||
active_images: Vec<String>,
|
active_images: Vec<String>,
|
||||||
images: Vec<ImageView>,
|
images: Vec<ImageView>,
|
||||||
|
display: DisplayStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
struct SetupView {
|
struct SetupView {
|
||||||
custom_panel: bool,
|
custom_panel: bool,
|
||||||
switch_time: String,
|
switch_time: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
struct ImageView {
|
struct ImageView {
|
||||||
name: String,
|
name: String,
|
||||||
size: u64,
|
size: u64,
|
||||||
url: String,
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct ActivateRequest {
|
struct ActivateRequest {
|
||||||
images: Vec<String>,
|
images: Vec<String>,
|
||||||
@@ -88,12 +145,22 @@ async fn main() -> Result<()> {
|
|||||||
.parse()
|
.parse()
|
||||||
.with_context(|| format!("Invalid bind address: {}", cli.bind))?;
|
.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 {
|
let state = Arc::new(AppState {
|
||||||
monitor_path: cli.config_dir.join("Monitor3.json"),
|
monitor_path: cli.config_dir.join("Monitor3.json"),
|
||||||
image_dir: cli.config_dir.join("img"),
|
image_dir: cli.config_dir.join("img"),
|
||||||
|
display_status: Arc::new(RwLock::new(initial_display_status(&display_config))),
|
||||||
});
|
});
|
||||||
|
|
||||||
ensure_layout(&state).await?;
|
ensure_layout(&state).await?;
|
||||||
|
spawn_display_worker(state.clone(), display_config);
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
@@ -113,6 +180,28 @@ async fn main() -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
async fn ensure_layout(state: &AppState) -> Result<()> {
|
||||||
fs::create_dir_all(&state.image_dir).await?;
|
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<()> {
|
async fn save_monitor_json(state: &AppState, value: &Value) -> Result<()> {
|
||||||
let payload = serde_json::to_string_pretty(value)?;
|
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
|
.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 {
|
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
||||||
@@ -172,8 +274,13 @@ async fn index() -> Html<&'static str> {
|
|||||||
Html(INDEX_HTML)
|
Html(INDEX_HTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn healthz() -> Json<Value> {
|
async fn healthz(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||||
Json(json!({"ok": true}))
|
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 {
|
async fn api_state(State(state): State<Arc<AppState>>) -> Response {
|
||||||
@@ -313,7 +420,10 @@ async fn api_delete(
|
|||||||
Json(json!({ "ok": true })).into_response()
|
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('\\') {
|
if name.contains('/') || name.contains('\\') {
|
||||||
return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
|
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));
|
let mut response = Response::new(Body::from(bytes));
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
header::CONTENT_TYPE,
|
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
|
response
|
||||||
}
|
}
|
||||||
@@ -363,6 +474,7 @@ async fn build_state_response(state: &AppState) -> Result<StateResponse> {
|
|||||||
},
|
},
|
||||||
active_images: current_active_images(&monitor),
|
active_images: current_active_images(&monitor),
|
||||||
images: list_images(state).await?,
|
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)
|
.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]) {
|
fn set_custom_panels(monitor: &mut Value, enabled: bool, switch_time: u32, images: &[String]) {
|
||||||
let setup = monitor
|
let setup = monitor
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
@@ -482,6 +611,244 @@ fn make_image_name(file_name: &str) -> String {
|
|||||||
format!("panel-{timestamp}-{clean}.jpg")
|
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>
|
const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -510,8 +877,9 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
font-family: "IBM Plex Sans", sans-serif;
|
font-family: "IBM Plex Sans", sans-serif;
|
||||||
}
|
}
|
||||||
main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; }
|
main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; }
|
||||||
.hero, .content { display: grid; gap: 18px; }
|
.hero, .content, .status-grid { display: grid; gap: 18px; }
|
||||||
.hero { grid-template-columns: 1.5fr 1fr; margin-bottom: 18px; }
|
.hero { grid-template-columns: 1.4fr 1fr; margin-bottom: 18px; }
|
||||||
|
.status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.card {
|
.card {
|
||||||
background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9));
|
background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9));
|
||||||
border: 1px solid var(--line);
|
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 h1 { margin: 0 0 8px; font-size: clamp(2rem, 3vw, 3.4rem); line-height: 1; }
|
||||||
.hero p, .muted { color: var(--muted); }
|
.hero p, .muted { color: var(--muted); }
|
||||||
.status { display: grid; gap: 12px; }
|
.status strong { display: block; margin-top: 8px; font-size: 1.3rem; }
|
||||||
.status strong { display: block; margin-top: 8px; font-size: 1.5rem; }
|
|
||||||
.controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; }
|
.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; }
|
label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; }
|
||||||
input, button {
|
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);
|
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; }
|
.index { color: var(--accent-2); font-weight: 700; }
|
||||||
|
.meta { display: grid; gap: 4px; }
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px;
|
position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px;
|
||||||
padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line);
|
padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line);
|
||||||
background: rgba(10,14,20,.94); display: none;
|
background: rgba(10,14,20,.94); display: none;
|
||||||
}
|
}
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.hero, .content, .controls { grid-template-columns: 1fr; }
|
.hero, .content, .controls, .status-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -571,7 +939,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="muted">aoostar-rs fork</p>
|
<p class="muted">aoostar-rs fork</p>
|
||||||
<h1>aster-webui</h1>
|
<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">
|
<div class="controls">
|
||||||
<label>
|
<label>
|
||||||
<span>Switch Time</span>
|
<span>Switch Time</span>
|
||||||
@@ -588,10 +956,13 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status">
|
<div class="status-grid">
|
||||||
<div class="card"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
|
<div class="card status"><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 status"><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="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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="content">
|
<section class="content">
|
||||||
@@ -602,18 +973,28 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Rotation Order</h2>
|
<h2>Rotation Order</h2>
|
||||||
<div id="order" class="order"></div>
|
<div id="order" class="order"></div>
|
||||||
|
<p id="displayErrorValue" class="muted"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
<script>
|
<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 = {
|
const el = {
|
||||||
gallery: document.getElementById("gallery"),
|
gallery: document.getElementById("gallery"),
|
||||||
order: document.getElementById("order"),
|
order: document.getElementById("order"),
|
||||||
switchTime: document.getElementById("switchTime"),
|
switchTime: document.getElementById("switchTime"),
|
||||||
customPanelValue: document.getElementById("customPanelValue"),
|
customPanelValue: document.getElementById("customPanelValue"),
|
||||||
imageCountValue: document.getElementById("imageCountValue"),
|
imageCountValue: document.getElementById("imageCountValue"),
|
||||||
|
displayStatusValue: document.getElementById("displayStatusValue"),
|
||||||
|
currentImageValue: document.getElementById("currentImageValue"),
|
||||||
|
displayTargetValue: document.getElementById("displayTargetValue"),
|
||||||
|
displayErrorValue: document.getElementById("displayErrorValue"),
|
||||||
configPathValue: document.getElementById("configPathValue"),
|
configPathValue: document.getElementById("configPathValue"),
|
||||||
upload: document.getElementById("upload"),
|
upload: document.getElementById("upload"),
|
||||||
apply: document.getElementById("apply"),
|
apply: document.getElementById("apply"),
|
||||||
@@ -666,9 +1047,16 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
state.images = data.images;
|
state.images = data.images;
|
||||||
state.activeImages = data.active_images;
|
state.activeImages = data.active_images;
|
||||||
state.setup = data.setup;
|
state.setup = data.setup;
|
||||||
|
state.display = data.display || {};
|
||||||
el.switchTime.value = data.setup.switch_time || "10";
|
el.switchTime.value = data.setup.switch_time || "10";
|
||||||
el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled";
|
el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled";
|
||||||
el.imageCountValue.textContent = String(data.active_images.length);
|
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;
|
el.configPathValue.textContent = data.monitor_path;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -746,7 +1134,10 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
row.className = "order-item";
|
row.className = "order-item";
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="index">${index + 1}</div>
|
<div class="index">${index + 1}</div>
|
||||||
|
<div class="meta">
|
||||||
<div>${name}</div>
|
<div>${name}</div>
|
||||||
|
<div class="muted">${state.display.current_image === name ? "Currently shown" : ""}</div>
|
||||||
|
</div>
|
||||||
<div class="mini">
|
<div class="mini">
|
||||||
<button class="ghost" type="button">Up</button>
|
<button class="ghost" type="button">Up</button>
|
||||||
<button class="ghost" type="button">Down</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)));
|
el.refresh.addEventListener("click", () => load().catch((err) => toast(err.message)));
|
||||||
|
|
||||||
load().catch((err) => toast(err.message));
|
load().catch((err) => toast(err.message));
|
||||||
|
setInterval(() => load().catch(() => {}), 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user