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:
@@ -25,7 +25,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Clippy & Rustfmt
|
||||
name: Clippy, Rustfmt, Tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -54,9 +54,13 @@ jobs:
|
||||
- name: Run rustfmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Unit tests
|
||||
run: cargo test
|
||||
|
||||
build:
|
||||
name: Linux-x64 build
|
||||
needs: lint
|
||||
# using an older Ubuntu release on purpose to link against an older libc version for greater compatibility
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -93,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Release build
|
||||
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
|
||||
- name: Create upload artifact
|
||||
@@ -102,8 +106,9 @@ jobs:
|
||||
ls -la target/release
|
||||
mkdir -p ${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 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 "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 }} .
|
||||
|
||||
Generated
+1
-1
@@ -1,7 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run demo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<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$" />
|
||||
<envs />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
|
||||
Generated
+20
@@ -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>
|
||||
Generated
+3
-3
@@ -1,7 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run release demo" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="buildProfileId" value="release" />
|
||||
<option name="command" value="run --package AOOstar-rs --bin AOOstar-rs -- --demo -d /dev/cu.usbserial-AB0KOHLS -w -c Monitor3.json" />
|
||||
<configuration default="false" name="Run sensor panel" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||
<option name="buildProfileId" value="dev" />
|
||||
<option name="command" value="run --package aoostar-rs --bin asterctl -- -c monitor.json" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<envs />
|
||||
<option name="emulateTerminal" value="true" />
|
||||
+20
@@ -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>
|
||||
Generated
+20
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
|
||||
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
_Changes in the next release_
|
||||
|
||||
### Added
|
||||
- Simple sensor panel with a file-based data source (#6)
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0 - 2025-08-02
|
||||
|
||||
Generated
+596
-67
File diff suppressed because it is too large
Load Diff
+19
-3
@@ -13,17 +13,33 @@ strip = true # Automatically strip symbols from the binary.
|
||||
name = "asterctl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "sysinfo"
|
||||
path = "src/bin/sysinfo.rs"
|
||||
required-features = ["sysinfo"]
|
||||
|
||||
[features]
|
||||
sysinfo = ["dep:sysinfo"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.98"
|
||||
bytes = "1.10.1"
|
||||
clap = { version = "4.5.41", features = ["derive"] }
|
||||
clap = { version = "4.5.42", features = ["derive"] }
|
||||
serialport = "4.7.2"
|
||||
image = "0.25.6"
|
||||
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"
|
||||
env_logger = "0.11.8"
|
||||
notify = "8.2.0"
|
||||
regex = "1.11"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.141"
|
||||
serde_json = "1.0.142"
|
||||
serde_repr = "0.1.20"
|
||||
once_cell = "1.21.3"
|
||||
sysinfo = { version = "0.37.0", optional = true }
|
||||
itertools = "0.14"
|
||||
tempfile = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.26"
|
||||
|
||||
-3767
File diff suppressed because it is too large
Load Diff
@@ -90,9 +90,11 @@ cd aoostar-rs
|
||||
A release build is highly recommended, as it significantly improves graphics performance:
|
||||
|
||||
```shell
|
||||
cargo build --release
|
||||
cargo build --release --bins --all-features
|
||||
```
|
||||
|
||||
The `--bins` option builds the main `asterctl` app and all other tools.
|
||||
|
||||
### Install
|
||||
|
||||
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.
|
||||
|
||||
```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
|
||||
@@ -118,14 +120,26 @@ first panel.
|
||||
- `--help` — Show all options.
|
||||
|
||||
|
||||
### Sensor Panel
|
||||
|
||||
```shell
|
||||
asterctl --config monitor.json
|
||||
```
|
||||
|
||||
See [sensor panels](doc/sensor_panels.md) for more information.
|
||||
|
||||
### Control Commands
|
||||
|
||||
> Aster: Greek for star and similar to AOOSTAR.
|
||||
|
||||
Besides demo mode, the following control commands have been implemented.
|
||||
|
||||
The `asterctl` binary is built in `./target/release`.
|
||||
Alternatively, use `cargo run --release --` to build and run automatically, for example: `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:**
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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!
|
||||
@@ -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.
Executable
+73
@@ -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
|
||||
Executable
+84
@@ -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
|
||||
@@ -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
@@ -6,6 +6,7 @@
|
||||
//! Derived from the available Monitor3.json file in AOOSTAR-X v1.3.4.
|
||||
//! Likely not fully compatible with files created with the original editor.
|
||||
|
||||
use anyhow::Context;
|
||||
use image::Rgb;
|
||||
use imageproc::definitions::HasWhite;
|
||||
use log::warn;
|
||||
@@ -15,10 +16,12 @@ use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
use std::io::BufReader;
|
||||
use std::num::ParseIntError;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::{fmt, fs};
|
||||
|
||||
pub fn load_cfg(path: &str) -> anyhow::Result<MonitorConfig> {
|
||||
let file = fs::File::open(path)?;
|
||||
pub fn load_cfg<P: AsRef<Path>>(path: P) -> anyhow::Result<MonitorConfig> {
|
||||
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 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];
|
||||
|
||||
println!(
|
||||
"Panel {active}: type={}, {}",
|
||||
panel.panel_type,
|
||||
"Panel {active}: {}",
|
||||
panel.img.as_deref().unwrap_or_default()
|
||||
);
|
||||
for sensor in &panel.sensor {
|
||||
@@ -55,28 +57,69 @@ pub fn load_cfg(path: &str) -> anyhow::Result<MonitorConfig> {
|
||||
/// AOOSTAR-X monitor json configuration file
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MonitorConfig {
|
||||
/// _Not used_
|
||||
pub credentials: Option<Credentials>,
|
||||
// _Not used_
|
||||
// pub credentials: Option<Credentials>,
|
||||
/// Configuration settings.
|
||||
pub setup: Setup,
|
||||
/// Panels: 1-based index into diy[i]
|
||||
/// Panels: 1-based index into `panels`
|
||||
#[serde(rename = "mianban")]
|
||||
pub active_panels: Vec<u32>,
|
||||
/// Custom panels / DIY "Do It Yourself",
|
||||
#[serde(rename = "diy")]
|
||||
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
|
||||
///
|
||||
/// Not used, part of AOOSTAR-X json configuration file.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Configuration Settings
|
||||
/// Configuration settings.
|
||||
///
|
||||
/// Note: Trimmed down object to include only required fields for `asterctl`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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
|
||||
pub off_display: bool,
|
||||
/// Selection of default panels based on theme / control_params / control_disk_temp ?
|
||||
@@ -89,8 +132,6 @@ pub struct Setup {
|
||||
pub custom_panel: bool,
|
||||
/// Language index. Default: 0
|
||||
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.
|
||||
pub operation_mode: Option<OperationMode>,
|
||||
/// Operation type 1 or 2 (?). Default: 1
|
||||
@@ -106,20 +147,25 @@ pub struct Setup {
|
||||
#[serde(deserialize_with = "empty_string_as_none")]
|
||||
#[serde(rename = "ha_token")]
|
||||
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)]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)]
|
||||
pub enum Language {
|
||||
Chinese = 0,
|
||||
English = 1,
|
||||
Japanese = 2,
|
||||
}
|
||||
|
||||
/// Not used, part of AOOSTAR-X json configuration file.
|
||||
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
|
||||
#[repr(i16)]
|
||||
#[allow(dead_code)]
|
||||
pub enum OperationMode {
|
||||
None = -1,
|
||||
HighPerformance = 0,
|
||||
@@ -137,11 +183,14 @@ pub struct Panel {
|
||||
pub id: Option<String>,
|
||||
/// Custom panel name
|
||||
pub name: Option<String>,
|
||||
/*
|
||||
// The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl`
|
||||
/// TODO
|
||||
pub checked: Option<bool>,
|
||||
/// TODO panel type: 5 = built-in? 6 = custom ?
|
||||
#[serde(rename = "type")]
|
||||
pub panel_type: i32,
|
||||
*/
|
||||
/// Background image filename
|
||||
pub img: Option<String>,
|
||||
/// Sensors
|
||||
@@ -169,38 +218,47 @@ pub struct Sensor {
|
||||
pub name: Option<String>,
|
||||
/// Label name for custom panels.
|
||||
pub item_name: Option<String>,
|
||||
/// TODO Data source?
|
||||
/// Label identifier, also used as data source identifier.
|
||||
pub label: String,
|
||||
|
||||
/// 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
|
||||
|
||||
/// Sensor value. Ignored: value is used from a sensor source
|
||||
#[serde(deserialize_with = "empty_string_as_none")]
|
||||
pub value: Option<String>, // "" or numbers, so Option<String>
|
||||
|
||||
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
|
||||
#[serde(deserialize_with = "empty_string_as_none")]
|
||||
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 max_angle: i32,
|
||||
pub min_value: i32,
|
||||
@@ -222,6 +280,7 @@ pub struct Sensor {
|
||||
pub data: Option<String>,
|
||||
/// For type = 6
|
||||
pub interval: Option<u32>,
|
||||
*/
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq)]
|
||||
|
||||
+16
-4
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder
|
||||
|
||||
use crate::dummy_serialport::DummySerialPort;
|
||||
use crate::img::rgb888_to_565;
|
||||
use anyhow::{Context, anyhow};
|
||||
use bytes::{BufMut, BytesMut};
|
||||
@@ -65,6 +66,16 @@ impl AooScreenBuilder {
|
||||
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
|
||||
pub fn open_usb_id(self, id: &str) -> anyhow::Result<AooScreen> {
|
||||
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()?;
|
||||
for p in ports {
|
||||
debug!("Found serial port: {}", p.port_name);
|
||||
if let SerialPortType::UsbPort(info) = p.port_type {
|
||||
if info.pid == pid && info.vid == vid {
|
||||
return Ok(p.port_name);
|
||||
}
|
||||
if let SerialPortType::UsbPort(info) = p.port_type
|
||||
&& info.pid == pid
|
||||
&& info.vid == vid
|
||||
{
|
||||
return Ok(p.port_name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -3,21 +3,31 @@
|
||||
|
||||
mod cfg;
|
||||
mod display;
|
||||
mod dummy_serialport;
|
||||
mod font;
|
||||
mod format_value;
|
||||
mod img;
|
||||
mod sensors;
|
||||
|
||||
use crate::cfg::Panel;
|
||||
use crate::cfg::{MonitorConfig, Panel, SensorMode, TextAlign};
|
||||
use crate::display::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
||||
use crate::font::FontHandler;
|
||||
use crate::format_value::format_value;
|
||||
use crate::sensors::start_file_slurper;
|
||||
use ab_glyph::{Font, PxScale};
|
||||
use anyhow::anyhow;
|
||||
use clap::Parser;
|
||||
use env_logger::Env;
|
||||
use image::imageops::FilterType;
|
||||
use image::{ImageReader, Rgb, RgbImage};
|
||||
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut};
|
||||
use log::{debug, info, warn};
|
||||
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut, text_size};
|
||||
use log::{debug, error, info};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread::sleep;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -45,14 +55,31 @@ struct Args {
|
||||
#[arg(short, long)]
|
||||
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
|
||||
#[arg(long)]
|
||||
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.
|
||||
#[arg(short, long)]
|
||||
off_after: Option<u32>,
|
||||
@@ -64,20 +91,23 @@ struct Args {
|
||||
/// Test mode: save changed images in ./out folder.
|
||||
#[arg(short, long)]
|
||||
save: bool,
|
||||
|
||||
/// Simulate serial port for testing and development, `--device` and `--usb` options are ignored.
|
||||
#[arg(long)]
|
||||
simulate: bool,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
|
||||
|
||||
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
|
||||
let mut builder = AooScreenBuilder::new();
|
||||
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)?
|
||||
} else if let Some(usb) = args.usb {
|
||||
builder.open_usb_id(&usb)?
|
||||
@@ -97,6 +127,31 @@ fn main() -> anyhow::Result<()> {
|
||||
// switch on screen for remaining commands
|
||||
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 {
|
||||
info!("Loading and displaying background image {image}...");
|
||||
let rgb_img = img::load_image(&image, DISPLAY_SIZE)?;
|
||||
@@ -107,7 +162,13 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
if args.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 {
|
||||
@@ -121,7 +182,115 @@ fn main() -> anyhow::Result<()> {
|
||||
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()?;
|
||||
|
||||
// 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)?;
|
||||
|
||||
if let Some(config) = config {
|
||||
let cfg = cfg::load_cfg(config)?;
|
||||
for active in cfg.active_panels.clone() {
|
||||
if active == 0 || active > cfg.panels.len() as u32 {
|
||||
warn!("Ignoring invalid active panel {active}");
|
||||
continue;
|
||||
let mut cfg = load_configuration(config, &config_dir)?;
|
||||
|
||||
if let Some(panel) = cfg.get_next_active_panel() {
|
||||
info!("Displaying demo panel...");
|
||||
|
||||
// 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)?;
|
||||
break;
|
||||
|
||||
let mut fh = FontHandler::new(font_dir);
|
||||
let out_filename = if save_images {
|
||||
fs::create_dir_all("out")?;
|
||||
Some("out/demo_panel.png")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn demo_panel(
|
||||
fn update_panel<P: AsRef<Path>>(
|
||||
screen: &mut AooScreen,
|
||||
background: &RgbImage,
|
||||
fh: &mut FontHandler,
|
||||
panel: &Panel,
|
||||
save_image: bool,
|
||||
values: Arc<RwLock<HashMap<String, String>>>,
|
||||
img_save_path: Option<P>,
|
||||
) -> 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 fh = FontHandler::new("fonts");
|
||||
|
||||
for sensor in &panel.sensor {
|
||||
println!(
|
||||
"({:03},{:03}): {}{}",
|
||||
sensor.x,
|
||||
sensor.y,
|
||||
sensor.value.as_deref().unwrap_or_default(),
|
||||
sensor.unit.as_deref().unwrap_or_default()
|
||||
);
|
||||
if sensor.mode != SensorMode::Text {
|
||||
debug!(
|
||||
"Skipping sensor {}: unsupported sensor mode {:?}",
|
||||
sensor.label, sensor.mode
|
||||
);
|
||||
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);
|
||||
// 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(
|
||||
&mut rgb_img,
|
||||
sensor.font_color.into(),
|
||||
// TODO figure out x,y unit conversion, something is off, probably in font scaling
|
||||
sensor.x as i32,
|
||||
sensor.y as i32,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
&font,
|
||||
&text,
|
||||
);
|
||||
|
||||
screen.send_image(&rgb_img)?;
|
||||
}
|
||||
}
|
||||
|
||||
if save_image {
|
||||
fs::create_dir_all("out")?;
|
||||
rgb_img.save_with_format("out/panel.png", image::ImageFormat::Png)?;
|
||||
screen.send_image(&rgb_img)?;
|
||||
|
||||
if let Some(path) = img_save_path {
|
||||
rgb_img.save_with_format(path, image::ImageFormat::Png)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+169
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user