diff --git a/README.md b/README.md index 2bdaab4..eb61424 100644 --- a/README.md +++ b/README.md @@ -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. - diff --git a/cfg/monitor.json b/cfg/monitor.json index c1df70e..5cfd7ae 100644 --- a/cfg/monitor.json +++ b/cfg/monitor.json @@ -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, diff --git a/cfg/progress1.png b/cfg/progress1.png new file mode 100644 index 0000000..66ec657 Binary files /dev/null and b/cfg/progress1.png differ diff --git a/cfg/progress2.png b/cfg/progress2.png new file mode 100644 index 0000000..6c8eadd Binary files /dev/null and b/cfg/progress2.png differ diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..3911e53 --- /dev/null +++ b/doc/README.md @@ -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) + diff --git a/doc/asterctl.md b/doc/asterctl.md new file mode 100644 index 0000000..f13e0eb --- /dev/null +++ b/doc/asterctl.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 + Serial device, for example "/dev/cu.usbserial-AB0KOHLS". Takes priority over --usb option + + -u, --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 to display, other sizes than 960x376 will be scaled + + -c, --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 + 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 + Configuration directory containing configuration files and background images specified in the `config` file. Default: `./cfg` + + --font-dir + Font directory for fonts specified in the `config` file. Default: `./fonts` + + --sensor-path + Single sensor value input file or directory for multiple sensor input files. Default: `./cfg/sensors` + + --demo + Run a demo + + -o, --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). diff --git a/doc/img/mode4_pic.png b/doc/img/mode4_pic.png new file mode 100644 index 0000000..d14666a Binary files /dev/null and b/doc/img/mode4_pic.png differ diff --git a/doc/img/progress.png b/doc/img/progress.png new file mode 100644 index 0000000..6c8eadd Binary files /dev/null and b/doc/img/progress.png differ diff --git a/doc/img/progress_circle.png b/doc/img/progress_circle.png new file mode 100644 index 0000000..9d85c91 Binary files /dev/null and b/doc/img/progress_circle.png differ diff --git a/doc/img/sensor_mode1.png b/doc/img/sensor_mode1.png new file mode 100644 index 0000000..bb4f384 Binary files /dev/null and b/doc/img/sensor_mode1.png differ diff --git a/doc/img/sensor_mode1_background.png b/doc/img/sensor_mode1_background.png new file mode 100644 index 0000000..3d6b020 Binary files /dev/null and b/doc/img/sensor_mode1_background.png differ diff --git a/doc/img/sensor_mode2.jpg b/doc/img/sensor_mode2.jpg new file mode 100644 index 0000000..2d3e821 Binary files /dev/null and b/doc/img/sensor_mode2.jpg differ diff --git a/doc/img/sensor_mode2_background.jpg b/doc/img/sensor_mode2_background.jpg new file mode 100644 index 0000000..68384ae Binary files /dev/null and b/doc/img/sensor_mode2_background.jpg differ diff --git a/doc/img/sensor_mode3.png b/doc/img/sensor_mode3.png new file mode 100644 index 0000000..223fd53 Binary files /dev/null and b/doc/img/sensor_mode3.png differ diff --git a/doc/img/sensor_mode3_background.png b/doc/img/sensor_mode3_background.png new file mode 100644 index 0000000..e938374 Binary files /dev/null and b/doc/img/sensor_mode3_background.png differ diff --git a/doc/img/sensor_mode4.png b/doc/img/sensor_mode4.png new file mode 100644 index 0000000..4e4228d Binary files /dev/null and b/doc/img/sensor_mode4.png differ diff --git a/doc/img/sensor_mode4_background.png b/doc/img/sensor_mode4_background.png new file mode 100644 index 0000000..0f3414b Binary files /dev/null and b/doc/img/sensor_mode4_background.png differ diff --git a/doc/sensor_custom_panel.md b/doc/sensor_custom_panel.md new file mode 100644 index 0000000..564a045 --- /dev/null +++ b/doc/sensor_custom_panel.md @@ -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: , 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 +``` diff --git a/doc/sensor_data_shell.md b/doc/sensor_data_shell.md new file mode 100644 index 0000000..7a5f749 --- /dev/null +++ b/doc/sensor_data_shell.md @@ -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 +``` diff --git a/doc/sensor_data_sysinfo.md b/doc/sensor_data_sysinfo.md new file mode 100644 index 0000000..4709098 --- /dev/null +++ b/doc/sensor_data_sysinfo.md @@ -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 + Output sensor file + + -t, --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 + System sensor refresh interval in seconds + + --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! diff --git a/doc/sensor_data_txt_file.md b/doc/sensor_data_txt_file.md new file mode 100644 index 0000000..878fe7f --- /dev/null +++ b/doc/sensor_data_txt_file.md @@ -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 +``` diff --git a/doc/sensor_mode1_text.md b/doc/sensor_mode1_text.md new file mode 100644 index 0000000..d429afb --- /dev/null +++ b/doc/sensor_mode1_text.md @@ -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`: + +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: + +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. diff --git a/doc/sensor_mode2_fan.md b/doc/sensor_mode2_fan.md new file mode 100644 index 0000000..5119875 --- /dev/null +++ b/doc/sensor_mode2_fan.md @@ -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`: + +sensor mode 2 background image example + +```json +{ + "name": "Fan test panel", + "img": "background.jpg", + "sensor": [ + { + "id": "29d9ef2d-30b4-459d-b2b0-43cb6d4d6b41", + "itemName": "CPU usage", + "mode": 2, + "type": 1, + "direction": 1, + "label": "cpu_percent", + "value": "47.7", + "x": 168, + "y": 184, + "width": 237, + "height": 237, + "fontColor": "#ffffff", + "fontSize": 14, + "fontFamily": "default_font", + "textAlign": "left", + "minAngle": -160, + "maxAngle": 30, + "minValue": 0, + "maxValue": 80, + "xz_x": 0, + "xz_y": 0, + "pic": "progress_circle.png" + } + ] +} +``` + +Progress image `"pic": "progress_circle.png"`: + +![progress graphic](img/progress_circle.png) + +The following graphic is rendered for progress example above: + +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 + diff --git a/doc/sensor_mode3_progress.md b/doc/sensor_mode3_progress.md new file mode 100644 index 0000000..0fafaad --- /dev/null +++ b/doc/sensor_mode3_progress.md @@ -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`: + +sensor mode 3 background image example + +The background image and sensor definitions are taken from the default system panel configuration in the AOOSTAR-X app. + +```json +{ + "name": "Progress test panel", + "img": "background.png", + "sensor": [ + { + "mode": 3, + "type": 2, + "name": "SSD 4 usage", + "label": "storage_ssd4_usage", + "x": 400, + "y": 45, + "direction": 1, + "value": "35", + "fontFamily": "HarmonyOS_Sans_SC_Bold", + "fontSize": 24, + "fontColor": -1, + "textAlign": "center", + "minValue": 0, + "maxValue": 100, + "pic": "progress.png" + }, + { + "mode": 3, + "type": 2, + "name": "SSD 5 usage", + "label": "storage_ssd5_usage", + "x": 400, + "y": 106, + "direction": 1, + "value": "80", + "fontFamily": "HarmonyOS_Sans_SC_Bold", + "fontSize": 24, + "fontColor": -1, + "textAlign": "center", + "minValue": 0, + "maxValue": 100, + "pic": "progress.png" + }, + { + "mode": 1, + "type": 2, + "name": "SSD 4 temp", + "label": "storage_ssd4_temperature", + "x": 580, + "y": 70, + "direction": 1, + "value": "34", + "fontFamily": "HarmonyOS_Sans_SC_Bold", + "fontSize": 24, + "fontColor": -1, + "textAlign": "center", + "integerDigits": -1, + "decimalDigits": 0, + "unit": " ℃" + }, + { + "mode": 1, + "type": 2, + "name": "SSD 5 temp", + "label": "storage_ssd5_temperature", + "x": 580, + "y": 130, + "direction": 1, + "value": "35", + "fontFamily": "HarmonyOS_Sans_SC_Bold", + "fontSize": 24, + "fontColor": -1, + "textAlign": "center", + "integerDigits": -1, + "decimalDigits": 0, + "unit": " ℃" + } + ] +} +``` + +Progress image `"pic": "progress.png"`: + +![progress graphic](img/progress.png) + +The following graphic is rendered for progress example above: + +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 diff --git a/doc/sensor_mode4_pointer.md b/doc/sensor_mode4_pointer.md new file mode 100644 index 0000000..ac07e47 --- /dev/null +++ b/doc/sensor_mode4_pointer.md @@ -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`: + +sensor mode 4 background image example + +```json +{ + "name": "Pointer test panel", + "img": "background.jpg", + "sensor": [ + { + "id": "a9d4acac-2af9-4fe0-9f69-86cd09f25696", + "itemName": "CPU dial", + "mode": 4, + "type": 1, + "direction": 1, + "label": "cpu_percent", + "value": "47.7", + "x": 160, + "y": 208, + "width": 302, + "height": 302, + "fontColor": "#ffffff", + "fontSize": 14, + "fontFamily": "", + "fontWeight": "normal", + "textAlign": "left", + "minAngle": -110, + "maxAngle": 110, + "minValue": 0, + "maxValue": 90, + "xz_x": 0, + "xz_y": 0, + "pic": "pointer.png" + } + ] +} +``` + +Pointer image `"pic": "pointer.png"`: + +![pointer graphic](img/mode4_pic.png) + +The following graphic is rendered for a sensor value of `47.7`: + +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 diff --git a/doc/sensor_panels.md b/doc/sensor_panels.md index fc602fe..cf923b4 100644 --- a/doc/sensor_panels.md +++ b/doc/sensor_panels.md @@ -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: - -
- -``` -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 -``` - -
- -### 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 - Output sensor file - - -t, --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 - System sensor refresh interval in seconds - - --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. diff --git a/src/cfg.rs b/src/cfg.rs index 67aebc4..8540db7 100644 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -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>(path: P) -> anyhow::Result { @@ -54,6 +54,51 @@ pub fn load_cfg>(path: P) -> anyhow::Result { 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 +pub fn load_custom_panel>(path: P) -> anyhow::Result { + 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, } +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, // "" or numbers, so Option + + /// Image for progress, fan and pointer indicators + pub min_value: Option, + /// Image for progress, fan and pointer indicators + pub max_value: Option, + /// Optional unit text to print after the value #[serde(deserialize_with = "empty_string_as_none")] pub unit: Option, /// 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, - /// _Not (yet) used_ + /// Used for pointer type pub height: Option, - /// _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, /// Font name matching font filename without file extension. pub font_family: String, @@ -257,23 +343,25 @@ pub struct Sensor { // -1 ≈ unset ⇒ Option #[serde(deserialize_with = "option_none_if_minus_one")] pub decimal_digits: Option, - /* - // 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, // "" when unused + pub pic: Option, + + /// Used for fan & pointer sensors + pub min_angle: Option, + /// Used for fan & pointer sensors + pub max_angle: Option, + /// Pivot x #[serde(rename = "xz_x")] pub xz_x: Option, /// Pivot y #[serde(rename = "xz_y")] pub xz_y: Option, - + /* + // 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, /// 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 { - 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 for Rgb { } } +impl From for Rgba { + fn from(val: FontColor) -> Self { + Rgba([val.0[0], val.0[1], val.0[2], 255]) + } +} + impl Serialize for FontColor { fn serialize(&self, serializer: S) -> Result where diff --git a/src/display.rs b/src/display.rs index 043c551..bbd6a2f 100644 --- a/src/display.rs +++ b/src/display.rs @@ -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(), diff --git a/src/font.rs b/src/font.rs index 2b38014..44ec4b3 100644 --- a/src/font.rs +++ b/src/font.rs @@ -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(); + } } diff --git a/src/img.rs b/src/img.rs index a8929ac..5b42507 100644 --- a/src/img.rs +++ b/src/img.rs @@ -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

(path: P, size: (u32, u32)) -> anyhow::Result +/// Width, height type +pub type Size = (u32, u32); + +pub fn load_image

(path: P, size: Option) -> anyhow::Result where P: AsRef, { @@ -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 { - 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>, +} + +impl ImageCache { + pub fn new(img_path: impl Into) -> Self { + Self { + img_path: img_path.into(), + cache: HashMap::new(), + } + } + + /// Load and cache an image, returns None if loading fails + pub fn get>(&mut self, path: P, size: Option) -> 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 } diff --git a/src/main.rs b/src/main.rs index 7a261f3..287373c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + /// 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>, + /// 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>(config: P, config_dir: P) -> anyhow::Result { +fn load_configuration>( + config: P, + config_dir: P, + panels: Option>, +) -> anyhow::Result { 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, B: Into>( +fn run_sensor_panel>( screen: &mut AooScreen, mut cfg: MonitorConfig, config_dir: B, font_dir: B, sensor_path: B, - img_save_path: Option

, + img_save_path: Option, ) -> anyhow::Result<()> { + let font_dir = font_dir.into(); let config_dir = config_dir.into(); - let sensor_values: Arc>> = 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>> = Arc::new(RwLock::new(HashMap::new())); start_file_slurper(sensor_path, sensor_values.clone())?; @@ -226,23 +252,6 @@ fn run_sensor_panel, B: Into>( .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, B: Into>( 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, B: Into>( } } +fn update_panel( + screen: &mut AooScreen, + renderer: &mut PanelRenderer, + panel: &Panel, + values: &HashMap, +) -> 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>( - screen: &mut AooScreen, - background: &RgbImage, - fh: &mut FontHandler, - panel: &Panel, - values: Arc>>, - img_save_path: Option

, -) -> 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(()) -} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..94493a7 --- /dev/null +++ b/src/render.rs @@ -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 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, + 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, +} + +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, img_dir: impl Into) -> 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) { + 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) { + 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, + ) -> Result { + 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, + mut background: RgbaImage, + ) -> Result { + 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::() + .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::() + .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::() + .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 + } + } +}