diff --git a/Cargo.lock b/Cargo.lock index 466c5fd..df082b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/README.md b/README.md index dfd440b..a80d77b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/aster-webui/Cargo.toml b/crates/aster-webui/Cargo.toml new file mode 100644 index 0000000..8875335 --- /dev/null +++ b/crates/aster-webui/Cargo.toml @@ -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"] } + diff --git a/crates/aster-webui/src/main.rs b/crates/aster-webui/src/main.rs new file mode 100644 index 0000000..0d7f410 --- /dev/null +++ b/crates/aster-webui/src/main.rs @@ -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, + images: Vec, +} + +#[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, + switch_time: Option, +} + +#[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 { + 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) -> 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 { + Json(json!({"ok": true})) +} + +async fn api_state(State(state): State>) -> 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>, 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>, + Json(payload): Json, +) -> 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 = 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>) -> 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>, + Json(payload): Json, +) -> 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, State(state): State>) -> 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 { + 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> { + 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 { + 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::().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 { + 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##" + + + + + aster-webui + + + +
+
+
+

aoostar-rs fork

+

aster-webui

+

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

+
+ + +
+ + + +
+
+
+
+
Custom Panel-
+
Active Images-
+
Config Path-
+
+
+
+
+

Available Images

+ +
+
+

Rotation Order

+
+
+
+
+
+ + + +"##;