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
This commit is contained in:
Markus Zehnder
2025-08-24 17:12:59 +02:00
committed by GitHub
parent e85d616da7
commit 98941a00fe
32 changed files with 1830 additions and 435 deletions
+2 -62
View File
@@ -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.
## Demo App Usage
## Usage
Currently, the project includes a proof-of-concept demo application that loads an image, draws rectangles, and writes
text over the image.
By default, the original LCD USB UART device `416:90A1` is used. See optional parameters to specify a different device.
```shell
cargo run --release -- --demo --config 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).
See [asterctl documentation](doc/README.md) for more information or run `asterctl --help` for available command line options.
## Contributing
@@ -177,4 +118,3 @@ Licensed under either of
- MIT License ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
+12 -12
View File
@@ -337,7 +337,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "25",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -365,7 +365,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "5",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -421,7 +421,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "15",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -477,7 +477,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "25",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -533,7 +533,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "35",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -589,7 +589,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "40",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -645,7 +645,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "45",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -701,7 +701,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "55",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -757,7 +757,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "65",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -813,7 +813,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "75",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -869,7 +869,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "85",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
@@ -925,7 +925,7 @@
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"value": "95",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

+37
View File
@@ -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)
+124
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+33
View File
@@ -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
```
+29
View File
@@ -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
```
+49
View File
@@ -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!
+57
View File
@@ -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
```
+98
View File
@@ -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.
+74
View File
@@ -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"`:
![progress graphic](img/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
+112
View File
@@ -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"`:
![progress graphic](img/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
+71
View File
@@ -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"`:
![pointer graphic](img/mode4_pic.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
+11 -149
View File
@@ -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`).
- Each panel can be configured with multiple sensor fields.
- Only text sensor value fields are supported (`sensor.mode: 1`).
- Fan (2), progress (3) and pointer (4) sensors are not supported.
- 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) 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.
- 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.
@@ -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 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:
- `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
- `fontColor`: Font color in `#RRGGBB` notation, or `-1` if not set. Examples: `#ffffff` = white, `#ff0000` = red. Default: `#ffffff`
- `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).
@@ -58,149 +65,4 @@ Sensor values are not read from the configuration file (the `sensor.value` field
More options might be supported later.
## Sensor Data Sources
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!
See [custom sensor panels](sensor_custom_panel.md) for including custom panels.
+122 -23
View File
@@ -7,16 +7,16 @@
//! Likely not fully compatible with files created with the original editor.
use anyhow::Context;
use image::Rgb;
use image::{Rgb, Rgba};
use imageproc::definitions::HasWhite;
use log::warn;
use log::{info, warn};
use serde::de::Visitor;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_repr::{Deserialize_repr, Serialize_repr};
use std::io::BufReader;
use std::num::ParseIntError;
use std::ops::Deref;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::{fmt, fs};
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)
}
/// 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
#[derive(Debug, Serialize, Deserialize)]
pub struct MonitorConfig {
@@ -97,6 +142,11 @@ impl MonitorConfig {
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
@@ -153,7 +203,7 @@ pub struct Setup {
/// Language setting.
///
/// 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)]
#[allow(dead_code)]
pub enum Language {
@@ -163,7 +213,7 @@ pub enum Language {
}
/// 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)]
#[allow(dead_code)]
pub enum OperationMode {
@@ -176,6 +226,17 @@ pub enum OperationMode {
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
#[derive(Debug, Serialize, Deserialize)]
pub struct Panel {
@@ -197,6 +258,25 @@ pub struct Panel {
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
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -223,21 +303,27 @@ pub struct Sensor {
/// Sensor value. Ignored: value is used from a sensor source
#[serde(deserialize_with = "empty_string_as_none")]
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
#[serde(deserialize_with = "empty_string_as_none")]
pub unit: Option<String>,
/// x-position. Custom panel coordinates are stored as float!
// TODO use i32 and round from f32 in deserialization
pub x: f32,
/// y-position.
// TODO use i32 and round from f32 in deserialization
pub y: f32,
/// _Not (yet) used_
/// Used for pointer type
pub width: Option<i32>,
/// _Not (yet) used_
/// Used for pointer type
pub height: Option<i32>,
/// _Not (yet) used_
pub text_direction: i32, // layout direction
/// _Not (yet) used_
pub direction: i32, // sensor orientation, 0/1
/// Sensor graphic orientation
pub direction: Option<SensorDirection>,
/// Font name matching font filename without file extension.
pub font_family: String,
@@ -257,23 +343,25 @@ pub struct Sensor {
// -1 ≈ unset ⇒ Option<i32>
#[serde(deserialize_with = "option_none_if_minus_one")]
pub decimal_digits: Option<i32>,
/*
// 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?
/// Image for progress, fan and pointer indicators
#[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
#[serde(rename = "xz_x")]
pub xz_x: Option<i32>,
/// Pivot y
#[serde(rename = "xz_y")]
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
pub url: Option<String>,
/// 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)]
pub enum SensorMode {
/// Text element
Text = 1,
/// Circular/arc progress indicator
Fan = 2,
/// Horizontal or vertical progress indicator
Progress = 3,
/// Rotating pointer/dial indicator
Pointer = 4,
}
@@ -385,7 +478,7 @@ impl TryFrom<&str> for FontColor {
type Error = ParseIntError;
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}");
Ok(FontColor::default())
} 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 {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
+3 -4
View File
@@ -2,10 +2,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use crate::dummy_serialport::DummySerialPort;
use crate::img::rgb888_to_565;
use crate::img::ToRgb565;
use anyhow::{Context, anyhow};
use bytes::{BufMut, BytesMut};
use image::RgbImage;
use log::{debug, error, info, warn};
use serialport::{SerialPort, SerialPortType};
use std::io::{Read, Write};
@@ -178,8 +177,8 @@ impl AooScreen {
.with_context(|| "Failed to send display off")
}
pub fn send_image(&mut self, rgb_img: &RgbImage) -> anyhow::Result<()> {
let img_rgb565 = rgb888_to_565(rgb_img)?;
pub fn send_image(&mut self, image: impl ToRgb565) -> anyhow::Result<()> {
let img_rgb565 = image.to_rgb565_le();
debug!(
"Start sending image (size {}) {} cache... ",
img_rgb565.len(),
+7
View File
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Font handling and caching.
use ab_glyph::{FontArc, FontRef, FontVec};
use anyhow::{Context, anyhow};
use log::warn;
@@ -61,4 +63,9 @@ impl FontHandler {
Ok(font)
}
#[allow(dead_code)]
pub fn clear(&mut self) {
self.ttf_cache.clear();
}
}
+186 -18
View File
@@ -1,13 +1,21 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Image helper functions.
use bytes::{BufMut, BytesMut};
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 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
P: AsRef<Path>,
{
@@ -18,30 +26,190 @@ where
img.color()
);
if img.dimensions() != size {
if let Some(size) = size
&& img.dimensions() != size
{
warn!(
"Resizing invalid image dimensions {:?} to expected size {:?}, ignoring aspect ratio",
img.dimensions(),
size
);
Ok(img
.resize_exact(size.0, size.1, FilterType::Lanczos3)
.to_rgb8())
Ok(img.resize_exact(size.0, size.1, FilterType::Lanczos3))
} else {
Ok(img.to_rgb8())
Ok(img)
}
}
pub fn rgb888_to_565(rgb_img: &RgbImage) -> anyhow::Result<BytesMut> {
let mut img_rgb565 =
BytesMut::with_capacity(rgb_img.width() as usize * rgb_img.height() as usize * 2);
/// 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;
for (_x, _y, pixel) in rgb_img.enumerate_pixels() {
img_rgb565.put_u16_le(
((pixel.0[0] & 248) as u16) << 8
| ((pixel.0[1] & 252) as u16) << 3
| ((pixel.0[2] as u16) >> 3),
);
/// 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)
}
Ok(img_rgb565)
}
// TODO quick & dirty approach for converting RgbImage & RgbaImage to RGB 565.
// There should be a more generic way, maybe with PixelEnumerator...
impl ToRgb565 for &RgbImage {
fn to_rgb565_le(&self) -> BytesMut {
let mut img_rgb565 =
BytesMut::with_capacity(self.width() as usize * self.height() as usize * 2);
for (_x, _y, pixel) in self.enumerate_pixels() {
img_rgb565.put_u16_le(self.convert_rgb(pixel.0[0], pixel.0[1], pixel.0[2]));
}
img_rgb565
}
}
impl ToRgb565 for &RgbaImage {
fn to_rgb565_le(&self) -> BytesMut {
let mut img_rgb565 =
BytesMut::with_capacity(self.width() as usize * self.height() as usize * 2);
for (_x, _y, pixel) in self.enumerate_pixels() {
img_rgb565.put_u16_le(self.convert_rgb(pixel.0[0], pixel.0[1], pixel.0[2]));
}
img_rgb565
}
}
/// 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
}
+77 -167
View File
@@ -7,20 +7,21 @@ mod dummy_serialport;
mod font;
mod format_value;
mod img;
mod render;
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::font::FontHandler;
use crate::format_value::format_value;
use crate::render::PanelRenderer;
use crate::sensors::start_file_slurper;
use ab_glyph::{Font, PxScale};
use ab_glyph::PxScale;
use anyhow::anyhow;
use clap::Parser;
use env_logger::Env;
use image::imageops::FilterType;
use image::{ImageReader, Rgb, RgbImage};
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut, text_size};
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut};
use log::{debug, error, info};
use std::collections::HashMap;
use std::fs;
@@ -62,6 +63,12 @@ struct Args {
#[arg(short, long)]
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
/// specified in the `config` file. Default: `./cfg`
#[arg(long)]
@@ -140,7 +147,7 @@ fn main() -> anyhow::Result<()> {
};
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(
&mut screen,
cfg,
@@ -154,7 +161,7 @@ fn main() -> anyhow::Result<()> {
if let Some(image) = args.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();
screen.send_image(&rgb_img)?;
debug!("Image sent in {}ms", timestamp.elapsed().as_millis());
@@ -182,31 +189,50 @@ fn main() -> anyhow::Result<()> {
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_dir = config_dir.as_ref();
if config.is_absolute() {
cfg::load_cfg(config)
let mut cfg = if config.is_absolute() {
cfg::load_cfg(config)?
} 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)?);
}
}
Ok(cfg)
}
fn run_sensor_panel<P: AsRef<Path>, B: Into<PathBuf>>(
fn run_sensor_panel<B: Into<PathBuf>>(
screen: &mut AooScreen,
mut cfg: MonitorConfig,
config_dir: B,
font_dir: B,
sensor_path: B,
img_save_path: Option<P>,
img_save_path: Option<B>,
) -> anyhow::Result<()> {
let font_dir = font_dir.into();
let config_dir = config_dir.into();
let sensor_values: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new()));
let mut fh = FontHandler::new(font_dir);
let img_save_path = img_save_path.map(|p| p.into());
let mut rgb_img;
let mut save_img_name;
let mut renderer = PanelRenderer::new(DISPLAY_SIZE, &font_dir, &config_dir);
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())?;
@@ -226,23 +252,6 @@ fn run_sensor_panel<P: AsRef<Path>, B: Into<PathBuf>>(
.get_next_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();
// active panel refresh loop
@@ -250,24 +259,14 @@ fn run_sensor_panel<P: AsRef<Path>, B: Into<PathBuf>>(
loop {
let upd_start_time = Instant::now();
let out_filename = if let Some(save_path) = &img_save_path {
let save_path = save_path.as_ref();
Some(save_path.join(format!(
"{}-{refresh_count:02}.png",
save_img_name.as_deref().unwrap_or("panel")
)))
} else {
None
};
if img_save_path.is_some() {
renderer.set_img_suffix(format!("-{refresh_count:02}"));
}
update_panel(
screen,
&rgb_img,
&mut fh,
panel,
sensor_values.clone(),
out_filename,
)?;
// Keeping the read lock during panel rendering should be ok, otherwise we could always clone the HashMap
let values = sensor_values.read().expect("RwLock is poisoned");
update_panel(screen, &mut renderer, panel, &values)?;
drop(values);
let elapsed = upd_start_time.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(
screen: &mut AooScreen,
config: Option<&Path>,
@@ -301,7 +322,7 @@ fn run_demo(
demo_text(screen, &rgb_img, save_images)?;
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() {
info!("Displaying demo panel...");
@@ -315,22 +336,12 @@ fn run_demo(
);
}
let mut fh = FontHandler::new(font_dir);
let out_filename = if save_images {
fs::create_dir_all("out")?;
Some("out/demo_panel.png")
} else {
None
};
let mut renderer = PanelRenderer::new(DISPLAY_SIZE, &font_dir, &config_dir);
renderer.set_save_render_img(save_images);
renderer.set_save_processed_pic(save_images);
renderer.set_save_progress_layer(save_images);
update_panel(
screen,
&rgb_img,
&mut fh,
panel,
Arc::new(RwLock::new(demo_values)),
out_filename,
)?;
update_panel(screen, &mut renderer, panel, &demo_values)?;
} else {
error!("No active panel found");
}
@@ -417,13 +428,6 @@ fn demo_blinds(
for y in 0..DISPLAY_SIZE.1 {
let color = *rgb_img.get_pixel(width + 1, y);
// draw_antialiased_line_segment_mut(
// &mut rgb_img,
// (0, y as i32),
// (width as i32, y as i32),
// color,
// interpolate,
// );
draw_line_segment_mut(
&mut rgb_img,
(0.0, y as f32),
@@ -431,13 +435,6 @@ fn demo_blinds(
color,
);
let color = *rgb_img.get_pixel(DISPLAY_SIZE.0 - width - 1, y);
// draw_antialiased_line_segment_mut(
// &mut rgb_img,
// ((DISPLAY_SIZE.0 - width) as i32, y as i32),
// (DISPLAY_SIZE.0 as i32, y as i32),
// color,
// interpolate,
// );
draw_line_segment_mut(
&mut rgb_img,
((DISPLAY_SIZE.0 - width) as f32, y as f32),
@@ -459,90 +456,3 @@ fn demo_blinds(
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(())
}
+726
View File
@@ -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 pieslice 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);
// alphablend: out = src.a*src + (1src.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
}
}
}