diff --git a/.idea/misc.xml b/.idea/misc.xml
index dd8986a..e5db904 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/.idea/runConfigurations/Run_demo.xml b/.idea/runConfigurations/Run_demo.xml
index 6eeb8af..7e05cc7 100644
--- a/.idea/runConfigurations/Run_demo.xml
+++ b/.idea/runConfigurations/Run_demo.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/.idea/runConfigurations/Run_demo_DEV.xml b/.idea/runConfigurations/Run_demo_DEV.xml
index a907063..756e800 100644
--- a/.idea/runConfigurations/Run_demo_DEV.xml
+++ b/.idea/runConfigurations/Run_demo_DEV.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/.idea/runConfigurations/Run_sensor_panel.xml b/.idea/runConfigurations/Run_sensor_panel.xml
index 981ccf9..f9eeddb 100644
--- a/.idea/runConfigurations/Run_sensor_panel.xml
+++ b/.idea/runConfigurations/Run_sensor_panel.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/.idea/runConfigurations/Run_sensor_panel_DEV.xml b/.idea/runConfigurations/Run_sensor_panel_DEV.xml
index 18429cb..41ab62f 100644
--- a/.idea/runConfigurations/Run_sensor_panel_DEV.xml
+++ b/.idea/runConfigurations/Run_sensor_panel_DEV.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/.idea/runConfigurations/Run_sysinfo.xml b/.idea/runConfigurations/Run_sysinfo.xml
index d9edb39..5fc489b 100644
--- a/.idea/runConfigurations/Run_sysinfo.xml
+++ b/.idea/runConfigurations/Run_sysinfo.xml
@@ -1,7 +1,7 @@
-
+q
diff --git a/.idea/runConfigurations/Run_sysinfo_repeat.xml b/.idea/runConfigurations/Run_sysinfo_repeat.xml
index 0c2c5f9..2f5fbfe 100644
--- a/.idea/runConfigurations/Run_sysinfo_repeat.xml
+++ b/.idea/runConfigurations/Run_sysinfo_repeat.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/.idea/runConfigurations/clippy_sysinfo.xml b/.idea/runConfigurations/clippy_sysinfo.xml
deleted file mode 100644
index 7e17347..0000000
--- a/.idea/runConfigurations/clippy_sysinfo.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 029a1a8..a081b18 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -8,6 +8,6 @@
-
+
\ No newline at end of file
diff --git a/AOOstar-rs.iml b/AOOstar-rs.iml
index 2fecef3..94c628b 100644
--- a/AOOstar-rs.iml
+++ b/AOOstar-rs.iml
@@ -1,12 +1,16 @@
-
+
-
+
+
+
+
+
+
-
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a482e78..bc1b0e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@ _Changes in the next release_
### Added
- Simple sensor panel with a file-based data source (#6)
+- Initial support for fan-, progress-, & pointer-sensors (#8)
+
+### Changed
+- Project structure using a Cargo workspace
---
diff --git a/Cargo.lock b/Cargo.lock
index 5774350..a912d6f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -98,31 +98,6 @@ version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
-[[package]]
-name = "aoostar-rs"
-version = "0.1.0"
-dependencies = [
- "ab_glyph",
- "anyhow",
- "bytes",
- "clap",
- "env_logger",
- "image",
- "imageproc",
- "itertools 0.14.0",
- "log",
- "notify",
- "once_cell",
- "regex",
- "rstest",
- "serde",
- "serde_json",
- "serde_repr",
- "serialport",
- "sysinfo",
- "tempfile",
-]
-
[[package]]
name = "approx"
version = "0.5.1"
@@ -155,6 +130,37 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+[[package]]
+name = "asterctl"
+version = "0.1.0"
+dependencies = [
+ "ab_glyph",
+ "anyhow",
+ "asterctl-lcd",
+ "clap",
+ "env_logger",
+ "image",
+ "imageproc",
+ "log",
+ "notify",
+ "once_cell",
+ "rstest",
+ "serde",
+ "serde_json",
+ "serde_repr",
+]
+
+[[package]]
+name = "asterctl-lcd"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "image",
+ "log",
+ "serialport",
+]
+
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -198,9 +204,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
-version = "2.9.2"
+version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29"
+checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d"
[[package]]
name = "bitstream-io"
@@ -240,9 +246,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cc"
-version = "1.2.33"
+version = "1.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f"
+checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc"
dependencies = [
"jobserver",
"libc",
@@ -651,9 +657,9 @@ checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
[[package]]
name = "indexmap"
-version = "2.10.0"
+version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
+checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
dependencies = [
"equivalent",
"hashbrown",
@@ -665,7 +671,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
- "bitflags 2.9.2",
+ "bitflags 2.9.3",
"inotify-sys",
"libc",
]
@@ -756,9 +762,9 @@ dependencies = [
[[package]]
name = "jobserver"
-version = "0.1.33"
+version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.3",
"libc",
@@ -986,7 +992,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
- "bitflags 2.9.2",
+ "bitflags 2.9.3",
"fsevent-sys",
"inotify",
"kqueue",
@@ -1104,7 +1110,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
- "bitflags 2.9.2",
+ "bitflags 2.9.3",
]
[[package]]
@@ -1384,9 +1390,9 @@ dependencies = [
[[package]]
name = "regex"
-version = "1.11.1"
+version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
+checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
@@ -1396,9 +1402,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.4.9"
+version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
+checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
@@ -1407,9 +1413,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
-version = "0.8.5"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
+checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "relative-path"
@@ -1467,7 +1473,7 @@ version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
- "bitflags 2.9.2",
+ "bitflags 2.9.3",
"errno",
"libc",
"linux-raw-sys",
@@ -1570,11 +1576,11 @@ dependencies = [
[[package]]
name = "serialport"
-version = "4.7.2"
+version = "4.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdb0bc984f6af6ef8bab54e6cf2071579ee75b9286aa9f2319a0d220c28b0a2b"
+checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b"
dependencies = [
- "bitflags 2.9.2",
+ "bitflags 2.9.3",
"cfg-if",
"core-foundation",
"core-foundation-sys",
@@ -1650,6 +1656,19 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sysinfo"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "env_logger",
+ "itertools 0.14.0",
+ "log",
+ "regex",
+ "sysinfo 0.37.0",
+ "tempfile",
+]
+
[[package]]
name = "sysinfo"
version = "0.37.0"
@@ -2221,9 +2240,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
-version = "0.7.12"
+version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
+checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
@@ -2234,7 +2253,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
- "bitflags 2.9.2",
+ "bitflags 2.9.3",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 1df2530..0c6be52 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,45 +1,10 @@
-[package]
-name = "aoostar-rs"
-version = "0.1.0"
-edition = "2024"
+[workspace]
+members = ["crates/*"]
+resolver = "3"
+
+[workspace.package]
rust-version = "1.88"
+edition = "2024"
authors = ["Markus Zehnder"]
license = "MIT or Apache-2.0"
-
-[profile.release]
-strip = true # Automatically strip symbols from the binary.
-
-[[bin]]
-name = "asterctl"
-path = "src/main.rs"
-
-[[bin]]
-name = "sysinfo"
-path = "src/bin/sysinfo.rs"
-required-features = ["sysinfo"]
-
-[features]
-sysinfo = ["dep:sysinfo"]
-
-[dependencies]
-anyhow = "1.0.98"
-bytes = "1.10.1"
-clap = { version = "4.5.42", features = ["derive"] }
-serialport = "4.7.2"
-image = "0.25.6"
-imageproc = { version = "0.25.0", default-features = false }
-ab_glyph = { version = "0.2.31", default-features = false, features = ["std"] }
-log = "0.4.27"
-env_logger = "0.11.8"
-notify = "8.2.0"
-regex = "1.11"
-serde = { version = "1.0.219", features = ["derive"] }
-serde_json = "1.0.142"
-serde_repr = "0.1.20"
-once_cell = "1.21.3"
-sysinfo = { version = "0.37.0", optional = true }
-itertools = "0.14"
-tempfile = "3"
-
-[dev-dependencies]
-rstest = "0.26"
+repository = "https://github.com/zehnm/aoostar-rs"
diff --git a/README.md b/README.md
index eb61424..fbba75d 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# AOOSTAR WTR MAX Screen Control
+# AOOSTAR WTR MAX / GEM12+ PRO Screen Control
Reverse engineering the [AOOSTAR WTR MAX](https://aoostar.com/products/aoostar-wtr-max-amd-r7-pro-8845hs-11-bays-mini-pc)
display protocol, with a proof-of-concept application written in Rust.
-This project should also support the GEM12+ PRO device.
+It has only been tested on the WTR MAX, but should also support the GEM12+ PRO device.
**Disclaimer: ‼️ EXPERIMENTAL — use at your own risk ‼️**
@@ -10,6 +10,8 @@ This project should also support the GEM12+ PRO device.
> There is no official documentation available;
> all display control commands have been reverse engineered from the original AOOSTAR-X software.
+Even though this software works fine **for me**, I cannot guarantee that it is risk-free:
+
- It may or may not work.
- It could crash the display firmware, requiring a power cycle.
- It could even brick the display firmware.
@@ -20,14 +22,14 @@ Note: Multiple attempts to contact the manufacturer for documentation have recei
With that out of the way, on to the fun stuff!
-**See [Linux shell commands](doc/shell_commands.md) on how to switch off the display with standard Linux commands!**
+**See [Linux shell commands](docs/shell_commands.md) on how to switch off the display with standard Linux commands!**
See [releases](https://github.com/zehnm/aoostar-rs/releases) for binary Linux x64 releases and [Linux systemd Service](linux/)
on how to automatically switch off the LCD at boot up. A Debian package for easy installation is planned for the future!
## Reverse Engineering
-Reverse engineered LCD commands: [doc/lcd_protocol.md](doc/lcd_protocol.md)
+Reverse engineered LCD commands: [docs/lcd_protocol.md](docs/lcd_protocol.md)
### Motivation
@@ -50,7 +52,7 @@ The display remains on continuously (24×7) if the official software is not runn
- [x] Reverse engineer the LCD serial protocol to provide open screen software.
- Utilize the official AOOSTAR-X display software by sniffing USB communication, using `strace`, and decompiling the Python app.
- [x] Document known commands so clients in other programming languages can be written.
-- [ ] Eventually, create a Rust crate for easy integration into other Rust applications.
+- [ ] Eventually, publish a Rust crate for easy integration into other Rust applications.
**Out of scope:**
@@ -63,7 +65,9 @@ The display remains on continuously (24×7) if the official software is not runn
- Control the AOOSTAR WTR MAX and GEM12+ PRO second screen from Linux.
- Switch the display on or off.
- Display images (with automatic scaling and partial update support).
-- Proof-of-concept demo for drawing shapes and text.
+- Render dynamic sensor panels defined from the AOOSTAR-X software.
+ - Update sensor values from simple text files.
+ - Rotate through multiple panels in a defined interval.
- USB device/serial port selection.
## Setup
@@ -87,21 +91,19 @@ cd aoostar-rs
### Build
-A release build is highly recommended, as it significantly improves graphics performance:
+A release build is highly recommended, as it significantly improves graphic rendering performance:
```shell
-cargo build --release --bins --all-features
+cargo build --release
```
-The `--bins` option builds the main `asterctl` app and all other tools.
-
### Install
See [Linux systemd Service](linux/) on how to automatically switch off the LCD at boot up.
## Usage
-See [asterctl documentation](doc/README.md) for more information or run `asterctl --help` for available command line options.
+See [asterctl documentation](docs/README.md) for more information or run `asterctl --help` for available command line options.
## Contributing
diff --git a/cfg/sensors/values.txt b/cfg/sensors/values.txt
index beab7fc..306811a 100644
--- a/cfg/sensors/values.txt
+++ b/cfg/sensors/values.txt
@@ -1,5 +1,5 @@
cpu_temperature: 65
-cpu_percent: 98
+cpu_percent: 47.7
memory_usage: 77
memory_Temperature: 48
net_ip_address: 146.56.182.244
@@ -17,9 +17,9 @@ storage_ssd[1]['used']: 18
storage_ssd[2]['temperature']: 33
storage_ssd[2]['used']: 19
storage_ssd[3]['temperature']: 34
-storage_ssd[3]['used']: 20
+storage_ssd[3]['used']: 35
storage_ssd[4]['temperature']: 35
-storage_ssd[4]['used']: 21
+storage_ssd[4]['used']: 80
storage_hdd[0]['temperature']: 36
storage_hdd[0]['used']: 22
storage_hdd[1]['temperature']: 37
diff --git a/crates/asterctl-lcd/Cargo.toml b/crates/asterctl-lcd/Cargo.toml
new file mode 100644
index 0000000..4e9897c
--- /dev/null
+++ b/crates/asterctl-lcd/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "asterctl-lcd"
+version = "0.1.0"
+description = "AOOSTAR WTR MAX / GEM12+ PRO screen protocol"
+
+rust-version.workspace = true
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+anyhow = "1.0.98"
+bytes = "1.10.1"
+# TODO make image an optional feature
+image = "0.25.6"
+log = "0.4.27"
+serialport = "4.7.3"
diff --git a/crates/asterctl-lcd/LICENSE-APACHE b/crates/asterctl-lcd/LICENSE-APACHE
new file mode 120000
index 0000000..1cd601d
--- /dev/null
+++ b/crates/asterctl-lcd/LICENSE-APACHE
@@ -0,0 +1 @@
+../../LICENSE-APACHE
\ No newline at end of file
diff --git a/crates/asterctl-lcd/LICENSE-MIT b/crates/asterctl-lcd/LICENSE-MIT
new file mode 120000
index 0000000..b2cfbdc
--- /dev/null
+++ b/crates/asterctl-lcd/LICENSE-MIT
@@ -0,0 +1 @@
+../../LICENSE-MIT
\ No newline at end of file
diff --git a/crates/asterctl-lcd/README.md b/crates/asterctl-lcd/README.md
new file mode 100644
index 0000000..73e591c
--- /dev/null
+++ b/crates/asterctl-lcd/README.md
@@ -0,0 +1,17 @@
+# AOOSTAR WTR MAX / GEM12+ PRO UART Screen Protocol
+
+Reverse engineered [AOOSTAR WTR MAX](https://aoostar.com/products/aoostar-wtr-max-amd-r7-pro-8845hs-11-bays-mini-pc)
+UART display protocol, written in Rust.
+This project should also support the GEM12+ PRO device.
+
+- [LCD Protocol](../../docs/lcd_protocol.md)
+- See [README](../../README.md) for more information about the `asterctl` screen control tool.
+
+## Display Information
+
+- **Resolution:** 960 × 376
+- **Manufacturer:** Synwit
+- **Connected over USB UART** with a proprietary serial communication protocol:
+ - **USB device ID:** `416:90A1` (as shown by `lsusb`)
+ - **Linux device (example on Debian):** `/dev/ttyACM0`
+ - **1,500,000 baud**, 8N1 (likely ignored; actual USB transfer speed is much higher)
diff --git a/src/display.rs b/crates/asterctl-lcd/src/aoo_screen.rs
similarity index 98%
rename from src/display.rs
rename to crates/asterctl-lcd/src/aoo_screen.rs
index bbd6a2f..df09e10 100644
--- a/src/display.rs
+++ b/crates/asterctl-lcd/src/aoo_screen.rs
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
-use crate::dummy_serialport::DummySerialPort;
-use crate::img::ToRgb565;
+use crate::FakeSerialPort;
+use crate::ToRgb565;
+
use anyhow::{Context, anyhow};
use bytes::{BufMut, BytesMut};
use log::{debug, error, info, warn};
@@ -68,7 +69,7 @@ impl AooScreenBuilder {
/// Simulate the LCD device. No real device or serial port is required.
pub fn simulate(self) -> anyhow::Result {
Ok(AooScreen {
- port: Some(Box::new(DummySerialPort::new())),
+ port: Some(Box::new(FakeSerialPort::new())),
enable_cache: self.enable_cache.unwrap_or(true),
prev_frame: None,
no_init_check: self.no_init_check.unwrap_or(false),
diff --git a/src/dummy_serialport.rs b/crates/asterctl-lcd/src/fake_serialport.rs
similarity index 92%
rename from src/dummy_serialport.rs
rename to crates/asterctl-lcd/src/fake_serialport.rs
index 849d79b..c7ee3be 100644
--- a/src/dummy_serialport.rs
+++ b/crates/asterctl-lcd/src/fake_serialport.rs
@@ -5,7 +5,7 @@ use serialport::{ClearBuffer, DataBits, FlowControl, Parity, SerialPort, StopBit
use std::thread::sleep;
use std::time::Duration;
-pub struct DummySerialPort {
+pub struct FakeSerialPort {
baud_rate: u32,
data_bits: DataBits,
flow_control: FlowControl,
@@ -14,8 +14,14 @@ pub struct DummySerialPort {
timeout: Duration,
}
-impl DummySerialPort {
- pub fn new() -> DummySerialPort {
+impl Default for FakeSerialPort {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl FakeSerialPort {
+ pub fn new() -> FakeSerialPort {
Self {
baud_rate: 1_500_000,
data_bits: DataBits::Eight,
@@ -27,14 +33,14 @@ impl DummySerialPort {
}
}
-impl std::io::Read for DummySerialPort {
+impl std::io::Read for FakeSerialPort {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result {
buf[0] = b'A';
Ok(1)
}
}
-impl std::io::Write for DummySerialPort {
+impl std::io::Write for FakeSerialPort {
fn write(&mut self, buf: &[u8]) -> std::io::Result {
// just some approximation, additional overhead like flushing etc is not considered
let byte_rate =
@@ -49,7 +55,7 @@ impl std::io::Write for DummySerialPort {
}
}
-impl SerialPort for DummySerialPort {
+impl SerialPort for FakeSerialPort {
fn name(&self) -> Option {
Some("Dummy Serial".into())
}
diff --git a/crates/asterctl-lcd/src/lib.rs b/crates/asterctl-lcd/src/lib.rs
new file mode 100644
index 0000000..c6fb9c6
--- /dev/null
+++ b/crates/asterctl-lcd/src/lib.rs
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
+
+#![forbid(non_ascii_idents)]
+#![deny(unsafe_code)]
+
+use bytes::{BufMut, BytesMut};
+use image::{RgbImage, RgbaImage};
+
+mod aoo_screen;
+mod fake_serialport;
+
+pub use aoo_screen::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
+pub use fake_serialport::FakeSerialPort;
+
+/// Trait definition to get a RGB 565 representation from a source image.
+pub trait ToRgb565 {
+ /// Get an RGB 565 representation of the image in little endian format.
+ fn to_rgb565_le(&self) -> BytesMut;
+
+ /// Convert a single RGB 888 pixel to 16 bit RGB 565 format.
+ fn convert_rgb(&self, r: u8, g: u8, b: u8) -> u16 {
+ ((r & 248) as u16) << 8 | ((g & 252) as u16) << 3 | ((b as u16) >> 3)
+ }
+}
+
+// TODO quick & dirty approach for converting RgbImage & RgbaImage to RGB 565.
+// There should be a more generic way, maybe with PixelEnumerator...
+impl ToRgb565 for &RgbImage {
+ fn to_rgb565_le(&self) -> BytesMut {
+ let mut img_rgb565 =
+ BytesMut::with_capacity(self.width() as usize * self.height() as usize * 2);
+
+ for (_x, _y, pixel) in self.enumerate_pixels() {
+ img_rgb565.put_u16_le(self.convert_rgb(pixel.0[0], pixel.0[1], pixel.0[2]));
+ }
+
+ img_rgb565
+ }
+}
+
+impl ToRgb565 for &RgbaImage {
+ fn to_rgb565_le(&self) -> BytesMut {
+ let mut img_rgb565 =
+ BytesMut::with_capacity(self.width() as usize * self.height() as usize * 2);
+
+ for (_x, _y, pixel) in self.enumerate_pixels() {
+ img_rgb565.put_u16_le(self.convert_rgb(pixel.0[0], pixel.0[1], pixel.0[2]));
+ }
+
+ img_rgb565
+ }
+}
diff --git a/crates/asterctl/Cargo.toml b/crates/asterctl/Cargo.toml
new file mode 100644
index 0000000..58e414f
--- /dev/null
+++ b/crates/asterctl/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "asterctl"
+version = "0.1.0"
+description = "AOOSTAR WTR MAX Screen Control tool"
+readme = "../../README.md"
+
+rust-version.workspace = true
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+asterctl-lcd = { path = "../asterctl-lcd", version = "0.1.0" }
+
+anyhow = "1.0.98"
+clap = { version = "4.5.42", features = ["derive"] }
+image = "0.25.6"
+imageproc = { version = "0.25.0", default-features = false }
+ab_glyph = { version = "0.2.31", default-features = false, features = ["std"] }
+log = "0.4.27"
+env_logger = "0.11.8"
+notify = "8.2.0"
+serde = { version = "1.0.219", features = ["derive"] }
+serde_json = "1.0.142"
+serde_repr = "0.1.20"
+once_cell = "1.21.3"
+
+[dev-dependencies]
+rstest = "0.26"
diff --git a/crates/asterctl/LICENSE-APACHE b/crates/asterctl/LICENSE-APACHE
new file mode 120000
index 0000000..1cd601d
--- /dev/null
+++ b/crates/asterctl/LICENSE-APACHE
@@ -0,0 +1 @@
+../../LICENSE-APACHE
\ No newline at end of file
diff --git a/crates/asterctl/LICENSE-MIT b/crates/asterctl/LICENSE-MIT
new file mode 120000
index 0000000..b2cfbdc
--- /dev/null
+++ b/crates/asterctl/LICENSE-MIT
@@ -0,0 +1 @@
+../../LICENSE-MIT
\ No newline at end of file
diff --git a/crates/asterctl/README.md b/crates/asterctl/README.md
new file mode 100644
index 0000000..510bb03
--- /dev/null
+++ b/crates/asterctl/README.md
@@ -0,0 +1,3 @@
+# Screen control tool for AOOSTAR WTR MAX / GEM12+ PRO
+
+See [README](../../README.md) in root directory for more information.
diff --git a/img/aybabtu.png b/crates/asterctl/src/bin/aybabtu.png
similarity index 100%
rename from img/aybabtu.png
rename to crates/asterctl/src/bin/aybabtu.png
diff --git a/crates/asterctl/src/bin/demo.rs b/crates/asterctl/src/bin/demo.rs
new file mode 100644
index 0000000..9e73ca9
--- /dev/null
+++ b/crates/asterctl/src/bin/demo.rs
@@ -0,0 +1,262 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
+
+use asterctl::cfg;
+use asterctl::font::FontHandler;
+use asterctl::render::PanelRenderer;
+use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
+
+use ab_glyph::PxScale;
+use clap::Parser;
+use env_logger::Env;
+use image::imageops::FilterType;
+use image::{ImageReader, Rgb, RgbImage};
+use imageproc::drawing::{draw_line_segment_mut, draw_text_mut};
+use log::{error, info};
+use std::collections::HashMap;
+use std::fs;
+use std::io::Cursor;
+use std::path::{Path, PathBuf};
+use std::thread::sleep;
+use std::time::{Duration, Instant};
+
+/// AOOSTAR WTR MAX and GEM12+ PRO screen control demo.
+#[derive(Parser, Debug)]
+#[command(version, about, long_about = None)]
+struct Args {
+ /// Serial device, for example "/dev/cu.usbserial-AB0KOHLS". Takes priority over --usb option.
+ #[arg(short, long)]
+ device: Option,
+
+ /// USB serial UART "vid:pid" in hex notation (lsusb output). Default: 416:90A1
+ #[arg(short, long)]
+ usb: Option,
+
+ /// AOOSTAR-X json configuration file to parse.
+ ///
+ /// The configuration file will be loaded from the `config_dir` directory if no full path is
+ /// specified.
+ #[arg(short, long)]
+ config: Option,
+
+ /// Configuration directory containing configuration files and background images
+ /// specified in the `config` file. Default: `./cfg`
+ #[arg(long)]
+ config_dir: Option,
+
+ /// Font directory for fonts specified in the `config` file. Default: `./fonts`
+ #[arg(long)]
+ font_dir: Option,
+
+ /// Switch off display n seconds after loading image.
+ #[arg(short, long)]
+ off_after: Option,
+
+ /// Test mode: only write to the display without checking response.
+ #[arg(short, long)]
+ write_only: bool,
+
+ /// Test mode: save changed images in ./out folder.
+ #[arg(short, long)]
+ save: bool,
+
+ /// Simulate serial port for testing and development, `--device` and `--usb` options are ignored.
+ #[arg(long)]
+ simulate: bool,
+}
+
+fn main() -> anyhow::Result<()> {
+ env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
+
+ let args = Args::parse();
+
+ // initialize display with given UART port parameter
+ let mut builder = AooScreenBuilder::new();
+ builder.no_init_check(args.write_only);
+ let mut screen = if args.simulate {
+ builder.simulate()?
+ } else if let Some(device) = args.device {
+ builder.open_device(&device)?
+ } else if let Some(usb) = args.usb {
+ builder.open_usb_id(&usb)?
+ } else {
+ builder.open_default()?
+ };
+
+ info!("Loading and displaying demo...");
+ run_demo(
+ &mut screen,
+ args.config.as_deref(),
+ args.config_dir.unwrap_or_else(|| "cfg".into()),
+ args.font_dir.unwrap_or_else(|| "fonts".into()),
+ args.save,
+ )?;
+
+ if let Some(off) = args.off_after {
+ info!("Switching off display in {off}s");
+ sleep(Duration::from_secs(off as u64));
+ screen.off()?;
+ }
+
+ info!("Bye bye!");
+ Ok(())
+}
+
+fn run_demo(
+ screen: &mut AooScreen,
+ config: Option<&Path>,
+ config_dir: PathBuf,
+ font_dir: PathBuf,
+ save_images: bool,
+) -> anyhow::Result<()> {
+ let rgb_img = demo_image()?;
+
+ // fill left and right side of the loaded image with neighboring pixel color
+ const WIDTH: u32 = 108;
+ let rgb_img = demo_blinds(screen, &rgb_img, WIDTH, save_images)?;
+
+ // print demo text over background image
+ demo_text(screen, &rgb_img, save_images)?;
+
+ if let Some(config) = config {
+ let mut cfg = if config.is_absolute() {
+ cfg::load_cfg(config)?
+ } else {
+ cfg::load_cfg(config_dir.join(config))?
+ };
+
+ if let Some(panel) = cfg.get_next_active_panel() {
+ info!("Displaying demo panel...");
+
+ // get sensor values from panel configuration
+ let mut demo_values = HashMap::new();
+ for sensor in &panel.sensor {
+ demo_values.insert(
+ sensor.label.clone(),
+ sensor.value.clone().unwrap_or_default(),
+ );
+ }
+
+ let mut renderer = PanelRenderer::new(DISPLAY_SIZE, &font_dir, &config_dir);
+ renderer.set_save_render_img(save_images);
+ renderer.set_save_processed_pic(save_images);
+ renderer.set_save_progress_layer(save_images);
+
+ match renderer.render(panel, &demo_values) {
+ Ok(image) => screen.send_image(&image)?,
+ Err(e) => error!("Error rendering panel '{}': {e:?}", panel.friendly_name()),
+ }
+ } else {
+ error!("No active panel found");
+ }
+ }
+
+ Ok(())
+}
+
+fn demo_image() -> anyhow::Result {
+ let reader = ImageReader::new(Cursor::new(include_bytes!("aybabtu.png")))
+ .with_guessed_format()
+ .expect("Cursor io never fails");
+
+ Ok(reader
+ .decode()?
+ .resize_exact(DISPLAY_SIZE.0, DISPLAY_SIZE.1, FilterType::Lanczos3)
+ .to_rgb8())
+}
+
+fn demo_text(
+ screen: &mut AooScreen,
+ background: &RgbImage,
+ save_images: bool,
+) -> anyhow::Result<()> {
+ let text = "ALL YOUR BASE ARE BELONG TO US.";
+ let text_upd_delay = Duration::from_millis(0);
+ let font = FontHandler::default_font();
+ let height = 36.0;
+ let scale = PxScale {
+ x: height,
+ y: height,
+ };
+
+ if save_images {
+ fs::create_dir_all("out")?;
+ }
+
+ for text_idx in 0..text.len() {
+ info!("Printing: {}", &text[0..text_idx + 1]);
+ let text_upd = Instant::now();
+ let mut rgb_img = background.clone();
+ draw_text_mut(
+ &mut rgb_img,
+ Rgb([118u8, 118u8, 97u8]),
+ 4 * 47,
+ 300,
+ scale,
+ &font,
+ &text[0..text_idx + 1],
+ );
+
+ if save_images {
+ rgb_img.save_with_format(
+ format!("out/demo_text-{text_idx}.png"),
+ image::ImageFormat::Png,
+ )?;
+ }
+
+ screen.send_image(&rgb_img)?;
+
+ let elapsed = text_upd.elapsed();
+ if elapsed < text_upd_delay {
+ sleep(text_upd_delay - elapsed);
+ }
+ }
+
+ Ok(())
+}
+
+// CPU intensive! Release build is ~ 5x faster on M1 Max
+fn demo_blinds(
+ screen: &mut AooScreen,
+ background: &RgbImage,
+ width: u32,
+ save_images: bool,
+) -> anyhow::Result {
+ let mut rgb_img = background.clone();
+
+ info!("Masking {width} pixels of left & right image...");
+
+ if save_images {
+ fs::create_dir_all("out")?;
+ }
+
+ for y in 0..DISPLAY_SIZE.1 {
+ let color = *rgb_img.get_pixel(width + 1, y);
+ draw_line_segment_mut(
+ &mut rgb_img,
+ (0.0, y as f32),
+ (width as f32, y as f32),
+ color,
+ );
+ let color = *rgb_img.get_pixel(DISPLAY_SIZE.0 - width - 1, y);
+ draw_line_segment_mut(
+ &mut rgb_img,
+ ((DISPLAY_SIZE.0 - width) as f32, y as f32),
+ (DISPLAY_SIZE.0 as f32, y as f32),
+ color,
+ );
+
+ if y % 5 == 0 {
+ screen.send_image(&rgb_img)?;
+ }
+
+ if save_images {
+ rgb_img
+ .save_with_format(format!("out/demo_blinds-{y}.png"), image::ImageFormat::Png)?;
+ }
+ }
+
+ screen.send_image(&rgb_img)?;
+
+ Ok(rgb_img)
+}
diff --git a/src/cfg.rs b/crates/asterctl/src/cfg.rs
similarity index 100%
rename from src/cfg.rs
rename to crates/asterctl/src/cfg.rs
diff --git a/src/font.rs b/crates/asterctl/src/font.rs
similarity index 95%
rename from src/font.rs
rename to crates/asterctl/src/font.rs
index 44ec4b3..4933f3f 100644
--- a/src/font.rs
+++ b/crates/asterctl/src/font.rs
@@ -13,7 +13,7 @@ use std::path::PathBuf;
static DEFAULT_TTF_FONT: Lazy = Lazy::new(|| {
FontArc::new(
- FontRef::try_from_slice(include_bytes!("../fonts/DejaVuSans.ttf"))
+ FontRef::try_from_slice(include_bytes!("../../../fonts/DejaVuSans.ttf"))
.expect("Failed to load default font"),
)
});
diff --git a/src/format_value.rs b/crates/asterctl/src/format_value.rs
similarity index 98%
rename from src/format_value.rs
rename to crates/asterctl/src/format_value.rs
index 39be26e..b3d553d 100644
--- a/src/format_value.rs
+++ b/crates/asterctl/src/format_value.rs
@@ -47,7 +47,7 @@ impl From