initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
/out
|
||||||
|
/sys_img
|
||||||
|
/target
|
||||||
|
.DS_Store
|
||||||
|
*.bak
|
||||||
|
Cargo.lock.tmp
|
||||||
Generated
+10
@@ -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
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="CopyrightManager">
|
||||||
|
<copyright>
|
||||||
|
<option name="notice" value="SPDX-License-Identifier: MIT OR Apache-2.0 SPDX-FileCopyrightText: Copyright (c) &#36;today.year Markus Zehnder" />
|
||||||
|
<option name="myName" value="MIT or Apache-2.0" />
|
||||||
|
</copyright>
|
||||||
|
</component>
|
||||||
Generated
+11
@@ -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
@@ -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>
|
||||||
Generated
+26
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+20
@@ -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
@@ -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>
|
||||||
Generated
+19
@@ -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
@@ -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>
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+29
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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$
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
+413
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user