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.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
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`).
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||