1764 lines
60 KiB
Rust
1764 lines
60 KiB
Rust
use std::{
|
|
net::SocketAddr,
|
|
path::PathBuf,
|
|
sync::{Arc, OnceLock, RwLock},
|
|
thread,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use ab_glyph::{FontArc, PxScale};
|
|
use anyhow::{Context, Result};
|
|
use asterctl::img;
|
|
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
|
use axum::{
|
|
Json, Router,
|
|
body::Body,
|
|
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
|
|
http::{HeaderValue, StatusCode, header},
|
|
response::{Html, IntoResponse, Response},
|
|
routing::{get, post},
|
|
};
|
|
use chrono::{Local, Utc};
|
|
use clap::Parser;
|
|
use image::{DynamicImage, ImageFormat, Rgb, RgbImage, imageops::FilterType};
|
|
use imageproc::{
|
|
drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut},
|
|
rect::Rect,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use sysinfo::{Components, Disks, System};
|
|
use tokio::fs;
|
|
use tracing::{error, info, warn};
|
|
|
|
const DISPLAY_WIDTH: u32 = 960;
|
|
const DISPLAY_HEIGHT: u32 = 376;
|
|
const SYSTEM_FRAME_NAME: &str = "System Specs";
|
|
const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif");
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(author, version, about)]
|
|
struct Cli {
|
|
#[arg(long, default_value = "0.0.0.0:8080")]
|
|
bind: String,
|
|
#[arg(long, default_value = "/config")]
|
|
config_dir: PathBuf,
|
|
#[arg(long)]
|
|
device: Option<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(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,
|
|
specs_enabled: bool,
|
|
memes_enabled: bool,
|
|
active_images: Vec<String>,
|
|
}
|
|
|
|
impl RotationSnapshot {
|
|
fn rotation_active(&self) -> bool {
|
|
self.custom_panel && !self.frames().is_empty()
|
|
}
|
|
|
|
fn frames(&self) -> Vec<RotationFrame> {
|
|
let mut frames = Vec::new();
|
|
if self.memes_enabled {
|
|
frames.extend(self.active_images.iter().cloned().map(RotationFrame::Image));
|
|
}
|
|
if frames.is_empty() && self.specs_enabled {
|
|
frames.push(RotationFrame::SystemSpecs);
|
|
}
|
|
frames
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum RotationFrame {
|
|
SystemSpecs,
|
|
Image(String),
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
struct StateResponse {
|
|
monitor_path: String,
|
|
image_dir: String,
|
|
setup: SetupView,
|
|
active_images: Vec<String>,
|
|
images: Vec<ImageView>,
|
|
display: DisplayStatus,
|
|
system: SystemView,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
struct SetupView {
|
|
custom_panel: bool,
|
|
switch_time: String,
|
|
specs_enabled: bool,
|
|
memes_enabled: bool,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
struct ImageView {
|
|
name: String,
|
|
size: u64,
|
|
url: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize)]
|
|
struct DisplayStatus {
|
|
native_enabled: bool,
|
|
connected: bool,
|
|
mode: String,
|
|
device: String,
|
|
custom_panel: bool,
|
|
specs_enabled: bool,
|
|
memes_enabled: bool,
|
|
rotation_active: bool,
|
|
switch_time: String,
|
|
active_images: Vec<String>,
|
|
current_image: Option<String>,
|
|
last_error: Option<String>,
|
|
updated_at: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
temperature_gpu: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ActivateRequest {
|
|
images: Vec<String>,
|
|
switch_time: Option<u32>,
|
|
specs_enabled: Option<bool>,
|
|
memes_enabled: Option<bool>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DeleteRequest {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ErrorResponse {
|
|
error: String,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "info,aster_webui=debug".into()),
|
|
)
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
let addr: SocketAddr = cli
|
|
.bind
|
|
.parse()
|
|
.with_context(|| format!("Invalid bind address: {}", cli.bind))?;
|
|
|
|
let display_config = DisplayConfig {
|
|
device: cli.device,
|
|
usb: cli.usb,
|
|
simulate: cli.simulate,
|
|
write_only: cli.write_only,
|
|
native_enabled: !cli.disable_display,
|
|
};
|
|
|
|
let state = Arc::new(AppState {
|
|
monitor_path: cli.config_dir.join("Monitor3.json"),
|
|
image_dir: cli.config_dir.join("img"),
|
|
display_status: Arc::new(RwLock::new(initial_display_status(&display_config))),
|
|
});
|
|
|
|
ensure_layout(&state).await?;
|
|
spawn_display_worker(state.clone(), display_config);
|
|
|
|
let app = Router::new()
|
|
.route("/", get(index))
|
|
.route("/healthz", get(healthz))
|
|
.route("/api/state", get(api_state))
|
|
.route("/api/upload", post(api_upload))
|
|
.route("/api/panels/activate", post(api_activate))
|
|
.route("/api/panels/disable", post(api_disable))
|
|
.route("/api/images/delete", post(api_delete))
|
|
.route("/api/assets/nyan-cat.gif", get(api_nyan_cat))
|
|
.route("/api/images/{name}", get(api_image))
|
|
.layer(DefaultBodyLimit::max(64 * 1024 * 1024))
|
|
.with_state(state);
|
|
|
|
info!("Starting aster-webui on {addr}");
|
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
|
|
let mut status = DisplayStatus {
|
|
native_enabled: config.native_enabled,
|
|
connected: false,
|
|
mode: display_mode(config),
|
|
device: display_target(config),
|
|
custom_panel: false,
|
|
specs_enabled: false,
|
|
memes_enabled: false,
|
|
rotation_active: false,
|
|
switch_time: "10".into(),
|
|
active_images: Vec::new(),
|
|
current_image: None,
|
|
last_error: None,
|
|
updated_at: Some(stamp_now()),
|
|
};
|
|
|
|
if !config.native_enabled {
|
|
status.last_error = Some("Native display loop disabled via --disable-display".into());
|
|
}
|
|
|
|
status
|
|
}
|
|
|
|
async fn ensure_layout(state: &AppState) -> Result<()> {
|
|
fs::create_dir_all(&state.image_dir).await?;
|
|
|
|
if fs::try_exists(&state.monitor_path).await? {
|
|
return Ok(());
|
|
}
|
|
|
|
let default = json!({
|
|
"credentials": {
|
|
"username": "admin",
|
|
"password": "123456"
|
|
},
|
|
"setup": {
|
|
"type": 1,
|
|
"offDisplay": true,
|
|
"controlParams": true,
|
|
"controlDiskTemp": true,
|
|
"customPanel": false,
|
|
"language": 1,
|
|
"switchTime": "10",
|
|
"nativeSpecs": false,
|
|
"nativeMemes": true,
|
|
"operationMode": 0,
|
|
"theme": 1,
|
|
"diskUpdate": 300,
|
|
"ha_url": "",
|
|
"ha_token": "",
|
|
"refresh": 1
|
|
},
|
|
"mianban": [],
|
|
"diy": []
|
|
});
|
|
|
|
save_monitor_json(state, &default).await
|
|
}
|
|
|
|
async fn load_monitor_json(state: &AppState) -> Result<Value> {
|
|
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<String>) -> 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<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 {
|
|
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<Arc<AppState>>, 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<Arc<AppState>>,
|
|
Json(payload): Json<ActivateRequest>,
|
|
) -> 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<String> = available.into_iter().map(|item| item.name).collect();
|
|
let mut valid = Vec::new();
|
|
for name in payload.images {
|
|
if available_names.iter().any(|candidate| candidate == &name) && !valid.contains(&name) {
|
|
valid.push(name);
|
|
}
|
|
}
|
|
|
|
let memes_enabled = payload
|
|
.memes_enabled
|
|
.unwrap_or(!valid.is_empty());
|
|
|
|
if !specs_enabled && (!memes_enabled || valid.is_empty()) {
|
|
return error_response(
|
|
StatusCode::BAD_REQUEST,
|
|
"Enable specs or select at least one meme image",
|
|
);
|
|
}
|
|
|
|
let mut monitor = match load_monitor_json(&state).await {
|
|
Ok(value) => value,
|
|
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
|
};
|
|
|
|
set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &valid);
|
|
|
|
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
|
|
Json(json!({
|
|
"ok": true,
|
|
"switchTime": switch_time,
|
|
"specsEnabled": specs_enabled,
|
|
"memesEnabled": memes_enabled,
|
|
"activeImages": valid,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn api_disable(State(state): State<Arc<AppState>>) -> Response {
|
|
let mut monitor = match load_monitor_json(&state).await {
|
|
Ok(value) => value,
|
|
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
|
};
|
|
let switch_time = current_switch_time(&monitor);
|
|
let active_images = current_active_images(&monitor);
|
|
|
|
set_custom_panels(&mut monitor, switch_time, false, false, &active_images);
|
|
|
|
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
|
|
Json(json!({ "ok": true })).into_response()
|
|
}
|
|
|
|
async fn api_delete(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(payload): Json<DeleteRequest>,
|
|
) -> Response {
|
|
if payload.name.contains('/') || payload.name.contains('\\') {
|
|
return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
|
|
}
|
|
|
|
let path = state.image_dir.join(&payload.name);
|
|
match fs::remove_file(&path).await {
|
|
Ok(_) => {}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
return error_response(StatusCode::NOT_FOUND, "Image not found");
|
|
}
|
|
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
|
}
|
|
|
|
let mut monitor = match load_monitor_json(&state).await {
|
|
Ok(value) => value,
|
|
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
|
};
|
|
|
|
let mut active_images = current_active_images(&monitor);
|
|
let switch_time = current_switch_time(&monitor);
|
|
let specs_enabled = current_specs_enabled(&monitor);
|
|
let memes_enabled = current_memes_enabled(&monitor);
|
|
let before = active_images.len();
|
|
active_images.retain(|name| name != &payload.name);
|
|
if active_images.len() != before {
|
|
set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &active_images);
|
|
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
}
|
|
|
|
Json(json!({ "ok": true })).into_response()
|
|
}
|
|
|
|
async fn api_image(
|
|
AxumPath(name): AxumPath<String>,
|
|
State(state): State<Arc<AppState>>,
|
|
) -> 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<StateResponse> {
|
|
let monitor = load_monitor_json(state).await?;
|
|
let setup = monitor
|
|
.get("setup")
|
|
.and_then(Value::as_object)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let custom_panel = setup
|
|
.get("customPanel")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
let switch_time = setup
|
|
.get("switchTime")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("10")
|
|
.to_string();
|
|
let specs_enabled = current_specs_enabled(&monitor);
|
|
let memes_enabled = current_memes_enabled(&monitor);
|
|
|
|
Ok(StateResponse {
|
|
monitor_path: state.monitor_path.display().to_string(),
|
|
image_dir: state.image_dir.display().to_string(),
|
|
setup: SetupView {
|
|
custom_panel,
|
|
switch_time,
|
|
specs_enabled,
|
|
memes_enabled,
|
|
},
|
|
active_images: current_active_images(&monitor),
|
|
images: list_images(state).await?,
|
|
display: read_display_status(&state.display_status),
|
|
system: collect_system_view(),
|
|
})
|
|
}
|
|
|
|
fn collect_system_view() -> SystemView {
|
|
fn mount_usage(disks: &Disks, mount_point: &str) -> (String, String, String) {
|
|
disks
|
|
.iter()
|
|
.find(|disk| disk.mount_point() == std::path::Path::new(mount_point))
|
|
.map(|disk| {
|
|
let total = disk.total_space();
|
|
let used = total.saturating_sub(disk.available_space());
|
|
let usage_percent = if total == 0 {
|
|
0.0
|
|
} else {
|
|
used as f64 / total as f64 * 100.0
|
|
};
|
|
(
|
|
format!("{usage_percent:.0}"),
|
|
format_bytes(used),
|
|
format_bytes(total),
|
|
)
|
|
})
|
|
.unwrap_or_else(|| ("n/a".into(), "n/a".into(), "n/a".into()))
|
|
}
|
|
|
|
let mut sys = System::new_all();
|
|
sys.refresh_all();
|
|
|
|
let load_avg = System::load_average();
|
|
let total_memory = sys.total_memory();
|
|
let used_memory = sys.used_memory();
|
|
let total_swap = sys.total_swap();
|
|
let used_swap = sys.used_swap();
|
|
|
|
let mut disks = Disks::new();
|
|
disks.refresh(false);
|
|
let disk_total: u64 = disks.iter().map(|disk| disk.total_space()).sum();
|
|
let disk_used: u64 = disks
|
|
.iter()
|
|
.map(|disk| disk.total_space().saturating_sub(disk.available_space()))
|
|
.sum();
|
|
|
|
let mut components = Components::new();
|
|
components.refresh(false);
|
|
|
|
let (cache_usage_percent, cache_used, cache_total) = mount_usage(&disks, "/mnt/cache");
|
|
let (user_usage_percent, user_used, user_total) = mount_usage(&disks, "/mnt/user");
|
|
|
|
SystemView {
|
|
cache_usage_percent,
|
|
cache_used,
|
|
cache_total,
|
|
user_usage_percent,
|
|
user_used,
|
|
user_total,
|
|
cpu_usage_percent: format!("{:.1}", sys.global_cpu_usage()),
|
|
load_avg_one: format!("{:.2}", load_avg.one),
|
|
load_avg_five: format!("{:.2}", load_avg.five),
|
|
load_avg_fifteen: format!("{:.2}", load_avg.fifteen),
|
|
mem_usage_percent: format!("{:.1}", percentage(used_memory, total_memory)),
|
|
mem_used: format_bytes(used_memory),
|
|
mem_total: format_bytes(total_memory),
|
|
swap_usage_percent: format!("{:.1}", percentage(used_swap, total_swap)),
|
|
swap_used: format_bytes(used_swap),
|
|
swap_total: format_bytes(total_swap),
|
|
disk_usage_percent: format!("{:.1}", percentage(disk_used, disk_total)),
|
|
disk_used: format_bytes(disk_used),
|
|
disk_total: format_bytes(disk_total),
|
|
cpu_count: sys.cpus().len(),
|
|
process_count: sys.processes().len(),
|
|
uptime: format_uptime(System::uptime()),
|
|
temperature_cpu: component_temperature(&components, "Tctl"),
|
|
temperature_gpu: component_temperature(&components, "amdgpu"),
|
|
}
|
|
}
|
|
|
|
fn component_temperature(components: &Components, needle: &str) -> Option<String> {
|
|
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<Vec<ImageView>> {
|
|
let mut out = Vec::new();
|
|
let mut dir = fs::read_dir(&state.image_dir).await?;
|
|
while let Some(entry) = dir.next_entry().await? {
|
|
let path = entry.path();
|
|
let file_type = entry.file_type().await?;
|
|
if !file_type.is_file() {
|
|
continue;
|
|
}
|
|
let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else {
|
|
continue;
|
|
};
|
|
let meta = entry.metadata().await?;
|
|
out.push(ImageView {
|
|
url: format!("/api/images/{name}"),
|
|
name,
|
|
size: meta.len(),
|
|
});
|
|
}
|
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(out)
|
|
}
|
|
|
|
fn current_active_images(monitor: &Value) -> Vec<String> {
|
|
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::<u32>().ok())
|
|
.filter(|value| (1..=600).contains(value))
|
|
.unwrap_or(10)
|
|
}
|
|
|
|
fn current_custom_panel_enabled(monitor: &Value) -> bool {
|
|
monitor
|
|
.get("setup")
|
|
.and_then(Value::as_object)
|
|
.and_then(|setup| setup.get("customPanel"))
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn current_specs_enabled(monitor: &Value) -> bool {
|
|
monitor
|
|
.get("setup")
|
|
.and_then(Value::as_object)
|
|
.and_then(|setup| setup.get("nativeSpecs"))
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn current_memes_enabled(monitor: &Value) -> bool {
|
|
monitor
|
|
.get("setup")
|
|
.and_then(Value::as_object)
|
|
.and_then(|setup| setup.get("nativeMemes"))
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or_else(|| !current_active_images(monitor).is_empty())
|
|
}
|
|
|
|
fn rotation_snapshot(monitor: &Value) -> RotationSnapshot {
|
|
RotationSnapshot {
|
|
custom_panel: current_custom_panel_enabled(monitor),
|
|
switch_time: current_switch_time(monitor),
|
|
specs_enabled: current_specs_enabled(monitor),
|
|
memes_enabled: current_memes_enabled(monitor),
|
|
active_images: current_active_images(monitor),
|
|
}
|
|
}
|
|
|
|
fn set_custom_panels(
|
|
monitor: &mut Value,
|
|
switch_time: u32,
|
|
specs_enabled: bool,
|
|
memes_enabled: bool,
|
|
images: &[String],
|
|
) {
|
|
let enabled = specs_enabled || (memes_enabled && !images.is_empty());
|
|
let setup = monitor
|
|
.as_object_mut()
|
|
.expect("monitor config must be an object")
|
|
.entry("setup")
|
|
.or_insert_with(|| Value::Object(Default::default()));
|
|
|
|
if let Some(setup) = setup.as_object_mut() {
|
|
setup.insert("customPanel".into(), Value::Bool(enabled));
|
|
setup.insert("switchTime".into(), Value::String(switch_time.to_string()));
|
|
setup.insert("nativeSpecs".into(), Value::Bool(specs_enabled));
|
|
setup.insert("nativeMemes".into(), Value::Bool(memes_enabled));
|
|
}
|
|
|
|
monitor["mianban"] = Value::Array(
|
|
(1..=images.len())
|
|
.map(|index| Value::Number((index as u64).into()))
|
|
.collect(),
|
|
);
|
|
monitor["diy"] = Value::Array(
|
|
images
|
|
.iter()
|
|
.map(|name| json!({"type": 5, "img": name, "sensor": []}))
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8]) -> Result<String> {
|
|
let image = image::load_from_memory(bytes)
|
|
.with_context(|| format!("Unsupported image format: {file_name}"))?;
|
|
let target_name = make_image_name(file_name);
|
|
let path = state.image_dir.join(&target_name);
|
|
let rendered = render_display_panel(&image);
|
|
rendered
|
|
.save_with_format(&path, ImageFormat::Jpeg)
|
|
.with_context(|| format!("Failed to save {}", path.display()))?;
|
|
Ok(target_name)
|
|
}
|
|
|
|
fn render_display_panel(image: &DynamicImage) -> RgbImage {
|
|
let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
|
|
let rgb = resized.to_rgb8();
|
|
|
|
let mut canvas = RgbImage::from_pixel(
|
|
DISPLAY_WIDTH,
|
|
DISPLAY_HEIGHT,
|
|
image::Rgb([8, 10, 12]),
|
|
);
|
|
|
|
let offset_x = ((DISPLAY_WIDTH - rgb.width()) / 2) as i64;
|
|
let offset_y = ((DISPLAY_HEIGHT - rgb.height()) / 2) as i64;
|
|
image::imageops::overlay(&mut canvas, &rgb, offset_x, offset_y);
|
|
canvas
|
|
}
|
|
|
|
fn render_system_panel() -> RgbImage {
|
|
let system = collect_system_view();
|
|
let mut canvas = RgbImage::from_pixel(DISPLAY_WIDTH, DISPLAY_HEIGHT, Rgb([10, 14, 18]));
|
|
let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
|
|
let timestamp = Local::now().format("%d.%m.%Y %H:%M:%S").to_string();
|
|
|
|
draw_filled_rect_mut(
|
|
&mut canvas,
|
|
Rect::at(0, 0).of_size(DISPLAY_WIDTH, 84),
|
|
Rgb([18, 27, 36]),
|
|
);
|
|
draw_filled_rect_mut(
|
|
&mut canvas,
|
|
Rect::at(0, 84).of_size(DISPLAY_WIDTH, DISPLAY_HEIGHT - 84),
|
|
Rgb([8, 11, 15]),
|
|
);
|
|
|
|
draw_text_line(
|
|
&mut canvas,
|
|
Rgb([216, 253, 114]),
|
|
28,
|
|
20,
|
|
36.0,
|
|
"AOOSTAR Native Specs",
|
|
);
|
|
draw_text_line(
|
|
&mut canvas,
|
|
Rgb([146, 163, 181]),
|
|
30,
|
|
58,
|
|
20.0,
|
|
&format!("{host_name} | {timestamp}"),
|
|
);
|
|
|
|
let cards = [
|
|
(
|
|
24,
|
|
106,
|
|
"CPU",
|
|
format!("{} %", system.cpu_usage_percent),
|
|
format!(
|
|
"Load {} / {} / {}",
|
|
system.load_avg_one, system.load_avg_five, system.load_avg_fifteen
|
|
),
|
|
Rgb([124, 231, 191]),
|
|
),
|
|
(
|
|
332,
|
|
106,
|
|
"Memory",
|
|
format!("{} %", system.mem_usage_percent),
|
|
format!("{} / {}", system.mem_used, system.mem_total),
|
|
Rgb([125, 196, 255]),
|
|
),
|
|
(
|
|
640,
|
|
106,
|
|
"Storage",
|
|
format!("C {}% U {}%", system.cache_usage_percent, system.user_usage_percent),
|
|
format!(
|
|
"/mnt/cache {} / {} | /mnt/user {} / {}",
|
|
system.cache_used, system.cache_total, system.user_used, system.user_total
|
|
),
|
|
Rgb([255, 199, 115]),
|
|
),
|
|
(
|
|
24,
|
|
236,
|
|
"Swap",
|
|
format!("{} %", system.swap_usage_percent),
|
|
format!("{} / {}", system.swap_used, system.swap_total),
|
|
Rgb([194, 167, 255]),
|
|
),
|
|
(
|
|
332,
|
|
236,
|
|
"Temperatures",
|
|
format!("CPU {}", system.temperature_cpu.as_deref().unwrap_or("-")),
|
|
format!("GPU {}", system.temperature_gpu.as_deref().unwrap_or("-")),
|
|
Rgb([255, 127, 115]),
|
|
),
|
|
(
|
|
640,
|
|
236,
|
|
"System",
|
|
system.uptime.clone(),
|
|
format!("{} CPU / {} proc", system.cpu_count, system.process_count),
|
|
Rgb([216, 253, 114]),
|
|
),
|
|
];
|
|
|
|
for (x, y, title, value, meta, accent) in cards {
|
|
draw_metric_card(&mut canvas, x, y, title, &value, &meta, accent);
|
|
}
|
|
|
|
canvas
|
|
}
|
|
|
|
fn overlay_system_specs(canvas: &mut RgbImage) {
|
|
let system = collect_system_view();
|
|
let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
|
|
let timestamp = Local::now().format("%H:%M:%S").to_string();
|
|
|
|
draw_filled_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([12, 18, 24]));
|
|
draw_hollow_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([36, 50, 67]));
|
|
draw_text_line(
|
|
canvas,
|
|
Rgb([216, 253, 114]),
|
|
34,
|
|
28,
|
|
22.0,
|
|
&format!(
|
|
"{host_name} CPU {}% RAM {}% C {}% U {}%",
|
|
system.cpu_usage_percent,
|
|
system.mem_usage_percent,
|
|
system.cache_usage_percent,
|
|
system.user_usage_percent
|
|
),
|
|
);
|
|
draw_text_line(
|
|
canvas,
|
|
Rgb([146, 163, 181]),
|
|
34,
|
|
52,
|
|
16.0,
|
|
&format!(
|
|
"Load {} / {} / {} Temp {} / {} Uptime {} {}",
|
|
system.load_avg_one,
|
|
system.load_avg_five,
|
|
system.load_avg_fifteen,
|
|
system.temperature_cpu.as_deref().unwrap_or("-"),
|
|
system.temperature_gpu.as_deref().unwrap_or("-"),
|
|
system.uptime,
|
|
timestamp
|
|
),
|
|
);
|
|
|
|
draw_filled_rect_mut(canvas, Rect::at(18, 306).of_size(924, 52), Rgb([12, 18, 24]));
|
|
draw_hollow_rect_mut(canvas, Rect::at(18, 306).of_size(924, 52), Rgb([36, 50, 67]));
|
|
draw_text_line(
|
|
canvas,
|
|
Rgb([237, 242, 247]),
|
|
34,
|
|
320,
|
|
18.0,
|
|
&format!(
|
|
"Cache {} / {} User {} / {} Proc {} CPU {} GPU {}",
|
|
system.cache_used,
|
|
system.cache_total,
|
|
system.user_used,
|
|
system.user_total,
|
|
system.process_count,
|
|
system.temperature_cpu.as_deref().unwrap_or("-"),
|
|
system.temperature_gpu.as_deref().unwrap_or("-"),
|
|
),
|
|
);
|
|
}
|
|
|
|
fn draw_metric_card(
|
|
canvas: &mut RgbImage,
|
|
x: i32,
|
|
y: i32,
|
|
title: &str,
|
|
value: &str,
|
|
meta: &str,
|
|
accent: Rgb<u8>,
|
|
) {
|
|
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<u8>,
|
|
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<FontArc> = OnceLock::new();
|
|
FONT.get_or_init(|| {
|
|
FontArc::try_from_slice(include_bytes!("../../../fonts/DejaVuSans.ttf"))
|
|
.expect("embedded display font must be valid")
|
|
})
|
|
}
|
|
|
|
fn make_image_name(file_name: &str) -> String {
|
|
let base = file_name
|
|
.rsplit_once('.')
|
|
.map(|(name, _)| name)
|
|
.unwrap_or(file_name);
|
|
let clean: String = base
|
|
.chars()
|
|
.map(|ch| match ch {
|
|
'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(),
|
|
_ => '-',
|
|
})
|
|
.collect();
|
|
let clean = clean.trim_matches('-');
|
|
let clean = if clean.is_empty() { "panel" } else { clean };
|
|
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
|
|
format!("panel-{timestamp}-{clean}.jpg")
|
|
}
|
|
|
|
fn spawn_display_worker(state: Arc<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 cycle_started_at = Instant::now();
|
|
let mut current_frame_index = None;
|
|
|
|
loop {
|
|
let monitor = match load_monitor_json_sync(&state.monitor_path) {
|
|
Ok(monitor) => monitor,
|
|
Err(err) => {
|
|
update_display_status(&state.display_status, |status| {
|
|
status.last_error = Some(format!("Config read failed: {err}"));
|
|
status.connected = true;
|
|
status.updated_at = Some(stamp_now());
|
|
});
|
|
thread::sleep(Duration::from_secs(1));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let new_snapshot = rotation_snapshot(&monitor);
|
|
if new_snapshot != snapshot {
|
|
snapshot = new_snapshot;
|
|
cycle_started_at = Instant::now();
|
|
current_frame_index = None;
|
|
}
|
|
|
|
let frames = snapshot.frames();
|
|
|
|
update_display_status(&state.display_status, |status| {
|
|
status.custom_panel = snapshot.custom_panel;
|
|
status.specs_enabled = snapshot.specs_enabled;
|
|
status.memes_enabled = snapshot.memes_enabled;
|
|
status.rotation_active = snapshot.rotation_active();
|
|
status.switch_time = snapshot.switch_time.to_string();
|
|
status.active_images = snapshot.active_images.clone();
|
|
if !snapshot.rotation_active() {
|
|
status.current_image = None;
|
|
}
|
|
status.connected = true;
|
|
status.updated_at = Some(stamp_now());
|
|
});
|
|
|
|
if !snapshot.rotation_active() {
|
|
thread::sleep(Duration::from_millis(750));
|
|
continue;
|
|
}
|
|
|
|
let switch_after = Duration::from_secs(snapshot.switch_time as u64);
|
|
let elapsed = cycle_started_at.elapsed();
|
|
let frame_slot = elapsed.as_secs() / snapshot.switch_time as u64;
|
|
let next_index = (frame_slot as usize) % frames.len();
|
|
let send_due = current_frame_index != Some(next_index);
|
|
|
|
if send_due {
|
|
let frame = frames[next_index].clone();
|
|
match frame {
|
|
RotationFrame::SystemSpecs => {
|
|
let panel_name = SYSTEM_FRAME_NAME.to_string();
|
|
let rgb_img = render_system_panel();
|
|
screen
|
|
.send_image(&rgb_img)
|
|
.context("Failed to send system specs panel")?;
|
|
|
|
update_display_status(&state.display_status, |status| {
|
|
status.current_image = Some(panel_name);
|
|
status.last_error = None;
|
|
status.connected = true;
|
|
status.updated_at = Some(stamp_now());
|
|
});
|
|
}
|
|
RotationFrame::Image(image_name) => match load_panel_rgb(&state.image_dir, &image_name)
|
|
{
|
|
Ok(mut rgb_img) => {
|
|
if snapshot.specs_enabled {
|
|
overlay_system_specs(&mut rgb_img);
|
|
}
|
|
screen
|
|
.send_image(&rgb_img)
|
|
.with_context(|| format!("Failed to send panel {image_name}"))?;
|
|
|
|
update_display_status(&state.display_status, |status| {
|
|
status.current_image = Some(image_name.clone());
|
|
status.last_error = None;
|
|
status.connected = true;
|
|
status.updated_at = Some(stamp_now());
|
|
});
|
|
}
|
|
Err(err) => {
|
|
warn!("Skipping panel {image_name}: {err}");
|
|
update_display_status(&state.display_status, |status| {
|
|
status.current_image = Some(image_name.clone());
|
|
status.last_error =
|
|
Some(format!("Panel {image_name} could not be rendered: {err}"));
|
|
status.connected = true;
|
|
status.updated_at = Some(stamp_now());
|
|
});
|
|
}
|
|
},
|
|
}
|
|
current_frame_index = Some(next_index);
|
|
}
|
|
|
|
thread::sleep(rotation_sleep(frames.len(), switch_after, cycle_started_at));
|
|
}
|
|
}
|
|
|
|
fn load_monitor_json_sync(path: &PathBuf) -> Result<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(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration {
|
|
if frame_count <= 1 {
|
|
return Duration::from_millis(750);
|
|
}
|
|
|
|
let elapsed = cycle_started_at.elapsed();
|
|
let switch_secs = switch_after.as_secs().max(1);
|
|
let next_boundary_secs = ((elapsed.as_secs() / switch_secs) + 1) * switch_secs;
|
|
let remaining = Duration::from_secs(next_boundary_secs).saturating_sub(elapsed);
|
|
if remaining > Duration::from_millis(750) {
|
|
Duration::from_millis(750)
|
|
} else if remaining < Duration::from_millis(200) {
|
|
Duration::from_millis(200)
|
|
} else {
|
|
remaining
|
|
}
|
|
}
|
|
|
|
fn read_display_status(status: &Arc<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>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>aster-webui</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0d1117;
|
|
--panel: #121821;
|
|
--line: #243243;
|
|
--text: #edf2f7;
|
|
--muted: #92a3b5;
|
|
--accent: #7ce7bf;
|
|
--accent-2: #d8fd72;
|
|
--danger: #ff7f73;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(124,231,191,.15), transparent 30%),
|
|
radial-gradient(circle at bottom right, rgba(216,253,114,.12), transparent 25%),
|
|
var(--bg);
|
|
color: var(--text);
|
|
font-family: "IBM Plex Sans", sans-serif;
|
|
}
|
|
main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; }
|
|
.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);
|
|
border-radius: 26px;
|
|
padding: 22px;
|
|
}
|
|
.hero h1 { margin: 0 0 8px; font-size: clamp(2rem, 3vw, 3.4rem); line-height: 1; }
|
|
.hero p, .muted { color: var(--muted); }
|
|
.status strong { display: block; margin-top: 8px; font-size: 1.3rem; }
|
|
.status small { display: block; margin-top: 6px; color: var(--muted); }
|
|
.controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; }
|
|
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
|
|
.toggle-pill {
|
|
display: inline-flex; align-items: center; gap: 10px; padding: 10px 14px;
|
|
border: 1px solid var(--line); border-radius: 999px; background: rgba(9,14,20,.7);
|
|
color: var(--text); font-size: .95rem;
|
|
}
|
|
.toggle-pill input { margin: 0; width: 18px; height: 18px; accent-color: var(--accent); }
|
|
label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; }
|
|
input, button {
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
padding: 12px 14px;
|
|
font: inherit;
|
|
}
|
|
input { background: rgba(6,10,15,.8); color: var(--text); }
|
|
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
button { cursor: pointer; }
|
|
.primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #071016; font-weight: 700; }
|
|
.ghost { background: rgba(9,14,20,.7); color: var(--text); }
|
|
.danger { background: rgba(255,127,115,.14); color: #ffc7c1; }
|
|
.content { grid-template-columns: 1.45fr 1fr; }
|
|
.metrics-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-bottom: 18px; }
|
|
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
|
.tile {
|
|
border: 1px solid var(--line);
|
|
border-radius: 20px;
|
|
overflow: hidden;
|
|
background: rgba(8,12,18,.78);
|
|
}
|
|
.tile.selected { outline: 2px solid var(--accent-2); }
|
|
.tile img { width: 100%; aspect-ratio: 960 / 376; object-fit: cover; display: block; }
|
|
.tile-body { padding: 12px; display: grid; gap: 10px; }
|
|
.tile-title { word-break: break-word; }
|
|
.mini { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.order { display: grid; gap: 10px; }
|
|
.order-item {
|
|
display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center;
|
|
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, .status-grid, .metrics-grid { grid-template-columns: 1fr; }
|
|
}
|
|
.nyan-wrap {
|
|
position: fixed;
|
|
right: 24px;
|
|
bottom: 18px;
|
|
width: 260px;
|
|
pointer-events: none;
|
|
z-index: 30;
|
|
opacity: 0.98;
|
|
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.35));
|
|
}
|
|
.nyan-wrap img {
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.nyan-wrap {
|
|
right: 14px;
|
|
bottom: 12px;
|
|
width: 180px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<section class="hero">
|
|
<div class="card">
|
|
<p class="muted">aoostar-rs fork</p>
|
|
<h1>aster-webui</h1>
|
|
<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>
|
|
<input id="switchTime" type="number" min="1" max="600" value="10">
|
|
</label>
|
|
<label>
|
|
<span>Upload New Source</span>
|
|
<input id="upload" type="file" accept="image/*">
|
|
</label>
|
|
<div class="actions">
|
|
<button id="apply" class="primary" type="button">Apply Rotation</button>
|
|
<button id="disable" class="danger" type="button">Disable</button>
|
|
<button id="refresh" class="ghost" type="button">Refresh</button>
|
|
</div>
|
|
</div>
|
|
<div class="toggle-row">
|
|
<label class="toggle-pill"><input id="specsEnabled" type="checkbox"> System Specs on LCD</label>
|
|
<label class="toggle-pill"><input id="memesEnabled" type="checkbox"> Memes on LCD</label>
|
|
</div>
|
|
</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="metrics-grid">
|
|
<div class="card status"><span class="muted">CPU</span><strong id="cpuUsageValue">-</strong><small id="cpuMetaValue">-</small></div>
|
|
<div class="card status"><span class="muted">Memory</span><strong id="memUsageValue">-</strong><small id="memMetaValue">-</small></div>
|
|
<div class="card status"><span class="muted">Storage</span><strong id="diskUsageValue">-</strong><small id="diskMetaValue">-</small></div>
|
|
<div class="card status"><span class="muted">Swap</span><strong id="swapUsageValue">-</strong><small id="swapMetaValue">-</small></div>
|
|
<div class="card status"><span class="muted">Load Avg</span><strong id="loadValue">-</strong><small id="uptimeValue">-</small></div>
|
|
<div class="card status"><span class="muted">Temperatures</span><strong id="tempCpuValue">-</strong><small id="tempGpuValue">-</small></div>
|
|
</section>
|
|
<section class="content">
|
|
<div class="card">
|
|
<h2>Available Images</h2>
|
|
<div id="gallery" class="gallery"></div>
|
|
</div>
|
|
<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>
|
|
<div class="nyan-wrap" aria-hidden="true">
|
|
<img src="/api/assets/nyan-cat.gif" alt="Nyancat">
|
|
</div>
|
|
|
|
<script>
|
|
const state = {
|
|
images: [],
|
|
activeImages: [],
|
|
setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false },
|
|
display: {},
|
|
system: {},
|
|
};
|
|
const el = {
|
|
gallery: document.getElementById("gallery"),
|
|
order: document.getElementById("order"),
|
|
switchTime: document.getElementById("switchTime"),
|
|
specsEnabled: document.getElementById("specsEnabled"),
|
|
memesEnabled: document.getElementById("memesEnabled"),
|
|
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"),
|
|
cpuUsageValue: document.getElementById("cpuUsageValue"),
|
|
cpuMetaValue: document.getElementById("cpuMetaValue"),
|
|
memUsageValue: document.getElementById("memUsageValue"),
|
|
memMetaValue: document.getElementById("memMetaValue"),
|
|
diskUsageValue: document.getElementById("diskUsageValue"),
|
|
diskMetaValue: document.getElementById("diskMetaValue"),
|
|
swapUsageValue: document.getElementById("swapUsageValue"),
|
|
swapMetaValue: document.getElementById("swapMetaValue"),
|
|
loadValue: document.getElementById("loadValue"),
|
|
uptimeValue: document.getElementById("uptimeValue"),
|
|
tempCpuValue: document.getElementById("tempCpuValue"),
|
|
tempGpuValue: document.getElementById("tempGpuValue"),
|
|
upload: document.getElementById("upload"),
|
|
apply: document.getElementById("apply"),
|
|
disable: document.getElementById("disable"),
|
|
refresh: document.getElementById("refresh"),
|
|
toast: document.getElementById("toast"),
|
|
};
|
|
|
|
function toast(msg) {
|
|
el.toast.textContent = msg;
|
|
el.toast.style.display = "block";
|
|
clearTimeout(toast.timer);
|
|
toast.timer = setTimeout(() => el.toast.style.display = "none", 2600);
|
|
}
|
|
|
|
function bytes(n) {
|
|
if (n < 1024) return n + " B";
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
}
|
|
|
|
function move(index, delta) {
|
|
const target = index + delta;
|
|
if (target < 0 || target >= state.activeImages.length) return;
|
|
const next = [...state.activeImages];
|
|
const [item] = next.splice(index, 1);
|
|
next.splice(target, 0, item);
|
|
state.activeImages = next;
|
|
render();
|
|
}
|
|
|
|
function toggle(name) {
|
|
if (state.activeImages.includes(name)) {
|
|
state.activeImages = state.activeImages.filter((item) => item !== name);
|
|
} else {
|
|
state.activeImages = [...state.activeImages, name];
|
|
}
|
|
render();
|
|
}
|
|
|
|
async function request(url, options = {}) {
|
|
const res = await fetch(url, options);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || "request failed");
|
|
return data;
|
|
}
|
|
|
|
async function load() {
|
|
const data = await request("/api/state");
|
|
state.images = data.images;
|
|
state.activeImages = data.active_images;
|
|
state.setup = data.setup;
|
|
state.display = data.display || {};
|
|
state.system = data.system || {};
|
|
el.switchTime.value = data.setup.switch_time || "10";
|
|
el.specsEnabled.checked = !!data.setup.specs_enabled;
|
|
el.memesEnabled.checked = !!data.setup.memes_enabled;
|
|
const enabledModes = [
|
|
data.setup.specs_enabled ? "Specs" : "",
|
|
data.setup.memes_enabled ? "Memes" : "",
|
|
].filter(Boolean);
|
|
el.customPanelValue.textContent = data.setup.custom_panel
|
|
? (enabledModes.join(" + ") || "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;
|
|
el.cpuUsageValue.textContent = `${state.system.cpu_usage_percent || "-"} %`;
|
|
el.cpuMetaValue.textContent = `Load ${state.system.load_avg_one || "-"} / ${state.system.load_avg_five || "-"} / ${state.system.load_avg_fifteen || "-"}`;
|
|
el.memUsageValue.textContent = `${state.system.mem_usage_percent || "-"} %`;
|
|
el.memMetaValue.textContent = `${state.system.mem_used || "-"} / ${state.system.mem_total || "-"}`;
|
|
el.diskUsageValue.innerHTML = `/mnt/cache ${state.system.cache_usage_percent || "-"}%<br>/mnt/user ${state.system.user_usage_percent || "-"}%`;
|
|
el.diskMetaValue.textContent = `${state.system.cache_used || "-"} / ${state.system.cache_total || "-"} | ${state.system.user_used || "-"} / ${state.system.user_total || "-"}`;
|
|
el.swapUsageValue.textContent = `${state.system.swap_usage_percent || "-"} %`;
|
|
el.swapMetaValue.textContent = `${state.system.swap_used || "-"} / ${state.system.swap_total || "-"}`;
|
|
el.loadValue.textContent = `${state.system.cpu_count || "-"} CPU / ${state.system.process_count || "-"} proc`;
|
|
el.uptimeValue.textContent = `Uptime ${state.system.uptime || "-"}`;
|
|
el.tempCpuValue.textContent = `CPU ${state.system.temperature_cpu || "-"}`;
|
|
el.tempGpuValue.textContent = `GPU ${state.system.temperature_gpu || "-"}`;
|
|
render();
|
|
}
|
|
|
|
async function uploadFile(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
await request("/api/upload", { method: "POST", body: form });
|
|
event.target.value = "";
|
|
toast("Upload complete");
|
|
await load();
|
|
}
|
|
|
|
async function apply() {
|
|
await request("/api/panels/activate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
images: state.activeImages,
|
|
switch_time: Number.parseInt(el.switchTime.value || "10", 10),
|
|
specs_enabled: el.specsEnabled.checked,
|
|
memes_enabled: el.memesEnabled.checked,
|
|
}),
|
|
});
|
|
toast("Rotation updated");
|
|
await load();
|
|
}
|
|
|
|
async function disablePanels() {
|
|
await request("/api/panels/disable", { method: "POST" });
|
|
toast("Custom panels disabled");
|
|
await load();
|
|
}
|
|
|
|
async function removeImage(name) {
|
|
await request("/api/images/delete", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
toast("Image deleted");
|
|
await load();
|
|
}
|
|
|
|
function render() {
|
|
el.gallery.innerHTML = "";
|
|
for (const image of state.images) {
|
|
const selected = state.activeImages.includes(image.name);
|
|
const tile = document.createElement("article");
|
|
tile.className = "tile" + (selected ? " selected" : "");
|
|
tile.innerHTML = `
|
|
<img src="${image.url}" alt="${image.name}">
|
|
<div class="tile-body">
|
|
<div class="tile-title">${image.name}</div>
|
|
<div class="muted">${bytes(image.size)}</div>
|
|
<div class="mini">
|
|
<button class="ghost" type="button">${selected ? "Remove" : "Add"}</button>
|
|
<button class="danger" type="button">Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const [toggleBtn, deleteBtn] = tile.querySelectorAll("button");
|
|
toggleBtn.addEventListener("click", () => toggle(image.name));
|
|
deleteBtn.addEventListener("click", () => removeImage(image.name));
|
|
el.gallery.appendChild(tile);
|
|
}
|
|
|
|
el.order.innerHTML = "";
|
|
if (state.setup.specs_enabled && !state.activeImages.length) {
|
|
const row = document.createElement("div");
|
|
row.className = "order-item";
|
|
row.innerHTML = `
|
|
<div class="index">S</div>
|
|
<div class="meta">
|
|
<div>System Specs</div>
|
|
<div class="muted">${state.display.current_image === "System Specs" ? "Currently shown" : "Fixed first frame when enabled"}</div>
|
|
</div>
|
|
<div class="mini"><span class="muted">Toggle above</span></div>
|
|
`;
|
|
el.order.appendChild(row);
|
|
}
|
|
|
|
if (state.setup.specs_enabled && state.activeImages.length) {
|
|
const row = document.createElement("div");
|
|
row.className = "order-item";
|
|
row.innerHTML = `
|
|
<div class="index">S</div>
|
|
<div class="meta">
|
|
<div>System Specs Overlay</div>
|
|
<div class="muted">Live stats are drawn over every meme while enabled.</div>
|
|
</div>
|
|
<div class="mini"><span class="muted">Toggle above</span></div>
|
|
`;
|
|
el.order.appendChild(row);
|
|
}
|
|
|
|
if (!state.activeImages.length && !state.setup.specs_enabled) {
|
|
el.order.innerHTML = `<p class="muted">No active images.</p>`;
|
|
return;
|
|
}
|
|
state.activeImages.forEach((name, index) => {
|
|
const row = document.createElement("div");
|
|
row.className = "order-item";
|
|
row.innerHTML = `
|
|
<div class="index">${index + 1}</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>
|
|
<button class="danger" type="button">Remove</button>
|
|
</div>
|
|
`;
|
|
const [up, down, remove] = row.querySelectorAll("button");
|
|
up.addEventListener("click", () => move(index, -1));
|
|
down.addEventListener("click", () => move(index, 1));
|
|
remove.addEventListener("click", () => toggle(name));
|
|
el.order.appendChild(row);
|
|
});
|
|
}
|
|
|
|
el.upload.addEventListener("change", (event) => uploadFile(event).catch((err) => toast(err.message)));
|
|
el.apply.addEventListener("click", () => apply().catch((err) => toast(err.message)));
|
|
el.disable.addEventListener("click", () => disablePanels().catch((err) => toast(err.message)));
|
|
el.refresh.addEventListener("click", () => load().catch((err) => toast(err.message)));
|
|
|
|
load().catch((err) => toast(err.message));
|
|
setInterval(() => load().catch(() => {}), 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"##;
|