feat: simple sensor panels with file-based data source (#7)

* add simulation mode for easier development

* improved sensor file watcher, poc cpu & mem usage

Trigger file read by rename event

* feat: system information sensor tool

Gather various sensor values with the sysinfo crate:
https://github.com/GuillaumeGomez/sysinfo
Values can be written to a sensor source file with the `--out` cmd line
option for the `asterctl` tool.

* ci: build sysinfo tool and include in build artifact

* feat: support integerDigits, decimalDigits sensor value format options

* docs: update documentation

Closes #6
This commit is contained in:
Markus Zehnder
2025-08-22 13:41:31 +02:00
committed by GitHub
parent 3f174251d6
commit e85d616da7
32 changed files with 3803 additions and 3933 deletions
+8 -3
View File
@@ -25,7 +25,7 @@ permissions:
jobs: jobs:
lint: lint:
name: Clippy & Rustfmt name: Clippy, Rustfmt, Tests
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -54,9 +54,13 @@ jobs:
- name: Run rustfmt - name: Run rustfmt
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Unit tests
run: cargo test
build: build:
name: Linux-x64 build name: Linux-x64 build
needs: lint needs: lint
# using an older Ubuntu release on purpose to link against an older libc version for greater compatibility
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -93,7 +97,7 @@ jobs:
- name: Release build - name: Release build
shell: bash shell: bash
run: cargo build --release run: cargo build --release --bins --all-features
# Archive is required to preserve file permissions and re-used for release uploads # Archive is required to preserve file permissions and re-used for release uploads
- name: Create upload artifact - name: Create upload artifact
@@ -102,8 +106,9 @@ jobs:
ls -la target/release ls -la target/release
mkdir -p ${GITHUB_WORKSPACE}/${{env.BIN_OUTPUT_PATH }} mkdir -p ${GITHUB_WORKSPACE}/${{env.BIN_OUTPUT_PATH }}
cp target/release/${{ env.APP_NAME }} ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }} cp target/release/${{ env.APP_NAME }} ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }}
cp target/release/sysinfo ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }}
cp linux/*.service ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }} cp linux/*.service ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }}
cp Monitor3.json ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }} cp -r cfg ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }}
echo "VERSION=${{ env.APP_VERSION }}" > ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }}/version.txt echo "VERSION=${{ env.APP_VERSION }}" > ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }}/version.txt
echo "TIMESTAMP=$(date +"%Y%m%d_%H%M%S")" >> ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }}/version.txt echo "TIMESTAMP=$(date +"%Y%m%d_%H%M%S")" >> ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }}/version.txt
tar czvf ${{ env.ARTIFACT_NAME }}.tar.gz -C ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }} . tar czvf ${{ env.ARTIFACT_NAME }}.tar.gz -C ${GITHUB_WORKSPACE}/${{ env.BIN_OUTPUT_PATH }} .
+1 -1
View File
@@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run demo" type="CargoCommandRunConfiguration" factoryName="Cargo Command"> <configuration default="false" name="Run demo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="dev" /> <option name="buildProfileId" value="dev" />
<option name="command" value="run --package AOOstar-rs --bin AOOstar-rs -- --demo -d /dev/cu.usbserial-AB0KOHLS -w -c Monitor3.json" /> <option name="command" value="run --package aoostar-rs --bin asterctl -- --demo -c monitor.json" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" /> <option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs /> <envs />
<option name="emulateTerminal" value="true" /> <option name="emulateTerminal" value="true" />
+20
View File
@@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run demo DEV" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="release" />
<option name="command" value="run --package aoostar-rs --bin asterctl -- --demo -c monitor.json --save --simulate" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
@@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run release demo" type="CargoCommandRunConfiguration" factoryName="Cargo Command"> <configuration default="false" name="Run sensor panel" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="release" /> <option name="buildProfileId" value="dev" />
<option name="command" value="run --package AOOstar-rs --bin AOOstar-rs -- --demo -d /dev/cu.usbserial-AB0KOHLS -w -c Monitor3.json" /> <option name="command" value="run --package aoostar-rs --bin asterctl -- -c monitor.json" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" /> <option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs /> <envs />
<option name="emulateTerminal" value="true" /> <option name="emulateTerminal" value="true" />
+20
View File
@@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run sensor panel DEV" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="dev" />
<option name="command" value="run --package aoostar-rs --bin asterctl -- -c monitor.json --save --simulate" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
+20
View File
@@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run sysinfo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="dev" />
<option name="command" value="run --package aoostar-rs --bin sysinfo -- --console --out ./cfg/sensors/sysinfo.txt" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
+20
View File
@@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run sysinfo repeat" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="buildProfileId" value="dev" />
<option name="command" value="run --package aoostar-rs --bin sysinfo -- --console --out ./cfg/sensors/sysinfo.txt --refresh 3" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
+19
View File
@@ -0,0 +1,19 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="clippy sysinfo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="clippy --bin sysinfo --features=sysinfo" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
Generated
+7
View File
@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="BodyLimit" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SubjectBodySeparation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SubjectLimit" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
</component> </component>
+3
View File
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
_Changes in the next release_ _Changes in the next release_
### Added
- Simple sensor panel with a file-based data source (#6)
--- ---
## v0.1.0 - 2025-08-02 ## v0.1.0 - 2025-08-02
Generated
+596 -67
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -13,17 +13,33 @@ strip = true # Automatically strip symbols from the binary.
name = "asterctl" name = "asterctl"
path = "src/main.rs" path = "src/main.rs"
[[bin]]
name = "sysinfo"
path = "src/bin/sysinfo.rs"
required-features = ["sysinfo"]
[features]
sysinfo = ["dep:sysinfo"]
[dependencies] [dependencies]
anyhow = "1.0.98" anyhow = "1.0.98"
bytes = "1.10.1" bytes = "1.10.1"
clap = { version = "4.5.41", features = ["derive"] } clap = { version = "4.5.42", features = ["derive"] }
serialport = "4.7.2" serialport = "4.7.2"
image = "0.25.6" image = "0.25.6"
imageproc = { version = "0.25.0", default-features = false } imageproc = { version = "0.25.0", default-features = false }
ab_glyph = { version = "0.2.23", default-features = false, features = ["std"] } ab_glyph = { version = "0.2.31", default-features = false, features = ["std"] }
log = "0.4.27" log = "0.4.27"
env_logger = "0.11.8" env_logger = "0.11.8"
notify = "8.2.0"
regex = "1.11"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.142"
serde_repr = "0.1.20" serde_repr = "0.1.20"
once_cell = "1.21.3" once_cell = "1.21.3"
sysinfo = { version = "0.37.0", optional = true }
itertools = "0.14"
tempfile = "3"
[dev-dependencies]
rstest = "0.26"
-3767
View File
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -90,9 +90,11 @@ cd aoostar-rs
A release build is highly recommended, as it significantly improves graphics performance: A release build is highly recommended, as it significantly improves graphics performance:
```shell ```shell
cargo build --release cargo build --release --bins --all-features
``` ```
The `--bins` option builds the main `asterctl` app and all other tools.
### Install ### Install
See [Linux systemd Service](linux/) on how to automatically switch off the LCD at boot up. See [Linux systemd Service](linux/) on how to automatically switch off the LCD at boot up.
@@ -105,7 +107,7 @@ text over the image.
By default, the original LCD USB UART device `416:90A1` is used. See optional parameters to specify a different device. By default, the original LCD USB UART device `416:90A1` is used. See optional parameters to specify a different device.
```shell ```shell
cargo run --release -- --demo --config Monitor3.json 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 The `--config` parameter is optional. It loads the official configuration file and displays the defined sensors in the
@@ -118,14 +120,26 @@ first panel.
- `--help` — Show all options. - `--help` — Show all options.
### Sensor Panel
```shell
asterctl --config monitor.json
```
See [sensor panels](doc/sensor_panels.md) for more information.
### Control Commands ### Control Commands
> Aster: Greek for star and similar to AOOSTAR.
Besides demo mode, the following control commands have been implemented. Besides demo mode, the following control commands have been implemented.
The `asterctl` binary is built in `./target/release`. The `asterctl` binary is built in `./target/release`.
Alternatively, use `cargo run --release --` to build and run automatically, for example: `cargo run --release -- --off`. Alternatively, use `cargo run --release --` to build and run automatically, for example:
> Aster: Greek for star and similar to AOOSTAR. ```shell
cargo run --release -- --off
```
**Switch display on:** **Switch display on:**
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+948
View File
@@ -0,0 +1,948 @@
{
"setup": {
"switchTime": "30",
"refresh": 1
},
"mianban": [
1,
2
],
"diy": [
{
"type": 5,
"img": "default_1_index.jpg",
"sensor": [
{
"mode": 1,
"type": 1,
"name": "CPU温度",
"label": "cpu_temperature",
"x": 195,
"y": 110,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "98",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 120,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "CPU占用",
"label": "cpu_percent",
"x": 200,
"y": 285,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "98",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 60,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "%",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "RAM占用",
"label": "memory_usage",
"x": 560,
"y": 70,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "98",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " %",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "RAM温度",
"label": "memory_Temperature",
"x": 560,
"y": 131,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "98",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "tt hh:mm:ss",
"label": "DATE_m_d_h_m_2",
"x": 515,
"y": 246,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "下午 06:08:08",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": -1,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "External IP Address",
"label": "net_ip_address",
"x": 515,
"y": 307,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "146.56.182.244",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": -1,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "GPU占用",
"label": "gpu_core",
"x": 852,
"y": 70,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "98",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " %",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "GPU温度",
"label": "gpu_temperature",
"x": 852,
"y": 131,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "98",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "网络上传",
"label": "net_upload_speed",
"x": 852,
"y": 246,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "100 K/S",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": -1,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 1,
"name": "网络下载",
"label": "net_download_speed",
"x": 852,
"y": 307,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "120 K/S",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": -1,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
}
]
},
{
"type": 5,
"img": "default_1_hdd.jpg",
"sensor": [
{
"mode": 1,
"type": 1,
"name": "主板温度",
"label": "motherboard_temperature",
"x": 315,
"y": 105,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "98",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 40,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "right",
"integerDigits": -1,
"decimalDigits": 1,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "固态1",
"label": "storage_ssd[0]['temperature']",
"x": 265,
"y": 181,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "固态1-进度条",
"label": "storage_ssd[0]['used']",
"x": 50,
"y": 157,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress1.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "固态2",
"label": "storage_ssd[1]['temperature']",
"x": 265,
"y": 246,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "固态2-进度条",
"label": "storage_ssd[1]['used']",
"x": 50,
"y": 220,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress1.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "固态3",
"label": "storage_ssd[2]['temperature']",
"x": 265,
"y": 306,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "固态3-进度条",
"label": "storage_ssd[2]['used']",
"x": 50,
"y": 282,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress1.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "固态4",
"label": "storage_ssd[3]['temperature']",
"x": 580,
"y": 70,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "固态4-进度条",
"label": "storage_ssd[3]['used']",
"x": 400,
"y": 45,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress2.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "固态5",
"label": "storage_ssd[4]['temperature']",
"x": 580,
"y": 130,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "固态5-进度条",
"label": "storage_ssd[4]['used']",
"x": 400,
"y": 106,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress2.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "硬盘1",
"label": "storage_hdd[0]['temperature']",
"x": 580,
"y": 247,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "硬盘1-进度条",
"label": "storage_hdd[0]['used']",
"x": 400,
"y": 221,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress2.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "硬盘2",
"label": "storage_hdd[1]['temperature']",
"x": 580,
"y": 307,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "硬盘2-进度条",
"label": "storage_hdd[1]['used']",
"x": 400,
"y": 282,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress2.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "硬盘3",
"label": "storage_hdd[2]['temperature']",
"x": 870,
"y": 70,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "硬盘3-进度条",
"label": "storage_hdd[2]['used']",
"x": 691,
"y": 45,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress2.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "硬盘4",
"label": "storage_hdd[3]['temperature']",
"x": 870,
"y": 130,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "硬盘4-进度条",
"label": "storage_hdd[3]['used']",
"x": 691,
"y": 106,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress2.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "硬盘5",
"label": "storage_hdd[4]['temperature']",
"x": 870,
"y": 246,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "硬盘5-进度条",
"label": "storage_hdd[4]['used']",
"x": 691,
"y": 221,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress2.png",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 1,
"type": 2,
"name": "硬盘6",
"label": "storage_hdd[5]['temperature']",
"x": 870,
"y": 307,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": " ℃",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "",
"xz_x": 0,
"xz_y": 0
},
{
"mode": 3,
"type": 2,
"name": "硬盘6-进度条",
"label": "storage_hdd[5]['used']",
"x": 691,
"y": 282,
"width": 0,
"height": 0,
"textDirection": 0,
"direction": 1,
"value": "7",
"fontFamily": "HarmonyOS_Sans_SC_Bold",
"fontSize": 24,
"fontColor": -1,
"fontWeight": "normal",
"textAlign": "center",
"integerDigits": -1,
"decimalDigits": 0,
"unit": "",
"minAngle": 0,
"maxAngle": 180,
"minValue": 0,
"maxValue": 100,
"pic": "progress2.png",
"xz_x": 0,
"xz_y": 0
}
]
}
]
}
+34
View File
@@ -0,0 +1,34 @@
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']: 18
storage_ssd[2]['temperature']: 33
storage_ssd[2]['used']: 19
storage_ssd[3]['temperature']: 34
storage_ssd[3]['used']: 20
storage_ssd[4]['temperature']: 35
storage_ssd[4]['used']: 21
storage_hdd[0]['temperature']: 36
storage_hdd[0]['used']: 22
storage_hdd[1]['temperature']: 37
storage_hdd[1]['used']: 23
storage_hdd[2]['temperature']: 38
storage_hdd[2]['used']: 24
storage_hdd[3]['temperature']: 39
storage_hdd[3]['used']: 25
storage_hdd[4]['temperature']: 40
storage_hdd[4]['used']: 26
storage_hdd[5]['temperature']: 41
storage_hdd[5]['used']: 27
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

+206
View File
@@ -0,0 +1,206 @@
# Sensor Panels
The `asterctl` tool is started in sensor panel mode if the `--config` command line option is specified.
Sensor panels are dynamic screens showing various sensor values. Multiple rotating panels are supported.
The sensor values must be provided in simple key-value text files from external scripts or tools. The `asterctl` tool
is only responsible for rendering the panels on the embedded screen.
Example panels from the AOOSTAR-X software, rendered with `asterctl` using dummy sensor values:
<img src="img/sensor_panel-01.png" alt="Sensor panel 1">
<img src="img/sensor_panel-02.png" alt="Sensor panel 1">
## Supported Features
- 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.
- 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.
## Panel Configuration File
Specify configuration file to use:
```shell
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:
- Setup object fields:
- `switchTime`: Optional switch time between panels in seconds, string value interpreted as float and converted to milliseconds. Default: 5
- `refresh`: Panel redraw interval in seconds specified as a float number. Default: 1
- Panel object fields in `diy[]`:
- `img`: Background image filename. Loaded from the specified configuration directory if not an absolute path is specified.
- `sensor`: Array of sensor objects.
- Sensor object fields:
- `label`: label identifier, also used as sensor value data source identifier
- `integerDigits`: sensor value format option: number of integer places. Value is 0-prefixed to number of places and set to `99` if overflown.
- `decimalDigits`: sensor value format option: number of decimal places.
- `unit`: optional unit label, appended after the sensor value
- `x`: x-position
- `y`: y-position
- `fontFamily`: Font name matching font filename without file extension. Fonts are loaded from the configured font directory.
- `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`
Example configuration file: [cfg/monitor.json](../cfg/monitor.json).
Sensor values are not read from the configuration file (the `sensor.value` field is ignored). See data sources below.
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!
+26
View File
@@ -0,0 +1,26 @@
https://consumer.huawei.com/za/community/details/topicId-145064/
Huawei has officially launched its self-developed operating system HarmonyOS 2 and also released a brand new custom
font HarmonyOS Sans, which is free for commercial use. The main feature is optimized font grayscale and unified
multilingual glyphs style, etc., the official download is now available.
Download HarmonyOS Sans
Huawei stated that by studying users reading feedback on multi-terminal devices in different scenarios, it
comprehensively considers factors such as the size and usage scenarios of different devices, and comprehensively
considers the font size and characters caused by differences in viewing distance and viewing angle when users use
devices. This font was designed with heavy and different demands.
It is worth mentioning that in addition to Huawei, Google, Ali, OPPO and other vendors have released their own free
commercial fonts, while vendors such as Microsoft and Xiaomi have their own fonts, but they are not commercially
available.
HarmonyOS Sans was produced by the font design team of Hanyi font library, focusing on the functionality and
universality of the Internet of Everything. It is a multi-language, stepless, variable font that makes the reading
experience in multiple languages more consistent.
It is reported that the font supports five writing systems including simplified and traditional Chinese, Latin,
Cyrillic, Greek, and Arabic, and supports 105 languages for global coverage, helping to build a smart world with all
things connected.
Download from: https://developer.huawei.com/consumer/en/design/resource/
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# Simple proof-of-concept Linux shell script to periodically write the CPU usage into a sensor text file.
#
# A single sensor `cpu_percent` is written to the sensor file containing the overall cpu usage in percent.
# CPU usage is calculated every second.
#
# CPU usage logic from:
# https://stackoverflow.com/questions/9229333/how-to-get-overall-cpu-usage-e-g-57-on-linux
#
set -e
set -o pipefail
REFRESH=1
TEMP_DIR="${TMPDIR:-/tmp}"
TMP_SENSOR_FILE="${TEMP_DIR}/cpu.txt.tmp"
SENSOR_FILE="${TEMP_DIR}/sensors/cpu.txt"
#=============================================================
usage() {
cat << EOF
Simple PoC script to periodically write the CPU usage into a sensor text file.
Usage:
$0 [-r REFRESH] [-s SENSOR_FILE] [-t TEMP_DIR]
-r REFRESH refresh in seconds. Default: $REFRESH
-s SENSOR_FILE output sensor file. Default: $SENSOR_FILE
-t TEMP_DIR temporary directory. Default: $TEMP_DIR
EOF
exit 1
}
#=============================================================
#------------------------------------------------------------------------------
# Start of script
#------------------------------------------------------------------------------
# check command line arguments
while getopts "r:s:t:h" opt; do
case ${opt} in
"r")
REFRESH="$OPTARG"
;;
"s")
SENSOR_FILE="$OPTARG"
;;
"t")
TMP_SENSOR_FILE="$OPTARG/cpu.txt.tmp"
;;
h )
usage
;;
: )
echo "Option: -$OPTARG requires an argument" 1>&2
usage
;;
\? )
echo "Invalid option: -$OPTARG" 1>&2
usage
;;
esac
done
mkdir -p "$(dirname "$SENSOR_FILE")"
while :
do
USAGE=$({ head -n1 /proc/stat;sleep "$REFRESH";head -n1 /proc/stat; } | awk '/^cpu /{u=$2-u;s=$4-s;i=$5-i;w=$6-w}END{print "cpu_percent:"int(0.5+100*(u+s+w)/(u+s+i+w))}')
echo "$USAGE" > "$TMP_SENSOR_FILE"
mv "$TMP_SENSOR_FILE" "$SENSOR_FILE"
done
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# Simple proof-of-concept Linux shell script to periodically write the memory usage into a sensor text file.
#
# The following sensor values are written to the sensor file.
# - memory_total in bytes
# - memory_free in bytes
# - memory_available in bytes
# - memory_usage in percent
#
# Memory usage is calculated every five seconds.
set -e
set -o pipefail
REFRESH=5
TEMP_DIR="${TMPDIR:-/tmp}"
TMP_SENSOR_FILE="${TEMP_DIR}/mem.txt.tmp"
SENSOR_FILE="${TEMP_DIR}/sensors/mem.txt"
#=============================================================
usage() {
cat << EOF
Simple PoC script to periodically write the memory usage into a sensor text file.
Usage:
$0 [-r REFRESH] [-s SENSOR_FILE] [-t TEMP_DIR]
-r REFRESH refresh in seconds. Default: $REFRESH
-s SENSOR_FILE output sensor file. Default: $SENSOR_FILE
-t TEMP_DIR temporary directory. Default: $TEMP_DIR
EOF
exit 1
}
#=============================================================
#------------------------------------------------------------------------------
# Start of script
#------------------------------------------------------------------------------
# check command line arguments
while getopts "r:s:t:h" opt; do
case ${opt} in
"r")
REFRESH="$OPTARG"
;;
"s")
SENSOR_FILE="$OPTARG"
;;
"t")
TMP_SENSOR_FILE="$OPTARG/mem.txt.tmp"
;;
h )
usage
;;
: )
echo "Option: -$OPTARG requires an argument" 1>&2
usage
;;
\? )
echo "Invalid option: -$OPTARG" 1>&2
usage
;;
esac
done
mkdir -p "$(dirname "$SENSOR_FILE")"
while :
do
MEM_TOTAL=$(grep MemTotal /proc/meminfo | awk '{print $2}')
MEM_FREE=$(grep MemFree /proc/meminfo | awk '{print $2}')
MEM_AVAILABLE=$(grep MemAvailable /proc/meminfo | awk '{print $2}')
printf "memory_total:%d\nmemory_free:%d\nmemory_available:%d\nmemory_usage:%d\n" \
"$MEM_TOTAL" \
"$MEM_FREE" \
"$MEM_AVAILABLE" \
$(((MEM_TOTAL - MEM_AVAILABLE) * 100 / MEM_TOTAL)) > "$TMP_SENSOR_FILE"
mv "$TMP_SENSOR_FILE" "$SENSOR_FILE"
sleep "$REFRESH"
done
+765
View File
@@ -0,0 +1,765 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use clap::Parser;
use env_logger::Env;
use itertools::Itertools;
use log::{debug, error, info};
use regex::Regex;
use std::cmp::PartialEq;
use std::collections::HashMap;
use std::fmt::Display;
use std::fs;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, exit};
use std::thread::sleep;
use std::time::{Duration, Instant};
use sysinfo::{Components, DiskKind, Disks, Networks, System};
use tempfile::NamedTempFile;
/// Proof of concept sensor value collection for the asterctl screen control tool.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Output sensor file.
#[arg(short, long)]
out: Option<PathBuf>,
/// 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!
#[arg(short, long)]
temp_dir: Option<PathBuf>,
/// Print values in console
#[arg(long)]
console: bool,
/// System sensor refresh interval in seconds
#[arg(short, long)]
refresh: Option<u16>,
/// Enable individual disk refresh logic as used in AOOSTAR-X. Refresh interval in seconds.
#[arg(long)]
disk_refresh: Option<u16>,
/// Retrieve drive temperature if `disk-update` option is enabled.
///
/// Requires smartctl and password-less sudo!
#[cfg(target_os = "linux")]
#[arg(long)]
smartctl: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let args = Args::parse();
#[cfg(target_os = "linux")]
let use_smartctl = args.smartctl;
#[cfg(not(target_os = "linux"))]
let use_smartctl = false;
let mut sensors = HashMap::with_capacity(64);
let mut sysinfo_source = SysinfoSource::new();
let refresh = Duration::from_secs(args.refresh.unwrap_or_default() as u64);
let disk_refresh = Duration::from_secs(args.disk_refresh.unwrap_or_default() as u64);
let mut disk_refresh_time = Instant::now();
if !disk_refresh.is_zero() {
update_linux_storage_sensors(&mut sensors, use_smartctl)?;
}
loop {
let upd_start_time = Instant::now();
sysinfo_source.refresh();
sysinfo_source.update_sensors(&mut sensors)?;
if !disk_refresh.is_zero() && disk_refresh_time.elapsed() > disk_refresh {
info!("Refreshing individual disks");
update_linux_storage_sensors(&mut sensors, use_smartctl)?;
disk_refresh_time = Instant::now();
}
if let Some(out_file) = &args.out {
write_sensor_file(out_file, args.temp_dir.as_deref(), &sensors)?;
}
if args.console {
// pretty print console output with sorted keys
for (label, value) in sensors.iter().sorted() {
println!("{}: {}", label, value);
}
println!();
}
if refresh.is_zero() {
break;
}
let elapsed = upd_start_time.elapsed();
if refresh > elapsed {
sleep(refresh - elapsed);
}
}
Ok(())
}
fn write_sensor_file(
out_file: &Path,
temp_dir: Option<&Path>,
sensors: &HashMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> {
if out_file.is_dir() {
error!("Output cannot be a directory: {}", out_file.display());
exit(1);
}
let tmp_file = if let Some(temp_path) = temp_dir {
fs::create_dir_all(temp_path)?;
NamedTempFile::new_in(temp_path)?
} else {
NamedTempFile::new()?
};
let mut stream = BufWriter::new(&tmp_file);
for (label, value) in sensors.iter() {
writeln!(stream, "{label}: {value}")?;
}
stream.flush()?;
drop(stream);
tmp_file.persist(out_file)?;
Ok(())
}
pub struct SysinfoSource {
sys: System,
disks: Disks,
components: Components,
networks: Networks,
last_refresh: Option<Instant>,
refresh_duration: Option<Duration>,
}
impl Default for SysinfoSource {
fn default() -> Self {
Self::new()
}
}
impl SysinfoSource {
pub fn new() -> Self {
Self {
sys: System::new_all(),
disks: Disks::new(),
components: Components::new(),
networks: Networks::new(),
last_refresh: None,
refresh_duration: None,
}
}
pub fn refresh(&mut self) {
self.sys.refresh_all();
// TODO research "remove_not_listed_###" refresh parameter
self.disks.refresh(false);
self.components.refresh(false);
self.networks.refresh(false);
if let Some(last_refresh) = self.last_refresh {
self.refresh_duration = Some(last_refresh.elapsed());
}
self.last_refresh = Some(Instant::now());
}
fn update_sensors(
&self,
sensors: &mut HashMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> {
for cpu in self.sys.cpus() {
add_sensor(
sensors,
format!("cpu_{}_frequency", cpu.name()),
cpu.frequency(),
);
add_sensor(
sensors,
format!("cpu_{}_usage", cpu.name()),
format!("{:.2}", cpu.cpu_usage()),
);
}
let load_avg = System::load_average();
add_sensor(sensors, "load_avg_one", format!("{:.2}", load_avg.one));
add_sensor(sensors, "load_avg_five", format!("{:.2}", load_avg.five));
add_sensor(
sensors,
"load_avg_fifteen",
format!("{:.2}", load_avg.fifteen),
);
// RAM and swap information:
add_sensor(sensors, "mem_free_bytes", self.sys.free_memory());
add_sensor(sensors, "mem_free", format_bytes(self.sys.free_memory()));
add_sensor(sensors, "mem_total_bytes", self.sys.total_memory());
add_sensor(sensors, "mem_total", format_bytes(self.sys.total_memory()));
add_sensor(sensors, "mem_used_bytes", self.sys.used_memory());
add_sensor(sensors, "mem_used", format_bytes(self.sys.used_memory()));
add_sensor(
sensors,
"mem_usage_percent",
format!(
"{:.1}",
(self.sys.used_memory() * 100) as f64 / self.sys.total_memory() as f64
),
);
add_sensor(sensors, "swap_free_bytes", self.sys.free_swap());
add_sensor(sensors, "swap_free", format_bytes(self.sys.free_swap()));
add_sensor(sensors, "swap_total_bytes", self.sys.total_swap());
add_sensor(sensors, "swap_total", format_bytes(self.sys.total_swap()));
add_sensor(sensors, "swap_used_bytes", self.sys.used_swap());
add_sensor(sensors, "swap_used", format_bytes(self.sys.used_swap()));
add_sensor(
sensors,
"swap_usage_percent",
format!(
"{:.1}",
(self.sys.used_swap() * 100) as f64 / self.sys.total_swap() as f64
),
);
// System information:
if let Some(name) = System::name() {
add_sensor(sensors, "system_name", name);
}
if let Some(kernel_version) = System::kernel_version() {
add_sensor(sensors, "system_kernel_version", kernel_version);
}
if let Some(os_version) = System::os_version() {
add_sensor(sensors, "system_os_version", os_version);
}
if let Some(host_name) = System::host_name() {
add_sensor(sensors, "system_hostname", host_name);
}
add_sensor(sensors, "cpu_count", self.sys.cpus().len());
add_sensor(sensors, "total_processes", self.sys.processes().len());
// disks' information:
let mut ssd_idx = 0;
let mut hdd_idx = 0;
for disk in &self.disks {
let label;
match disk.kind() {
DiskKind::SSD => {
label = format!("storage_ssd[{}]", ssd_idx);
ssd_idx += 1;
}
DiskKind::HDD => {
label = format!("storage_hdd[{}]", hdd_idx);
hdd_idx += 1;
}
_ => continue,
}
// special label for AOOSTAR-X system panel
add_sensor(
sensors,
format!("{label}_usage_percent"),
(disk.total_space() - disk.available_space()) * 100 / disk.total_space(),
);
// using similar labels as AOOSTAR-X, but combining `{label2}_{label}`
let device = disk.name().to_string_lossy().replace(' ', "_");
add_sensor(
sensors,
format!("disk_{device}_total_bytes"),
disk.total_space(),
);
add_sensor(
sensors,
format!("disk_{device}_total"),
format_bytes(disk.total_space()),
);
let used = disk.total_space() - disk.available_space();
add_sensor(sensors, format!("disk_{device}_used_bytes"), used);
add_sensor(sensors, format!("disk_{device}_used"), format_bytes(used));
add_sensor(
sensors,
format!("disk_{device}_free_bytes"),
disk.available_space(),
);
add_sensor(
sensors,
format!("disk_{device}_free"),
format_bytes(disk.available_space()),
);
add_sensor(
sensors,
format!("disk_{device}_usage_percent"),
format!(
"{:.1}",
(disk.total_space() - disk.available_space()) as f64 * 100.0
/ disk.total_space() as f64
),
);
}
// Components temperature:
for component in &self.components {
if let Some(temperature) = component.temperature() {
let label;
if component.label().contains("spd5118") {
label = "temperature_memory".to_string();
} else if component.label().contains("amdgpu") {
label = "temperature_gpu".to_string();
} else if component.label().contains("Tctl") {
label = "temperature_cpu".to_string();
} else if component.label().contains("Composite")
&& !component.label().contains("nvme")
{
// just a guess...
label = "temperature_motherboard".to_string();
} else {
label = format!("temperature_{}", component.label().replace(' ', "_"));
// println!("label={}, type_id={:?}, id={:?}, {component:?}",
// component.label(), component.type_id(), component.id());
}
// TODO add unit as a separate sensor?
add_sensor(sensors, label, format!("{temperature:.1} °C"));
}
}
// Network interfaces name, total data received and total data transmitted:
for (interface_name, data) in &self.networks {
// only consider specific interfaces
let if_name = interface_name.to_lowercase();
if !["eth", "en", "em", "wlan", "wlp", "wlo"]
.iter()
.any(|i| if_name.starts_with(*i))
{
continue;
}
// Sort by address to avoid random order in refreshes
for (idx, addr) in data
.ip_networks()
.iter()
.map(|net| net.addr)
.sorted()
.enumerate()
{
add_sensor(
sensors,
format!("network_{interface_name}_address{idx}"),
addr,
);
}
if let Some(refresh) = self.refresh_duration {
let interval = refresh.as_millis() as u64;
if interval > 0 {
add_sensor(
sensors,
format!("network_{interface_name}_download_speed"),
format!("{}/s", format_bytes(1000 * data.received() / interval)),
);
add_sensor(
sensors,
format!("network_{interface_name}_upload_speed"),
format!("{}/s", format_bytes(1000 * data.transmitted() / interval)),
);
}
}
add_sensor(
sensors,
format!("network_{interface_name}_total_received_bytes"),
data.total_received(),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_received"),
format_bytes(data.total_received()),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_transmitted_bytes"),
data.total_transmitted(),
);
add_sensor(
sensors,
format!("network_{interface_name}_total_transmitted"),
format_bytes(data.total_transmitted()),
);
}
Ok(())
}
}
fn add_sensor(
sensors: &mut HashMap<String, String>,
label: impl Into<String>,
value: impl Display,
) {
sensors.insert(label.into(), value.to_string());
}
fn update_linux_storage_sensors(
sensors: &mut HashMap<String, String>,
use_smartctl: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Note: AOOSTAR-X only considered spinning Rust. Too bad if you're using SSDs in the HD bays...
if let Ok(hdd_devices) = get_storage_devices(StorageDevice::HddOrSsd) {
debug!("HDD devices : {:?}", hdd_devices);
for (idx, device) in hdd_devices.iter().enumerate() {
let usage = get_disk_usage(device)?;
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_size_bytes"),
usage.total_size,
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_size"),
format_bytes(usage.total_size),
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_used_bytes"),
usage.total_used,
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_total_used"),
format_bytes(usage.total_used),
);
add_sensor(
sensors,
format!("storage_hdd[{idx}]_usage_percent"),
usage.usage_percent,
);
if use_smartctl && let Some(temperature) = get_smartctl_disk_temperature(device)? {
add_sensor(
sensors,
format!("storage_hdd[{idx}]_temperature"),
temperature,
);
}
}
}
// AOOSTAR-X: ssd == nvme
if let Ok(nvme_devices) = get_storage_devices(StorageDevice::Nvme) {
debug!("NVME devices: {:?}", nvme_devices);
for (idx, device) in nvme_devices.iter().enumerate() {
let usage = get_disk_usage(device)?;
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_size_bytes"),
usage.total_size,
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_size"),
format_bytes(usage.total_size),
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_used_bytes"),
usage.total_used,
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_total_used"),
format_bytes(usage.total_used),
);
add_sensor(
sensors,
format!("storage_ssd[{idx}]_usage_percent"),
usage.usage_percent,
);
if use_smartctl && let Some(temperature) = get_smartctl_disk_temperature(device)? {
add_sensor(
sensors,
format!("storage_ssd[{idx}]_temperature"),
temperature,
);
}
}
}
Ok(())
}
#[derive(Debug)]
pub struct DiskInfo {
pub device: String,
pub temperature: i32,
pub used: f64,
pub total_used: u64,
pub total_size: u64,
}
#[derive(Debug)]
pub struct DiskUsage {
pub usage_percent: f64,
pub total_used: u64,
pub total_size: u64,
}
#[derive(Debug, PartialEq)]
pub enum StorageDevice {
All,
Hdd,
Ssd,
HddOrSsd,
Nvme,
}
pub type DiskResult = Result<Vec<DiskInfo>, Box<dyn std::error::Error>>;
/// Get storage devices of the given type: NVME, SSD or HD
///
/// Storage devices are identified from /sys/block attributes.
/// Removable devices are excluded.
///
/// # Arguments
///
/// * `kind`: type of storage device
///
/// returns: sorted list of found device names (`sd*` and `nvme*`)
pub fn get_storage_devices(kind: StorageDevice) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut devices = Vec::new();
let sys_block = Path::new("/sys/block");
if !sys_block.exists() {
info!("No storage device found");
return Ok(devices);
}
let device_regex = Regex::new(r"^sd[a-z]+$")?;
let nvme_regex = Regex::new(r"^nvme[0-9]+n[0-9]+$")?;
for entry in fs::read_dir(sys_block)? {
let entry = entry?;
let dev_name = entry.file_name();
let dev_str = dev_name.to_string_lossy();
// filter out all non sd* and nvme* devices
let is_nvme = nvme_regex.is_match(&dev_str);
let is_storage = device_regex.is_match(&dev_str);
if !(is_nvme || is_storage) {
continue;
}
match kind {
StorageDevice::All => {}
StorageDevice::Hdd | StorageDevice::Ssd | StorageDevice::HddOrSsd => {
if !is_storage {
continue;
}
}
StorageDevice::Nvme => {
if !is_nvme {
continue;
}
}
};
if is_nvme {
let dev_name = entry.file_name();
let dev_str = dev_name.to_string_lossy();
devices.push(dev_str.to_string());
continue;
}
let rotational_path = sys_block.join(dev_str.as_ref()).join("queue/rotational");
let removable_path = sys_block.join(dev_str.as_ref()).join("removable");
match (
fs::read_to_string(&rotational_path),
fs::read_to_string(&removable_path),
) {
(Ok(rotational), Ok(removable)) => {
let rotational = rotational.trim();
let removable = removable.trim();
// ignore removable
if removable == "1" {
continue;
}
if kind == StorageDevice::Hdd && rotational == "1"
|| kind == StorageDevice::Ssd && rotational == "0"
|| kind == StorageDevice::HddOrSsd
{
devices.push(dev_str.to_string());
}
}
(Err(e), _) | (_, Err(e)) => {
error!("Unable to read device {dev_str} attributes: {e}");
}
}
}
devices.sort();
Ok(devices)
}
/// Retrieve temperature from NVMe or SDD/HDD with smartctl
pub fn get_smartctl_disk_temperature(dev: &str) -> Result<Option<i32>, Box<dyn std::error::Error>> {
let temp_regex =
Regex::new(r"194\s+Temperature_Celsius\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+-\s+(\d+)")?;
let nvme_temp_regex = Regex::new(r"Temperature:\s+(\d+)\s")?;
let dev = format!("/dev/{}", dev);
match Command::new("sudo")
.arg("-n")
.arg("smartctl")
.arg("-A")
.arg(&dev)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(temp_captures) = temp_regex
.captures(&stdout)
.or_else(|| nvme_temp_regex.captures(&stdout))
&& let Some(temp_match) = temp_captures.get(1)
{
let temperature = temp_match.as_str().parse::<i32>()?;
return Ok(Some(temperature));
}
}
Err(e) => {
error!("Device {dev} acquisition failed, error: {e}");
}
}
Ok(None)
}
/// Calculate actual filesystem usage rate of hard disk (based on df command)
pub fn get_disk_usage(dev: &str) -> Result<DiskUsage, Box<dyn std::error::Error>> {
let mut tmp = DiskUsage {
usage_percent: 0.0,
total_used: 0,
total_size: 0,
};
// Get mounted partitions for this device
let cmd = format!(
"df -h --output=source,target,pcent | grep '/dev/{}[0-9]*'",
dev
);
match Command::new("sh").arg("-c").arg(&cmd).output() {
Ok(output) => {
if !output.status.success() {
return Ok(tmp);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut total_used: u64 = 0;
let mut total_size: u64 = 0;
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
continue;
}
let mountpoint = parts[1];
// Get size in bytes
let size_cmd = format!(
"df --block-size=1 {} | awk 'NR==2 {{print $2}}'",
mountpoint
);
if let Ok(size_output) = Command::new("sh").arg("-c").arg(&size_cmd).output()
&& let Ok(size_str) = String::from_utf8(size_output.stdout)
&& let Ok(size) = size_str.trim().parse::<u64>()
{
total_size += size;
}
// Get used space in bytes
let used_cmd = format!(
"df --block-size=1 {} | awk 'NR==2 {{print $3}}'",
mountpoint
);
if let Ok(used_output) = Command::new("sh").arg("-c").arg(&used_cmd).output()
&& let Ok(used_str) = String::from_utf8(used_output.stdout)
&& let Ok(used) = used_str.trim().parse::<u64>()
{
total_used += used;
}
}
if total_size != 0 {
tmp.usage_percent =
((total_used as f64 / total_size as f64) * 100.0 * 100.0).round() / 100.0;
tmp.total_used = total_used;
tmp.total_size = total_size;
}
Ok(tmp)
}
Err(_) => Ok(tmp),
}
}
/// Format bytes into human-readable string
pub fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
const THRESHOLD: f64 = 1024.0;
if bytes == 0 {
return "0 B".to_string();
}
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= THRESHOLD && unit_index < UNITS.len() - 1 {
size /= THRESHOLD;
unit_index += 1;
}
if unit_index > 0 {
format!("{:.2} {}", size, UNITS[unit_index])
} else {
format!("{} {}", size, UNITS[unit_index])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(1024), "1.00 KB");
assert_eq!(format_bytes(1048576), "1.00 MB");
assert_eq!(format_bytes(1073741824), "1.00 GB");
}
}
+96 -37
View File
@@ -6,6 +6,7 @@
//! Derived from the available Monitor3.json file in AOOSTAR-X v1.3.4. //! Derived from the available Monitor3.json file in AOOSTAR-X v1.3.4.
//! Likely not fully compatible with files created with the original editor. //! Likely not fully compatible with files created with the original editor.
use anyhow::Context;
use image::Rgb; use image::Rgb;
use imageproc::definitions::HasWhite; use imageproc::definitions::HasWhite;
use log::warn; use log::warn;
@@ -15,10 +16,12 @@ use serde_repr::{Deserialize_repr, Serialize_repr};
use std::io::BufReader; use std::io::BufReader;
use std::num::ParseIntError; use std::num::ParseIntError;
use std::ops::Deref; use std::ops::Deref;
use std::path::Path;
use std::{fmt, fs}; use std::{fmt, fs};
pub fn load_cfg(path: &str) -> anyhow::Result<MonitorConfig> { pub fn load_cfg<P: AsRef<Path>>(path: P) -> anyhow::Result<MonitorConfig> {
let file = fs::File::open(path)?; let path = path.as_ref();
let file = fs::File::open(path).with_context(|| format!("Failed to load config {path:?}"))?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
let config: MonitorConfig = serde_json::from_reader(reader)?; let config: MonitorConfig = serde_json::from_reader(reader)?;
@@ -30,8 +33,7 @@ pub fn load_cfg(path: &str) -> anyhow::Result<MonitorConfig> {
let panel = &config.panels[active as usize - 1]; let panel = &config.panels[active as usize - 1];
println!( println!(
"Panel {active}: type={}, {}", "Panel {active}: {}",
panel.panel_type,
panel.img.as_deref().unwrap_or_default() panel.img.as_deref().unwrap_or_default()
); );
for sensor in &panel.sensor { for sensor in &panel.sensor {
@@ -55,28 +57,69 @@ pub fn load_cfg(path: &str) -> anyhow::Result<MonitorConfig> {
/// AOOSTAR-X monitor json configuration file /// AOOSTAR-X monitor json configuration file
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct MonitorConfig { pub struct MonitorConfig {
/// _Not used_ // _Not used_
pub credentials: Option<Credentials>, // pub credentials: Option<Credentials>,
/// Configuration settings.
pub setup: Setup, pub setup: Setup,
/// Panels: 1-based index into diy[i] /// Panels: 1-based index into `panels`
#[serde(rename = "mianban")] #[serde(rename = "mianban")]
pub active_panels: Vec<u32>, pub active_panels: Vec<u32>,
/// Custom panels / DIY "Do It Yourself", /// Custom panels / DIY "Do It Yourself",
#[serde(rename = "diy")] #[serde(rename = "diy")]
pub panels: Vec<Panel>, pub panels: Vec<Panel>,
/// Internal index of the currently active panel. 1-based!
#[serde(skip)]
active_panel_idx: Option<usize>,
}
impl MonitorConfig {
pub fn get_next_active_panel(&mut self) -> Option<&Panel> {
let mut active_panel_idx = self.active_panel_idx.unwrap_or(0) + 1;
if active_panel_idx > self.panels.len() {
active_panel_idx = 1;
}
for (index, active) in self
.active_panels
.iter()
.filter(|&active| *active > 0)
.enumerate()
{
if *active > self.panels.len() as u32 {
warn!("Ignoring invalid active panel {active}");
continue;
}
if index + 1 == active_panel_idx {
self.active_panel_idx = Some(active_panel_idx);
return Some(&self.panels[*active as usize - 1]);
}
}
None
}
} }
/// Web-app user login /// Web-app user login
///
/// Not used, part of AOOSTAR-X json configuration file.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Credentials { pub struct Credentials {
pub username: String, pub username: String,
pub password: String, pub password: String,
} }
/// Configuration Settings /// Configuration settings.
///
/// Note: Trimmed down object to include only required fields for `asterctl`.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Setup { pub struct Setup {
/// Switch time between panels in seconds, interpreted as float and converted to milliseconds. Default: 5
pub switch_time: Option<String>, // existed as "30" string
/// Panel redraw interval in seconds. Default: 1
pub refresh: f32,
/*
// The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl`
/// Default: true /// Default: true
pub off_display: bool, pub off_display: bool,
/// Selection of default panels based on theme / control_params / control_disk_temp ? /// Selection of default panels based on theme / control_params / control_disk_temp ?
@@ -89,8 +132,6 @@ pub struct Setup {
pub custom_panel: bool, pub custom_panel: bool,
/// Language index. Default: 0 /// Language index. Default: 0
pub language: Language, pub language: Language,
/// Switch time between panels (?) in seconds, interpreted as int. Default: 5
pub switch_time: Option<String>, // existed as "30" string
/// Operation mode: performance, power saving, etc. /// Operation mode: performance, power saving, etc.
pub operation_mode: Option<OperationMode>, pub operation_mode: Option<OperationMode>,
/// Operation type 1 or 2 (?). Default: 1 /// Operation type 1 or 2 (?). Default: 1
@@ -106,20 +147,25 @@ pub struct Setup {
#[serde(deserialize_with = "empty_string_as_none")] #[serde(deserialize_with = "empty_string_as_none")]
#[serde(rename = "ha_token")] #[serde(rename = "ha_token")]
pub ha_token: Option<String>, // "" in JSON ⇒ Option<String> pub ha_token: Option<String>, // "" in JSON ⇒ Option<String>
/// Panel refresh in seconds. Default: 1 */
pub refresh: f32,
} }
/// Language setting.
///
/// Not used, part of AOOSTAR-X json configuration file.
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)] #[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
#[repr(u8)] #[repr(u8)]
#[allow(dead_code)]
pub enum Language { pub enum Language {
Chinese = 0, Chinese = 0,
English = 1, English = 1,
Japanese = 2, Japanese = 2,
} }
/// Not used, part of AOOSTAR-X json configuration file.
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)] #[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
#[repr(i16)] #[repr(i16)]
#[allow(dead_code)]
pub enum OperationMode { pub enum OperationMode {
None = -1, None = -1,
HighPerformance = 0, HighPerformance = 0,
@@ -137,11 +183,14 @@ pub struct Panel {
pub id: Option<String>, pub id: Option<String>,
/// Custom panel name /// Custom panel name
pub name: Option<String>, pub name: Option<String>,
/*
// The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl`
/// TODO /// TODO
pub checked: Option<bool>, pub checked: Option<bool>,
/// TODO panel type: 5 = built-in? 6 = custom ? /// TODO panel type: 5 = built-in? 6 = custom ?
#[serde(rename = "type")] #[serde(rename = "type")]
pub panel_type: i32, pub panel_type: i32,
*/
/// Background image filename /// Background image filename
pub img: Option<String>, pub img: Option<String>,
/// Sensors /// Sensors
@@ -169,38 +218,47 @@ pub struct Sensor {
pub name: Option<String>, pub name: Option<String>,
/// Label name for custom panels. /// Label name for custom panels.
pub item_name: Option<String>, pub item_name: Option<String>,
/// TODO Data source? /// Label identifier, also used as data source identifier.
pub label: String, pub label: String,
/// Sensor value. Ignored: value is used from a sensor source
/// x-position. Custom panel coordinates are stored as float!
pub x: f32,
/// x-position. TODO unit
pub y: f32,
pub width: Option<i32>,
pub height: Option<i32>,
pub text_direction: i32, // layout direction
pub direction: i32, // sensor orientation, 0/1
#[serde(deserialize_with = "empty_string_as_none")] #[serde(deserialize_with = "empty_string_as_none")]
pub value: Option<String>, // "" or numbers, so Option<String> pub value: Option<String>, // "" or numbers, so Option<String>
pub font_family: String,
pub font_size: i32,
/// Font color in `#RRGGBB` notation, or -1 if not set. #ffffff = white, #ff0000 = red
pub font_color: FontColor,
pub font_weight: FontWeight,
pub text_align: TextAlign,
#[serde(deserialize_with = "option_none_if_minus_one")]
pub integer_digits: Option<i32>, // -1 ≈ unset ⇒ Option<i32>
#[serde(deserialize_with = "option_none_if_minus_one")]
pub decimal_digits: Option<i32>, // -1 ≈ unset ⇒ Option<i32>
/// Optional unit text to print after the value /// Optional unit text to print after the value
#[serde(deserialize_with = "empty_string_as_none")] #[serde(deserialize_with = "empty_string_as_none")]
pub unit: Option<String>, pub unit: Option<String>,
/// x-position. Custom panel coordinates are stored as float!
pub x: f32,
/// y-position.
pub y: f32,
/// _Not (yet) used_
pub width: Option<i32>,
/// _Not (yet) used_
pub height: Option<i32>,
/// _Not (yet) used_
pub text_direction: i32, // layout direction
/// _Not (yet) used_
pub direction: i32, // sensor orientation, 0/1
/// Font name matching font filename without file extension.
pub font_family: String,
/// TODO font size unit: points or pixels?
pub font_size: i32,
/// Font color in `#RRGGBB` notation, or -1 if not set. #ffffff = white, #ff0000 = red
pub font_color: FontColor,
/// _Not (yet) used_
pub font_weight: FontWeight,
pub text_align: TextAlign,
/// Number of integer places for the sensor value.
// -1 ≈ unset ⇒ Option<i32>
#[serde(deserialize_with = "option_none_if_minus_one")]
pub integer_digits: Option<i32>,
/// Number of decimal places for the sensor value.
// -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 min_angle: i32,
pub max_angle: i32, pub max_angle: i32,
pub min_value: i32, pub min_value: i32,
@@ -222,6 +280,7 @@ pub struct Sensor {
pub data: Option<String>, pub data: Option<String>,
/// For type = 6 /// For type = 6
pub interval: Option<u32>, pub interval: Option<u32>,
*/
} }
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)] #[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
+16 -4
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT OR Apache-2.0 // SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder // SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use crate::dummy_serialport::DummySerialPort;
use crate::img::rgb888_to_565; use crate::img::rgb888_to_565;
use anyhow::{Context, anyhow}; use anyhow::{Context, anyhow};
use bytes::{BufMut, BytesMut}; use bytes::{BufMut, BytesMut};
@@ -65,6 +66,16 @@ impl AooScreenBuilder {
self.open_usb(USB_UART_VID, USB_UART_PID) self.open_usb(USB_UART_VID, USB_UART_PID)
} }
/// Simulate the LCD device. No real device or serial port is required.
pub fn simulate(self) -> anyhow::Result<AooScreen> {
Ok(AooScreen {
port: Some(Box::new(DummySerialPort::new())),
enable_cache: self.enable_cache.unwrap_or(true),
prev_frame: None,
no_init_check: self.no_init_check.unwrap_or(false),
})
}
/// Open the specified USB UART device id. Format: vid:pid /// Open the specified USB UART device id. Format: vid:pid
pub fn open_usb_id(self, id: &str) -> anyhow::Result<AooScreen> { pub fn open_usb_id(self, id: &str) -> anyhow::Result<AooScreen> {
let (vid, pid) = id let (vid, pid) = id
@@ -276,10 +287,11 @@ pub fn find_usb_serial_port(vid: u16, pid: u16) -> serialport::Result<String> {
let ports = serialport::available_ports()?; let ports = serialport::available_ports()?;
for p in ports { for p in ports {
debug!("Found serial port: {}", p.port_name); debug!("Found serial port: {}", p.port_name);
if let SerialPortType::UsbPort(info) = p.port_type { if let SerialPortType::UsbPort(info) = p.port_type
if info.pid == pid && info.vid == vid { && info.pid == pid
return Ok(p.port_name); && info.vid == vid
} {
return Ok(p.port_name);
} }
} }
+158
View File
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
use serialport::{ClearBuffer, DataBits, FlowControl, Parity, SerialPort, StopBits};
use std::thread::sleep;
use std::time::Duration;
pub struct DummySerialPort {
baud_rate: u32,
data_bits: DataBits,
flow_control: FlowControl,
parity: Parity,
stop_bits: StopBits,
timeout: Duration,
}
impl DummySerialPort {
pub fn new() -> DummySerialPort {
Self {
baud_rate: 1_500_000,
data_bits: DataBits::Eight,
flow_control: FlowControl::None,
parity: Parity::None,
stop_bits: StopBits::One,
timeout: Default::default(),
}
}
}
impl std::io::Read for DummySerialPort {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
buf[0] = b'A';
Ok(1)
}
}
impl std::io::Write for DummySerialPort {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
// just some approximation, additional overhead like flushing etc is not considered
let byte_rate =
self.baud_rate / (1 + u8::from(self.data_bits) + u8::from(self.stop_bits)) as u32;
let delay = Duration::from_micros((buf.len() * 1000 * 1000 / byte_rate as usize) as u64);
sleep(delay);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl SerialPort for DummySerialPort {
fn name(&self) -> Option<String> {
Some("Dummy Serial".into())
}
fn baud_rate(&self) -> serialport::Result<u32> {
Ok(self.baud_rate)
}
fn data_bits(&self) -> serialport::Result<DataBits> {
Ok(self.data_bits)
}
fn flow_control(&self) -> serialport::Result<FlowControl> {
Ok(self.flow_control)
}
fn parity(&self) -> serialport::Result<Parity> {
Ok(self.parity)
}
fn stop_bits(&self) -> serialport::Result<StopBits> {
Ok(self.stop_bits)
}
fn timeout(&self) -> Duration {
self.timeout
}
fn set_baud_rate(&mut self, baud_rate: u32) -> serialport::Result<()> {
self.baud_rate = baud_rate;
Ok(())
}
fn set_data_bits(&mut self, data_bits: DataBits) -> serialport::Result<()> {
self.data_bits = data_bits;
Ok(())
}
fn set_flow_control(&mut self, flow_control: FlowControl) -> serialport::Result<()> {
self.flow_control = flow_control;
Ok(())
}
fn set_parity(&mut self, parity: Parity) -> serialport::Result<()> {
self.parity = parity;
Ok(())
}
fn set_stop_bits(&mut self, stop_bits: StopBits) -> serialport::Result<()> {
self.stop_bits = stop_bits;
Ok(())
}
fn set_timeout(&mut self, timeout: Duration) -> serialport::Result<()> {
self.timeout = timeout;
Ok(())
}
fn write_request_to_send(&mut self, _level: bool) -> serialport::Result<()> {
Ok(())
}
fn write_data_terminal_ready(&mut self, _level: bool) -> serialport::Result<()> {
Ok(())
}
fn read_clear_to_send(&mut self) -> serialport::Result<bool> {
Ok(true)
}
fn read_data_set_ready(&mut self) -> serialport::Result<bool> {
Ok(true)
}
fn read_ring_indicator(&mut self) -> serialport::Result<bool> {
Ok(false)
}
fn read_carrier_detect(&mut self) -> serialport::Result<bool> {
Ok(false)
}
fn bytes_to_read(&self) -> serialport::Result<u32> {
Ok(1)
}
fn bytes_to_write(&self) -> serialport::Result<u32> {
Ok(0)
}
fn clear(&self, _buffer_to_clear: ClearBuffer) -> serialport::Result<()> {
Ok(())
}
fn try_clone(&self) -> serialport::Result<Box<dyn SerialPort>> {
todo!()
}
fn set_break(&self) -> serialport::Result<()> {
Ok(())
}
fn clear_break(&self) -> serialport::Result<()> {
Ok(())
}
}
+200
View File
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Sensor value format functions based on the AOOSTAR-X application.
#[derive(Debug, Clone)]
pub enum IntegerDigits {
/// Keep all integer digits
Auto, // -1 in Python
/// Only keep the integer part of a decimal number
Zero, // 0 in Python
/// Limit integer part of a decimal number to the given length
Fixed(usize), // positive values
}
impl From<i32> for IntegerDigits {
fn from(value: i32) -> Self {
match value {
-1 => IntegerDigits::Auto,
0 => IntegerDigits::Zero,
n if n > 0 => IntegerDigits::Fixed(n as usize),
_ => IntegerDigits::Auto,
}
}
}
impl From<Option<i32>> for IntegerDigits {
fn from(value: Option<i32>) -> Self {
match value {
None => IntegerDigits::Auto,
Some(digits) => IntegerDigits::from(digits),
}
}
}
/// Format a sensor value in string format to the specified fixed point number.
///
/// # Arguments
///
/// * `value`: decimal number to format
/// * `integer_digits`: number of integer places
/// * `decimal_digits`: fixed point numbers
/// * `unit`: unit suffix to append after the formatted number
///
/// returns: String
///
/// # Examples
///
/// ```
/// let value = format_value("123.456", IntegerDigits::Auto, 0, "foobar");
/// assert_eq!(value, "123foobar");
/// ```
pub fn format_value(
value: &str,
integer_digits: IntegerDigits,
decimal_digits: usize,
unit: &str,
) -> String {
let num = match value.parse::<f64>() {
Ok(n) => n,
Err(_) => return format!("{}{}", value, unit),
};
// Round number to the specified decimal digits
let factor = 10f64.powi(decimal_digits as i32);
let rounded = if decimal_digits == 0 {
num.round()
} else {
(num * factor).round() / factor
};
// Get integer and decimal parts
// The integer part may increase due to rounding!
let integer_part = rounded.trunc() as i64;
let decimal_part = if decimal_digits > 0 {
let mut dec = (rounded.fract().abs() * factor).round() as u64;
// Handle cases where rounding makes the decimal part equal to factor
if dec == factor as u64 {
// e.g. 9.999 rounded to 1 decimal = 10.0
// We set decimal part to 0
dec = 0;
}
format!("{:0width$}", dec, width = decimal_digits)
} else {
"".to_string()
};
// Format integer part according to padding rules
let integer_str = integer_part.to_string();
let integer_filled = match integer_digits {
IntegerDigits::Auto => integer_str.clone(),
IntegerDigits::Zero => "".to_string(),
IntegerDigits::Fixed(digits) => {
if integer_str.len() > digits {
"9".repeat(digits)
} else {
format!("{:0width$}", integer_part, width = digits)
}
}
};
let formatted = if decimal_digits > 0 {
format!("{}.{}", integer_filled, decimal_part)
} else {
integer_filled
};
format!("{}{}", formatted, unit)
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case(5, 2, "00123.46°C")]
#[case(5, 1, "00123.5°C")]
#[case(5, 0, "00123°C")]
#[case(-1, 2, "123.46°C")]
#[case(-1, 1, "123.5°C")]
#[case(-1, 0, "123°C")]
#[case(2, 0, "99°C")]
fn test_format_value_with_decimal(
#[case] digits: i32,
#[case] decimals: usize,
#[case] output: &str,
) {
let result = format_value("123.456", IntegerDigits::from(digits), decimals, "°C");
assert_eq!(output, result);
}
#[rstest]
#[case(5, 2, "00123.00°C")]
#[case(5, 1, "00123.0°C")]
#[case(5, 0, "00123°C")]
#[case(-1, 2, "123.00°C")]
#[case(-1, 1, "123.0°C")]
#[case(-1, 0, "123°C")]
#[case(2, 0, "99°C")]
fn test_format_value_with_integer(
#[case] digits: i32,
#[case] decimals: usize,
#[case] output: &str,
) {
let result = format_value("123", IntegerDigits::from(digits), decimals, "°C");
assert_eq!(output, result);
}
#[rstest]
#[case(5, 2, "-0123.00°C")]
#[case(5, 0, "-0123°C")]
#[case(-1, 2, "-123.00°C")]
#[case(-1, 1, "-123.0°C")]
#[case(-1, 0, "-123°C")]
#[case(2, 0, "99°C")] // Overflow
fn test_format_value_with_negative_integer(
#[case] digits: i32,
#[case] decimals: usize,
#[case] output: &str,
) {
let result = format_value("-123", IntegerDigits::from(digits), decimals, "°C");
assert_eq!(output, result);
}
#[rstest]
#[case("0", 3, 1, "V", "000.0V")]
#[case("999.99", 2, 1, "%", "99.0%")]
#[case("invalid", 2, 2, "unit", "invalidunit")]
fn test_format_value_edge_cases(
#[case] input: &str,
#[case] digits: i32,
#[case] decimals: usize,
#[case] unit: &str,
#[case] output: &str,
) {
let result = format_value(input, IntegerDigits::from(digits), decimals, unit);
assert_eq!(output, result);
}
#[rstest]
#[case("1.999", 2, 1, "", "02.0")]
#[case("1.999", 2, 0, "", "02")]
#[case("1.999", 1, 1, "", "2.0")]
#[case("1.999", -1, 1, "", "2.0")]
#[case("0.999", 1, 2, "", "1.00")]
#[case("0.999", 1, 1, "", "1.0")]
#[case("0.999", 1, 0, "", "1")]
#[case("123.6", -1, 0, "", "124")]
fn test_format_value_rounding(
#[case] input: &str,
#[case] digits: i32,
#[case] decimals: usize,
#[case] unit: &str,
#[case] output: &str,
) {
let result = format_value(input, IntegerDigits::from(digits), decimals, unit);
assert_eq!(output, result);
}
}
+274 -44
View File
@@ -3,21 +3,31 @@
mod cfg; mod cfg;
mod display; mod display;
mod dummy_serialport;
mod font; mod font;
mod format_value;
mod img; mod img;
mod sensors;
use crate::cfg::Panel; use crate::cfg::{MonitorConfig, Panel, SensorMode, TextAlign};
use crate::display::{AooScreen, AooScreenBuilder, DISPLAY_SIZE}; use crate::display::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
use crate::font::FontHandler; use crate::font::FontHandler;
use crate::format_value::format_value;
use crate::sensors::start_file_slurper;
use ab_glyph::{Font, PxScale}; use ab_glyph::{Font, PxScale};
use anyhow::anyhow;
use clap::Parser; use clap::Parser;
use env_logger::Env; use env_logger::Env;
use image::imageops::FilterType; use image::imageops::FilterType;
use image::{ImageReader, Rgb, RgbImage}; use image::{ImageReader, Rgb, RgbImage};
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut}; use imageproc::drawing::{draw_line_segment_mut, draw_text_mut, text_size};
use log::{debug, info, warn}; use log::{debug, error, info};
use std::collections::HashMap;
use std::fs; use std::fs;
use std::io::Cursor; use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, RwLock};
use std::thread::sleep; use std::thread::sleep;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -45,14 +55,31 @@ struct Args {
#[arg(short, long)] #[arg(short, long)]
image: Option<String>, image: Option<String>,
/// AOOSTAR-X json configuration file to parse.
///
/// The configuration file will be loaded from the `config_dir` directory if no full path is
/// specified.
#[arg(short, long)]
config: Option<PathBuf>,
/// Configuration directory containing configuration files and background images
/// specified in the `config` file. Default: `./cfg`
#[arg(long)]
config_dir: Option<PathBuf>,
/// Font directory for fonts specified in the `config` file. Default: `./fonts`
#[arg(long)]
font_dir: Option<PathBuf>,
/// Single sensor value input file or directory for multiple sensor input files.
/// Default: `./cfg/sensors`
#[arg(long)]
sensor_path: Option<PathBuf>,
/// Run a demo /// Run a demo
#[arg(long)] #[arg(long)]
demo: bool, demo: bool,
/// Only for demo mode: AOOSTAR-X json configuration file to parse.
#[arg(short, long)]
config: Option<String>,
/// Switch off display n seconds after loading image or running demo. /// Switch off display n seconds after loading image or running demo.
#[arg(short, long)] #[arg(short, long)]
off_after: Option<u32>, off_after: Option<u32>,
@@ -64,20 +91,23 @@ struct Args {
/// Test mode: save changed images in ./out folder. /// Test mode: save changed images in ./out folder.
#[arg(short, long)] #[arg(short, long)]
save: bool, save: bool,
/// Simulate serial port for testing and development, `--device` and `--usb` options are ignored.
#[arg(long)]
simulate: bool,
} }
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let args = Args::parse(); let args = Args::parse();
if let Some(config) = args.config.as_ref() {
let _cfg = cfg::load_cfg(config)?;
}
// initialize display with given UART port parameter // initialize display with given UART port parameter
let mut builder = AooScreenBuilder::new(); let mut builder = AooScreenBuilder::new();
builder.no_init_check(args.write_only); builder.no_init_check(args.write_only);
let mut screen = if let Some(device) = args.device { let mut screen = if args.simulate {
builder.simulate()?
} else if let Some(device) = args.device {
builder.open_device(&device)? builder.open_device(&device)?
} else if let Some(usb) = args.usb { } else if let Some(usb) = args.usb {
builder.open_usb_id(&usb)? builder.open_usb_id(&usb)?
@@ -97,6 +127,31 @@ fn main() -> anyhow::Result<()> {
// switch on screen for remaining commands // switch on screen for remaining commands
screen.init()?; screen.init()?;
if !args.demo
&& let Some(config) = args.config
{
info!("Starting sensor panel mode");
let img_save_path = if args.save {
let img_save_path = PathBuf::from("out");
fs::create_dir_all(&img_save_path)?;
Some(img_save_path)
} else {
None
};
let cfg_dir = args.config_dir.unwrap_or_else(|| "cfg".into());
let cfg = load_configuration(&config, &cfg_dir)?;
run_sensor_panel(
&mut screen,
cfg,
cfg_dir,
args.font_dir.unwrap_or_else(|| "fonts".into()),
args.sensor_path.unwrap_or_else(|| "cfg/sensors".into()),
img_save_path,
)?;
return Ok(());
}
if let Some(image) = args.image { if let Some(image) = args.image {
info!("Loading and displaying background image {image}..."); info!("Loading and displaying background image {image}...");
let rgb_img = img::load_image(&image, DISPLAY_SIZE)?; let rgb_img = img::load_image(&image, DISPLAY_SIZE)?;
@@ -107,7 +162,13 @@ fn main() -> anyhow::Result<()> {
if args.demo { if args.demo {
info!("Loading and displaying demo..."); info!("Loading and displaying demo...");
run_demo(&mut screen, args.config.as_deref(), args.save)?; run_demo(
&mut screen,
args.config.as_deref(),
args.config_dir.unwrap_or_else(|| "cfg".into()),
args.font_dir.unwrap_or_else(|| "fonts".into()),
args.save,
)?;
} }
if let Some(off) = args.off_after { if let Some(off) = args.off_after {
@@ -121,7 +182,115 @@ fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
fn run_demo(screen: &mut AooScreen, config: Option<&str>, save_images: bool) -> anyhow::Result<()> { fn load_configuration<P: AsRef<Path>>(config: P, config_dir: P) -> anyhow::Result<MonitorConfig> {
let config = config.as_ref();
let config_dir = config_dir.as_ref();
if config.is_absolute() {
cfg::load_cfg(config)
} else {
cfg::load_cfg(config_dir.join(config))
}
}
fn run_sensor_panel<P: AsRef<Path>, B: Into<PathBuf>>(
screen: &mut AooScreen,
mut cfg: MonitorConfig,
config_dir: B,
font_dir: B,
sensor_path: B,
img_save_path: Option<P>,
) -> anyhow::Result<()> {
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 mut rgb_img;
let mut save_img_name;
start_file_slurper(sensor_path, sensor_values.clone())?;
let refresh = Duration::from_millis((cfg.setup.refresh * 1000f32) as u64);
let switch_time = cfg
.setup
.switch_time
.as_deref()
.and_then(|v| f32::from_str(v).ok())
.map(|v| Duration::from_millis((v * 1000.0) as u64))
.unwrap_or(Duration::from_secs(5));
// panel switching loop
loop {
let panel = cfg
.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
let mut refresh_count = 1;
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
};
update_panel(
screen,
&rgb_img,
&mut fh,
panel,
sensor_values.clone(),
out_filename,
)?;
let elapsed = upd_start_time.elapsed();
if refresh > elapsed {
sleep(refresh - elapsed);
}
if panel_switch_time.elapsed() >= switch_time {
info!("Switching panels");
break;
}
refresh_count += 1;
}
}
}
fn run_demo(
screen: &mut AooScreen,
config: Option<&Path>,
config_dir: PathBuf,
font_dir: PathBuf,
save_images: bool,
) -> anyhow::Result<()> {
let rgb_img = demo_image()?; let rgb_img = demo_image()?;
// fill left and right side of the loaded image with neighboring pixel color // fill left and right side of the loaded image with neighboring pixel color
@@ -132,15 +301,38 @@ fn run_demo(screen: &mut AooScreen, config: Option<&str>, save_images: bool) ->
demo_text(screen, &rgb_img, save_images)?; demo_text(screen, &rgb_img, save_images)?;
if let Some(config) = config { if let Some(config) = config {
let cfg = cfg::load_cfg(config)?; let mut cfg = load_configuration(config, &config_dir)?;
for active in cfg.active_panels.clone() {
if active == 0 || active > cfg.panels.len() as u32 { if let Some(panel) = cfg.get_next_active_panel() {
warn!("Ignoring invalid active panel {active}"); info!("Displaying demo panel...");
continue;
// get sensor values from panel configuration
let mut demo_values = HashMap::new();
for sensor in &panel.sensor {
demo_values.insert(
sensor.label.clone(),
sensor.value.clone().unwrap_or_default(),
);
} }
let panel = &cfg.panels[active as usize - 1];
demo_panel(screen, &rgb_img, panel, save_images)?; let mut fh = FontHandler::new(font_dir);
break; let out_filename = if save_images {
fs::create_dir_all("out")?;
Some("out/demo_panel.png")
} else {
None
};
update_panel(
screen,
&rgb_img,
&mut fh,
panel,
Arc::new(RwLock::new(demo_values)),
out_filename,
)?;
} else {
error!("No active panel found");
} }
} }
@@ -268,50 +460,88 @@ fn demo_blinds(
Ok(rgb_img) Ok(rgb_img)
} }
fn demo_panel( fn update_panel<P: AsRef<Path>>(
screen: &mut AooScreen, screen: &mut AooScreen,
background: &RgbImage, background: &RgbImage,
fh: &mut FontHandler,
panel: &Panel, panel: &Panel,
save_image: bool, values: Arc<RwLock<HashMap<String, String>>>,
img_save_path: Option<P>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
info!("Displaying panel information..."); debug!(
"Displaying panel {}...",
panel
.name
.as_deref()
.unwrap_or_else(|| panel.id.as_deref().unwrap_or_default())
);
let mut rgb_img = background.clone(); let mut rgb_img = background.clone();
let mut fh = FontHandler::new("fonts");
for sensor in &panel.sensor { for sensor in &panel.sensor {
println!( if sensor.mode != SensorMode::Text {
"({:03},{:03}): {}{}", debug!(
sensor.x, "Skipping sensor {}: unsupported sensor mode {:?}",
sensor.y, sensor.label, sensor.mode
sensor.value.as_deref().unwrap_or_default(), );
sensor.unit.as_deref().unwrap_or_default() continue;
); }
if let Some(value) = &sensor.value { 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); 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
);
let text = format!("{value}{}", sensor.unit.as_deref().unwrap_or_default());
let scale = font.pt_to_px_scale(sensor.font_size as f32).unwrap();
draw_text_mut( draw_text_mut(
&mut rgb_img, &mut rgb_img,
sensor.font_color.into(), sensor.font_color.into(),
// TODO figure out x,y unit conversion, something is off, probably in font scaling x,
sensor.x as i32, y,
sensor.y as i32,
scale, scale,
&font, &font,
&text, &text,
); );
screen.send_image(&rgb_img)?;
} }
} }
if save_image { screen.send_image(&rgb_img)?;
fs::create_dir_all("out")?;
rgb_img.save_with_format("out/panel.png", image::ImageFormat::Png)?; if let Some(path) = img_save_path {
rgb_img.save_with_format(path, image::ImageFormat::Png)?;
} }
Ok(()) Ok(())
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
//! Sensor value sources.
//!
//! Only implementation is a file-based value provider with simple key-value pairs.
use log::{debug, error, info, warn};
use notify::event::{ModifyKind, RenameMode};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::ops::DerefMut;
use std::path::{Path, PathBuf};
use std::process::exit;
use std::sync::{Arc, RwLock, mpsc};
/// Read all sensor value source files from the given path and stort monitoring for changes.
///
/// The source path is either a single sensor source file or a directory containing multiple sensor
/// source files.
///
/// The source path is monitored for changes in a separate thread.
/// All updated files are automatically read and stored in the shared HashMap.
///
/// # Arguments
///
/// * `source_path`: Single source file path or a directory path.
/// * `values`: a shared, reader-writer lock protected HashMap
///
/// returns: Result<(), Error>
pub fn start_file_slurper<P: Into<PathBuf>>(
source_path: P,
values: Arc<RwLock<HashMap<String, String>>>,
) -> anyhow::Result<()> {
let dir_path = source_path.into();
// read existing file(s)
{
let mut val = values.write().expect("Failed to lock values");
read_path(&dir_path, val.deref_mut())?;
}
let file_values = values.clone();
std::thread::spawn(move || {
// watch sensor file/directory for changes
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
let mut watcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(e) => {
error!("Failed to initialize watcher: {e}");
exit(1);
}
};
info!("Starting sensor file watcher for {dir_path:?}");
if let Err(e) = watcher.watch(&dir_path, RecursiveMode::NonRecursive) {
error!("Failed to start file watcher: {e}");
exit(1);
}
// Block forever, printing out events as they come in
for res in rx {
let event = match res {
Ok(event) => event,
Err(e) => {
warn!("watch error: {e:?}");
continue;
}
};
match event.kind {
EventKind::Modify(kind)
if matches!(kind, ModifyKind::Data(_) | ModifyKind::Name(RenameMode::To)) =>
{
for path in event.paths.iter() {
if path.extension().unwrap_or_default() != "txt" {
continue;
}
debug!("Modified sensor file ({kind:?}): {path:?}");
let mut val = file_values.write().expect("Poisoned sensor RwLock");
if let Err(e) = read_from_file(path, val.deref_mut()) {
warn!("Failed to read sensor file {path:?}: {e}");
continue;
}
}
}
_ => {
// just for debugging
debug!("Watch event {:?}: {:?}", event.kind, event.paths);
}
}
}
});
Ok(())
}
/// Read a single key-value-based source file or all source file for a given directory path.
///
/// # Arguments
///
/// * `path`: Single source file path or a directory path.
/// * `values`: HashMap to store all read key-value pairs.
///
/// returns: Result<(), Error>
fn read_path<P: AsRef<Path>>(path: P, values: &mut HashMap<String, String>) -> anyhow::Result<()> {
let path = path.as_ref();
if !path.try_exists()? {
return Ok(());
}
if path.is_file() {
return read_from_file(path, values);
}
for entry in fs::read_dir(path)? {
let path = entry?.path();
if path.is_file()
&& path.extension().unwrap_or_default() == "txt"
&& let Err(e) = read_from_file(&path, values)
{
warn!("Failed to read sensor file {path:?}: {e}");
}
}
Ok(())
}
/// Read a key-value-based sensor source file and store content in the provided hashmap.
///
/// - Empty lines are skipped
/// - Lines starting with # are skipped
/// - Key-value pairs must be separated by `:`
/// - All keys and values are trimmed
///
/// # Arguments
///
/// * `path`: file path to read
/// * `values`: HashMap to store read key-value pairs.
///
/// returns: Result<(), Error>
fn read_from_file<P: AsRef<Path>>(
path: P,
values: &mut HashMap<String, String>,
) -> anyhow::Result<()> {
debug!("Reading sensor file {:?}", path.as_ref());
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once(':') {
values.insert(key.trim().to_string(), value.trim().to_string());
} else {
warn!("Skipping invalid entry in sensor value file: {line}");
}
}
Ok(())
}