feat: Initial support for fan-, progress-, & pointer-sensors (#8)
* feat: support progress, fan, pointer sensor types Initial implementation, not yet fully working. * only use one rendering layer per sensor type instead one per sensor Each layer uses > 1 MB and a panel can contain easily over 20 sensors! * support custom panels with --panels cli argument This includes one or more additional custom panels into the base configuration. Multiple --panels arguments are supported * update docs
@@ -99,68 +99,9 @@ The `--bins` option builds the main `asterctl` app and all other tools.
|
|||||||
|
|
||||||
See [Linux systemd Service](linux/) on how to automatically switch off the LCD at boot up.
|
See [Linux systemd Service](linux/) on how to automatically switch off the LCD at boot up.
|
||||||
|
|
||||||
## Demo App Usage
|
## Usage
|
||||||
|
|
||||||
Currently, the project includes a proof-of-concept demo application that loads an image, draws rectangles, and writes
|
See [asterctl documentation](doc/README.md) for more information or run `asterctl --help` for available command line options.
|
||||||
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 monitor.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.
|
|
||||||
|
|
||||||
|
|
||||||
### Sensor Panel
|
|
||||||
|
|
||||||
```shell
|
|
||||||
asterctl --config monitor.json
|
|
||||||
```
|
|
||||||
|
|
||||||
See [sensor panels](doc/sensor_panels.md) for more information.
|
|
||||||
|
|
||||||
### Control Commands
|
|
||||||
|
|
||||||
> Aster: Greek for star and similar to AOOSTAR.
|
|
||||||
|
|
||||||
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, for example:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
cargo run --release -- --off
|
|
||||||
```
|
|
||||||
|
|
||||||
**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 [supported image formats](https://github.com/image-rs/image?tab=readme-ov-file#supported-image-formats).
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -177,4 +118,3 @@ Licensed under either of
|
|||||||
- MIT License ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
|
- MIT License ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
at your option.
|
at your option.
|
||||||
|
|
||||||
|
|||||||
@@ -337,7 +337,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "25",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "5",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "15",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "25",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -533,7 +533,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "35",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -589,7 +589,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "40",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -645,7 +645,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "45",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -701,7 +701,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "55",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -757,7 +757,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "65",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -813,7 +813,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "75",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -869,7 +869,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "85",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
@@ -925,7 +925,7 @@
|
|||||||
"height": 0,
|
"height": 0,
|
||||||
"textDirection": 0,
|
"textDirection": 0,
|
||||||
"direction": 1,
|
"direction": 1,
|
||||||
"value": "7",
|
"value": "95",
|
||||||
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": -1,
|
"fontColor": -1,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 830 B |
|
After Width: | Height: | Size: 849 B |
@@ -0,0 +1,37 @@
|
|||||||
|
# AOOSTAR WTR MAX Screen Control
|
||||||
|
|
||||||
|
- [asterctl usage](asterctl.md)
|
||||||
|
- [Linux shell control commands](shell_commands.md) without using asterctl
|
||||||
|
|
||||||
|
## Sensor Panels
|
||||||
|
|
||||||
|
- [Sensor panels](sensor_panels.md)
|
||||||
|
- [Custom sensor panels](sensor_custom_panel.md)
|
||||||
|
|
||||||
|
### Sensor Modes
|
||||||
|
|
||||||
|
Different sensor modes are supported:
|
||||||
|
|
||||||
|
- [Sensor mode 1: Text](sensor_mode1_text.md)
|
||||||
|
- [Sensor mode 2: Circular Progress](sensor_mode2_fan.md)
|
||||||
|
- [Sensor mode 3: Progress](sensor_mode3_progress.md)
|
||||||
|
- [Sensor mode 4: Pointer](sensor_mode4_pointer.md)
|
||||||
|
|
||||||
|
### Sensor Data Sources
|
||||||
|
|
||||||
|
The sensor value reading is separated from the `asterctl` tool.
|
||||||
|
|
||||||
|
Sensor values are provided in separate text files and are automatically read when the file changes.
|
||||||
|
Only the file data source is supported at the moment, other sources like pipes, sockets etc. might be supported later.
|
||||||
|
|
||||||
|
- [Text file data source](sensor_data_txt_file.md)
|
||||||
|
|
||||||
|
### Sensor Data Providers
|
||||||
|
|
||||||
|
- Proof of concept [Linux shell scripts](sensor_data_shell.md)
|
||||||
|
- [sysinfo tool](sensor_data_sysinfo.md)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- [LCD Protocol](lcd_protocol.md)
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# asterctl Documentation
|
||||||
|
|
||||||
|
> Aster: Greek for star and similar to AOOSTAR.
|
||||||
|
|
||||||
|
Currently, the project includes a proof-of-concept demo application that loads an image, draws rectangles, and writes
|
||||||
|
text over the image.
|
||||||
|
|
||||||
|
A work-in-progress "panel-mode" mimics the AOOSTAR-X software and uses the same configuration files for rendering sensor
|
||||||
|
panels with dynamic sensor values.
|
||||||
|
|
||||||
|
By default, the original LCD USB UART device `416:90A1` is used. See optional parameters to specify a different device.
|
||||||
|
|
||||||
|
```
|
||||||
|
./asterctl --help
|
||||||
|
AOOSTAR WTR MAX and GEM12+ PRO screen control
|
||||||
|
|
||||||
|
Usage: asterctl [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-d, --device <DEVICE>
|
||||||
|
Serial device, for example "/dev/cu.usbserial-AB0KOHLS". Takes priority over --usb option
|
||||||
|
|
||||||
|
-u, --usb <USB>
|
||||||
|
USB serial UART "vid:pid" in hex notation (lsusb output). Default: 416:90A1
|
||||||
|
|
||||||
|
--on
|
||||||
|
Switch display on and exit. This will show the last displayed image
|
||||||
|
|
||||||
|
--off
|
||||||
|
Switch display off and exit
|
||||||
|
|
||||||
|
-i, --image <IMAGE>
|
||||||
|
Image to display, other sizes than 960x376 will be scaled
|
||||||
|
|
||||||
|
-c, --config <CONFIG>
|
||||||
|
AOOSTAR-X json configuration file to parse.
|
||||||
|
|
||||||
|
The configuration file will be loaded from the `config_dir` directory if no full path is specified.
|
||||||
|
|
||||||
|
-p, --panels <PANELS>
|
||||||
|
Include one or more additional custom panels into the base configuration.
|
||||||
|
|
||||||
|
Specify the path to the panel directory containing panel.json and fonts / img subdirectories.
|
||||||
|
|
||||||
|
--config-dir <CONFIG_DIR>
|
||||||
|
Configuration directory containing configuration files and background images specified in the `config` file. Default: `./cfg`
|
||||||
|
|
||||||
|
--font-dir <FONT_DIR>
|
||||||
|
Font directory for fonts specified in the `config` file. Default: `./fonts`
|
||||||
|
|
||||||
|
--sensor-path <SENSOR_PATH>
|
||||||
|
Single sensor value input file or directory for multiple sensor input files. Default: `./cfg/sensors`
|
||||||
|
|
||||||
|
--demo
|
||||||
|
Run a demo
|
||||||
|
|
||||||
|
-o, --off-after <OFF_AFTER>
|
||||||
|
Switch off display n seconds after loading image or running demo
|
||||||
|
|
||||||
|
-w, --write-only
|
||||||
|
Test mode: only write to the display without checking response
|
||||||
|
|
||||||
|
-s, --save
|
||||||
|
Test mode: save changed images in ./out folder
|
||||||
|
|
||||||
|
--simulate
|
||||||
|
Simulate serial port for testing and development, `--device` and `--usb` options are ignored
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
-V, --version
|
||||||
|
Print version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demo Mode
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cargo run --release -- --demo --config monitor.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.
|
||||||
|
|
||||||
|
## Sensor Panel Mode
|
||||||
|
|
||||||
|
```shell
|
||||||
|
asterctl --config monitor.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Control Commands
|
||||||
|
|
||||||
|
The following control commands are available to switch the display off or display a static image.
|
||||||
|
|
||||||
|
**Switch display on:**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
asterctl --on
|
||||||
|
```
|
||||||
|
This will display the last image that was shown before the display was switched off.
|
||||||
|
This image is stored in the display firmware and not sent by `asterctl`.
|
||||||
|
|
||||||
|
**Switch display off:**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
asterctl --off
|
||||||
|
```
|
||||||
|
|
||||||
|
Switching the display off is also possible with pure [shell commands](shell_commands.md).
|
||||||
|
|
||||||
|
**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 [supported image formats](https://github.com/image-rs/image?tab=readme-ov-file#supported-image-formats).
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 849 B |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
# Custom Panels
|
||||||
|
|
||||||
|
By default, the defined panels in the main configuration file are loaded and rendered.
|
||||||
|
|
||||||
|
Additional custom panels can be included with the `--panels` command line parameter.
|
||||||
|
|
||||||
|
A custom panel consists of:
|
||||||
|
- a `panel.json` file with just the json object of the `diy` array of the main configuration file.
|
||||||
|
- `img` subdirectory containing the referenced images in `panel.json`
|
||||||
|
- `fonts` subdirectory containing the referenced fonts in `panel.json`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── fonts
|
||||||
|
│ ├── HarmonyOS_Sans_SC_Bold.ttf
|
||||||
|
│ └── ROGFontsv.ttf
|
||||||
|
├── img
|
||||||
|
│ ├── 6ae90fde-d0a1-44ec-9e15-7b6af14e3b7b.jpg
|
||||||
|
│ ├── 95f38f70-9e0c-4b54-80a9-6bd7b0b4475c_1744449208_1746941971.png
|
||||||
|
│ ├── f1c3d74c-0157-4b77-82a6-f07e565fe439_1744447224_1746941971.png
|
||||||
|
│ └── f5d534e5-4527-4ca0-a0e9-69e8eef86f62_1744447151_1746941971.png
|
||||||
|
└── panel.json
|
||||||
|
```
|
||||||
|
|
||||||
|
There are lots of custom panel configurations available online.
|
||||||
|
AOOSTAR support sent the following link: <http://pan.sztbkj.com:5244/>, look for a file called [`有线网卡 windows驱动.rar`](http://pan.sztbkj.com:5244/WTR%20MAX%206+5%E7%9B%98%E4%BD%8D/%E9%A9%B1%E5%8A%A8/%E6%9C%89%E7%BA%BF%E7%BD%91%E5%8D%A1%20windows%E9%A9%B1%E5%8A%A8.rar)
|
||||||
|
in the `WTR MAX 6+5盘位/驱动/` subfolder.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```shell
|
||||||
|
asterctl --config monitor.json --panels cfg/01_custom --panels cfg/02_custom
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Sensor Data Provider Shell Scripts
|
||||||
|
|
||||||
|
The [/linux/scripts](../linux/scripts) directory contains some proof-of-concept Linux shell scripts.
|
||||||
|
|
||||||
|
CPU and memory usage are written into a sensor data source text file that can be used by `asterctl`.
|
||||||
|
|
||||||
|
```
|
||||||
|
./cpu_usage.sh -h
|
||||||
|
Simple PoC script to periodically write the CPU usage into a sensor text file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./cpu_usage.sh [-r REFRESH] [-s SENSOR_FILE] [-t TEMP_DIR]
|
||||||
|
|
||||||
|
-r REFRESH refresh in seconds. Default: 1
|
||||||
|
-s SENSOR_FILE output sensor file. Default: /tmp/sensors/cpu.txt
|
||||||
|
-t TEMP_DIR temporary directory. Default: /tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
./mem_usage.sh -h
|
||||||
|
Simple PoC script to periodically write the memory usage into a sensor text file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./mem_usage.sh [-r REFRESH] [-s SENSOR_FILE] [-t TEMP_DIR]
|
||||||
|
|
||||||
|
-r REFRESH refresh in seconds. Default: 5
|
||||||
|
-s SENSOR_FILE output sensor file. Default: /tmp/sensors/mem.txt
|
||||||
|
-t TEMP_DIR temporary directory. Default: /tmp
|
||||||
|
```
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# sysinfo Tool
|
||||||
|
|
||||||
|
The Rust based [/src/bin/sysinfo.rs](../src/bin/sysinfo.rs) tool gathers many more system sensor values with the help of
|
||||||
|
the [sysinfo](https://github.com/GuillaumeGomez/sysinfo) crate.
|
||||||
|
|
||||||
|
It supports FreeBSD, Linux, macOS, Windows and other OSes, but it has only been tested on Linux so far.
|
||||||
|
|
||||||
|
```
|
||||||
|
Proof of concept sensor value collection for the asterctl screen control tool
|
||||||
|
|
||||||
|
Usage: sysinfo [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --out <OUT>
|
||||||
|
Output sensor file
|
||||||
|
|
||||||
|
-t, --temp-dir <TEMP_DIR>
|
||||||
|
Temporary directory for preparing the output sensor file.
|
||||||
|
|
||||||
|
The system temp directory is used if not specified.
|
||||||
|
The temp directory must be on the same file system for atomic rename operation!
|
||||||
|
|
||||||
|
--console
|
||||||
|
Print values in console
|
||||||
|
|
||||||
|
-r, --refresh <REFRESH>
|
||||||
|
System sensor refresh interval in seconds
|
||||||
|
|
||||||
|
--disk-refresh <DISK_REFRESH>
|
||||||
|
Enable individual disk refresh logic as used in AOOSTAR-X. Refresh interval in seconds
|
||||||
|
|
||||||
|
--smartctl
|
||||||
|
Retrieve drive temperature if `disk-update` option is enabled.
|
||||||
|
|
||||||
|
Requires smartctl and password-less sudo!
|
||||||
|
```
|
||||||
|
|
||||||
|
Single test run with printing all sensors on the console:
|
||||||
|
```shell
|
||||||
|
sysinfo --console
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal mode providing sensor values for `asterctl` in `/tmp/sensors/sysinfo.txt`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sysinfo --refresh 3 --out /tmp/sensor/sysinfo.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the lower the refresh rate, the more resources are used!
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Text File Data Source
|
||||||
|
|
||||||
|
- Text file with ending: `.txt`
|
||||||
|
- Simple key / value pairs, separated by a colon `:`. Example: `foo: bar`
|
||||||
|
- Line based: one key / value per line.
|
||||||
|
- Key and value are trimmed. Any whitespace will be removed.
|
||||||
|
- Empty lines and comments are ignored.
|
||||||
|
- Comments start with `#` at the beginning of the line.
|
||||||
|
- Support for special keys: if key ends with `#unit` then the value is the unit for the corresponding key before the suffix
|
||||||
|
- Example: `net_download_speed#unit: M/S` is the unit value for `net_download_speed`.
|
||||||
|
- This can be used for dynamic unit values if they sensor value provider cannot add the unit to the corresponding value.
|
||||||
|
- File contents will automatically be read when updated.
|
||||||
|
- This requires the sensor value provider to use atomic file updates!
|
||||||
|
- Best practice is to use a temporary file on the same filesystem and use a move or rename operation after all values have been written.
|
||||||
|
- One or multiple sensor text files are supported.
|
||||||
|
- Either a single file can be specified, or a directory path.
|
||||||
|
- If a directory is specified, all children matching the sensor file naming pattern will be read and monitored.
|
||||||
|
- Any subdirectories are ignored (no recursive support).
|
||||||
|
|
||||||
|
Example text file for the [cfg/monitor.json](../cfg/monitor.json) panel configuration:
|
||||||
|
|
||||||
|
```
|
||||||
|
cpu_temperature: 65
|
||||||
|
cpu_percent: 98
|
||||||
|
memory_usage: 77
|
||||||
|
memory_Temperature: 48
|
||||||
|
net_ip_address: 146.56.182.244
|
||||||
|
gpu_core: 98
|
||||||
|
gpu_temperature: 78
|
||||||
|
net_upload_speed: 100
|
||||||
|
net_upload_speed#unit: K/S
|
||||||
|
net_download_speed: 120
|
||||||
|
net_download_speed#unit: M/S
|
||||||
|
motherboard_temperature: 38
|
||||||
|
storage_ssd[0]['temperature']: 31
|
||||||
|
storage_ssd[0]['used']: 17
|
||||||
|
storage_ssd[1]['temperature']: 32
|
||||||
|
storage_ssd[1]['used']: 27
|
||||||
|
storage_ssd[2]['temperature']: 33
|
||||||
|
storage_ssd[2]['used']: 37
|
||||||
|
storage_ssd[3]['temperature']: 34
|
||||||
|
storage_ssd[3]['used']: 47
|
||||||
|
storage_ssd[4]['temperature']: 35
|
||||||
|
storage_ssd[4]['used']: 57
|
||||||
|
storage_hdd[0]['temperature']: 36
|
||||||
|
storage_hdd[0]['used']: 17
|
||||||
|
storage_hdd[1]['temperature']: 37
|
||||||
|
storage_hdd[1]['used']: 27
|
||||||
|
storage_hdd[2]['temperature']: 38
|
||||||
|
storage_hdd[2]['used']: 37
|
||||||
|
storage_hdd[3]['temperature']: 39
|
||||||
|
storage_hdd[3]['used']: 47
|
||||||
|
storage_hdd[4]['temperature']: 40
|
||||||
|
storage_hdd[4]['used']: 57
|
||||||
|
storage_hdd[5]['temperature']: 10
|
||||||
|
storage_hdd[5]['used']: 67
|
||||||
|
```
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Sensor Mode 1 Text
|
||||||
|
|
||||||
|
A text sensor renders a text label with a sensor value and an optional unit text on the panel.
|
||||||
|
The value can be formatted as a fixed point decimal number or as an integer.
|
||||||
|
|
||||||
|
Text sensor configuration fields:
|
||||||
|
- `mode`: 1 (for text)
|
||||||
|
- `label`: label identifier, also used as sensor value data source identifier
|
||||||
|
- `direction`: 1 = left to right, 2 = right to left, 3 = top to bottom, 4 = bottom to top
|
||||||
|
- `label`: data source id to retrieve the current value from
|
||||||
|
- `unit`: optional unit label, appended after the sensor value
|
||||||
|
- `x`, `y`: position on the panel
|
||||||
|
- `fontFamily`: Font name matching font filename without file extension.
|
||||||
|
- Fonts are loaded from the configured font directory, or from the custom panel's `fonts` directory.
|
||||||
|
- An absolute file path can also be used.
|
||||||
|
- `fontSize`: Font size
|
||||||
|
- `fontColor`: Font color in `#RRGGBB` notation, or `-1` if not set.
|
||||||
|
- Examples: `#ffffff` = white, `#ff0000` = red. Default: `#ffffff`
|
||||||
|
- `textAlign`: Text alignment: `left`, `center`, `right`
|
||||||
|
- `integerDigits`: number of integer digits: -1 or missing field = all digits, > 0 prefix with `0` and set to `9` if overflown
|
||||||
|
- `decimalDigits`: number fixed point digits: -1 = auto, 0 = integer number without decimal digits, > 0 fixed number of decimal digits
|
||||||
|
|
||||||
|
## Value Formatting
|
||||||
|
|
||||||
|
The sensor value can be formatted with the `unit` and `integerDigits` & `decimalDigits` options.
|
||||||
|
|
||||||
|
- The unit value is simply appended to the value, without whitespace.
|
||||||
|
- Example formatting for the value `123.456` with `integerDigits` & `decimalDigits`:
|
||||||
|
|
||||||
|
| integer | decimal | output |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| 5 | 2 | 00123.46 |
|
||||||
|
| 5 | 1 | 00123.5 |
|
||||||
|
| 5 | 0 | 00123 |
|
||||||
|
| -1 | 2 | 123.46 |
|
||||||
|
| -1 | 1 | 123.5 |
|
||||||
|
| -1 | 0 | 123 |
|
||||||
|
| 2 | 0 | 99 |
|
||||||
|
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Example `panel.json` with two "text" indicator sensors and the following (partial) background image in `img`:
|
||||||
|
|
||||||
|
<img src="img/sensor_mode1_background.png" alt="sensor mode 1 background image example">
|
||||||
|
|
||||||
|
The background image and sensor definitions are taken from the default system panel configuration in the AOOSTAR-X app.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Text test panel",
|
||||||
|
"img": "background.png",
|
||||||
|
"sensor": [
|
||||||
|
{
|
||||||
|
"mode": 1,
|
||||||
|
"type": 1,
|
||||||
|
"name": "CPU temp",
|
||||||
|
"label": "cpu_temperature",
|
||||||
|
"x": 195,
|
||||||
|
"y": 110,
|
||||||
|
"value": "65",
|
||||||
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
|
"fontSize": 120,
|
||||||
|
"fontColor": -1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"decimalDigits": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": 1,
|
||||||
|
"type": 1,
|
||||||
|
"name": "CPU usage",
|
||||||
|
"label": "cpu_percent",
|
||||||
|
"unit": "%",
|
||||||
|
"x": 200,
|
||||||
|
"y": 285,
|
||||||
|
"value": "47.4",
|
||||||
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
|
"fontSize": 60,
|
||||||
|
"fontColor": -1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"decimalDigits": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The following graphic is rendered for the two text fields defined above:
|
||||||
|
|
||||||
|
<img src="img/sensor_mode1.png" alt="sensor mode 1 example">
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
Text sensor formatting has been reverse engineered from the AOOSTAR-X app. Not all options are supported
|
||||||
|
|
||||||
|
- Text position and font size calculation doesn't always match AOOSTAR-X.
|
||||||
|
- Needs investigation if value is in pixel or points.
|
||||||
|
- Might also need dpi adjustments.
|
||||||
|
- `fontWeight` not yet supported.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Sensor Mode 2 Circular Progress
|
||||||
|
|
||||||
|
A circular progress sensor (known as `fan` in the AOOSTAR-X software) masks a progress bar image for a certain angular
|
||||||
|
range based on the corresponding sensor value. The masked image is alpha-blended with the panel image.
|
||||||
|
|
||||||
|
Sensor configuration fields:
|
||||||
|
- `mode`: 2 (for fan)
|
||||||
|
- `direction`: 1 = clockwise, 2 = counter-clockwise
|
||||||
|
- `label`: label identifier, also used as sensor value data source identifier
|
||||||
|
- `x`, `y`: position on the panel
|
||||||
|
- `width`, `height`: size of the circular progress element (not yet used)
|
||||||
|
- `pic`: circular progress image to overlay. Should match `width`, `height`
|
||||||
|
- `minAngle`, `maxAngle`: range of the masked image
|
||||||
|
- `minValue`, `maxValue`: clamp sensor value to this range
|
||||||
|
- `xz_x`, `xz_y`
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
The following configuration and graphics are taken from the `仪表盘_windows` panel configuration in `有线网卡 windows驱动.rar`.
|
||||||
|
|
||||||
|
Example `panel.json` with a single "fan" indicator sensor and the following (partial) background image in `img`:
|
||||||
|
|
||||||
|
<img src="img/sensor_mode2_background.jpg" alt="sensor mode 2 background image example">
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Fan test panel",
|
||||||
|
"img": "background.jpg",
|
||||||
|
"sensor": [
|
||||||
|
{
|
||||||
|
"id": "29d9ef2d-30b4-459d-b2b0-43cb6d4d6b41",
|
||||||
|
"itemName": "CPU usage",
|
||||||
|
"mode": 2,
|
||||||
|
"type": 1,
|
||||||
|
"direction": 1,
|
||||||
|
"label": "cpu_percent",
|
||||||
|
"value": "47.7",
|
||||||
|
"x": 168,
|
||||||
|
"y": 184,
|
||||||
|
"width": 237,
|
||||||
|
"height": 237,
|
||||||
|
"fontColor": "#ffffff",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": "default_font",
|
||||||
|
"textAlign": "left",
|
||||||
|
"minAngle": -160,
|
||||||
|
"maxAngle": 30,
|
||||||
|
"minValue": 0,
|
||||||
|
"maxValue": 80,
|
||||||
|
"xz_x": 0,
|
||||||
|
"xz_y": 0,
|
||||||
|
"pic": "progress_circle.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Progress image `"pic": "progress_circle.png"`:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The following graphic is rendered for progress example above:
|
||||||
|
|
||||||
|
<img src="img/sensor_mode2.jpg" alt="sensor mode 2 example">
|
||||||
|
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
Fan sensor rendering has been reverse engineered from the AOOSTAR-X app. Not all options are supported.
|
||||||
|
|
||||||
|
- Work in progress, not yet fully tested
|
||||||
|
- `direction: 2` doesn't seem to work
|
||||||
|
- `widht`, `height` should be considered and auto-resized as for mode 4
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Sensor Mode 3 Progress
|
||||||
|
|
||||||
|
A progress sensor crops a progress image based on the corresponding sensor value and alpha-blends it with the panel image.
|
||||||
|
|
||||||
|
Sensor configuration fields:
|
||||||
|
- `mode`: 3 (for progress)
|
||||||
|
- `label`: label identifier, also used as sensor value data source identifier
|
||||||
|
- `direction`: 1 = left to right, 2 = right to left, 3 = top to bottom, 4 = bottom to top
|
||||||
|
- `x`, `y`: position on the panel
|
||||||
|
- `pic`: progress image to crop and overlay
|
||||||
|
- `minValue`, `maxValue`: clamp sensor value to this range
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Example `panel.json` with two "progress" indicator sensor and the following (partial) background image in `img`:
|
||||||
|
|
||||||
|
<img src="img/sensor_mode3_background.png" alt="sensor mode 3 background image example">
|
||||||
|
|
||||||
|
The background image and sensor definitions are taken from the default system panel configuration in the AOOSTAR-X app.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Progress test panel",
|
||||||
|
"img": "background.png",
|
||||||
|
"sensor": [
|
||||||
|
{
|
||||||
|
"mode": 3,
|
||||||
|
"type": 2,
|
||||||
|
"name": "SSD 4 usage",
|
||||||
|
"label": "storage_ssd4_usage",
|
||||||
|
"x": 400,
|
||||||
|
"y": 45,
|
||||||
|
"direction": 1,
|
||||||
|
"value": "35",
|
||||||
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
|
"fontSize": 24,
|
||||||
|
"fontColor": -1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"minValue": 0,
|
||||||
|
"maxValue": 100,
|
||||||
|
"pic": "progress.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": 3,
|
||||||
|
"type": 2,
|
||||||
|
"name": "SSD 5 usage",
|
||||||
|
"label": "storage_ssd5_usage",
|
||||||
|
"x": 400,
|
||||||
|
"y": 106,
|
||||||
|
"direction": 1,
|
||||||
|
"value": "80",
|
||||||
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
|
"fontSize": 24,
|
||||||
|
"fontColor": -1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"minValue": 0,
|
||||||
|
"maxValue": 100,
|
||||||
|
"pic": "progress.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": 1,
|
||||||
|
"type": 2,
|
||||||
|
"name": "SSD 4 temp",
|
||||||
|
"label": "storage_ssd4_temperature",
|
||||||
|
"x": 580,
|
||||||
|
"y": 70,
|
||||||
|
"direction": 1,
|
||||||
|
"value": "34",
|
||||||
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
|
"fontSize": 24,
|
||||||
|
"fontColor": -1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"integerDigits": -1,
|
||||||
|
"decimalDigits": 0,
|
||||||
|
"unit": " ℃"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": 1,
|
||||||
|
"type": 2,
|
||||||
|
"name": "SSD 5 temp",
|
||||||
|
"label": "storage_ssd5_temperature",
|
||||||
|
"x": 580,
|
||||||
|
"y": 130,
|
||||||
|
"direction": 1,
|
||||||
|
"value": "35",
|
||||||
|
"fontFamily": "HarmonyOS_Sans_SC_Bold",
|
||||||
|
"fontSize": 24,
|
||||||
|
"fontColor": -1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"integerDigits": -1,
|
||||||
|
"decimalDigits": 0,
|
||||||
|
"unit": " ℃"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Progress image `"pic": "progress.png"`:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The following graphic is rendered for progress example above:
|
||||||
|
|
||||||
|
<img src="img/sensor_mode3.png" alt="sensor mode 3 example">
|
||||||
|
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
Progress sensor rendering has been reverse engineered from the AOOSTAR-X app. Not all options are supported.
|
||||||
|
|
||||||
|
- Work in progress, not yet fully tested
|
||||||
|
- `widht`, `height` should be considered and auto-resized as for mode 4
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Sensor Mode 4 Pointer
|
||||||
|
|
||||||
|
A pointer sensor rotates an image at a certain angle calculated from the current sensor value and alpha-blends it with
|
||||||
|
the panel image.
|
||||||
|
|
||||||
|
Sensor configuration fields:
|
||||||
|
- `mode`: 4 (for pointer)
|
||||||
|
- `direction`: 1 = clockwise, 2 = counter-clockwise
|
||||||
|
- `label`: label identifier, also used as sensor value data source identifier
|
||||||
|
- `x`, `y`: position on the panel
|
||||||
|
- `width`, `height`: size of the pointer
|
||||||
|
- `pic`: pointer image to overlay. Should match `width`, `height`, otherwise it will be resized
|
||||||
|
- `minAngle`, `maxAngle`: range of the rotated image
|
||||||
|
- `minValue`, `maxValue`: scaling range to apply on the value for `minAngle` .. `maxAngle` (to be verified)
|
||||||
|
- `xz_x`, `xz_y`
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
The following configuration and graphics are taken from the `三环_windows` panel configuration in `有线网卡 windows驱动.rar`.
|
||||||
|
|
||||||
|
Example `panel.json` with a single "pointer" indicator sensor and the following (partial) background image in `img`:
|
||||||
|
|
||||||
|
<img src="img/sensor_mode4_background.png" alt="sensor mode 4 background image example">
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Pointer test panel",
|
||||||
|
"img": "background.jpg",
|
||||||
|
"sensor": [
|
||||||
|
{
|
||||||
|
"id": "a9d4acac-2af9-4fe0-9f69-86cd09f25696",
|
||||||
|
"itemName": "CPU dial",
|
||||||
|
"mode": 4,
|
||||||
|
"type": 1,
|
||||||
|
"direction": 1,
|
||||||
|
"label": "cpu_percent",
|
||||||
|
"value": "47.7",
|
||||||
|
"x": 160,
|
||||||
|
"y": 208,
|
||||||
|
"width": 302,
|
||||||
|
"height": 302,
|
||||||
|
"fontColor": "#ffffff",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": "",
|
||||||
|
"fontWeight": "normal",
|
||||||
|
"textAlign": "left",
|
||||||
|
"minAngle": -110,
|
||||||
|
"maxAngle": 110,
|
||||||
|
"minValue": 0,
|
||||||
|
"maxValue": 90,
|
||||||
|
"xz_x": 0,
|
||||||
|
"xz_y": 0,
|
||||||
|
"pic": "pointer.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pointer image `"pic": "pointer.png"`:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The following graphic is rendered for a sensor value of `47.7`:
|
||||||
|
|
||||||
|
<img src="img/sensor_mode4.png" alt="sensor mode 4 example">
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
Pointer sensor rendering has been reverse engineered from the AOOSTAR-X app. Not all options are supported.
|
||||||
|
|
||||||
|
- Work in progress, not yet fully tested
|
||||||
@@ -16,8 +16,8 @@ Example panels from the AOOSTAR-X software, rendered with `asterctl` using dummy
|
|||||||
|
|
||||||
- One or multiple panels rotating in configurable interval (configuration value `setup.switchTime`).
|
- One or multiple panels rotating in configurable interval (configuration value `setup.switchTime`).
|
||||||
- Each panel can be configured with multiple sensor fields.
|
- Each panel can be configured with multiple sensor fields.
|
||||||
- Only text sensor value fields are supported (`sensor.mode: 1`).
|
- Text sensor value fields are supported (`sensor.mode: 1`), but there are still some text size and positioning issues.
|
||||||
- Fan (2), progress (3) and pointer (4) sensors are not supported.
|
- Fan (2), progress (3) and pointer (4) sensor modes are being worked on and not all configuration options are working yet.
|
||||||
- Each sensor field can be customized with an individual font, size, color and text alignment.
|
- Each sensor field can be customized with an individual font, size, color and text alignment.
|
||||||
- Panels are redrawn at a configurable interval (configuration value `setup.refresh`).
|
- Panels are redrawn at a configurable interval (configuration value `setup.refresh`).
|
||||||
- Only the updated areas of the image are sent to the display for faster updates.
|
- Only the updated areas of the image are sent to the display for faster updates.
|
||||||
@@ -32,7 +32,7 @@ asterctl --config monitor.json
|
|||||||
- The configuration file is loaded from the configuration directory if not an absolute path is specified.
|
- The configuration file is loaded from the configuration directory if not an absolute path is specified.
|
||||||
- The default configuration directory is `./cfg` and can be changed with the `--config-dir` command line option.
|
- The default configuration directory is `./cfg` and can be changed with the `--config-dir` command line option.
|
||||||
|
|
||||||
The original AOOSTAR-X json configuration file format is used, but only use a subset of the configuration is supported:
|
The original AOOSTAR-X json configuration file format is used, but only a subset of the configuration is supported:
|
||||||
|
|
||||||
- Setup object fields:
|
- Setup object fields:
|
||||||
- `switchTime`: Optional switch time between panels in seconds, string value interpreted as float and converted to milliseconds. Default: 5
|
- `switchTime`: Optional switch time between panels in seconds, string value interpreted as float and converted to milliseconds. Default: 5
|
||||||
@@ -51,6 +51,13 @@ The original AOOSTAR-X json configuration file format is used, but only use a su
|
|||||||
- `fontSize`: Font size
|
- `fontSize`: Font size
|
||||||
- `fontColor`: Font color in `#RRGGBB` notation, or `-1` if not set. Examples: `#ffffff` = white, `#ff0000` = red. Default: `#ffffff`
|
- `fontColor`: Font color in `#RRGGBB` notation, or `-1` if not set. Examples: `#ffffff` = white, `#ff0000` = red. Default: `#ffffff`
|
||||||
- `textAlign`: Text alignment: `left`, `right`, `center`
|
- `textAlign`: Text alignment: `left`, `right`, `center`
|
||||||
|
- Fields used for the fan (2), progress (3) and pointer (4) sensor modes:
|
||||||
|
- `min_value` and `max_value`
|
||||||
|
- `width` and `height`
|
||||||
|
- `direction`
|
||||||
|
- `pic`: progress image, loaded from the specified configuration directory if not an absolute path is specified.
|
||||||
|
- `min_angle` and `max_angle`
|
||||||
|
- `xz_x` and `xz_y`
|
||||||
|
|
||||||
Example configuration file: [cfg/monitor.json](../cfg/monitor.json).
|
Example configuration file: [cfg/monitor.json](../cfg/monitor.json).
|
||||||
|
|
||||||
@@ -58,149 +65,4 @@ Sensor values are not read from the configuration file (the `sensor.value` field
|
|||||||
|
|
||||||
More options might be supported later.
|
More options might be supported later.
|
||||||
|
|
||||||
## Sensor Data Sources
|
See [custom sensor panels](sensor_custom_panel.md) for including custom panels.
|
||||||
|
|
||||||
Sensor values are provided in separate text files and are automatically read when the file changes.
|
|
||||||
Only the file data source is supported at the moment, other sources like pipes, sockets etc. might be supported later.
|
|
||||||
|
|
||||||
### Text File Data Source
|
|
||||||
|
|
||||||
- Text file with ending: `.txt`
|
|
||||||
- Simple key / value pairs, separated by a colon `:`. Example: `foo: bar`
|
|
||||||
- Line based: one key / value per line.
|
|
||||||
- Key and value are trimmed. Any whitespace will be removed.
|
|
||||||
- Empty lines and comments are ignored.
|
|
||||||
- Comments start with `#` at the beginning of the line.
|
|
||||||
- Support for special keys: if key ends with `#unit` then the value is the unit for the corresponding key before the suffix
|
|
||||||
- Example: `net_download_speed#unit: M/S` is the unit value for `net_download_speed`.
|
|
||||||
- This can be used for dynamic unit values if they sensor value provider cannot add the unit to the corresponding value.
|
|
||||||
- File contents will automatically be read when updated.
|
|
||||||
- This requires the sensor value provider to use atomic file updates!
|
|
||||||
- Best practice is to use a temporary file on the same filesystem and use a move or rename operation after all values have been written.
|
|
||||||
- One or multiple sensor text files are supported.
|
|
||||||
- Either a single file can be specified, or a directory path.
|
|
||||||
- If a directory is specified, all children matching the sensor file naming pattern will be read and monitored.
|
|
||||||
- Any subdirectories are ignored (no recursive support).
|
|
||||||
|
|
||||||
Example text file for the [cfg/monitor.json](../cfg/monitor.json) panel configuration:
|
|
||||||
|
|
||||||
<details>
|
|
||||||
|
|
||||||
```
|
|
||||||
cpu_temperature: 65
|
|
||||||
cpu_percent: 98
|
|
||||||
memory_usage: 77
|
|
||||||
memory_Temperature: 48
|
|
||||||
net_ip_address: 146.56.182.244
|
|
||||||
gpu_core: 98
|
|
||||||
gpu_temperature: 78
|
|
||||||
net_upload_speed: 100
|
|
||||||
net_upload_speed#unit: K/S
|
|
||||||
net_download_speed: 120
|
|
||||||
net_download_speed#unit: M/S
|
|
||||||
motherboard_temperature: 38
|
|
||||||
storage_ssd[0]['temperature']: 31
|
|
||||||
storage_ssd[0]['used']: 17
|
|
||||||
storage_ssd[1]['temperature']: 32
|
|
||||||
storage_ssd[1]['used']: 27
|
|
||||||
storage_ssd[2]['temperature']: 33
|
|
||||||
storage_ssd[2]['used']: 37
|
|
||||||
storage_ssd[3]['temperature']: 34
|
|
||||||
storage_ssd[3]['used']: 47
|
|
||||||
storage_ssd[4]['temperature']: 35
|
|
||||||
storage_ssd[4]['used']: 57
|
|
||||||
storage_hdd[0]['temperature']: 36
|
|
||||||
storage_hdd[0]['used']: 17
|
|
||||||
storage_hdd[1]['temperature']: 37
|
|
||||||
storage_hdd[1]['used']: 27
|
|
||||||
storage_hdd[2]['temperature']: 38
|
|
||||||
storage_hdd[2]['used']: 37
|
|
||||||
storage_hdd[3]['temperature']: 39
|
|
||||||
storage_hdd[3]['used']: 47
|
|
||||||
storage_hdd[4]['temperature']: 40
|
|
||||||
storage_hdd[4]['used']: 57
|
|
||||||
storage_hdd[5]['temperature']: 10
|
|
||||||
storage_hdd[5]['used']: 67
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### Shell Scripts
|
|
||||||
|
|
||||||
The [/linux/scripts](../linux/scripts) directory contains some proof-of-concept Linux shell scripts.
|
|
||||||
|
|
||||||
CPU and memory usage are written into a sensor data source text file that can be used by `asterctl`.
|
|
||||||
|
|
||||||
```
|
|
||||||
./cpu_usage.sh -h
|
|
||||||
Simple PoC script to periodically write the CPU usage into a sensor text file.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
./cpu_usage.sh [-r REFRESH] [-s SENSOR_FILE] [-t TEMP_DIR]
|
|
||||||
|
|
||||||
-r REFRESH refresh in seconds. Default: 1
|
|
||||||
-s SENSOR_FILE output sensor file. Default: /tmp/sensors/cpu.txt
|
|
||||||
-t TEMP_DIR temporary directory. Default: /tmp
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
./mem_usage.sh -h
|
|
||||||
Simple PoC script to periodically write the memory usage into a sensor text file.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
./mem_usage.sh [-r REFRESH] [-s SENSOR_FILE] [-t TEMP_DIR]
|
|
||||||
|
|
||||||
-r REFRESH refresh in seconds. Default: 5
|
|
||||||
-s SENSOR_FILE output sensor file. Default: /tmp/sensors/mem.txt
|
|
||||||
-t TEMP_DIR temporary directory. Default: /tmp
|
|
||||||
```
|
|
||||||
|
|
||||||
### sysinfo Tool
|
|
||||||
|
|
||||||
The Rust based [/src/bin/sysinfo.rs](../src/bin/sysinfo.rs) tool gathers many more system sensor values with the help of
|
|
||||||
the [sysinfo](https://github.com/GuillaumeGomez/sysinfo) crate.
|
|
||||||
|
|
||||||
It supports FreeBSD, Linux, macOS, Windows and other OSes, but it has only been tested on Linux so far.
|
|
||||||
|
|
||||||
```
|
|
||||||
Proof of concept sensor value collection for the asterctl screen control tool
|
|
||||||
|
|
||||||
Usage: sysinfo [OPTIONS]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-o, --out <OUT>
|
|
||||||
Output sensor file
|
|
||||||
|
|
||||||
-t, --temp-dir <TEMP_DIR>
|
|
||||||
Temporary directory for preparing the output sensor file.
|
|
||||||
|
|
||||||
The system temp directory is used if not specified.
|
|
||||||
The temp directory must be on the same file system for atomic rename operation!
|
|
||||||
|
|
||||||
--console
|
|
||||||
Print values in console
|
|
||||||
|
|
||||||
-r, --refresh <REFRESH>
|
|
||||||
System sensor refresh interval in seconds
|
|
||||||
|
|
||||||
--disk-refresh <DISK_REFRESH>
|
|
||||||
Enable individual disk refresh logic as used in AOOSTAR-X. Refresh interval in seconds
|
|
||||||
|
|
||||||
--smartctl
|
|
||||||
Retrieve drive temperature if `disk-update` option is enabled.
|
|
||||||
|
|
||||||
Requires smartctl and password-less sudo!
|
|
||||||
```
|
|
||||||
|
|
||||||
Single test run with printing all sensors on the console:
|
|
||||||
```shell
|
|
||||||
sysinfo --console
|
|
||||||
```
|
|
||||||
|
|
||||||
Normal mode providing sensor values for `asterctl` in `/tmp/sensors/sysinfo.txt`:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
sysinfo --refresh 3 --out /tmp/sensor/sysinfo.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: the lower the refresh rate, the more resources are used!
|
|
||||||
|
|||||||
@@ -7,16 +7,16 @@
|
|||||||
//! Likely not fully compatible with files created with the original editor.
|
//! Likely not fully compatible with files created with the original editor.
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use image::Rgb;
|
use image::{Rgb, Rgba};
|
||||||
use imageproc::definitions::HasWhite;
|
use imageproc::definitions::HasWhite;
|
||||||
use log::warn;
|
use log::{info, warn};
|
||||||
use serde::de::Visitor;
|
use serde::de::Visitor;
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::num::ParseIntError;
|
use std::num::ParseIntError;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::{fmt, fs};
|
use std::{fmt, fs};
|
||||||
|
|
||||||
pub fn load_cfg<P: AsRef<Path>>(path: P) -> anyhow::Result<MonitorConfig> {
|
pub fn load_cfg<P: AsRef<Path>>(path: P) -> anyhow::Result<MonitorConfig> {
|
||||||
@@ -54,6 +54,51 @@ pub fn load_cfg<P: AsRef<Path>>(path: P) -> anyhow::Result<MonitorConfig> {
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load a custom panel configuration.
|
||||||
|
///
|
||||||
|
/// The distributed panel ZIP file must be extracted and contain:
|
||||||
|
/// - `panel.json` configuration file
|
||||||
|
/// - `img` subdirectory containing the referenced images in panel.json
|
||||||
|
/// - `fonts` subdirectory containing the referenced fonts in panel.json
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path`: directory path of the extracted custom panel.
|
||||||
|
///
|
||||||
|
/// returns: Result<Panel, Error>
|
||||||
|
pub fn load_custom_panel<P: AsRef<Path>>(path: P) -> anyhow::Result<Panel> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let panel_file = path.join("panel.json");
|
||||||
|
|
||||||
|
info!("Loading custom panel {panel_file:?}");
|
||||||
|
|
||||||
|
let file = fs::File::open(&panel_file)
|
||||||
|
.with_context(|| format!("Failed to load custom panel {panel_file:?}"))?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut panel: Panel = serde_json::from_reader(reader)?;
|
||||||
|
|
||||||
|
// adjust font and image file paths
|
||||||
|
let img_path = fs::canonicalize(path.join("img"))?;
|
||||||
|
let font_path = fs::canonicalize(path.join("fonts"))?;
|
||||||
|
if let Some(img) = &panel.img
|
||||||
|
&& !Path::new(img).is_absolute()
|
||||||
|
{
|
||||||
|
panel.img = Some(img_path.join(img).display().to_string());
|
||||||
|
}
|
||||||
|
for sensor in panel.sensor.iter_mut() {
|
||||||
|
if let Some(pic) = &sensor.pic
|
||||||
|
&& !Path::new(pic).is_absolute()
|
||||||
|
{
|
||||||
|
sensor.pic = Some(img_path.join(pic).display().to_string());
|
||||||
|
}
|
||||||
|
if !sensor.font_family.is_empty() && !Path::new(&sensor.font_family).is_absolute() {
|
||||||
|
sensor.font_family = font_path.join(&sensor.font_family).display().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(panel)
|
||||||
|
}
|
||||||
|
|
||||||
/// AOOSTAR-X monitor json configuration file
|
/// AOOSTAR-X monitor json configuration file
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct MonitorConfig {
|
pub struct MonitorConfig {
|
||||||
@@ -97,6 +142,11 @@ impl MonitorConfig {
|
|||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn include_custom_panel(&mut self, panel: Panel) {
|
||||||
|
self.panels.push(panel);
|
||||||
|
self.active_panels.push(self.panels.len() as u32);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web-app user login
|
/// Web-app user login
|
||||||
@@ -153,7 +203,7 @@ pub struct Setup {
|
|||||||
/// Language setting.
|
/// Language setting.
|
||||||
///
|
///
|
||||||
/// Not used, part of AOOSTAR-X json configuration file.
|
/// Not used, part of AOOSTAR-X json configuration file.
|
||||||
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
|
#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum Language {
|
pub enum Language {
|
||||||
@@ -163,7 +213,7 @@ pub enum Language {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Not used, part of AOOSTAR-X json configuration file.
|
/// Not used, part of AOOSTAR-X json configuration file.
|
||||||
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
|
#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)]
|
||||||
#[repr(i16)]
|
#[repr(i16)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum OperationMode {
|
pub enum OperationMode {
|
||||||
@@ -176,6 +226,17 @@ pub enum OperationMode {
|
|||||||
Custom10W = 5,
|
Custom10W = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum SensorDirection {
|
||||||
|
/// Also used for clockwise in circular/arc progress & rotating pointer/dial indicator
|
||||||
|
LeftToRight = 1,
|
||||||
|
/// Also used for counter-clockwise in circular/arc & rotating pointer/dial progress indicator
|
||||||
|
RightToLeft = 2,
|
||||||
|
TopToBottom = 3,
|
||||||
|
BottomToTop = 4,
|
||||||
|
}
|
||||||
|
|
||||||
/// Custom DIY panel definition
|
/// Custom DIY panel definition
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Panel {
|
pub struct Panel {
|
||||||
@@ -197,6 +258,25 @@ pub struct Panel {
|
|||||||
pub sensor: Vec<Sensor>,
|
pub sensor: Vec<Sensor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Panel {
|
||||||
|
pub fn friendly_name(&self) -> String {
|
||||||
|
self.name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.id.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
if let Some(img_file) = &self.img {
|
||||||
|
let img_file = PathBuf::from(img_file);
|
||||||
|
img_file
|
||||||
|
.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "panel".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One Data Display Unit
|
/// One Data Display Unit
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -223,21 +303,27 @@ pub struct Sensor {
|
|||||||
/// Sensor value. Ignored: value is used from a sensor source
|
/// Sensor value. Ignored: value is used from a sensor source
|
||||||
#[serde(deserialize_with = "empty_string_as_none")]
|
#[serde(deserialize_with = "empty_string_as_none")]
|
||||||
pub value: Option<String>, // "" or numbers, so Option<String>
|
pub value: Option<String>, // "" or numbers, so Option<String>
|
||||||
|
|
||||||
|
/// Image for progress, fan and pointer indicators
|
||||||
|
pub min_value: Option<f32>,
|
||||||
|
/// Image for progress, fan and pointer indicators
|
||||||
|
pub max_value: Option<f32>,
|
||||||
|
|
||||||
/// Optional unit text to print after the value
|
/// Optional unit text to print after the value
|
||||||
#[serde(deserialize_with = "empty_string_as_none")]
|
#[serde(deserialize_with = "empty_string_as_none")]
|
||||||
pub unit: Option<String>,
|
pub unit: Option<String>,
|
||||||
/// x-position. Custom panel coordinates are stored as float!
|
/// x-position. Custom panel coordinates are stored as float!
|
||||||
|
// TODO use i32 and round from f32 in deserialization
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
/// y-position.
|
/// y-position.
|
||||||
|
// TODO use i32 and round from f32 in deserialization
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
/// _Not (yet) used_
|
/// Used for pointer type
|
||||||
pub width: Option<i32>,
|
pub width: Option<i32>,
|
||||||
/// _Not (yet) used_
|
/// Used for pointer type
|
||||||
pub height: Option<i32>,
|
pub height: Option<i32>,
|
||||||
/// _Not (yet) used_
|
/// Sensor graphic orientation
|
||||||
pub text_direction: i32, // layout direction
|
pub direction: Option<SensorDirection>,
|
||||||
/// _Not (yet) used_
|
|
||||||
pub direction: i32, // sensor orientation, 0/1
|
|
||||||
|
|
||||||
/// Font name matching font filename without file extension.
|
/// Font name matching font filename without file extension.
|
||||||
pub font_family: String,
|
pub font_family: String,
|
||||||
@@ -257,23 +343,25 @@ pub struct Sensor {
|
|||||||
// -1 ≈ unset ⇒ Option<i32>
|
// -1 ≈ unset ⇒ Option<i32>
|
||||||
#[serde(deserialize_with = "option_none_if_minus_one")]
|
#[serde(deserialize_with = "option_none_if_minus_one")]
|
||||||
pub decimal_digits: Option<i32>,
|
pub decimal_digits: Option<i32>,
|
||||||
/*
|
/// Image for progress, fan and pointer indicators
|
||||||
// The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl`
|
|
||||||
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")]
|
#[serde(deserialize_with = "empty_string_as_none")]
|
||||||
pub pic: Option<String>, // "" when unused
|
pub pic: Option<String>,
|
||||||
|
|
||||||
|
/// Used for fan & pointer sensors
|
||||||
|
pub min_angle: Option<i32>,
|
||||||
|
/// Used for fan & pointer sensors
|
||||||
|
pub max_angle: Option<i32>,
|
||||||
|
|
||||||
/// Pivot x
|
/// Pivot x
|
||||||
#[serde(rename = "xz_x")]
|
#[serde(rename = "xz_x")]
|
||||||
pub xz_x: Option<i32>,
|
pub xz_x: Option<i32>,
|
||||||
/// Pivot y
|
/// Pivot y
|
||||||
#[serde(rename = "xz_y")]
|
#[serde(rename = "xz_y")]
|
||||||
pub xz_y: Option<i32>,
|
pub xz_y: Option<i32>,
|
||||||
|
/*
|
||||||
|
// The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl`
|
||||||
|
/// _Not (yet) used_
|
||||||
|
pub text_direction: i32, // layout direction
|
||||||
/// For type = 6
|
/// For type = 6
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
/// For type = 6
|
/// For type = 6
|
||||||
@@ -283,12 +371,17 @@ pub struct Sensor {
|
|||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
|
/// Sensor element type. Name is based on AOOSTAR-X web configuration
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, Eq, Hash, PartialEq)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum SensorMode {
|
pub enum SensorMode {
|
||||||
|
/// Text element
|
||||||
Text = 1,
|
Text = 1,
|
||||||
|
/// Circular/arc progress indicator
|
||||||
Fan = 2,
|
Fan = 2,
|
||||||
|
/// Horizontal or vertical progress indicator
|
||||||
Progress = 3,
|
Progress = 3,
|
||||||
|
/// Rotating pointer/dial indicator
|
||||||
Pointer = 4,
|
Pointer = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +478,7 @@ impl TryFrom<&str> for FontColor {
|
|||||||
type Error = ParseIntError;
|
type Error = ParseIntError;
|
||||||
|
|
||||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
if value.len() != 7 || value.starts_with('#') {
|
if value.len() != 7 || !value.starts_with('#') {
|
||||||
warn!("Invalid font color: {value}");
|
warn!("Invalid font color: {value}");
|
||||||
Ok(FontColor::default())
|
Ok(FontColor::default())
|
||||||
} else {
|
} else {
|
||||||
@@ -410,6 +503,12 @@ impl From<FontColor> for Rgb<u8> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<FontColor> for Rgba<u8> {
|
||||||
|
fn from(val: FontColor) -> Self {
|
||||||
|
Rgba([val.0[0], val.0[1], val.0[2], 255])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Serialize for FontColor {
|
impl Serialize for FontColor {
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
|
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
|
||||||
|
|
||||||
use crate::dummy_serialport::DummySerialPort;
|
use crate::dummy_serialport::DummySerialPort;
|
||||||
use crate::img::rgb888_to_565;
|
use crate::img::ToRgb565;
|
||||||
use anyhow::{Context, anyhow};
|
use anyhow::{Context, anyhow};
|
||||||
use bytes::{BufMut, BytesMut};
|
use bytes::{BufMut, BytesMut};
|
||||||
use image::RgbImage;
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use serialport::{SerialPort, SerialPortType};
|
use serialport::{SerialPort, SerialPortType};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
@@ -178,8 +177,8 @@ impl AooScreen {
|
|||||||
.with_context(|| "Failed to send display off")
|
.with_context(|| "Failed to send display off")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_image(&mut self, rgb_img: &RgbImage) -> anyhow::Result<()> {
|
pub fn send_image(&mut self, image: impl ToRgb565) -> anyhow::Result<()> {
|
||||||
let img_rgb565 = rgb888_to_565(rgb_img)?;
|
let img_rgb565 = image.to_rgb565_le();
|
||||||
debug!(
|
debug!(
|
||||||
"Start sending image (size {}) {} cache... ",
|
"Start sending image (size {}) {} cache... ",
|
||||||
img_rgb565.len(),
|
img_rgb565.len(),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||||
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
|
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
|
||||||
|
|
||||||
|
//! Font handling and caching.
|
||||||
|
|
||||||
use ab_glyph::{FontArc, FontRef, FontVec};
|
use ab_glyph::{FontArc, FontRef, FontVec};
|
||||||
use anyhow::{Context, anyhow};
|
use anyhow::{Context, anyhow};
|
||||||
use log::warn;
|
use log::warn;
|
||||||
@@ -61,4 +63,9 @@ impl FontHandler {
|
|||||||
|
|
||||||
Ok(font)
|
Ok(font)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.ttf_cache.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||||
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
|
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
|
||||||
|
|
||||||
|
//! Image helper functions.
|
||||||
|
|
||||||
use bytes::{BufMut, BytesMut};
|
use bytes::{BufMut, BytesMut};
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use image::{GenericImageView, ImageReader, RgbImage};
|
use image::{DynamicImage, GenericImageView, ImageBuffer, ImageReader, RgbImage, Rgba, RgbaImage};
|
||||||
|
use imageproc::geometric_transformations::{Interpolation, rotate};
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
use std::path::Path;
|
use std::collections::HashMap;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub fn load_image<P>(path: P, size: (u32, u32)) -> anyhow::Result<RgbImage>
|
/// Width, height type
|
||||||
|
pub type Size = (u32, u32);
|
||||||
|
|
||||||
|
pub fn load_image<P>(path: P, size: Option<Size>) -> anyhow::Result<DynamicImage>
|
||||||
where
|
where
|
||||||
P: AsRef<Path>,
|
P: AsRef<Path>,
|
||||||
{
|
{
|
||||||
@@ -18,30 +26,190 @@ where
|
|||||||
img.color()
|
img.color()
|
||||||
);
|
);
|
||||||
|
|
||||||
if img.dimensions() != size {
|
if let Some(size) = size
|
||||||
|
&& img.dimensions() != size
|
||||||
|
{
|
||||||
warn!(
|
warn!(
|
||||||
"Resizing invalid image dimensions {:?} to expected size {:?}, ignoring aspect ratio",
|
"Resizing invalid image dimensions {:?} to expected size {:?}, ignoring aspect ratio",
|
||||||
img.dimensions(),
|
img.dimensions(),
|
||||||
size
|
size
|
||||||
);
|
);
|
||||||
Ok(img
|
Ok(img.resize_exact(size.0, size.1, FilterType::Lanczos3))
|
||||||
.resize_exact(size.0, size.1, FilterType::Lanczos3)
|
|
||||||
.to_rgb8())
|
|
||||||
} else {
|
} else {
|
||||||
Ok(img.to_rgb8())
|
Ok(img)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rgb888_to_565(rgb_img: &RgbImage) -> anyhow::Result<BytesMut> {
|
/// Trait definition to get a RGB 565 representation from a source image.
|
||||||
|
pub trait ToRgb565 {
|
||||||
|
/// Get an RGB 565 representation of the image in little endian format.
|
||||||
|
fn to_rgb565_le(&self) -> BytesMut;
|
||||||
|
|
||||||
|
/// Convert a single RGB 888 pixel to 16 bit RGB 565 format.
|
||||||
|
fn convert_rgb(&self, r: u8, g: u8, b: u8) -> u16 {
|
||||||
|
((r & 248) as u16) << 8 | ((g & 252) as u16) << 3 | ((b as u16) >> 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO quick & dirty approach for converting RgbImage & RgbaImage to RGB 565.
|
||||||
|
// There should be a more generic way, maybe with PixelEnumerator...
|
||||||
|
impl ToRgb565 for &RgbImage {
|
||||||
|
fn to_rgb565_le(&self) -> BytesMut {
|
||||||
let mut img_rgb565 =
|
let mut img_rgb565 =
|
||||||
BytesMut::with_capacity(rgb_img.width() as usize * rgb_img.height() as usize * 2);
|
BytesMut::with_capacity(self.width() as usize * self.height() as usize * 2);
|
||||||
|
|
||||||
for (_x, _y, pixel) in rgb_img.enumerate_pixels() {
|
for (_x, _y, pixel) in self.enumerate_pixels() {
|
||||||
img_rgb565.put_u16_le(
|
img_rgb565.put_u16_le(self.convert_rgb(pixel.0[0], pixel.0[1], pixel.0[2]));
|
||||||
((pixel.0[0] & 248) as u16) << 8
|
|
||||||
| ((pixel.0[1] & 252) as u16) << 3
|
|
||||||
| ((pixel.0[2] as u16) >> 3),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(img_rgb565)
|
|
||||||
|
img_rgb565
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToRgb565 for &RgbaImage {
|
||||||
|
fn to_rgb565_le(&self) -> BytesMut {
|
||||||
|
let mut img_rgb565 =
|
||||||
|
BytesMut::with_capacity(self.width() as usize * self.height() as usize * 2);
|
||||||
|
|
||||||
|
for (_x, _y, pixel) in self.enumerate_pixels() {
|
||||||
|
img_rgb565.put_u16_le(self.convert_rgb(pixel.0[0], pixel.0[1], pixel.0[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
img_rgb565
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache for loaded images to avoid repeated file I/O
|
||||||
|
pub struct ImageCache {
|
||||||
|
img_path: PathBuf,
|
||||||
|
cache: HashMap<PathBuf, Option<RgbaImage>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCache {
|
||||||
|
pub fn new(img_path: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
img_path: img_path.into(),
|
||||||
|
cache: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and cache an image, returns None if loading fails
|
||||||
|
pub fn get<P: AsRef<Path>>(&mut self, path: P, size: Option<Size>) -> Option<&RgbaImage> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let path = if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
self.img_path.join(path)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.cache.contains_key(&path) {
|
||||||
|
let image_result = match load_image(&path, size) {
|
||||||
|
Ok(img) => Some(img.to_rgba8()),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to load image {:?}: {:?}", path, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.cache.insert(path.clone(), image_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache.get(&path).and_then(|opt| opt.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quality settings for rotation
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum RotationQuality {
|
||||||
|
/// Nearest neighbor
|
||||||
|
Fast,
|
||||||
|
/// Bilinear
|
||||||
|
Good,
|
||||||
|
/// Bicubic
|
||||||
|
Best,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate image by specified angle in degrees
|
||||||
|
pub fn rotate_image(image: &RgbaImage, angle_degrees: i32) -> RgbaImage {
|
||||||
|
match angle_degrees {
|
||||||
|
0 => image.clone(),
|
||||||
|
90 => rotate_90_degrees(image, true),
|
||||||
|
270 => rotate_90_degrees(image, false),
|
||||||
|
180 => rotate_180_degrees(image),
|
||||||
|
angle => {
|
||||||
|
let angle_radians = angle as f32 * PI / 180.0;
|
||||||
|
// TODO check Bilinear vs Bicubic
|
||||||
|
rotate_about_center(image, angle_radians, RotationQuality::Good)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate image about its center, maintaining original dimensions
|
||||||
|
fn rotate_about_center(
|
||||||
|
image: &RgbaImage,
|
||||||
|
angle_radians: f32,
|
||||||
|
interpolation: RotationQuality,
|
||||||
|
) -> RgbaImage {
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
|
let center_x = width as f32 / 2.0;
|
||||||
|
let center_y = height as f32 / 2.0;
|
||||||
|
|
||||||
|
let interp_method = match interpolation {
|
||||||
|
RotationQuality::Fast => Interpolation::Nearest,
|
||||||
|
RotationQuality::Good => Interpolation::Bilinear,
|
||||||
|
RotationQuality::Best => Interpolation::Bicubic,
|
||||||
|
};
|
||||||
|
|
||||||
|
rotate(
|
||||||
|
image,
|
||||||
|
(center_x, center_y),
|
||||||
|
angle_radians,
|
||||||
|
interp_method,
|
||||||
|
Rgba([0, 0, 0, 0]), // Transparent background for areas outside original image
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast 90-degree rotations (optimized for common cases)
|
||||||
|
pub fn rotate_90_degrees(image: &RgbaImage, clockwise: bool) -> RgbaImage {
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
|
let mut rotated = ImageBuffer::new(height, width); // Swap dimensions
|
||||||
|
|
||||||
|
if clockwise {
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let pixel = *image.get_pixel(x, y);
|
||||||
|
rotated.put_pixel(height - 1 - y, x, pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let pixel = *image.get_pixel(x, y);
|
||||||
|
rotated.put_pixel(y, width - 1 - x, pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate by 180 degrees (optimized)
|
||||||
|
pub fn rotate_180_degrees(image: &RgbaImage) -> RgbaImage {
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
|
let mut rotated = ImageBuffer::new(width, height);
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let pixel = *image.get_pixel(x, y);
|
||||||
|
rotated.put_pixel(width - 1 - x, height - 1 - y, pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ mod dummy_serialport;
|
|||||||
mod font;
|
mod font;
|
||||||
mod format_value;
|
mod format_value;
|
||||||
mod img;
|
mod img;
|
||||||
|
mod render;
|
||||||
mod sensors;
|
mod sensors;
|
||||||
|
|
||||||
use crate::cfg::{MonitorConfig, Panel, SensorMode, TextAlign};
|
use crate::cfg::{MonitorConfig, Panel, load_custom_panel};
|
||||||
use crate::display::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
use crate::display::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
||||||
use crate::font::FontHandler;
|
use crate::font::FontHandler;
|
||||||
use crate::format_value::format_value;
|
use crate::render::PanelRenderer;
|
||||||
use crate::sensors::start_file_slurper;
|
use crate::sensors::start_file_slurper;
|
||||||
use ab_glyph::{Font, PxScale};
|
use ab_glyph::PxScale;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use env_logger::Env;
|
use env_logger::Env;
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use image::{ImageReader, Rgb, RgbImage};
|
use image::{ImageReader, Rgb, RgbImage};
|
||||||
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut, text_size};
|
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut};
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -62,6 +63,12 @@ struct Args {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
config: Option<PathBuf>,
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Include one or more additional custom panels into the base configuration.
|
||||||
|
///
|
||||||
|
/// Specify the path to the panel directory containing panel.json and fonts / img subdirectories.
|
||||||
|
#[arg(short, long)]
|
||||||
|
panels: Option<Vec<PathBuf>>,
|
||||||
|
|
||||||
/// Configuration directory containing configuration files and background images
|
/// Configuration directory containing configuration files and background images
|
||||||
/// specified in the `config` file. Default: `./cfg`
|
/// specified in the `config` file. Default: `./cfg`
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -140,7 +147,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cfg_dir = args.config_dir.unwrap_or_else(|| "cfg".into());
|
let cfg_dir = args.config_dir.unwrap_or_else(|| "cfg".into());
|
||||||
let cfg = load_configuration(&config, &cfg_dir)?;
|
let cfg = load_configuration(&config, &cfg_dir, args.panels)?;
|
||||||
run_sensor_panel(
|
run_sensor_panel(
|
||||||
&mut screen,
|
&mut screen,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -154,7 +161,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
if let Some(image) = args.image {
|
if let Some(image) = args.image {
|
||||||
info!("Loading and displaying background image {image}...");
|
info!("Loading and displaying background image {image}...");
|
||||||
let rgb_img = img::load_image(&image, DISPLAY_SIZE)?;
|
let rgb_img = img::load_image(&image, Some(DISPLAY_SIZE))?.to_rgb8();
|
||||||
let timestamp = Instant::now();
|
let timestamp = Instant::now();
|
||||||
screen.send_image(&rgb_img)?;
|
screen.send_image(&rgb_img)?;
|
||||||
debug!("Image sent in {}ms", timestamp.elapsed().as_millis());
|
debug!("Image sent in {}ms", timestamp.elapsed().as_millis());
|
||||||
@@ -182,31 +189,50 @@ fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_configuration<P: AsRef<Path>>(config: P, config_dir: P) -> anyhow::Result<MonitorConfig> {
|
fn load_configuration<P: AsRef<Path>>(
|
||||||
|
config: P,
|
||||||
|
config_dir: P,
|
||||||
|
panels: Option<Vec<PathBuf>>,
|
||||||
|
) -> anyhow::Result<MonitorConfig> {
|
||||||
let config = config.as_ref();
|
let config = config.as_ref();
|
||||||
let config_dir = config_dir.as_ref();
|
let config_dir = config_dir.as_ref();
|
||||||
|
|
||||||
if config.is_absolute() {
|
let mut cfg = if config.is_absolute() {
|
||||||
cfg::load_cfg(config)
|
cfg::load_cfg(config)?
|
||||||
} else {
|
} else {
|
||||||
cfg::load_cfg(config_dir.join(config))
|
cfg::load_cfg(config_dir.join(config))?
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(panels) = panels {
|
||||||
|
for panel in panels {
|
||||||
|
cfg.include_custom_panel(load_custom_panel(panel)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_sensor_panel<P: AsRef<Path>, B: Into<PathBuf>>(
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_sensor_panel<B: Into<PathBuf>>(
|
||||||
screen: &mut AooScreen,
|
screen: &mut AooScreen,
|
||||||
mut cfg: MonitorConfig,
|
mut cfg: MonitorConfig,
|
||||||
config_dir: B,
|
config_dir: B,
|
||||||
font_dir: B,
|
font_dir: B,
|
||||||
sensor_path: B,
|
sensor_path: B,
|
||||||
img_save_path: Option<P>,
|
img_save_path: Option<B>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
let font_dir = font_dir.into();
|
||||||
let config_dir = config_dir.into();
|
let config_dir = config_dir.into();
|
||||||
let sensor_values: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new()));
|
let img_save_path = img_save_path.map(|p| p.into());
|
||||||
let mut fh = FontHandler::new(font_dir);
|
|
||||||
|
|
||||||
let mut rgb_img;
|
let mut renderer = PanelRenderer::new(DISPLAY_SIZE, &font_dir, &config_dir);
|
||||||
let mut save_img_name;
|
if let Some(img_save_path) = &img_save_path {
|
||||||
|
renderer.set_img_save_path(img_save_path);
|
||||||
|
renderer.set_save_render_img(true);
|
||||||
|
// renderer.set_save_processed_pic(true);
|
||||||
|
// renderer.set_save_progress_layer(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sensor_values: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
start_file_slurper(sensor_path, sensor_values.clone())?;
|
start_file_slurper(sensor_path, sensor_values.clone())?;
|
||||||
|
|
||||||
@@ -226,23 +252,6 @@ fn run_sensor_panel<P: AsRef<Path>, B: Into<PathBuf>>(
|
|||||||
.get_next_active_panel()
|
.get_next_active_panel()
|
||||||
.ok_or(anyhow!("No active panel"))?;
|
.ok_or(anyhow!("No active panel"))?;
|
||||||
|
|
||||||
if let Some(img_file) = &panel.img {
|
|
||||||
let img_file = PathBuf::from(img_file);
|
|
||||||
save_img_name = img_file
|
|
||||||
.file_stem()
|
|
||||||
.map(|s| s.to_string_lossy().to_string());
|
|
||||||
let file = if img_file.is_absolute() {
|
|
||||||
img_file
|
|
||||||
} else {
|
|
||||||
config_dir.join(img_file)
|
|
||||||
};
|
|
||||||
info!("Loading panel image {file:?}...");
|
|
||||||
rgb_img = img::load_image(&file, DISPLAY_SIZE)?;
|
|
||||||
} else {
|
|
||||||
save_img_name = None;
|
|
||||||
rgb_img = RgbImage::new(DISPLAY_SIZE.0, DISPLAY_SIZE.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let panel_switch_time = Instant::now();
|
let panel_switch_time = Instant::now();
|
||||||
|
|
||||||
// active panel refresh loop
|
// active panel refresh loop
|
||||||
@@ -250,24 +259,14 @@ fn run_sensor_panel<P: AsRef<Path>, B: Into<PathBuf>>(
|
|||||||
loop {
|
loop {
|
||||||
let upd_start_time = Instant::now();
|
let upd_start_time = Instant::now();
|
||||||
|
|
||||||
let out_filename = if let Some(save_path) = &img_save_path {
|
if img_save_path.is_some() {
|
||||||
let save_path = save_path.as_ref();
|
renderer.set_img_suffix(format!("-{refresh_count:02}"));
|
||||||
Some(save_path.join(format!(
|
}
|
||||||
"{}-{refresh_count:02}.png",
|
|
||||||
save_img_name.as_deref().unwrap_or("panel")
|
|
||||||
)))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
update_panel(
|
// Keeping the read lock during panel rendering should be ok, otherwise we could always clone the HashMap
|
||||||
screen,
|
let values = sensor_values.read().expect("RwLock is poisoned");
|
||||||
&rgb_img,
|
update_panel(screen, &mut renderer, panel, &values)?;
|
||||||
&mut fh,
|
drop(values);
|
||||||
panel,
|
|
||||||
sensor_values.clone(),
|
|
||||||
out_filename,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let elapsed = upd_start_time.elapsed();
|
let elapsed = upd_start_time.elapsed();
|
||||||
if refresh > elapsed {
|
if refresh > elapsed {
|
||||||
@@ -284,6 +283,28 @@ fn run_sensor_panel<P: AsRef<Path>, B: Into<PathBuf>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_panel(
|
||||||
|
screen: &mut AooScreen,
|
||||||
|
renderer: &mut PanelRenderer,
|
||||||
|
panel: &Panel,
|
||||||
|
values: &HashMap<String, String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
debug!(
|
||||||
|
"Displaying panel {}...",
|
||||||
|
panel
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| panel.id.as_deref().unwrap_or_default())
|
||||||
|
);
|
||||||
|
|
||||||
|
match renderer.render(panel, values) {
|
||||||
|
Ok(image) => screen.send_image(&image)?,
|
||||||
|
Err(e) => error!("Error rendering panel: {e:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn run_demo(
|
fn run_demo(
|
||||||
screen: &mut AooScreen,
|
screen: &mut AooScreen,
|
||||||
config: Option<&Path>,
|
config: Option<&Path>,
|
||||||
@@ -301,7 +322,7 @@ fn run_demo(
|
|||||||
demo_text(screen, &rgb_img, save_images)?;
|
demo_text(screen, &rgb_img, save_images)?;
|
||||||
|
|
||||||
if let Some(config) = config {
|
if let Some(config) = config {
|
||||||
let mut cfg = load_configuration(config, &config_dir)?;
|
let mut cfg = load_configuration(config, &config_dir, None)?;
|
||||||
|
|
||||||
if let Some(panel) = cfg.get_next_active_panel() {
|
if let Some(panel) = cfg.get_next_active_panel() {
|
||||||
info!("Displaying demo panel...");
|
info!("Displaying demo panel...");
|
||||||
@@ -315,22 +336,12 @@ fn run_demo(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut fh = FontHandler::new(font_dir);
|
let mut renderer = PanelRenderer::new(DISPLAY_SIZE, &font_dir, &config_dir);
|
||||||
let out_filename = if save_images {
|
renderer.set_save_render_img(save_images);
|
||||||
fs::create_dir_all("out")?;
|
renderer.set_save_processed_pic(save_images);
|
||||||
Some("out/demo_panel.png")
|
renderer.set_save_progress_layer(save_images);
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
update_panel(
|
update_panel(screen, &mut renderer, panel, &demo_values)?;
|
||||||
screen,
|
|
||||||
&rgb_img,
|
|
||||||
&mut fh,
|
|
||||||
panel,
|
|
||||||
Arc::new(RwLock::new(demo_values)),
|
|
||||||
out_filename,
|
|
||||||
)?;
|
|
||||||
} else {
|
} else {
|
||||||
error!("No active panel found");
|
error!("No active panel found");
|
||||||
}
|
}
|
||||||
@@ -417,13 +428,6 @@ fn demo_blinds(
|
|||||||
|
|
||||||
for y in 0..DISPLAY_SIZE.1 {
|
for y in 0..DISPLAY_SIZE.1 {
|
||||||
let color = *rgb_img.get_pixel(width + 1, y);
|
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(
|
draw_line_segment_mut(
|
||||||
&mut rgb_img,
|
&mut rgb_img,
|
||||||
(0.0, y as f32),
|
(0.0, y as f32),
|
||||||
@@ -431,13 +435,6 @@ fn demo_blinds(
|
|||||||
color,
|
color,
|
||||||
);
|
);
|
||||||
let color = *rgb_img.get_pixel(DISPLAY_SIZE.0 - width - 1, y);
|
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(
|
draw_line_segment_mut(
|
||||||
&mut rgb_img,
|
&mut rgb_img,
|
||||||
((DISPLAY_SIZE.0 - width) as f32, y as f32),
|
((DISPLAY_SIZE.0 - width) as f32, y as f32),
|
||||||
@@ -459,90 +456,3 @@ fn demo_blinds(
|
|||||||
|
|
||||||
Ok(rgb_img)
|
Ok(rgb_img)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_panel<P: AsRef<Path>>(
|
|
||||||
screen: &mut AooScreen,
|
|
||||||
background: &RgbImage,
|
|
||||||
fh: &mut FontHandler,
|
|
||||||
panel: &Panel,
|
|
||||||
values: Arc<RwLock<HashMap<String, String>>>,
|
|
||||||
img_save_path: Option<P>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
debug!(
|
|
||||||
"Displaying panel {}...",
|
|
||||||
panel
|
|
||||||
.name
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_else(|| panel.id.as_deref().unwrap_or_default())
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut rgb_img = background.clone();
|
|
||||||
|
|
||||||
for sensor in &panel.sensor {
|
|
||||||
if sensor.mode != SensorMode::Text {
|
|
||||||
debug!(
|
|
||||||
"Skipping sensor {}: unsupported sensor mode {:?}",
|
|
||||||
sensor.label, sensor.mode
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let values = values.read().expect("RwLock is poisoned");
|
|
||||||
let value = values.get(&sensor.label).cloned();
|
|
||||||
let unit = values
|
|
||||||
.get(&format!("{}#unit", sensor.label))
|
|
||||||
.cloned()
|
|
||||||
.or_else(|| sensor.unit.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
drop(values);
|
|
||||||
|
|
||||||
if let Some(value) = value {
|
|
||||||
let font = fh.get_ttf_font_or_default(&sensor.font_family);
|
|
||||||
// TODO verify pixel scaling! Is font_size point size or pixel size?
|
|
||||||
// This is still a bit off compared to the original AOOSTAR-X. Only tested with HarmonyOS_Sans_SC_Bold!
|
|
||||||
let adjustment_hack = 0.7;
|
|
||||||
let scale = font
|
|
||||||
.pt_to_px_scale(sensor.font_size as f32 * adjustment_hack)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let text = format_value(
|
|
||||||
&value,
|
|
||||||
sensor.integer_digits.into(),
|
|
||||||
sensor.decimal_digits.unwrap_or_default() as usize,
|
|
||||||
&unit,
|
|
||||||
);
|
|
||||||
let size = text_size(scale, &font, &text);
|
|
||||||
// TODO verify x & y-coordinate handling
|
|
||||||
let x = match sensor.text_align {
|
|
||||||
TextAlign::Left => sensor.x as i32,
|
|
||||||
TextAlign::Center => sensor.x as i32 - (size.0 / 2) as i32,
|
|
||||||
TextAlign::Right => sensor.x as i32 - size.0 as i32,
|
|
||||||
};
|
|
||||||
let y = (sensor.y - scale.y / 2f32) as i32;
|
|
||||||
// let y = sensor.y as i32 - (size.1 / 2) as i32;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Sensor({:03},{:03}), pixel({x:03},{y:03}), size{size:?}: {text}",
|
|
||||||
sensor.x, sensor.y
|
|
||||||
);
|
|
||||||
|
|
||||||
draw_text_mut(
|
|
||||||
&mut rgb_img,
|
|
||||||
sensor.font_color.into(),
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
scale,
|
|
||||||
&font,
|
|
||||||
&text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
screen.send_image(&rgb_img)?;
|
|
||||||
|
|
||||||
if let Some(path) = img_save_path {
|
|
||||||
rgb_img.save_with_format(path, image::ImageFormat::Png)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,726 @@
|
|||||||
|
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||||
|
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
|
||||||
|
|
||||||
|
//! Sensor panel rendering logic. Create an RGBa image from a panel configuration and sensor values.
|
||||||
|
|
||||||
|
use crate::cfg::{Panel, Sensor, SensorDirection, SensorMode, TextAlign};
|
||||||
|
use crate::font::FontHandler;
|
||||||
|
use crate::format_value::format_value;
|
||||||
|
use crate::img::{ImageCache, Size, rotate_image};
|
||||||
|
use ab_glyph::Font;
|
||||||
|
use image::{ImageBuffer, Rgba, RgbaImage};
|
||||||
|
use imageproc::drawing::{draw_text_mut, text_size};
|
||||||
|
use log::{debug, error};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Error type for image processing operations
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ImageProcessingError {
|
||||||
|
ImageLoadError(String),
|
||||||
|
InvalidMode(i32),
|
||||||
|
InvalidDirection(SensorDirection),
|
||||||
|
MathError(String),
|
||||||
|
IoError(std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for ImageProcessingError {
|
||||||
|
fn from(error: std::io::Error) -> Self {
|
||||||
|
ImageProcessingError::IoError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sensor panel renderer.
|
||||||
|
///
|
||||||
|
/// Renders a final display image from a sensor panel configuration and current sensor values.
|
||||||
|
/// All defined fonts and images of a sensor panel are cached after first use.
|
||||||
|
pub struct PanelRenderer {
|
||||||
|
size: Size,
|
||||||
|
composite_layer_map: HashMap<SensorMode, RgbaImage>,
|
||||||
|
font_handler: FontHandler,
|
||||||
|
image_cache: ImageCache,
|
||||||
|
// for debugging: save images for inspection
|
||||||
|
save_render_img: bool,
|
||||||
|
save_processed_pic: bool,
|
||||||
|
save_progress_layer: bool,
|
||||||
|
img_save_path: PathBuf,
|
||||||
|
img_suffix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelRenderer {
|
||||||
|
/// Create a new image processor instance for a given display size.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `size`: display size, used to render a panel image.
|
||||||
|
/// * `font_dir`: font directory to load TTF fonts specified in a sensor configuration.
|
||||||
|
/// * `img_dir`: image directory to load background and sensor images from.
|
||||||
|
///
|
||||||
|
/// returns: PanelRenderer
|
||||||
|
pub fn new(size: Size, font_dir: impl Into<PathBuf>, img_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
size,
|
||||||
|
composite_layer_map: HashMap::new(),
|
||||||
|
font_handler: FontHandler::new(font_dir),
|
||||||
|
image_cache: ImageCache::new(img_dir),
|
||||||
|
save_render_img: false,
|
||||||
|
save_processed_pic: false,
|
||||||
|
save_progress_layer: false,
|
||||||
|
img_save_path: PathBuf::from("out"),
|
||||||
|
img_suffix: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For debugging: save rendered panel image as .PNG graphic for inspection.
|
||||||
|
pub fn set_save_render_img(&mut self, save: bool) {
|
||||||
|
self.save_render_img = save;
|
||||||
|
self.create_img_save_path();
|
||||||
|
}
|
||||||
|
/// For debugging: save all processed sensor pic images as .PNG graphics for inspection.
|
||||||
|
pub fn set_save_processed_pic(&mut self, save: bool) {
|
||||||
|
self.save_processed_pic = save;
|
||||||
|
self.create_img_save_path();
|
||||||
|
}
|
||||||
|
/// For debugging: save all progress layer images as .PNG graphics for inspection.
|
||||||
|
pub fn set_save_progress_layer(&mut self, save: bool) {
|
||||||
|
self.save_progress_layer = save;
|
||||||
|
self.create_img_save_path();
|
||||||
|
}
|
||||||
|
/// Set output directory path for saving images.
|
||||||
|
///
|
||||||
|
/// Default output directory is `./out` in the current working directory.
|
||||||
|
pub fn set_img_save_path(&mut self, img_dir: impl Into<PathBuf>) {
|
||||||
|
self.img_save_path = img_dir.into();
|
||||||
|
self.create_img_save_path();
|
||||||
|
}
|
||||||
|
/// Set an optional image name suffix for saving a .PNG graphic file.
|
||||||
|
///
|
||||||
|
/// This function needs to be called before [render()] if a different suffix should be used for each rendered panel.
|
||||||
|
pub fn set_img_suffix(&mut self, img_suffix: impl Into<String>) {
|
||||||
|
self.img_suffix = Some(img_suffix.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a sensor panel with the given values and return the final panel image.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `panel`: the panel configuration
|
||||||
|
/// * `values`: current values for the defined panel sensors in a shared HashMap
|
||||||
|
///
|
||||||
|
/// returns: a rendered panel image in [RgbaImage] format, or an [ImageProcessingError] in case of an error.
|
||||||
|
pub fn render(
|
||||||
|
&mut self,
|
||||||
|
panel: &Panel,
|
||||||
|
values: &HashMap<String, String>,
|
||||||
|
) -> Result<RgbaImage, ImageProcessingError> {
|
||||||
|
debug!(
|
||||||
|
"Rendering panel {}...",
|
||||||
|
panel
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| panel.id.as_deref().unwrap_or_default())
|
||||||
|
);
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let background = if let Some(img) = &panel.img
|
||||||
|
&& let Some(background) = self.image_cache.get(img, Some(self.size))
|
||||||
|
{
|
||||||
|
background.clone()
|
||||||
|
} else {
|
||||||
|
RgbaImage::new(self.size.0, self.size.1)
|
||||||
|
};
|
||||||
|
self.composite_layer_map.clear();
|
||||||
|
|
||||||
|
let final_image = self.render_all_sensors(panel, values, background)?;
|
||||||
|
|
||||||
|
debug!("Rendered panel in {}ms", now.elapsed().as_millis());
|
||||||
|
|
||||||
|
if self.save_render_img {
|
||||||
|
let name = format!(
|
||||||
|
"render_{}{}.png",
|
||||||
|
panel.friendly_name(),
|
||||||
|
self.img_suffix.as_deref().unwrap_or_default()
|
||||||
|
);
|
||||||
|
if let Err(e) = final_image.save(self.img_save_path.join(name)) {
|
||||||
|
error!("Error saving rendered panel image: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(final_image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render all panel sensors with the given values on a background image
|
||||||
|
pub fn render_all_sensors(
|
||||||
|
&mut self,
|
||||||
|
panel: &Panel,
|
||||||
|
values: &HashMap<String, String>,
|
||||||
|
mut background: RgbaImage,
|
||||||
|
) -> Result<RgbaImage, ImageProcessingError> {
|
||||||
|
for sensor in &panel.sensor {
|
||||||
|
let value = values.get(&sensor.label).cloned();
|
||||||
|
let unit = values
|
||||||
|
.get(&format!("{}#unit", sensor.label))
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| sensor.unit.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(value) = value {
|
||||||
|
self.render_sensor(&mut background, sensor, &value, &unit)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final compositing
|
||||||
|
self.composite_layers(&mut background);
|
||||||
|
|
||||||
|
Ok(background)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a single sensor element based on its mode
|
||||||
|
fn render_sensor(
|
||||||
|
&mut self,
|
||||||
|
background: &mut RgbaImage,
|
||||||
|
sensor: &Sensor,
|
||||||
|
value: &str,
|
||||||
|
unit: &str,
|
||||||
|
) -> Result<(), ImageProcessingError> {
|
||||||
|
let direction = sensor.direction.unwrap_or(SensorDirection::LeftToRight);
|
||||||
|
|
||||||
|
match sensor.mode {
|
||||||
|
SensorMode::Text => self.render_text(background, sensor, value, unit),
|
||||||
|
SensorMode::Fan => self.render_fan(sensor, value, direction),
|
||||||
|
SensorMode::Progress => self.render_progress(sensor, value, direction),
|
||||||
|
SensorMode::Pointer => self.render_pointer(sensor, value, direction),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode 1 - Text
|
||||||
|
fn render_text(
|
||||||
|
&mut self,
|
||||||
|
background: &mut RgbaImage,
|
||||||
|
sensor: &Sensor,
|
||||||
|
value: &str,
|
||||||
|
unit: &str,
|
||||||
|
) -> Result<(), ImageProcessingError> {
|
||||||
|
let font = self
|
||||||
|
.font_handler
|
||||||
|
.get_ttf_font_or_default(&sensor.font_family);
|
||||||
|
// TODO verify pixel scaling! Is font_size point size or pixel size?
|
||||||
|
// This is still a bit off compared to the original AOOSTAR-X. Only tested with HarmonyOS_Sans_SC_Bold!
|
||||||
|
let adjustment_hack = 0.7;
|
||||||
|
let scale = font
|
||||||
|
.pt_to_px_scale(sensor.font_size as f32 * adjustment_hack)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let text = format_value(
|
||||||
|
value,
|
||||||
|
sensor.integer_digits.into(),
|
||||||
|
sensor.decimal_digits.unwrap_or_default() as usize,
|
||||||
|
unit,
|
||||||
|
);
|
||||||
|
let size = text_size(scale, &font, &text);
|
||||||
|
// TODO verify x & y-coordinate handling
|
||||||
|
let x = match sensor.text_align {
|
||||||
|
TextAlign::Left => sensor.x as i32,
|
||||||
|
TextAlign::Center => sensor.x as i32 - (size.0 / 2) as i32,
|
||||||
|
TextAlign::Right => sensor.x as i32 - size.0 as i32,
|
||||||
|
};
|
||||||
|
let y = (sensor.y - scale.y / 2f32) as i32;
|
||||||
|
// let y = sensor.y as i32 - (size.1 / 2) as i32;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Sensor({:03},{:03}), pixel({x:03},{y:03}), size{size:?}: {text}",
|
||||||
|
sensor.x, sensor.y
|
||||||
|
);
|
||||||
|
|
||||||
|
let font_color = sensor.font_color.into();
|
||||||
|
draw_text_mut(background, font_color, x, y, scale, &font, &text);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode 2 - Circular/Arc progress indicator
|
||||||
|
/// TODO needs testing
|
||||||
|
fn render_fan(
|
||||||
|
&mut self,
|
||||||
|
sensor: &Sensor,
|
||||||
|
value: &str,
|
||||||
|
direction: SensorDirection,
|
||||||
|
) -> Result<(), ImageProcessingError> {
|
||||||
|
if !matches!(
|
||||||
|
direction,
|
||||||
|
SensorDirection::LeftToRight | SensorDirection::RightToLeft
|
||||||
|
) {
|
||||||
|
return Err(ImageProcessingError::InvalidDirection(direction));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos_x = sensor.x as i32;
|
||||||
|
let pos_y = sensor.y as i32;
|
||||||
|
|
||||||
|
let pic_path = sensor.pic.as_ref().ok_or_else(|| {
|
||||||
|
ImageProcessingError::ImageLoadError("No picture specified".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let target_image = self
|
||||||
|
.image_cache
|
||||||
|
.get(pic_path, None)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ImageProcessingError::ImageLoadError(format!("Failed to load: {:?}", pic_path))
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let min_angle = sensor.min_angle.unwrap_or(0) as f32;
|
||||||
|
let max_angle = sensor.max_angle.unwrap_or(180) as f32;
|
||||||
|
let min_value = sensor.min_value.unwrap_or(0.0);
|
||||||
|
let max_value = sensor.max_value.unwrap_or(100.0);
|
||||||
|
|
||||||
|
let current_value = value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| ImageProcessingError::MathError("Invalid value".to_string()))?;
|
||||||
|
|
||||||
|
if current_value <= min_value {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let progress = if current_value >= max_value {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(current_value - min_value) / (max_value - min_value)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (start_angle, end_angle) = if direction == SensorDirection::LeftToRight {
|
||||||
|
// Clockwise
|
||||||
|
let start = min_angle - 90.0;
|
||||||
|
let end = min_angle + (max_angle - min_angle) * progress - 90.0;
|
||||||
|
(start, end)
|
||||||
|
} else {
|
||||||
|
// Counter-clockwise
|
||||||
|
// FIXME SensorDirection::RightToLeft does not yet work. Might also be related to certain minAngle / maxAngle values
|
||||||
|
let start = 360.0 - min_angle - (max_angle - min_angle) * progress - 90.0;
|
||||||
|
let end = 360.0 - min_angle - 90.0;
|
||||||
|
(start, end)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(sector_layer) = self.get_layer(SensorMode::Fan) {
|
||||||
|
PanelRenderer::draw_pie_slice(
|
||||||
|
sector_layer,
|
||||||
|
&target_image,
|
||||||
|
pos_x,
|
||||||
|
pos_y,
|
||||||
|
start_angle,
|
||||||
|
end_angle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode 3 - render progress graphic based on percentage value.
|
||||||
|
///
|
||||||
|
/// The progress graphic must show the 100% value and is cut based on the actual value.
|
||||||
|
fn render_progress(
|
||||||
|
&mut self,
|
||||||
|
sensor: &Sensor,
|
||||||
|
value: &str,
|
||||||
|
direction: SensorDirection,
|
||||||
|
) -> Result<(), ImageProcessingError> {
|
||||||
|
let pic_path = sensor.pic.as_ref().ok_or_else(|| {
|
||||||
|
ImageProcessingError::ImageLoadError("No picture specified".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut processed_img = self
|
||||||
|
.image_cache
|
||||||
|
.get(pic_path, None)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ImageProcessingError::ImageLoadError(format!("Failed to load: {:?}", pic_path))
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let min_val = sensor.min_value.unwrap_or(0.0);
|
||||||
|
let max_val = sensor.max_value.unwrap_or(100.0);
|
||||||
|
|
||||||
|
let current_value = value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| ImageProcessingError::MathError("Invalid value".to_string()))?;
|
||||||
|
|
||||||
|
let clamped_value = current_value.clamp(min_val, max_val);
|
||||||
|
let progress = ((clamped_value - min_val) / (max_val - min_val)).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
let (img_w, img_h) = processed_img.dimensions();
|
||||||
|
|
||||||
|
// Create progress mask based on direction
|
||||||
|
let crop_rect = match direction {
|
||||||
|
SensorDirection::LeftToRight => {
|
||||||
|
let crop_w = (img_w as f32 * progress).round() as u32;
|
||||||
|
(0, 0, crop_w, img_h)
|
||||||
|
}
|
||||||
|
SensorDirection::RightToLeft => {
|
||||||
|
let crop_w = (img_w as f32 * progress).round() as u32;
|
||||||
|
(img_w - crop_w, 0, img_w, img_h)
|
||||||
|
}
|
||||||
|
SensorDirection::TopToBottom => {
|
||||||
|
let crop_h = (img_h as f32 * progress).round() as u32;
|
||||||
|
(0, 0, img_w, crop_h)
|
||||||
|
}
|
||||||
|
SensorDirection::BottomToTop => {
|
||||||
|
let crop_h = (img_h as f32 * progress).round() as u32;
|
||||||
|
(0, img_h - crop_h, img_w, img_h)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply crop mask to image
|
||||||
|
self.apply_progress_mask(&mut processed_img, crop_rect, direction);
|
||||||
|
|
||||||
|
if self.save_processed_pic {
|
||||||
|
let name = format!(
|
||||||
|
"processed_img-{}{}.png",
|
||||||
|
sensor.label,
|
||||||
|
self.img_suffix.as_deref().unwrap_or_default()
|
||||||
|
);
|
||||||
|
if let Err(e) = processed_img.save(self.img_save_path.join(name)) {
|
||||||
|
error!("Error saving processed image: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos_x = sensor.x as i32;
|
||||||
|
let pos_y = sensor.y as i32;
|
||||||
|
|
||||||
|
if let Some(progress_layer) = self.get_layer(SensorMode::Progress) {
|
||||||
|
PanelRenderer::paste_image(progress_layer, &processed_img, pos_x, pos_y);
|
||||||
|
|
||||||
|
if self.save_progress_layer {
|
||||||
|
let name = format!(
|
||||||
|
"progress_layer-{}{}.png",
|
||||||
|
sensor.label,
|
||||||
|
self.img_suffix.as_deref().unwrap_or_default()
|
||||||
|
);
|
||||||
|
if let Err(e) = processed_img.save(self.img_save_path.join(name)) {
|
||||||
|
error!("Error saving progress layer image: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode 4 - Rotating pointer/dial indicator
|
||||||
|
/// TODO needs testing
|
||||||
|
fn render_pointer(
|
||||||
|
&mut self,
|
||||||
|
sensor: &Sensor,
|
||||||
|
value: &str,
|
||||||
|
direction: SensorDirection,
|
||||||
|
) -> Result<(), ImageProcessingError> {
|
||||||
|
if !matches!(
|
||||||
|
direction,
|
||||||
|
SensorDirection::LeftToRight | SensorDirection::RightToLeft
|
||||||
|
) {
|
||||||
|
return Err(ImageProcessingError::InvalidDirection(direction));
|
||||||
|
}
|
||||||
|
|
||||||
|
let x_center = sensor.x as i32;
|
||||||
|
let y_center = sensor.y as i32;
|
||||||
|
let xz_x = sensor.xz_x.unwrap_or(0);
|
||||||
|
let xz_y = sensor.xz_y.unwrap_or(0);
|
||||||
|
|
||||||
|
let pic_path = sensor.pic.as_ref().ok_or_else(|| {
|
||||||
|
ImageProcessingError::ImageLoadError("No picture specified".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut pic = self
|
||||||
|
.image_cache
|
||||||
|
.get(pic_path, None)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ImageProcessingError::ImageLoadError(format!("Failed to load: {:?}", pic_path))
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Resize if dimensions specified
|
||||||
|
if let (Some(width), Some(height)) = (sensor.width, sensor.height) {
|
||||||
|
let item_width = width as u32;
|
||||||
|
let item_height = height as u32;
|
||||||
|
pic = image::imageops::resize(
|
||||||
|
&pic,
|
||||||
|
item_width,
|
||||||
|
item_height,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_val = sensor.min_value.unwrap_or(0.0);
|
||||||
|
let max_val = sensor.max_value.unwrap_or(100.0);
|
||||||
|
let current_value = value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| ImageProcessingError::MathError("Invalid value".to_string()))?;
|
||||||
|
|
||||||
|
let clamped_value = current_value.clamp(min_val, max_val);
|
||||||
|
|
||||||
|
// Calculate progress
|
||||||
|
let progress = if (max_val - min_val).abs() < f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(clamped_value - min_val) / (max_val - min_val)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut min_angle = sensor.min_angle.unwrap_or(0) as f32;
|
||||||
|
let mut max_angle = sensor.max_angle.unwrap_or(360) as f32;
|
||||||
|
|
||||||
|
// Adjust angles for counter-clockwise
|
||||||
|
if direction == SensorDirection::RightToLeft {
|
||||||
|
min_angle = -min_angle;
|
||||||
|
max_angle = -max_angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
let angle = min_angle + progress * (max_angle - min_angle);
|
||||||
|
let angle_rad = angle.to_radians();
|
||||||
|
|
||||||
|
// Calculate offset based on rotation
|
||||||
|
let offset_x = (xz_x as f32 * angle_rad.cos() - xz_y as f32 * angle_rad.sin()) as i32;
|
||||||
|
let offset_y = (xz_x as f32 * angle_rad.sin() + xz_y as f32 * angle_rad.cos()) as i32;
|
||||||
|
|
||||||
|
// Rotate the image
|
||||||
|
let angle = angle.round() as i32;
|
||||||
|
let rotated_pic = rotate_image(&pic, -angle);
|
||||||
|
|
||||||
|
// Calculate final position
|
||||||
|
let final_x = x_center + offset_x - (rotated_pic.width() / 2) as i32;
|
||||||
|
let final_y = y_center + offset_y - (rotated_pic.height() / 2) as i32;
|
||||||
|
|
||||||
|
if let Some(pointer_layer) = self.get_layer(SensorMode::Pointer) {
|
||||||
|
PanelRenderer::paste_image(pointer_layer, &rotated_pic, final_x, final_y);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a pie‐slice sector of `source` into `layer`, centered at (center_x, center_y),
|
||||||
|
/// from `start_deg` to `end_deg` (both in degrees), blending with alpha.
|
||||||
|
fn draw_pie_slice(
|
||||||
|
layer: &mut RgbaImage,
|
||||||
|
source: &RgbaImage,
|
||||||
|
center_x: i32,
|
||||||
|
center_y: i32,
|
||||||
|
start_deg: f32,
|
||||||
|
end_deg: f32,
|
||||||
|
) {
|
||||||
|
let (src_w, src_h) = source.dimensions();
|
||||||
|
// Radius is half the smaller dimension
|
||||||
|
let radius = (src_w.min(src_h) as f32) / 2.0;
|
||||||
|
// Convert angles to radians and normalize
|
||||||
|
let start = start_deg.to_radians();
|
||||||
|
let end = end_deg.to_radians();
|
||||||
|
// Helper: check if angle t is between start and end (clockwise)
|
||||||
|
let in_sector = |t: f32| {
|
||||||
|
let mut a = t;
|
||||||
|
if a < 0.0 {
|
||||||
|
a += 2.0 * PI;
|
||||||
|
}
|
||||||
|
let mut s = start;
|
||||||
|
let mut e = end;
|
||||||
|
if s < 0.0 {
|
||||||
|
s += 2.0 * PI;
|
||||||
|
}
|
||||||
|
if e < 0.0 {
|
||||||
|
e += 2.0 * PI;
|
||||||
|
}
|
||||||
|
if e < s {
|
||||||
|
// wrap
|
||||||
|
a >= s || a <= e
|
||||||
|
} else {
|
||||||
|
a >= s && a <= e
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for sy in 0..src_h {
|
||||||
|
for sx in 0..src_w {
|
||||||
|
// Coordinates relative to center of source
|
||||||
|
let dx = sx as f32 - src_w as f32 / 2.0;
|
||||||
|
let dy = sy as f32 - src_h as f32 / 2.0;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt();
|
||||||
|
if dist <= radius {
|
||||||
|
// Polar angle (atan2 returns [-PI, PI], 0 at +x axis)
|
||||||
|
let angle = dy.atan2(dx);
|
||||||
|
if in_sector(angle) {
|
||||||
|
// Pixel is inside the slice: blend it into layer
|
||||||
|
let dest_x = center_x + sx as i32 - src_w as i32 / 2;
|
||||||
|
let dest_y = center_y + sy as i32 - src_h as i32 / 2;
|
||||||
|
if dest_x >= 0 && dest_y >= 0 {
|
||||||
|
let (lw, lh) = layer.dimensions();
|
||||||
|
if (dest_x as u32) < lw && (dest_y as u32) < lh {
|
||||||
|
let src_px = source.get_pixel(sx, sy);
|
||||||
|
let dst_px = layer.get_pixel_mut(dest_x as u32, dest_y as u32);
|
||||||
|
// alpha‐blend: out = src.a*src + (1−src.a)*dst
|
||||||
|
let alpha = src_px[3] as f32 / 255.0;
|
||||||
|
for i in 0..3 {
|
||||||
|
dst_px[i] = ((src_px[i] as f32 * alpha)
|
||||||
|
+ (dst_px[i] as f32 * (1.0 - alpha)))
|
||||||
|
.round()
|
||||||
|
as u8;
|
||||||
|
}
|
||||||
|
for i in 0..4 {
|
||||||
|
dst_px[i] = ((src_px[i] as f32 * alpha)
|
||||||
|
+ (dst_px[i] as f32 * (1.0 - alpha)))
|
||||||
|
.round()
|
||||||
|
as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply progress mask to image based on crop rectangle and direction
|
||||||
|
fn apply_progress_mask(
|
||||||
|
&self,
|
||||||
|
image: &mut RgbaImage,
|
||||||
|
crop_rect: (u32, u32, u32, u32),
|
||||||
|
direction: SensorDirection,
|
||||||
|
) {
|
||||||
|
let (crop_x, crop_y, crop_w, crop_h) = crop_rect;
|
||||||
|
let (img_w, img_h) = image.dimensions();
|
||||||
|
|
||||||
|
// Create mask - set alpha to 0 outside crop area
|
||||||
|
for y in 0..img_h {
|
||||||
|
for x in 0..img_w {
|
||||||
|
let should_keep = match direction {
|
||||||
|
SensorDirection::LeftToRight => x < crop_w,
|
||||||
|
SensorDirection::RightToLeft => x >= crop_x,
|
||||||
|
SensorDirection::TopToBottom => y < crop_h,
|
||||||
|
SensorDirection::BottomToTop => y >= crop_y,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !should_keep {
|
||||||
|
let pixel = image.get_pixel_mut(x, y);
|
||||||
|
pixel[3] = 0; // Set alpha to 0 (transparent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paste an image onto another image at specified position
|
||||||
|
fn paste_image(target: &mut RgbaImage, source: &RgbaImage, x: i32, y: i32) {
|
||||||
|
let (target_w, target_h) = target.dimensions();
|
||||||
|
let (source_w, source_h) = source.dimensions();
|
||||||
|
|
||||||
|
for sy in 0..source_h {
|
||||||
|
for sx in 0..source_w {
|
||||||
|
let target_x = x + sx as i32;
|
||||||
|
let target_y = y + sy as i32;
|
||||||
|
|
||||||
|
if target_x >= 0
|
||||||
|
&& target_y >= 0
|
||||||
|
&& (target_x as u32) < target_w
|
||||||
|
&& (target_y as u32) < target_h
|
||||||
|
{
|
||||||
|
let source_pixel = *source.get_pixel(sx, sy);
|
||||||
|
let target_pixel = target.get_pixel_mut(target_x as u32, target_y as u32);
|
||||||
|
|
||||||
|
// Alpha blending
|
||||||
|
let alpha = source_pixel[3] as f32 / 255.0;
|
||||||
|
let inv_alpha = 1.0 - alpha;
|
||||||
|
|
||||||
|
for i in 0..3 {
|
||||||
|
target_pixel[i] = ((source_pixel[i] as f32 * alpha)
|
||||||
|
+ (target_pixel[i] as f32 * inv_alpha))
|
||||||
|
as u8;
|
||||||
|
}
|
||||||
|
target_pixel[3] = ((source_pixel[3] as f32 * alpha)
|
||||||
|
+ (target_pixel[3] as f32 * inv_alpha))
|
||||||
|
as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_img_save_path(&mut self) {
|
||||||
|
if (self.save_render_img || self.save_processed_pic || self.save_progress_layer)
|
||||||
|
&& let Err(e) = fs::create_dir_all(&self.img_save_path)
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
"Error creating image output path {:?}: {e}",
|
||||||
|
self.img_save_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_layer(&mut self, mode: SensorMode) -> Option<&mut RgbaImage> {
|
||||||
|
if !self.composite_layer_map.contains_key(&mode) {
|
||||||
|
self.composite_layer_map.insert(mode, self.create_layer());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.composite_layer_map.get_mut(&mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an overlay image buffer with the same dimensions as the panel
|
||||||
|
fn create_layer(&self) -> RgbaImage {
|
||||||
|
ImageBuffer::from_fn(self.size.0, self.size.1, |_, _| Rgba([0, 0, 0, 0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composite all layers into final image
|
||||||
|
fn composite_layers(&mut self, background: &mut RgbaImage) {
|
||||||
|
// quick and dirty, this should be an ordered enum variant list
|
||||||
|
let modes = [SensorMode::Fan, SensorMode::Progress, SensorMode::Pointer];
|
||||||
|
for mode in modes {
|
||||||
|
if let Some(layer) = self.composite_layer_map.get(&mode) {
|
||||||
|
// Find bounding box of non-transparent pixels
|
||||||
|
let bbox = PanelRenderer::get_bounding_box(layer);
|
||||||
|
|
||||||
|
if let Some((min_x, min_y, max_x, max_y)) = bbox {
|
||||||
|
// Composite the layer onto final image
|
||||||
|
for y in min_y..=max_y {
|
||||||
|
for x in min_x..=max_x {
|
||||||
|
let layer_pixel = *layer.get_pixel(x, y);
|
||||||
|
if layer_pixel[3] > 0 {
|
||||||
|
// If not fully transparent
|
||||||
|
let final_pixel = background.get_pixel_mut(x, y);
|
||||||
|
|
||||||
|
// Alpha compositing
|
||||||
|
let alpha = layer_pixel[3] as f32 / 255.0;
|
||||||
|
let inv_alpha = 1.0 - alpha;
|
||||||
|
|
||||||
|
for i in 0..4 {
|
||||||
|
final_pixel[i] = ((layer_pixel[i] as f32 * alpha)
|
||||||
|
+ (final_pixel[i] as f32 * inv_alpha))
|
||||||
|
as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get bounding box of non-transparent pixels
|
||||||
|
fn get_bounding_box(image: &RgbaImage) -> Option<(u32, u32, u32, u32)> {
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
|
let mut min_x = width;
|
||||||
|
let mut min_y = height;
|
||||||
|
let mut max_x = 0;
|
||||||
|
let mut max_y = 0;
|
||||||
|
let mut found_pixel = false;
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let pixel = image.get_pixel(x, y);
|
||||||
|
if pixel[3] > 0 {
|
||||||
|
// Non-transparent
|
||||||
|
found_pixel = true;
|
||||||
|
min_x = min_x.min(x);
|
||||||
|
min_y = min_y.min(y);
|
||||||
|
max_x = max_x.max(x);
|
||||||
|
max_y = max_y.max(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found_pixel {
|
||||||
|
Some((min_x, min_y, max_x, max_y))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||