initial commit

This commit is contained in:
Markus Zehnder
2025-07-25 17:31:37 +02:00
commit 1756e1f919
26 changed files with 7409 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/out
/sys_img
/target
.DS_Store
*.bak
Cargo.lock.tmp
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Environment-dependent path to Maven home directory
/mavenHomeManager.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="SPDX-License-Identifier: MIT OR Apache-2.0&#10;SPDX-FileCopyrightText: Copyright (c) &amp;#36;today.year Markus Zehnder" />
<option name="myName" value="MIT or Apache-2.0" />
</copyright>
</component>
+11
View File
@@ -0,0 +1,11 @@
<component name="CopyrightManager">
<settings>
<module2copyright>
<element module="Project Files" copyright="MIT or Apache-2.0" />
</module2copyright>
<LanguageOptions name="Rust">
<option name="fileTypeOverride" value="3" />
<option name="block" value="false" />
</LanguageOptions>
</settings>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Pylint" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectInspectionProfilesVisibleTreeState">
<entry key="Project Default">
<profile-state>
<expanded-state>
<State />
<State>
<id>Pylint</id>
</State>
<State>
<id>Python</id>
</State>
</expanded-state>
<selected-state>
<State>
<id>Pylint</id>
</State>
</selected-state>
</profile-state>
</entry>
</component>
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/AOOstar-rs.iml" filepath="$PROJECT_DIR$/AOOstar-rs.iml" />
</modules>
</component>
</project>
+20
View File
@@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run demo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="dev" />
<option name="command" value="run --package AOOstar-rs --bin AOOstar-rs -- --demo -d /dev/cu.usbserial-AB0KOHLS -w -c Monitor3.json" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
+20
View File
@@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run release demo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="release" />
<option name="command" value="run --package AOOstar-rs --bin AOOstar-rs -- --demo -d /dev/cu.usbserial-AB0KOHLS -w -c Monitor3.json" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
+19
View File
@@ -0,0 +1,19 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="clippy" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="clippy" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="RUST_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+1753
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "aoostar-rs"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
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"
[dependencies]
anyhow = "1.0.98"
bytes = "1.10.1"
clap = { version = "4.5.41", features = ["derive"] }
serialport = "4.7.2"
image = "0.25.6"
imageproc = { version = "0.25.0", default-features = false }
ab_glyph = { version = "0.2.23", default-features = false, features = ["std"] }
log = "0.4.27"
env_logger = "0.11.8"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141"
serde_repr = "0.1.20"
once_cell = "1.21.3"
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+25
View File
@@ -0,0 +1,25 @@
MIT License
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+3767
View File
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
# AOOSTAR WTR MAX 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.
**Disclaimer: ‼️ EXPERIMENTAL — use at your own risk ‼️**
> I take no responsibility for the use of this software.
> There is no official documentation available;
> all display control commands have been reverse engineered from the original AOOSTAR-X software.
- It may or may not work.
- It could crash the display firmware, requiring a power cycle.
- It could even brick the display firmware.
- You have been warned!
The risk remains until the manufacturer provides official documentation, and the protocol can be reviewed.
Note: Multiple attempts to contact the manufacturer for documentation have received no response.
With that out of the way, on to the fun stuff!
## Features
- 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.
- USB device/serial port selection.
## Display
Known information:
- **Screen size:** 2.86" ≈ 68 × 27 mm
- **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)
## Reverse Engineering
### Motivation
Developing open client software to use the embedded second screen on various Linux distributions.
It *might* also work on Windows, but I neither have that OS, nor plan to install it.
The official proprietary AOOSTAR-X display software is not suitable for NAS and security-minded users:
- All-in-one solution that attempts to do everything, from sensor reading to running a web server for control and configuration (*exposed on all interfaces!*).
I prefer using existing monitoring tools and combining them to my liking.
- Resource hungry, written in Python. Archive of v1.3.4 is 178 MB.
- Closed source, requires root access, distributed over filesharing sites, some without HTTPS.
- Built-in expiration date. One must regularly update the software without being able to verify the source.
- Many untranslated messages in Chinese and missing instructions for included features.
The display remains on continuously (24×7) if the official software is not running.
### Goals
- [ ] 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.
- [ ] Document known commands so clients in other programming languages can be written.
- [ ] Eventually, create a Rust crate for easy integration into other Rust applications.
**Out of scope:**
- Reverse engineering the microcontroller firmware on the display board.
That would be an interesting task — potentially uncovering additional display commands — but is outside the project's current scope.
- Reimplementing the full AOOSTAR-X display software, which is overly complex for most use cases.
## Installation
Add your user to the `dialout` group for access to `/dev/ttyACM0`:
```shell
sudo usermod -a -G dialout $USER
```
> You may have to log out and back in for group changes to take effect.
## Build
A recent [Rust](https://rustup.rs/) toolchain is required.
On Ubuntu 25.04, you can install build dependencies with:
```shell
sudo apt install build-essential pkg-config libudev-dev
```
A release build is highly recommended, as it significantly improves graphics performance:
```shell
cargo build --release
```
## Demo App Usage
Currently, the project includes a proof-of-concept demo application that loads an image, draws rectangles, and writes
text over the image.
By default, the original LCD USB UART device `416:90A1` is used. See optional parameters to specify a different device.
```shell
cargo run --release -- --demo --config Monitor3.json
```
The `--config` parameter is optional. It loads the official configuration file and displays the defined sensors in the
first panel.
### Parameters
- `--device /dev/ttyACM0` — Specify the serial device.
- `--usb 0403:6001` — Specify the USB UART device by USB **VID:PID** (hexadecimal, as shown by `lsusb`).
- `--help` — Show all options.
### Control Commands
Besides demo mode, the following control commands have been implemented.
The `asterctl` binary is built in `./target/release`.
Alternatively, use `cargo run --release --` to build and run automatically.
> Aster: Greek for star and similar to AOOSTAR.
**Switch display on:**
```shell
asterctl --on
```
**Switch display off:**
```shell
asterctl --off
```
**Load and display an image:**
```shell
asterctl --image img/aybabtu.png
```
This expects a 960 × 376 image (other sizes are automatically scaled and the aspect ratio is ignored).
See Rust image crate for [support image formats](https://github.com/image-rs/image?tab=readme-ov-file#supported-image-formats).
## Development
- When sending an image to the screen, the image must be in **RGB565** format (16 bits per pixel).
- All graphic operations are performed on the loaded RGB888 image buffer.
- The image is automatically converted to RGB565 when sending it to the display.
- The 1.5 Mbps baud rate set in the client is ignored, as actual USB bulk transfer achieves much higher throughput.
For reference, at the nominal serial rate (~1,500,000 baud), it would take approximately 6 seconds to transfer a full image of 721,920 bytes (960 × 376 × 2):
- Display protocol: payload per chunk = 47 bytes; header per chunk = 12 bytes
- Number of chunks: 721,920 / 47 ≈ 15,360 chunks
- Total transmitted data: 15,360 chunks × 59 bytes/chunk = 906,240 bytes
- Serial frame format: 1 start bit + 8 data bits + 1 stop bit = 10 bits/byte
- Effective byte rate: 1,500,000 bits/sec / 10 bits/byte = 150,000 bytes/sec
- Transfer time: 906,240 bytes / 150,000 bytes/sec ≈ 6 seconds
- **Performance:**
- Displaying the first fullscreen image takes around 1.3 seconds.
- When switching the display on, the old image is immediately shown.
- Once the new image is fully transferred and the end-header command is sent, the display firmware switches to the new image.
- **Partial Updates:**
- A frame cache is used to send only changed chunks after the initial image is displayed, greatly speeding up partial screen updates.
- The chunk size is 47 bytes, determined from the original app. It is unknown if other chunk sizes are supported.
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
Please note that this software is currently in its initial development and will have major changes until the mentioned
goals above are reached!
## License
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
- MIT License ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
Binary file not shown.
+187
View File
@@ -0,0 +1,187 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.
TeX Gyre DJV Math
-----------------
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
(on behalf of TeX users groups) are in public domain.
Letters imported from Euler Fraktur from AMSfonts are (c) American
Mathematical Society (see below).
Bitstream Vera Fonts Copyright
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license (“Fonts”) and associated
documentation
files (the “Font Software”), to reproduce and distribute the Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute,
and/or sell copies of the Font Software, and to permit persons to whom
the Font Software is furnished to do so, subject to the following
conditions:
The above copyright and trademark notices and this permission notice
shall be
included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional
glyphs or characters may be added to the Fonts, only if the fonts are
renamed
to names not containing either the words “Bitstream” or the word “Vera”.
This License becomes null and void to the extent applicable to Fonts or
Font Software
that has been modified and is distributed under the “Bitstream Vera”
names.
The Font Software may be sold as part of a larger software package but
no copy
of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
INABILITY TO USE
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of GNOME, the GNOME
Foundation,
and Bitstream Inc., shall not be used in advertising or otherwise to promote
the sale, use or other dealings in this Font Software without prior written
authorization from the GNOME Foundation or Bitstream Inc., respectively.
For further information, contact: fonts at gnome dot org.
AMSFonts (v. 2.2) copyright
The PostScript Type 1 implementation of the AMSFonts produced by and
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
available for general use. This has been accomplished through the
cooperation
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
Members of this consortium include:
Elsevier Science IBM Corporation Society for Industrial and Applied
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
In order to assure the authenticity of these fonts, copyright will be
held by
the American Mathematical Society. This is not meant to restrict in any way
the legitimate use of the fonts, such as (but not limited to) electronic
distribution of documents containing these fonts, inclusion of these fonts
into other public domain or commercial font collections or computer
applications, use of the outline data to create derivative fonts and/or
faces, etc. However, the AMS does require that the AMS copyright notice be
removed from any derivative versions of the fonts which have been altered in
any way. In addition, to ensure the fidelity of TeX documents using Computer
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
has requested that any alterations which yield different font metrics be
given a different name.
$Id$
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+413
View File
@@ -0,0 +1,413 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! AOOSTAR-X json configuration file format.
//!
//! Derived from the available Monitor3.json file in AOOSTAR-X v1.3.4.
//! Likely not fully compatible with files created with the original editor.
use image::Rgb;
use imageproc::definitions::HasWhite;
use log::warn;
use serde::de::Visitor;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_repr::{Deserialize_repr, Serialize_repr};
use std::io::BufReader;
use std::num::ParseIntError;
use std::ops::Deref;
use std::{fmt, fs};
pub fn load_cfg(path: &str) -> anyhow::Result<MonitorConfig> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let config: MonitorConfig = serde_json::from_reader(reader)?;
for active in config.active_panels.clone() {
if active == 0 || active > config.panels.len() as u32 {
warn!("Ignoring invalid active panel {active}");
continue;
}
let panel = &config.panels[active as usize - 1];
println!(
"Panel {active}: type={}, {}",
panel.panel_type,
panel.img.as_deref().unwrap_or_default()
);
for sensor in &panel.sensor {
println!(
" {}: {} {} {}",
sensor.label,
sensor
.name
.as_deref()
.or(sensor.item_name.as_deref())
.unwrap_or_default(),
sensor.value.as_deref().unwrap_or_default(),
sensor.unit.as_deref().unwrap_or_default()
);
}
}
Ok(config)
}
/// AOOSTAR-X monitor json configuration file
#[derive(Debug, Serialize, Deserialize)]
pub struct MonitorConfig {
/// _Not used_
pub credentials: Option<Credentials>,
pub setup: Setup,
/// Panels: 1-based index into diy[i]
#[serde(rename = "mianban")]
pub active_panels: Vec<u32>,
/// Custom panels / DIY "Do It Yourself",
#[serde(rename = "diy")]
pub panels: Vec<Panel>,
}
/// Web-app user login
#[derive(Debug, Serialize, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
/// Configuration Settings
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Setup {
/// Default: true
pub off_display: bool,
/// Selection of default panels based on theme / control_params / control_disk_temp ?
pub theme: i32,
/// ? Default: true
pub control_params: bool,
/// ? Default: true
pub control_disk_temp: bool,
/// Default: false
pub custom_panel: bool,
/// Language index. Default: 0
pub language: Language,
/// Switch time between panels (?) in seconds, interpreted as int. Default: 5
pub switch_time: Option<String>, // existed as "30" string
/// Operation mode: performance, power saving, etc.
pub operation_mode: Option<OperationMode>,
/// Operation type 1 or 2 (?). Default: 1
#[serde(rename = "type")]
pub operation_type: Option<i16>,
/// Default: 300
pub disk_update: i32,
/// Home Assistant URL
#[serde(deserialize_with = "empty_string_as_none")]
#[serde(rename = "ha_url")]
pub ha_url: Option<String>, // "" in JSON ⇒ Option<String>
/// Home Assistant long-lived access token
#[serde(deserialize_with = "empty_string_as_none")]
#[serde(rename = "ha_token")]
pub ha_token: Option<String>, // "" in JSON ⇒ Option<String>
/// Panel refresh in seconds. Default: 1
pub refresh: f32,
}
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
#[repr(u8)]
pub enum Language {
Chinese = 0,
English = 1,
Japanese = 2,
}
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
#[repr(i16)]
pub enum OperationMode {
None = -1,
HighPerformance = 0,
Intelligent = 1,
PowerSaving = 2,
Custom30W = 3,
Custom20W = 4,
Custom10W = 5,
}
/// Custom DIY panel definition
#[derive(Debug, Serialize, Deserialize)]
pub struct Panel {
/// Custom panel id
pub id: Option<String>,
/// Custom panel name
pub name: Option<String>,
/// TODO
pub checked: Option<bool>,
/// TODO panel type: 5 = built-in? 6 = custom ?
#[serde(rename = "type")]
pub panel_type: i32,
/// Background image filename
pub img: Option<String>,
/// Sensors
pub sensor: Vec<Sensor>,
}
/// One Data Display Unit
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Sensor {
/// Sensor mode: text, fan, progress, pointer
pub mode: SensorMode,
/// Sensor type. TODO verify sensor type values
/// - 1 Time / Date Labels
/// - 2 Windows-specific system info
/// - 3 Hardware value
/// - 4 AIDA64 sensor
/// - 5 HA sensor
/// - 6 http fetch from url
/// - 7 system info ?
/// - 8 lm-sensor ?
#[serde(rename = "type")]
pub sensor_type: i32,
/// Label name for internal panels.
pub name: Option<String>,
/// Label name for custom panels.
pub item_name: Option<String>,
/// TODO Data source?
pub label: String,
/// x-position. Custom panel coordinates are stored as float!
pub x: f32,
/// x-position. TODO unit
pub y: f32,
pub width: Option<i32>,
pub height: Option<i32>,
pub text_direction: i32, // layout direction
pub direction: i32, // sensor orientation, 0/1
#[serde(deserialize_with = "empty_string_as_none")]
pub value: Option<String>, // "" or numbers, so Option<String>
pub font_family: String,
pub font_size: i32,
/// Font color in `#RRGGBB` notation, or -1 if not set. #ffffff = white, #ff0000 = red
pub font_color: FontColor,
pub font_weight: FontWeight,
pub text_align: TextAlign,
#[serde(deserialize_with = "option_none_if_minus_one")]
pub integer_digits: Option<i32>, // -1 ≈ unset ⇒ Option<i32>
#[serde(deserialize_with = "option_none_if_minus_one")]
pub decimal_digits: Option<i32>, // -1 ≈ unset ⇒ Option<i32>
/// Optional unit text to print after the value
#[serde(deserialize_with = "empty_string_as_none")]
pub unit: Option<String>,
pub min_angle: i32,
pub max_angle: i32,
pub min_value: i32,
pub max_value: i32,
/// TODO determine meaning of: pic - render picture?
#[serde(deserialize_with = "empty_string_as_none")]
pub pic: Option<String>, // "" when unused
/// Pivot x
#[serde(rename = "xz_x")]
pub xz_x: Option<i32>,
/// Pivot y
#[serde(rename = "xz_y")]
pub xz_y: Option<i32>,
/// For type = 6
pub url: Option<String>,
/// For type = 6
pub data: Option<String>,
/// For type = 6
pub interval: Option<u32>,
}
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
#[repr(u8)]
pub enum SensorMode {
Text = 1,
Fan = 2,
Progress = 3,
Pointer = 4,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum TimeDateLabel {
#[serde(rename = "DATE_year")]
Year,
#[serde(rename = "DATE_month")]
Month,
#[serde(rename = "DATE_day")]
Day,
#[serde(rename = "DATE_hour")]
Hour,
#[serde(rename = "DATE_minute")]
Minute,
#[serde(rename = "DATE_second")]
Second,
#[serde(rename = "DATE_m_d_h_m_1")]
MDHM1,
#[serde(rename = "DATE_m_d_h_m_2")]
MDHM2,
#[serde(rename = "DATE_m_d_1")]
MD1,
#[serde(rename = "DATE_m_d_2")]
MD2,
#[serde(rename = "DATE_y_m_d_1")]
YMD1,
#[serde(rename = "DATE_y_m_d_2")]
YMD2,
#[serde(rename = "DATE_y_m_d_3")]
YMD3,
#[serde(rename = "DATE_y_m_d_4")]
YMD4,
#[serde(rename = "DATE_h_m_s_1")]
HMS1,
#[serde(rename = "DATE_h_m_s_2")]
HMS2,
#[serde(rename = "DATE_h_m_s_3")]
HMS3,
#[serde(rename = "DATE_h_m_1")]
HM1,
#[serde(rename = "DATE_h_m_2")]
HM2,
#[serde(rename = "DATE_h_m_3")]
HM3,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FontWeight {
Normal,
Bold,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TextAlign {
Left,
Center,
Right,
}
fn option_none_if_minus_one<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<i32>::deserialize(deserializer)? {
Some(-1) | None => Ok(None),
Some(other) => Ok(Some(other)),
}
}
/// Special font color type since it is represented either as numeric -1 or as a string :-(
///
/// A good serde programming exercise...
#[derive(Debug, Clone, Copy)]
pub struct FontColor(Rgb<u8>);
impl Default for FontColor {
fn default() -> Self {
FontColor(Rgb::white())
}
}
impl Deref for FontColor {
type Target = Rgb<u8>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl TryFrom<&str> for FontColor {
type Error = ParseIntError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
if value.len() != 7 || value.starts_with('#') {
warn!("Invalid font color: {value}");
Ok(FontColor::default())
} else {
Ok(FontColor(Rgb([
u8::from_str_radix(&value[1..3], 16)?,
u8::from_str_radix(&value[3..5], 16)?,
u8::from_str_radix(&value[5..7], 16)?,
])))
}
}
}
impl From<Rgb<u8>> for FontColor {
fn from(value: Rgb<u8>) -> Self {
FontColor(value)
}
}
impl From<FontColor> for Rgb<u8> {
fn from(val: FontColor) -> Self {
val.0
}
}
impl Serialize for FontColor {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
format!("#{:02x}{:02x}{:02x}", self.0[0], self.0[1], self.0[2]).serialize(serializer)
}
}
impl<'de> Deserialize<'de> for FontColor {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct MyVisitor;
impl<'de> Visitor<'de> for MyVisitor {
type Value = FontColor;
fn expecting(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt.write_str("integer or string")
}
fn visit_i64<E>(self, val: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match val {
-1 => Ok(FontColor::default()),
_ => Err(E::custom("invalid integer value, expected -1")),
}
}
fn visit_str<E>(self, val: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if val.trim().is_empty() {
return Ok(FontColor::default());
}
match val.parse::<i32>() {
Ok(val) => self.visit_i32(val),
Err(_) => val
.try_into()
.map_err(|e| E::custom(format!("invalid font color value: {e}"))),
}
}
}
deserializer.deserialize_any(MyVisitor)
}
}
fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let option = Option::<String>::deserialize(deserializer)?;
Ok(option.and_then(|s| if s.trim().is_empty() { None } else { Some(s) }))
}
+290
View File
@@ -0,0 +1,290 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use crate::img::rgb888_to_565;
use anyhow::{Context, anyhow};
use bytes::{BufMut, BytesMut};
use image::RgbImage;
use log::{debug, error, info, warn};
use serialport::{SerialPort, SerialPortType};
use std::io::{Read, Write};
use std::thread::sleep;
use std::time::{Duration, Instant};
pub const DISPLAY_SIZE: (u32, u32) = (960, 376);
const SERIAL_RETRY: u8 = 3;
const UART_BAUDRATE: u32 = 1_500_000;
const USB_UART_VID: u16 = 0x416;
const USB_UART_PID: u16 = 0x90A1;
const IMG_CHUNK_SIZE: usize = 47;
static DISPLAY_OFF: [u8; 8] = [0xAA, 0x55, 0xAA, 0x55, 0x0A, 0x00, 0x00, 0x00];
static DISPLAY_ON: [u8; 8] = [0xAA, 0x55, 0xAA, 0x55, 0x0B, 0x00, 0x00, 0x00];
static HEADER_START: [u8; 16] = [
0xAA, 0x55, 0xAA, 0x55, 0x05, 0x00, 0x00, 0x00, 0x04, 0x00, 0x0F, 0x2F, 0x00, 0x04, 0x0B, 0x00,
];
static HEADER_END: [u8; 8] = [0xAA, 0x55, 0xAA, 0x55, 0x06, 0x00, 0x00, 0x00];
static HEADER: [u8; 8] = [0xAA, 0x55, 0xAA, 0x55, 0x08, 0x00, 0x00, 0x00];
#[derive(Default)]
pub struct AooScreenBuilder {
timeout: Option<Duration>,
enable_cache: Option<bool>,
no_init_check: Option<bool>,
}
#[allow(dead_code)]
impl AooScreenBuilder {
pub fn new() -> Self {
Self::default()
}
/// Set the amount of time to wait to receive data before timing out. Defaults to 1 sec.
pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
self.timeout = Some(timeout);
self
}
/// Cache previous frame sent to display for future diff updates. Enabled by default.
pub fn enable_cache(&mut self, enable: bool) -> &mut Self {
self.enable_cache = Some(enable);
self
}
/// Disable LCD initialization check and only write data to the display. Defaults to false.
pub fn no_init_check(&mut self, no_check: bool) -> &mut Self {
self.no_init_check = Some(no_check);
self
}
/// Open the default AOOSTAR LCD USB UART device 416:90A1.
pub fn open_default(self) -> anyhow::Result<AooScreen> {
self.open_usb(USB_UART_VID, USB_UART_PID)
}
/// Open the specified USB UART device id. Format: vid:pid
pub fn open_usb_id(self, id: &str) -> anyhow::Result<AooScreen> {
let (vid, pid) = id
.split_once(':')
.with_context(|| "Error parsing serial port ID. Expected `vid:pid` format.")?;
self.open_usb(u16::from_str_radix(vid, 16)?, u16::from_str_radix(pid, 16)?)
}
/// Open the specified USB UART
pub fn open_usb(self, vid: u16, pid: u16) -> anyhow::Result<AooScreen> {
let serial_dev = find_usb_serial_port(vid, pid)?;
self.open_device(&serial_dev)
}
/// Open the specified serial device
pub fn open_device(self, device: &str) -> anyhow::Result<AooScreen> {
let port = serialport::new(device, UART_BAUDRATE)
.timeout(self.timeout.unwrap_or(Duration::from_millis(1000)))
.open()
.with_context(|| format!("Error opening serial port: {device}"))?;
info!(
"Opened serial port {device}: baud={}, {}:{}:{}",
port.baud_rate()?,
port.data_bits()?,
port.parity()?,
port.stop_bits()?
);
Ok(AooScreen {
port: Some(port),
enable_cache: self.enable_cache.unwrap_or(true),
prev_frame: None,
no_init_check: self.no_init_check.unwrap_or(false),
})
}
}
pub struct AooScreen {
port: Option<Box<dyn SerialPort>>,
enable_cache: bool,
prev_frame: Option<BytesMut>,
no_init_check: bool,
}
#[allow(dead_code)]
impl AooScreen {
pub fn init(&mut self) -> anyhow::Result<()> {
let port = self.port.as_mut().ok_or(anyhow!("LCD port not open"))?;
port.write(&DISPLAY_ON)
.with_context(|| "Error sending display on command")?;
if self.no_init_check {
warn!("Test mode: only writing to the display");
} else {
// quick and dirty response check as in the original app
sleep(Duration::from_secs(1));
let available = port
.bytes_to_read()
.with_context(|| "Failed to get available bytes from serial port")?;
if available == 0 {
return Err(anyhow!("Initialization failed, no response received"));
}
let mut serial_buf: Vec<u8> = vec![0; available as usize];
port.read(serial_buf.as_mut_slice())
.with_context(|| "Failed to read from serial port")?;
let marker = b'A';
if !serial_buf.contains(&marker) {
return Err(anyhow!(
"Initialization failed, received: {}",
String::from_utf8_lossy(&serial_buf)
));
}
}
info!("Display initialized!");
Ok(())
}
pub fn close(&mut self) {
if self.port.is_some() {
if let Err(e) = self.off() {
warn!("Failed to close display: {e}");
}
self.port = None;
}
}
pub fn on(&mut self) -> anyhow::Result<()> {
self.send(&DISPLAY_ON)
.with_context(|| "Failed to send display on")
}
pub fn off(&mut self) -> anyhow::Result<()> {
self.send(&DISPLAY_OFF)
.with_context(|| "Failed to send display off")
}
pub fn send_image(&mut self, rgb_img: &RgbImage) -> anyhow::Result<()> {
let img_rgb565 = rgb888_to_565(rgb_img)?;
debug!(
"Start sending image (size {}) {} cache... ",
img_rgb565.len(),
if self.enable_cache && self.prev_frame.is_some() {
"with"
} else {
"without"
}
);
let start_time = Instant::now();
self.send(&HEADER_START)
.with_context(|| "Failed to send header start")?;
let mut buf = BytesMut::with_capacity(HEADER.len() + 4 + IMG_CHUNK_SIZE);
let mut sent_chunks = 0;
for (idx, chunk) in img_rgb565.chunks(IMG_CHUNK_SIZE).enumerate() {
let offset = idx * IMG_CHUNK_SIZE;
if self.enable_cache
&& let Some(cache) = self.prev_frame.as_mut()
{
let offset = idx * IMG_CHUNK_SIZE;
if offset + IMG_CHUNK_SIZE <= cache.len()
&& cache[offset..offset + IMG_CHUNK_SIZE].eq(chunk)
{
// Block is unchanged from the previous frame; skip sending
continue;
}
}
buf.clear();
buf.extend(&HEADER);
buf.put_u32_le(offset as u32);
buf.extend(chunk);
self.send(&buf)
.with_context(|| format!("Failed to send image data chunk {idx}"))?;
sent_chunks += 1;
}
self.send(&HEADER_END)
.with_context(|| "Failed to send header end")?;
if self.enable_cache {
self.prev_frame.replace(img_rgb565);
}
debug!(
"Image sent: {}ms, {sent_chunks} chunks",
start_time.elapsed().as_millis()
);
Ok(())
}
pub fn enable_cache(&mut self, enable: bool) {
self.enable_cache = enable;
if !enable {
self.clear_cache();
}
}
pub fn is_cache_enabled(&self) -> bool {
self.enable_cache
}
pub fn clear_cache(&mut self) {
self.prev_frame = None;
}
fn send(&mut self, data: &[u8]) -> anyhow::Result<()> {
// TODO not sure if retry logic is required. Need a real device to test...
let mut retry = 0;
let port = self.port.as_mut().ok_or(anyhow!("LCD port not open"))?;
loop {
return match port.write_all(data) {
Ok(()) => {
port.flush()?;
Ok(())
}
Err(e) => {
debug!(
"Bytes queued to send: {}",
port.bytes_to_write()
.with_context(|| "Error calling bytes_to_write")?
);
if retry < SERIAL_RETRY {
warn!("Failed to write to display, retrying! Error: {e}");
retry += 1;
continue;
}
error!("Failed to write to display: {e}");
Err(e.into())
}
};
}
}
}
pub fn find_usb_serial_port(vid: u16, pid: u16) -> serialport::Result<String> {
info!("Looking for USB serial port {vid:x}:{pid:x}");
let ports = serialport::available_ports()?;
for p in ports {
debug!("Found serial port: {}", p.port_name);
if let SerialPortType::UsbPort(info) = p.port_type {
if info.pid == pid && info.vid == vid {
return Ok(p.port_name);
}
}
}
Err(serialport::Error::new(
serialport::ErrorKind::NoDevice,
format!("USB serial port {vid:x}:{pid:x} not found"),
))
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use ab_glyph::{FontArc, FontRef, FontVec};
use anyhow::{Context, anyhow};
use log::warn;
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
static DEFAULT_TTF_FONT: Lazy<FontArc> = Lazy::new(|| {
FontArc::new(
FontRef::try_from_slice(include_bytes!("../fonts/DejaVuSans.ttf"))
.expect("Failed to load default font"),
)
});
pub struct FontHandler {
ttf_path: PathBuf,
ttf_cache: HashMap<String, FontArc>,
}
impl FontHandler {
pub fn new(ttf_path: impl Into<PathBuf>) -> Self {
Self {
ttf_path: ttf_path.into(),
ttf_cache: Default::default(),
}
}
pub fn default_font() -> FontArc {
DEFAULT_TTF_FONT.clone()
}
pub fn get_ttf_font_or_default(&mut self, name: &str) -> FontArc {
self.get_ttf_font(name).unwrap_or_else(|e| {
warn!("Failed to load font: {e}. Using default");
FontHandler::default_font()
})
}
pub fn get_ttf_font(&mut self, name: &str) -> anyhow::Result<FontArc> {
if let Some(font) = self.ttf_cache.get(name) {
return Ok(font.clone());
}
let mut path = self.ttf_path.join(name);
path.set_extension("ttf");
if !path.exists() {
return Err(anyhow!("{name}.ttf not found"));
}
let data = fs::read(path).with_context(|| format!("Error reading font {name}.ttf"))?;
let font = FontArc::new(
FontVec::try_from_vec(data)
.with_context(|| format!("Error parsing font {name}.ttf"))?,
);
self.ttf_cache.insert(name.to_string(), font.clone());
Ok(font)
}
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use bytes::{BufMut, BytesMut};
use image::imageops::FilterType;
use image::{GenericImageView, ImageReader, RgbImage};
use log::{debug, warn};
use std::path::Path;
pub fn load_image<P>(path: P, size: (u32, u32)) -> anyhow::Result<RgbImage>
where
P: AsRef<Path>,
{
let img = ImageReader::open(path)?.decode()?;
debug!(
"Image dimensions: {:?}, {:?}",
img.dimensions(),
img.color()
);
if img.dimensions() != size {
warn!(
"Resizing invalid image dimensions {:?} to expected size {:?}, ignoring aspect ratio",
img.dimensions(),
size
);
Ok(img
.resize_exact(size.0, size.1, FilterType::Lanczos3)
.to_rgb8())
} else {
Ok(img.to_rgb8())
}
}
pub fn rgb888_to_565(rgb_img: &RgbImage) -> anyhow::Result<BytesMut> {
let mut img_rgb565 =
BytesMut::with_capacity(rgb_img.width() as usize * rgb_img.height() as usize * 2);
for (_x, _y, pixel) in rgb_img.enumerate_pixels() {
img_rgb565.put_u16_le(
((pixel.0[0] & 248) as u16) << 8
| ((pixel.0[1] & 252) as u16) << 3
| ((pixel.0[2] as u16) >> 3),
);
}
Ok(img_rgb565)
}
+318
View File
@@ -0,0 +1,318 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
mod cfg;
mod display;
mod font;
mod img;
use crate::cfg::Panel;
use crate::display::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
use crate::font::FontHandler;
use ab_glyph::{Font, 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::{debug, info, warn};
use std::fs;
use std::io::Cursor;
use std::thread::sleep;
use std::time::{Duration, Instant};
/// AOOSTAR WTR MAX and GEM12+ PRO screen control.
#[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<String>,
/// USB serial UART "vid:pid" in hex notation (lsusb output). Default: 416:90A1
#[arg(short, long)]
usb: Option<String>,
/// Switch display on and exit. This will show the last displayed image.
#[arg(long)]
on: bool,
/// Switch display off and exit.
#[arg(long)]
off: bool,
/// Image to display, other sizes than 960x376 will be scaled.
#[arg(short, long)]
image: Option<String>,
/// Run a demo
#[arg(long)]
demo: bool,
/// Only for demo mode: AOOSTAR-X json configuration file to parse.
#[arg(short, long)]
config: Option<String>,
/// Switch off display n seconds after loading image or running demo.
#[arg(short, long)]
off_after: Option<u32>,
/// 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,
}
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let args = Args::parse();
if let Some(config) = args.config.as_ref() {
let _cfg = cfg::load_cfg(config)?;
}
// initialize display with given UART port parameter
let mut builder = AooScreenBuilder::new();
builder.no_init_check(args.write_only);
let mut screen = 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()?
};
// process simple commands
if args.off {
screen.off()?;
return Ok(());
} else if args.on {
screen.on()?;
return Ok(());
}
// switch on screen for remaining commands
screen.init()?;
if let Some(image) = args.image {
info!("Loading and displaying background image {image}...");
let rgb_img = img::load_image(&image, DISPLAY_SIZE)?;
let timestamp = Instant::now();
screen.send_image(&rgb_img)?;
debug!("Image sent in {}ms", timestamp.elapsed().as_millis());
}
if args.demo {
info!("Loading and displaying demo...");
run_demo(&mut screen, args.config.as_deref(), 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<&str>, 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 cfg = cfg::load_cfg(config)?;
for active in cfg.active_panels.clone() {
if active == 0 || active > cfg.panels.len() as u32 {
warn!("Ignoring invalid active panel {active}");
continue;
}
let panel = &cfg.panels[active as usize - 1];
demo_panel(screen, &rgb_img, panel, save_images)?;
break;
}
}
Ok(())
}
fn demo_image() -> anyhow::Result<RgbImage> {
let reader = ImageReader::new(Cursor::new(include_bytes!("../img/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<RgbImage> {
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_antialiased_line_segment_mut(
// &mut rgb_img,
// (0, y as i32),
// (width as i32, y as i32),
// color,
// interpolate,
// );
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_antialiased_line_segment_mut(
// &mut rgb_img,
// ((DISPLAY_SIZE.0 - width) as i32, y as i32),
// (DISPLAY_SIZE.0 as i32, y as i32),
// color,
// interpolate,
// );
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)
}
fn demo_panel(
screen: &mut AooScreen,
background: &RgbImage,
panel: &Panel,
save_image: bool,
) -> anyhow::Result<()> {
info!("Displaying panel information...");
let mut rgb_img = background.clone();
let mut fh = FontHandler::new("fonts");
for sensor in &panel.sensor {
println!(
"({:03},{:03}): {}{}",
sensor.x,
sensor.y,
sensor.value.as_deref().unwrap_or_default(),
sensor.unit.as_deref().unwrap_or_default()
);
if let Some(value) = &sensor.value {
let font = fh.get_ttf_font_or_default(&sensor.font_family);
let text = format!("{value}{}", sensor.unit.as_deref().unwrap_or_default());
let scale = font.pt_to_px_scale(sensor.font_size as f32).unwrap();
draw_text_mut(
&mut rgb_img,
sensor.font_color.into(),
// TODO figure out x,y unit conversion, something is off, probably in font scaling
sensor.x as i32,
sensor.y as i32,
scale,
&font,
&text,
);
screen.send_image(&rgb_img)?;
}
}
if save_image {
fs::create_dir_all("out")?;
rgb_img.save_with_format("out/panel.png", image::ImageFormat::Png)?;
}
Ok(())
}