Add experimental 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 15:55:56 +02:00
parent 2f4d95957d
commit 1e1d37bfc2
4 changed files with 1296 additions and 7 deletions
Generated
+474 -7
View File
@@ -152,6 +152,23 @@ dependencies = [
"tempfile",
]
[[package]]
name = "aster-webui"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"clap",
"image",
"mime_guess",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "asterctl"
version = "0.2.0"
@@ -185,6 +202,12 @@ dependencies = [
"serialport",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -214,6 +237,59 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -305,7 +381,7 @@ dependencies = [
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link 0.2.0",
"windows-link 0.2.1",
]
[[package]]
@@ -422,6 +498,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "env_filter"
version = "0.1.3"
@@ -521,6 +606,15 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -530,6 +624,15 @@ dependencies = [
"libc",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.31"
@@ -636,6 +739,86 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "iana-time-zone"
version = "0.1.64"
@@ -867,6 +1050,12 @@ dependencies = [
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lebe"
version = "0.5.2"
@@ -945,6 +1134,21 @@ dependencies = [
"libc",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
@@ -971,6 +1175,22 @@ version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -999,6 +1219,23 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "nalgebra"
version = "0.32.6"
@@ -1080,6 +1317,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -1211,6 +1457,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -1585,18 +1837,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "serde"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -1615,6 +1877,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -1635,6 +1908,18 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serialport"
version = "4.7.3"
@@ -1654,6 +1939,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1700,6 +1994,22 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -1717,6 +2027,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "sysinfo"
version = "0.37.0"
@@ -1803,6 +2119,15 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.9.1"
@@ -1814,6 +2139,31 @@ dependencies = [
"weezl",
]
[[package]]
name = "tokio"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.8.23"
@@ -1848,6 +2198,96 @@ dependencies = [
"winnow",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -1869,6 +2309,12 @@ dependencies = [
"thiserror 2.0.16",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.18"
@@ -1892,12 +2338,24 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -2104,9 +2562,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
@@ -2154,6 +2612,15 @@ dependencies = [
"windows-targets 0.53.3",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
+25
View File
@@ -18,6 +18,31 @@ Check out the **[User Guide](https://zehnm.github.io/aoostar-rs)** for a list of
- Rotate through multiple panels in a defined interval.
- USB device/serial port selection.
## Experimental Web UI
This fork also contains an experimental `aster-webui` crate for managing custom panel images and
`Monitor3.json` through a browser.
Current scope:
- upload source images through the browser
- normalize them to AOOSTAR-safe `960x376` JPG files
- manage rotation order and switch interval
- enable or disable custom panels without the vendor web UI
Start it from the workspace root:
```bash
cargo run -p aster-webui -- --config-dir /config --bind 0.0.0.0:8080
```
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
## Disclaimer
> I take no responsibility for the use of this software.
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "aster-webui"
version = "0.1.0"
description = "Web UI and config API for AOOSTAR WTR MAX / GEM12+ PRO"
rust-version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
anyhow = "1.0.98"
axum = { version = "0.8.4", features = ["multipart"] }
chrono = "0.4"
clap = { version = "4.5.42", features = ["derive"] }
image = "0.25.6"
mime_guess = "2.0.5"
serde = { version = "1.0.219", features = ["derive"] }
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"] }
+773
View File
@@ -0,0 +1,773 @@
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use anyhow::{Context, Result};
use axum::{
Json, Router,
body::Body,
extract::{DefaultBodyLimit, Multipart, Path, State},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use chrono::Local;
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;
const DISPLAY_WIDTH: u32 = 960;
const DISPLAY_HEIGHT: u32 = 376;
#[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,
}
#[derive(Clone)]
struct AppState {
monitor_path: PathBuf,
image_dir: PathBuf,
}
#[derive(Serialize)]
struct StateResponse {
monitor_path: String,
image_dir: String,
setup: SetupView,
active_images: Vec<String>,
images: Vec<ImageView>,
}
#[derive(Serialize)]
struct SetupView {
custom_panel: bool,
switch_time: String,
}
#[derive(Serialize)]
struct ImageView {
name: String,
size: u64,
url: String,
}
#[derive(Deserialize)]
struct ActivateRequest {
images: Vec<String>,
switch_time: Option<u32>,
}
#[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 state = Arc::new(AppState {
monitor_path: cli.config_dir.join("Monitor3.json"),
image_dir: cli.config_dir.join("img"),
});
ensure_layout(&state).await?;
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/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(())
}
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",
"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)?;
fs::write(&state.monitor_path, payload)
.await
.with_context(|| format!("Failed to write {:?}", state.monitor_path))
}
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
let body = Json(ErrorResponse {
error: message.into(),
});
(status, body).into_response()
}
async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
async fn healthz() -> Json<Value> {
Json(json!({"ok": true}))
}
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 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);
}
}
if valid.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "No valid images selected");
}
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, true, switch_time, &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,
"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()),
};
if let Some(setup) = monitor.get_mut("setup").and_then(Value::as_object_mut) {
setup.insert("customPanel".into(), Value::Bool(false));
}
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 before = active_images.len();
active_images.retain(|name| name != &payload.name);
if active_images.len() != before {
set_custom_panels(
&mut monitor,
!active_images.is_empty(),
switch_time,
&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(Path(name): Path<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 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();
Ok(StateResponse {
monitor_path: state.monitor_path.display().to_string(),
image_dir: state.image_dir.display().to_string(),
setup: SetupView {
custom_panel,
switch_time,
},
active_images: current_active_images(&monitor),
images: list_images(state).await?,
})
}
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 set_custom_panels(monitor: &mut Value, enabled: bool, switch_time: u32, images: &[String]) {
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()));
}
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 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")
}
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 { display: grid; gap: 18px; }
.hero { grid-template-columns: 1.5fr 1fr; margin-bottom: 18px; }
.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 { display: grid; gap: 12px; }
.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; }
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; }
.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; }
.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; }
}
</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 fuer Panelbilder, Rotation und `Monitor3.json`-Steuerung. Uploads werden auf 960x376 JPG normalisiert; animierte GIFs nutzen aktuell nur das erste Frame.</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>
<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>
</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>
</div>
</section>
</main>
<div id="toast" class="toast"></div>
<script>
const state = { images: [], activeImages: [], setup: { switch_time: "10", custom_panel: false } };
const el = {
gallery: document.getElementById("gallery"),
order: document.getElementById("order"),
switchTime: document.getElementById("switchTime"),
customPanelValue: document.getElementById("customPanelValue"),
imageCountValue: document.getElementById("imageCountValue"),
configPathValue: document.getElementById("configPathValue"),
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;
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.configPathValue.textContent = data.monitor_path;
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),
}),
});
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.activeImages.length) {
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>${name}</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));
</script>
</body>
</html>
"##;