From 2eda97537b63d68b2e9ba06500e3fb491894d10c Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Sat, 7 Feb 2026 17:29:48 +0100 Subject: feat: camper van energy monitoring widget for Plasma 6 Pure QML KPackage widget with Rust background service for real-time Victron energy system monitoring via MQTT and D-Bus. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 21 + AGENTS.md | 85 + CLAUDE.md | 1 + Justfile | 123 + LICENSE | 21 + README.md | 72 + package/contents/config/config.qml | 11 + package/contents/config/main.xml | 32 + package/contents/ui/CompactRepresentation.qml | 33 + package/contents/ui/ConfigGeneral.qml | 80 + package/contents/ui/FormatUtils.qml | 31 + package/contents/ui/FullRepresentation.qml | 203 ++ package/contents/ui/IconUtils.qml | 31 + package/contents/ui/main.qml | 161 ++ package/metadata.json | 21 + service/Cargo.lock | 3393 +++++++++++++++++++++++++ service/Cargo.toml | 31 + service/config/camper-widget-refresh.service | 14 + service/src/cache.rs | 63 + service/src/config.rs | 244 ++ service/src/dbus.rs | 667 +++++ service/src/lib.rs | 4 + service/src/main.rs | 238 ++ service/src/victron_mqtt.rs | 576 +++++ service/tests/dbus_integration.rs | 325 +++ service/tests/mqtt_to_dbus_e2e.rs | 641 +++++ tests/qml/tst_icon_utils.qml | 91 + tests/qml/tst_value_formatting.qml | 76 + 28 files changed, 7289 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Justfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package/contents/config/config.qml create mode 100644 package/contents/config/main.xml create mode 100644 package/contents/ui/CompactRepresentation.qml create mode 100644 package/contents/ui/ConfigGeneral.qml create mode 100644 package/contents/ui/FormatUtils.qml create mode 100644 package/contents/ui/FullRepresentation.qml create mode 100644 package/contents/ui/IconUtils.qml create mode 100644 package/contents/ui/main.qml create mode 100644 package/metadata.json create mode 100644 service/Cargo.lock create mode 100644 service/Cargo.toml create mode 100644 service/config/camper-widget-refresh.service create mode 100644 service/src/cache.rs create mode 100644 service/src/config.rs create mode 100644 service/src/dbus.rs create mode 100644 service/src/lib.rs create mode 100644 service/src/main.rs create mode 100644 service/src/victron_mqtt.rs create mode 100644 service/tests/dbus_integration.rs create mode 100644 service/tests/mqtt_to_dbus_e2e.rs create mode 100644 tests/qml/tst_icon_utils.qml create mode 100644 tests/qml/tst_value_formatting.qml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8e85ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Build artifacts (legacy, can be cleaned up) +build_release +build_debug +CMakeFiles/ +.cache +compile_commands.json + +# Rust specific +service/target/ +**/*.rs.bk +**/*.swp +**/*.swo +.DS_Store +.idea/ +.vscode/ + +external-docs/ + +# Claude Code +.claude/settings.local.json +SPRINT.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..81e8b57 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# AGENTS.md + +## Project Overview + +Camper Widget is a Plasma 6 applet displaying real-time camper van energy system data (battery, solar, AC input/load power). It consists of two components: + +1. **Widget** (`package/`) - Pure QML KPackage using `org.kde.plasma.workspace.dbus` for D-Bus property subscriptions +2. **Background Service** (`service/`) - Rust async service that reads Victron MQTT data and publishes it over D-Bus (`org.craftknight.CamperWidget`) + +## Commands + +All commands use [just](https://just.systems/man/en/introduction.html): + +| Command | Description | +|---|---| +| `just build` | Build service (release) — widget is pure QML, no compilation | +| `just check` | Format check + clippy + cargo check (service) | +| `just test` | Run Rust tests (service) | +| `just verify` | Run check + build | +| `just fmt` | Format Rust code (service) | +| `just install` | Install widget (kpackagetool6) + service | +| `just install-widget` | Install/update widget via kpackagetool6 | +| `just install-service` | Build and install systemd service | +| `just uninstall` | Uninstall widget + clean old artifacts | +| `just run` | Run applet in plasmoidviewer | +| `just run-service` | Run service (release) | +| `just build-service` | Build service only | +| `just check-service` | Check service only | +| `just check-deps` | Print versions of required tools | +| `just ci` | Full CI pipeline (check + test) | +| `just ci-quick` | Quick CI (check only, no build/test) | +| `just clean` | Delete build artifacts | + +## Architecture + +``` +package/ +├── metadata.json # KPackage metadata (installed via kpackagetool6) +├── contents/ui/ # QML UI (main, compact, full representations) +│ ├── main.qml # D-Bus bindings via org.kde.plasma.workspace.dbus +│ ├── CompactRepresentation.qml +│ ├── FullRepresentation.qml +│ ├── ConfigGeneral.qml # KCM config page +│ ├── IconUtils.qml # Battery/direction icon helpers +│ └── FormatUtils.qml # Value formatting helpers +└── contents/config/ # KCM config schema + +service/ +├── config/ +│ └── camper-widget-refresh.service # systemd user unit +└── src/ + ├── main.rs # Entry point, tokio async runtime + ├── lib.rs # Public module exports + ├── dbus.rs # D-Bus server (zbus), property signals + ├── victron_mqtt.rs # MQTT client reading Victron energy data + ├── cache.rs # Serial number caching + └── config.rs # Plasma INI config loading/watching + +tests/qml/ # QML unit tests for utility components +``` + +**Key patterns:** +- Widget is a pure QML KPackage — no compilation, installed via `kpackagetool6` +- Widget uses `org.kde.plasma.workspace.dbus` (`DBus.Properties`, `DBus.DBusServiceWatcher`) to subscribe to D-Bus property changes (requires Plasma 6.3+) +- Service uses `tokio` async runtime with `zbus` 5 and `rumqttc` +- D-Bus interface: `org.craftknight.CamperWidget` on session bus, with separate object paths per subsystem (Battery, Solar, AcInput, AcLoad) +- Config read from Plasma applet INI (`~/.config/plasma-org.kde.plasma.desktop-appletsrc`), configured via widget's KCM settings page + +## Conventions + +- Rust 2021 edition, standard `cargo fmt` + `clippy` formatting +- QML for Plasma UI components +- Config via Plasma KCM (stored in Plasma applet INI) +- `just` as the task runner (not make) + +## Commit Rules + +**IMPORTANT:** Before completing any task, you MUST run `/commit` to commit your changes. + +- Only commit files YOU modified in this session — never commit unrelated changes +- Use atomic commits with descriptive messages +- If there are no changes to commit, skip this step +- Do not push unless explicitly asked +- Always run `just check` before committing to catch formatting/lint issues +- Use `git add` + `git commit` — **never** use `git restore` during the commit workflow diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..73d189d --- /dev/null +++ b/Justfile @@ -0,0 +1,123 @@ +# Camper Widget Build Configuration + +set shell := ["/usr/bin/bash", "-cu"] + +# Required tools (validated at parse time) +_rustc := require("rustc") +_cargo := require("cargo") +_kpackagetool6 := require("kpackagetool6") +_dbus_daemon := require("dbus-daemon") + +# Help command +help: + @echo "This justfile supports the following targets:" + @echo " - build - Build service (release)" + @echo " - install - Install widget (kpackagetool6) + service" + @echo " - install-widget - Install/update widget via kpackagetool6" + @echo " - install-service - Build and install systemd service" + @echo " - uninstall - Uninstall widget + clean old artifacts" + @echo " - run - Run applet in plasmoidviewer" + @echo " - run-service - Run service (release)" + @echo " - fmt - Format Rust code (service)" + @echo " - check - Check Rust code (format, clippy, check)" + @echo " - test - Run Rust tests (service)" + @echo " - verify - Run check + build" + @echo " - build-service - Build camper-widget-refresh (Release)" + @echo " - check-service - Check service only" + @echo " - check-deps - Print versions of required tools" + @echo " - ci - Full CI pipeline (check + test)" + @echo " - ci-quick - Quick CI (check only, no build/test)" + @echo " - clean - Delete build artifacts" + @echo " - help - Print help" + @echo + +# Build (service only — widget is pure QML, no compilation needed) +build: build-service + +# Install widget via kpackagetool6 + service via cargo +install: install-widget install-service + +# Install/update widget KPackage +install-widget: + kpackagetool6 -t Plasma/Applet -u package/ 2>/dev/null || kpackagetool6 -t Plasma/Applet -i package/ + +# Uninstall widget + clean old compiled plugin artifacts +uninstall: + -kpackagetool6 -t Plasma/Applet -r craftknight.camper_widget + rm -f ~/.local/lib/qt6/plugins/plasma/applets/craftknight.camper_widget.so + rm -rf ~/.local/share/plasma/plasmoids/craftknight.camper_widget + rm -rf ~/.local/lib/qt6/qml/craftknight/camper_widget + +# Run applet in plasmoidviewer +run: + plasmoidviewer -a craftknight.camper_widget + +# Service commands +[working-directory: 'service'] +build-service: + cargo build --release + +[working-directory: 'service'] +run-service: + cargo run --release + +[working-directory: 'service'] +install-service: build-service + @echo "Installing camper-widget-refresh..." + mkdir -p ~/.config/systemd/user + mkdir -p ~/.local/bin + cp config/camper-widget-refresh.service ~/.config/systemd/user/ + systemctl --user daemon-reload + systemctl stop --user camper-widget-refresh + cp target/release/camper-widget-refresh ~/.local/bin/camper-widget-refresh + chmod +x ~/.local/bin/camper-widget-refresh + systemctl start --user camper-widget-refresh + @echo "Installation complete!" + @echo "To enable: systemctl --user enable camper-widget-refresh" + @echo "To start: systemctl --user start camper-widget-refresh" + +# Rust commands (service only) +[working-directory: 'service'] +fmt: + cargo fmt --all + +[working-directory: 'service'] +check: + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + cargo check --all-targets + +check-service: check + +[working-directory: 'service'] +test: + cargo test + +# Clean build artifacts +clean: + rm -rf service/target + +# Print versions of all required tools +[group: 'ci'] +check-deps: + @echo "Required tools:" + @echo " rustc: $(rustc --version)" + @echo " cargo: $(cargo --version)" + @echo " kpackagetool6: $(kpackagetool6 --version)" + @echo " dbus-daemon: $(dbus-daemon --version | head -1)" + @echo " just: $(just --version)" + +# Full CI pipeline: deps → lint/format → all tests +[group: 'ci'] +ci: check-deps check test + @echo "" + @echo "CI pipeline passed." + +# Quick CI: deps + lint/format only (no build/test) +[group: 'ci'] +ci-quick: check-deps check + @echo "" + @echo "Quick CI passed." + +# Combined verify command +verify: check build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0aaf61e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Dawid Rycerz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e73e60 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Camper Widget + +A Plasma 6 applet that displays real-time information about your camper van's energy system. It consists of a pure QML widget and a Rust background service that reads Victron MQTT data and publishes it over D-Bus. + +## Features + +- **Battery monitoring**: State of charge, power draw, charge/discharge/idle state +- **Solar tracking**: Solar panel power generation +- **AC monitoring**: AC input and AC load power +- **Compact view**: Direction icon, battery icon, battery percentage, and system power +- **Full view**: Detailed breakdown of all energy subsystems +- **Real-time updates**: D-Bus property signals push changes to the widget instantly +- **Rust backend**: Async service with tokio, zbus 5, and rumqttc + +## Requirements + +- Plasma 6.3+ (for `org.kde.plasma.workspace.dbus` QML module) +- Rust toolchain +- [just](https://just.systems/man/en/introduction.html) command runner +- D-Bus session bus +- Victron energy system accessible via MQTT + +## Installation + +### Build from source + +1. Clone the repository: + + ```bash + git clone https://codeberg.org/knightdave/camper-widget.git + cd camper-widget + ``` + +2. Build the service: + + ```bash + just build + ``` + +3. Install the widget and service: + + ```bash + just install + ``` + +4. Restart Plasma or log out and back in + +### Configuration + +Configure the MQTT connection (hosts, credentials, refresh interval) through the widget's settings page in Plasma. The service reads this configuration from the Plasma applet INI file and automatically picks up changes when you save. + +## Development + +Run `just` to see all available commands. + +```bash +just check # Format check + clippy + cargo check +just test # Run Rust tests +just verify # check + build +just ci # Full CI pipeline (check + test) +just ci-quick # Quick check (no build/test) +just run # Run widget in plasmoidviewer +just run-service # Run service locally +``` + +## Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. + +## Credits + +This project was initially inspired by [bcdt-rust_plasmoid_example](https://codeberg.org/black-cat-engineering/bcdt-rust_plasmoid_example) diff --git a/package/contents/config/config.qml b/package/contents/config/config.qml new file mode 100644 index 0000000..1b48b5d --- /dev/null +++ b/package/contents/config/config.qml @@ -0,0 +1,11 @@ +import QtQuick 6.0 + +import org.kde.plasma.configuration 2.0 as PlasmaConfig + +PlasmaConfig.ConfigModel { + PlasmaConfig.ConfigCategory { + name: i18n("General") + icon: "configure" + source: "ConfigGeneral.qml" + } +} diff --git a/package/contents/config/main.xml b/package/contents/config/main.xml new file mode 100644 index 0000000..d33f3dd --- /dev/null +++ b/package/contents/config/main.xml @@ -0,0 +1,32 @@ + + + + + + + + camper-widget-refresh + + + + + 127.0.0.1:1883 + + + + + + + + + + + + + + + 60 + 1 + + + diff --git a/package/contents/ui/CompactRepresentation.qml b/package/contents/ui/CompactRepresentation.qml new file mode 100644 index 0000000..85d62ee --- /dev/null +++ b/package/contents/ui/CompactRepresentation.qml @@ -0,0 +1,33 @@ +import QtQuick 6.0 +import QtQuick.Layouts 6.0 +import org.kde.plasma.components 3.0 as PlasmaComponents + +RowLayout { + IconUtils { id: icons } + FormatUtils { id: fmt } + spacing: 4 + + PlasmaComponents.Label { + text: icons.getDirectionIcon(root.direction) + font.pointSize: 12 + } + + PlasmaComponents.Label { + text: icons.getBatteryIcon(root.batterySoc) + font.pointSize: 12 + } + + PlasmaComponents.Label { + text: fmt.formatSoc(root.batterySoc) + font.pointSize: 10 + font.bold: true + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: (mouse) => { + root.expanded = !root.expanded + } + } +} diff --git a/package/contents/ui/ConfigGeneral.qml b/package/contents/ui/ConfigGeneral.qml new file mode 100644 index 0000000..9428ed0 --- /dev/null +++ b/package/contents/ui/ConfigGeneral.qml @@ -0,0 +1,80 @@ +import QtQuick 6.0 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 6.0 as Controls +import org.kde.kirigami as Kirigami +import org.kde.kcmutils as KCM +import org.kde.plasma.workspace.dbus as DBus + +KCM.SimpleKCM { + id: page + + // KCM properties that will be automatically managed + property alias cfg_mqttClientId: mqttClientIdField.text + property alias cfg_mqttHosts: mqttHostsField.text + property alias cfg_mqttUsername: mqttUsernameField.text + property alias cfg_mqttPassword: mqttPasswordField.text + property alias cfg_refreshIntervalSeconds: refreshIntervalField.value + + // Default values for KCM system + property string cfg_mqttClientIdDefault: "camper-widget-refresh" + property string cfg_mqttHostsDefault: "127.0.0.1:1883" + property string cfg_mqttUsernameDefault: "" + property string cfg_mqttPasswordDefault: "" + property int cfg_refreshIntervalSecondsDefault: 60 + + Kirigami.FormLayout { + anchors.fill: parent + + Controls.TextField { + id: mqttClientIdField + Kirigami.FormData.label: i18n("MQTT client ID") + placeholderText: "camper-widget-refresh" + } + + Controls.TextField { + id: mqttHostsField + Kirigami.FormData.label: i18n("MQTT hosts") + placeholderText: "127.0.0.1:1883" + Controls.ToolTip.visible: hovered + Controls.ToolTip.text: i18n("Comma separated list, e.g. 127.0.0.1 or 127.0.0.1,192.168.1.111 or 127.0.0.1:1883,192.168.1.111:1883") + } + + Controls.TextField { + id: mqttUsernameField + Kirigami.FormData.label: i18n("MQTT username") + placeholderText: "" + } + + Controls.TextField { + id: mqttPasswordField + Kirigami.FormData.label: i18n("MQTT password") + echoMode: Controls.TextField.Password + placeholderText: "" + } + + Controls.SpinBox { + id: refreshIntervalField + Kirigami.FormData.label: i18n("Refresh interval (seconds)") + from: 1 + to: 86400 + stepSize: 1 + } + } + + // Notify config changes when KCM properties change + onCfg_mqttClientIdChanged: notifyConfigChanged() + onCfg_mqttHostsChanged: notifyConfigChanged() + onCfg_mqttUsernameChanged: notifyConfigChanged() + onCfg_mqttPasswordChanged: notifyConfigChanged() + onCfg_refreshIntervalSecondsChanged: notifyConfigChanged() + + function notifyConfigChanged() { + DBus.SessionBus.asyncCall({ + service: "org.craftknight.CamperWidget", + path: "/org/craftknight/camper_widget", + iface: "org.craftknight.CamperWidget.Config", + member: "Reload", + signature: "" + }) + } +} diff --git a/package/contents/ui/FormatUtils.qml b/package/contents/ui/FormatUtils.qml new file mode 100644 index 0000000..9fe400a --- /dev/null +++ b/package/contents/ui/FormatUtils.qml @@ -0,0 +1,31 @@ +import QtQuick 6.0 + +QtObject { + function formatSoc(soc) { + if (soc === undefined || soc === null || isNaN(soc) || soc < 0) { + return "--" + } + return Math.round(soc) + "%" + } + + function formatPower(connected, power) { + if (!connected) { + return "--" + } + return Math.round(power) + "W" + } + + function formatSolar(solarPower) { + if (solarPower === undefined || solarPower === null || isNaN(solarPower) || solarPower < 0) { + return "--" + } + return Math.round(solarPower) + "W" + } + + function formatAcPower(power) { + if (power === undefined || power === null || isNaN(power) || power < 0) { + return "--" + } + return Math.round(power) + "W" + } +} diff --git a/package/contents/ui/FullRepresentation.qml b/package/contents/ui/FullRepresentation.qml new file mode 100644 index 0000000..b76da53 --- /dev/null +++ b/package/contents/ui/FullRepresentation.qml @@ -0,0 +1,203 @@ +import QtQuick 6.0 +import QtQuick.Layouts 6.0 +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PlasmaComponents + +ColumnLayout { + IconUtils { id: icons } + FormatUtils { id: fmt } + anchors.fill: parent + spacing: 8 + + // Header + PlasmaComponents.Label { + Layout.alignment: Qt.AlignCenter + text: "Camper Van Energy System" + font.pointSize: 14 + font.bold: true + } + + Kirigami.Separator { + Layout.fillWidth: true + } + + // Status availability indicator + PlasmaComponents.Label { + Layout.alignment: Qt.AlignCenter + text: root.connected ? "Status: Connected" : "Status: Disconnected" + color: root.connected ? "green" : "red" + font.pointSize: 10 + } + + // Battery level + RowLayout { + Layout.fillWidth: true + spacing: 8 + + PlasmaComponents.Label { + text: icons.getBatteryIcon(root.batterySoc) + font.pointSize: 16 + } + + PlasmaComponents.Label { + text: "Battery Level:" + font.pointSize: 11 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: fmt.formatSoc(root.batterySoc) + font.pointSize: 11 + font.bold: true + horizontalAlignment: Text.AlignRight + } + } + + // System power + RowLayout { + Layout.fillWidth: true + spacing: 8 + + PlasmaComponents.Label { + text: "\uf0e7" // + font.pointSize: 16 + } + + PlasmaComponents.Label { + text: "System Power:" + font.pointSize: 11 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: fmt.formatPower(root.connected, root.batteryPower) + font.pointSize: 11 + font.bold: true + horizontalAlignment: Text.AlignRight + } + } + + // Solar power + RowLayout { + Layout.fillWidth: true + spacing: 8 + + PlasmaComponents.Label { + text: "\uf185" // + font.pointSize: 16 + } + + PlasmaComponents.Label { + text: "Solar Power:" + font.pointSize: 11 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: fmt.formatSolar(root.solarPower) + font.pointSize: 11 + font.bold: true + horizontalAlignment: Text.AlignRight + } + } + + // AC input + RowLayout { + Layout.fillWidth: true + spacing: 8 + + PlasmaComponents.Label { + text: "\uf1e6" // + font.pointSize: 16 + } + + PlasmaComponents.Label { + text: "AC Input:" + font.pointSize: 11 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: fmt.formatAcPower(root.acInputPower) + font.pointSize: 11 + font.bold: true + horizontalAlignment: Text.AlignRight + } + } + + // AC load + RowLayout { + Layout.fillWidth: true + spacing: 8 + + PlasmaComponents.Label { + text: "\uf2db" // + font.pointSize: 16 + } + + PlasmaComponents.Label { + text: "AC Load:" + font.pointSize: 11 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: fmt.formatAcPower(root.acLoadPower) + font.pointSize: 11 + font.bold: true + horizontalAlignment: Text.AlignRight + } + } + + // Status direction + RowLayout { + Layout.fillWidth: true + spacing: 8 + + PlasmaComponents.Label { + text: "\uf1fe" // + font.pointSize: 16 + } + + PlasmaComponents.Label { + text: "Status:" + font.pointSize: 11 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: root.direction + font.pointSize: 11 + font.bold: true + horizontalAlignment: Text.AlignRight + } + } + + // Last updated + RowLayout { + Layout.fillWidth: true + spacing: 8 + + PlasmaComponents.Label { + text: "\uf017" // + font.pointSize: 16 + } + + PlasmaComponents.Label { + text: "Last Updated:" + font.pointSize: 11 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: root.lastUpdated + font.pointSize: 11 + font.bold: true + horizontalAlignment: Text.AlignRight + } + } + + Kirigami.Separator { + Layout.fillWidth: true + } +} diff --git a/package/contents/ui/IconUtils.qml b/package/contents/ui/IconUtils.qml new file mode 100644 index 0000000..344d26f --- /dev/null +++ b/package/contents/ui/IconUtils.qml @@ -0,0 +1,31 @@ +import QtQuick 6.0 + +QtObject { + function getBatteryIcon(soc) { + if (soc === undefined || soc === null || soc < 0) { + return "\uf244"; //  + } + var socNum = Math.floor(soc); + if (socNum <= 20) return "\uf244"; //  + if (socNum <= 40) return "\uf243"; //  + if (socNum <= 60) return "\uf242"; //  + if (socNum <= 80) return "\uf241"; //  + return "\uf240"; //  + } + + function getDirectionIcon(direction) { + if (!direction) return ""; + switch (direction) { + case "charge": + return "\uf185"; //  + case "discharge": + return "\uf0e7"; //  + case "idle": + return "\uf186"; //  + default: + return ""; + } + } +} + + diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml new file mode 100644 index 0000000..26b5045 --- /dev/null +++ b/package/contents/ui/main.qml @@ -0,0 +1,161 @@ +import QtQuick 6.0 +import org.kde.plasma.plasmoid 2.0 +import org.kde.plasma.workspace.dbus as DBus + +PlasmoidItem { + id: root + + // Exposed properties for child components + property bool connected: false + property real batterySoc: -1 + property real batteryPower: 0 + property real solarPower: -1 + property real acInputPower: -1 + property real acLoadPower: -1 + property string direction: "idle" + property string lastUpdated: "unknown" + + // Watch for service availability + DBus.DBusServiceWatcher { + id: serviceWatcher + watchedService: "org.craftknight.CamperWidget" + busType: DBus.BusType.Session + + onRegisteredChanged: { + if (registered) { + statusProps.updateAll() + batteryProps.updateAll() + solarProps.updateAll() + acInputProps.updateAll() + acLoadProps.updateAll() + } else { + root.connected = false + root.batterySoc = -1 + root.batteryPower = 0 + root.solarPower = -1 + root.acInputPower = -1 + root.acLoadPower = -1 + root.direction = "idle" + root.lastUpdated = "unknown" + } + } + } + + // Status interface - Connected property + DBus.Properties { + id: statusProps + service: "org.craftknight.CamperWidget" + path: "/org/craftknight/camper_widget" + iface: "org.craftknight.CamperWidget.Status" + busType: DBus.BusType.Session + + onPropertiesChanged: function(iface, changed, invalidated) { + if ("Connected" in changed) + root.connected = Boolean(changed["Connected"]) + updateTimestamp() + } + onRefreshed: { + var c = properties.Connected + if (c !== undefined) root.connected = Boolean(c) + } + } + + // Battery interface - Soc, Power, State + DBus.Properties { + id: batteryProps + service: "org.craftknight.CamperWidget" + path: "/org/craftknight/camper_widget/Battery" + iface: "org.craftknight.CamperWidget.Battery" + busType: DBus.BusType.Session + + onPropertiesChanged: function(iface, changed, invalidated) { + if ("Soc" in changed) + root.batterySoc = Number(changed["Soc"]) + if ("Power" in changed) + root.batteryPower = Number(changed["Power"]) + if ("State" in changed) + root.direction = mapDirection(String(changed["State"])) + updateTimestamp() + } + onRefreshed: { + var s = properties.Soc + var p = properties.Power + var st = properties.State + if (s !== undefined) root.batterySoc = Number(s) + if (p !== undefined) root.batteryPower = Number(p) + if (st !== undefined) root.direction = mapDirection(String(st)) + } + } + + // Solar interface - Power + DBus.Properties { + id: solarProps + service: "org.craftknight.CamperWidget" + path: "/org/craftknight/camper_widget/Solar" + iface: "org.craftknight.CamperWidget.Solar" + busType: DBus.BusType.Session + + onPropertiesChanged: function(iface, changed, invalidated) { + if ("Power" in changed) + root.solarPower = Number(changed["Power"]) + updateTimestamp() + } + onRefreshed: { + var p = properties.Power + if (p !== undefined) root.solarPower = Number(p) + } + } + + // AC input interface - Power + DBus.Properties { + id: acInputProps + service: "org.craftknight.CamperWidget" + path: "/org/craftknight/camper_widget/AcInput" + iface: "org.craftknight.CamperWidget.AcInput" + busType: DBus.BusType.Session + + onPropertiesChanged: function(iface, changed, invalidated) { + if ("Power" in changed) + root.acInputPower = Number(changed["Power"]) + updateTimestamp() + } + onRefreshed: { + var p = properties.Power + if (p !== undefined) root.acInputPower = Number(p) + } + } + + // AC load interface - Power + DBus.Properties { + id: acLoadProps + service: "org.craftknight.CamperWidget" + path: "/org/craftknight/camper_widget/AcLoad" + iface: "org.craftknight.CamperWidget.AcLoad" + busType: DBus.BusType.Session + + onPropertiesChanged: function(iface, changed, invalidated) { + if ("Power" in changed) + root.acLoadPower = Number(changed["Power"]) + updateTimestamp() + } + onRefreshed: { + var p = properties.Power + if (p !== undefined) root.acLoadPower = Number(p) + } + } + + function mapDirection(state) { + switch (state) { + case "charging": return "charge" + case "discharging": return "discharge" + default: return "idle" + } + } + + function updateTimestamp() { + root.lastUpdated = Qt.formatDateTime(new Date(), "hh:mm:ss") + } + + compactRepresentation: CompactRepresentation {} + fullRepresentation: FullRepresentation {} +} diff --git a/package/metadata.json b/package/metadata.json new file mode 100644 index 0000000..54bb38c --- /dev/null +++ b/package/metadata.json @@ -0,0 +1,21 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Id": "craftknight.camper_widget", + "Authors": [ + { + "Email": "dawid@rycerz.xyz", + "Name": "Dawid Rycerz" + } + ], + "Category": "System Information", + "Description": "Camper van energy system status widget (pure QML, reads D-Bus from camper-widget-refresh service)", + "EnabledByDefault": true, + "Icon": "battery", + "License": "MIT", + "Name": "Camper Widget", + "Version": "2.0", + "Website": "https://codeberg.org/knightdave/camper-widget" + }, + "X-Plasma-API-Minimum-Version": "6.0" +} diff --git a/service/Cargo.lock b/service/Cargo.lock new file mode 100644 index 0000000..115fe6d --- /dev/null +++ b/service/Cargo.lock @@ -0,0 +1,3393 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "asn1-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-tungstenite" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cca750b12e02c389c1694d35c16539f88b8bbaa5945934fdc1b41a776688589" +dependencies = [ + "futures-io", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "camper-widget-refresh" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "configparser", + "dbus-launch", + "dirs", + "futures-util", + "rumqttc", + "rumqttd", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "zbus", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust2", +] + +[[package]] +name = "configparser" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57e3272f0190c3f1584272d613719ba5fc7df7f4942fe542e63d949cf3a649b" +dependencies = [ + "tokio", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "dbus-launch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b605c1a7f0ca11e477d0eb78505f22e2337fd102e880eb5a357d10c080e02b1" +dependencies = [ + "libc", + "tempfile", +] + +[[package]] +name = "der-parser" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metrics" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d05972e8cbac2671e85aa9d04d9160d193f8bebd1a5c1a2f4542c62e65d1d0" +dependencies = [ + "ahash", + "portable-atomic", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bf4e7146e30ad172c42c39b3246864bd2d3c6396780711a1baf749cfe423e21" +dependencies = [ + "base64", + "hyper 0.14.32", + "indexmap", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "metrics-util" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b07a5eb561b8cbc16be2d216faf7757f9baf3bfb94dbb0fae3df8387a5bb47f" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.14.5", + "metrics", + "num_cpus", + "quanta", + "sketches-ddsketch", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bedf36ffb6ba96c2eb7144ef6270557b52e54b20c0a8e1eb2ff99a6c6959bff" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64", + "bitflags", + "serde", + "serde_derive", +] + +[[package]] +name = "rumqttc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0feff8d882bff0b2fddaf99355a10336d43dd3ed44204f85ece28cf9626ab519" +dependencies = [ + "bytes", + "fixedbitset", + "flume", + "futures-util", + "log", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki 0.102.8", + "thiserror 2.0.18", + "tokio", + "tokio-rustls 0.26.4", + "tokio-stream", + "tokio-util", +] + +[[package]] +name = "rumqttd" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32399b9dee6e96350790223dd7356bf7f8f15009d7e58e20f4093f1137213e16" +dependencies = [ + "async-tungstenite", + "axum", + "bytes", + "clap", + "config", + "flume", + "futures-util", + "metrics", + "metrics-exporter-prometheus", + "parking_lot", + "rand 0.8.5", + "rustls-pemfile", + "rustls-webpki 0.102.8", + "serde", + "serde_json", + "slab", + "subtle", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.25.0", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", + "ws_stream_tungstenite", + "x509-parser", +] + +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "rand 0.9.2", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "ws_stream_tungstenite" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f414f083fb19fcc1bffcb0fa0cf46d33ccfa229adf248cac12c180e91609" +dependencies = [ + "async-tungstenite", + "async_io_stream", + "bitflags", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "pharos", + "rustc_version", + "tokio", + "tracing", + "tungstenite", +] + +[[package]] +name = "x509-parser" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7069fba5b66b9193bd2c5d3d4ff12b839118f6bcbef5328efafafb5395cf63da" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure 0.13.2", +] + +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure 0.13.2", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.114", + "winnow", +] diff --git a/service/Cargo.toml b/service/Cargo.toml new file mode 100644 index 0000000..1c80a99 --- /dev/null +++ b/service/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "camper-widget-refresh" +version = "0.1.0" +edition = "2024" + +[lib] +name = "camper_widget_refresh" + +[[bin]] +name = "camper-widget-refresh" +path = "src/main.rs" + +[dependencies] +rumqttc = "0.25" +tokio = { version = "1", features = ["full"] } +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +zbus = { version = "5", default-features = false, features = ["tokio"] } +futures-util = "0.3" +configparser = { version = "3", features = ["tokio"] } +tokio-util = { version = "0.7", features = ["rt"] } + +[dev-dependencies] +dbus-launch = "0.2" +rumqttd = "0.20" diff --git a/service/config/camper-widget-refresh.service b/service/config/camper-widget-refresh.service new file mode 100644 index 0000000..538f3bc --- /dev/null +++ b/service/config/camper-widget-refresh.service @@ -0,0 +1,14 @@ +[Unit] +Description=Camper Widget Refresh service +After=network.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/camper-widget-refresh + + +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/service/src/cache.rs b/service/src/cache.rs new file mode 100644 index 0000000..acaf9d3 --- /dev/null +++ b/service/src/cache.rs @@ -0,0 +1,63 @@ +use std::{ + path::PathBuf, + time::{Duration, SystemTime}, +}; + +use anyhow::{Result, anyhow}; + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +struct SerialCacheEntry { + serial: String, + expires_at_unix: u64, +} + +fn cache_file_path() -> Result { + let proj_dir = dirs::cache_dir() + .ok_or_else(|| anyhow!("No cache dir"))? + .join("camper-widget-refresh"); + Ok(proj_dir.join("serial.json")) +} + +pub async fn read_serial_cache() -> Result> { + let path = cache_file_path()?; + if !path.exists() { + return Ok(None); + } + let content = tokio::fs::read_to_string(&path) + .await + .map_err(|e| anyhow!("Failed to read cache file at {}: {}", path.display(), e))?; + let entry: SerialCacheEntry = serde_json::from_str(&content)?; + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs(); + if now <= entry.expires_at_unix { + Ok(Some(entry.serial)) + } else { + Ok(None) + } +} + +pub async fn write_serial_cache(serial: &str, ttl: Duration) -> Result<()> { + let path = cache_file_path()?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + anyhow!( + "Failed to create cache directory {}: {}", + parent.display(), + e + ) + })?; + } + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs(); + let entry = SerialCacheEntry { + serial: serial.to_string(), + expires_at_unix: now + ttl.as_secs(), + }; + let data = serde_json::to_string(&entry)?; + tokio::fs::write(&path, data) + .await + .map_err(|e| anyhow!("Failed to write cache file at {}: {}", path.display(), e))?; + Ok(()) +} diff --git a/service/src/config.rs b/service/src/config.rs new file mode 100644 index 0000000..dd98ec7 --- /dev/null +++ b/service/src/config.rs @@ -0,0 +1,244 @@ +use anyhow::Result; +use configparser::ini::Ini; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; +use zbus::Connection; + +#[derive(Clone, Debug)] +pub struct Config { + pub endpoints: Vec<(String, u16)>, + pub username: Option, + pub password: Option, + pub client_id: String, + pub refresh_interval_seconds: u64, +} + +fn default_port() -> u16 { + 1883 +} +fn default_client_id() -> String { + "camper-widget-refresh".to_string() +} +fn default_refresh_interval() -> u64 { + 60 +} +// log level is now controlled via RUST_LOG in main; no per-config default needed + +fn plasma_applet_src_path() -> Option { + dirs::config_dir().map(|d| d.join("plasma-org.kde.plasma.desktop-appletsrc")) +} + +fn find_applet_section_id(ini: &Ini) -> Option { + for section in ini.sections() { + if let Some(plugin) = ini.get(§ion, "plugin") + && plugin == "craftknight.camper_widget" + { + return Some(section); + } + } + warn!("No applet section id found"); + None +} + +fn unescape_kde_value(s: &str) -> String { + s.replace("\\s", " ") + .replace("\\t", "\t") + .replace("\\n", "\n") + .replace("\\\\", "\\") +} + +fn parse_mqtt_hosts(raw: &str) -> Vec<(String, u16)> { + let unescaped = unescape_kde_value(raw); + let mut out: Vec<(String, u16)> = Vec::new(); + for entry in unescaped.split(',') { + let part = entry.trim(); + if part.is_empty() { + continue; + } + if let Some((host, port_str)) = part.rsplit_once(':') + && let Ok(port) = port_str.parse::() + { + out.push((host.trim().to_string(), port)); + continue; + } + out.push((part.to_string(), default_port())); + } + if out.is_empty() { + out.push(("127.0.0.1".to_string(), default_port())); + } + out +} + +pub async fn load_config() -> Config { + // Defaults + let mut endpoints: Vec<(String, u16)> = vec![("127.0.0.1".to_string(), default_port())]; + let mut username: Option = None; + let mut password: Option = None; + let client_id = default_client_id(); + let mut refresh_interval_seconds = default_refresh_interval(); + + if let Some(path) = plasma_applet_src_path() { + debug!("Reading Plasma INI config from {:?}", path); + match tokio::fs::read_to_string(&path).await { + Ok(contents) => { + let mut ini = Ini::new(); + if let Err(e) = ini.read(contents) { + warn!("Failed to parse Plasma INI config at {:?}: {}", path, e); + } + let sections = ini.sections(); + debug!("Found {} INI sections", sections.len()); + if let Some(base) = find_applet_section_id(&ini) { + debug!("Matched applet section id: {}", base); + // Build the [Configuration][General] section name from the found applet base + let sec_general = format!("{}][configuration][general", base); + if ini.sections().contains(&sec_general) { + debug!("Using section: {}", sec_general); + debug!("Looking under primary section: {}", sec_general); + let mut read_any = false; + let section = &sec_general; + debug!("Attempt reading values from section: {}", section); + if let Some(raw_hosts) = ini.get(section, "mqttHosts") { + debug!("Found mqttHosts='{}'", raw_hosts); + endpoints = parse_mqtt_hosts(&raw_hosts); + debug!("Parsed endpoints: {:?}", endpoints); + read_any = true; + } + if let Some(u) = ini.get(section, "mqttUsername") + && !u.is_empty() + { + username = Some(u); + debug!("Found mqttUsername set"); + read_any = true; + } + if let Some(p) = ini.get(section, "mqttPassword") + && !p.is_empty() + { + password = Some(p); + debug!("Found mqttPassword set (redacted)"); + read_any = true; + } + if let Some(sec) = ini.get(section, "refreshIntervalSeconds") + && let Ok(v) = sec.trim().parse::() + { + refresh_interval_seconds = v; + debug!("Found refreshIntervalSeconds={}", v); + read_any = true; + } + if !read_any { + warn!("No configuration keys found under {}", section); + } + } else { + warn!("Could not find target configuration section"); + } + } else { + warn!("No applet section id found"); + } + } + Err(e) => { + warn!("Failed to read Plasma INI config at {:?}: {}", path, e); + } + } + } else { + warn!("No config dir found (dirs::config_dir returned None)"); + } + + Config { + endpoints, + username, + password, + client_id, + refresh_interval_seconds, + } +} + +pub async fn listen_for_config_changes( + _conn: &Connection, + config_state: Arc>, + shared_state: &crate::dbus::SharedState, + token: CancellationToken, +) -> Result<()> { + info!("Starting config reload monitor"); + + loop { + tokio::select! { + _ = shared_state.config_reload_notify.notified() => {} + _ = token.cancelled() => { + info!("Config reload monitor shutting down"); + return Ok(()); + } + } + + info!("Config reload triggered, reloading configuration"); + + let new_config = load_config().await; + + { + let mut config_guard = config_state.write().await; + *config_guard = new_config; + } + + info!("Configuration reloaded successfully"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_mqtt_hosts_various() { + assert_eq!( + parse_mqtt_hosts("127.0.0.1"), + vec![("127.0.0.1".to_string(), 1883)] + ); + assert_eq!( + parse_mqtt_hosts("127.0.0.1:1883"), + vec![("127.0.0.1".to_string(), 1883)] + ); + assert_eq!( + parse_mqtt_hosts("127.0.0.1,192.168.1.111"), + vec![ + ("127.0.0.1".to_string(), 1883), + ("192.168.1.111".to_string(), 1883) + ] + ); + assert_eq!( + parse_mqtt_hosts("127.0.0.1:1883,192.168.1.111"), + vec![ + ("127.0.0.1".to_string(), 1883), + ("192.168.1.111".to_string(), 1883) + ] + ); + assert_eq!( + parse_mqtt_hosts("127.0.0.1:1883,192.168.1.111:1833"), + vec![ + ("127.0.0.1".to_string(), 1883), + ("192.168.1.111".to_string(), 1833) + ] + ); + // KDE config escapes leading spaces as \s + assert_eq!( + parse_mqtt_hosts("\\s192.168.10.202 :1883"), + vec![("192.168.10.202".to_string(), 1883)] + ); + } + + #[test] + fn test_find_applet_section_id() { + let mut ini = Ini::new(); + // Base section like [Containments][85][Applets][133] + ini.set( + "Containments][85][Applets][133", + "plugin", + Some("craftknight.camper_widget".to_string()), + ); + let found = find_applet_section_id(&ini); + // configparser normalizes section names to lowercase + assert_eq!(found, Some("containments][85][applets][133".to_string())); + } + + // We avoid testing load_config() directly because it depends on user's plasma file. +} diff --git a/service/src/dbus.rs b/service/src/dbus.rs new file mode 100644 index 0000000..2a07134 --- /dev/null +++ b/service/src/dbus.rs @@ -0,0 +1,667 @@ +use std::sync::Arc; + +use anyhow::Result; +use tokio::sync::{Notify, RwLock, Semaphore}; + +use tracing::{error, warn}; +use zbus::{Connection, connection, interface}; + +// Object paths +pub const ROOT_OBJECT_PATH: &str = "/org/craftknight/camper_widget"; +pub const BATTERY_OBJECT_PATH: &str = "/org/craftknight/camper_widget/Battery"; +pub const SOLAR_OBJECT_PATH: &str = "/org/craftknight/camper_widget/Solar"; +pub const AC_INPUT_OBJECT_PATH: &str = "/org/craftknight/camper_widget/AcInput"; +pub const AC_LOAD_OBJECT_PATH: &str = "/org/craftknight/camper_widget/AcLoad"; + +#[derive(Clone)] +pub struct SharedState { + // Status interface data + pub connected: Arc>, + + // Battery interface data + pub battery_soc: Arc>, + pub battery_power: Arc>, + pub battery_state: Arc>, + + // Solar interface data + pub solar_power: Arc>, + + // AC input interface data + pub ac_input_power: Arc>, + + // AC load interface data + pub ac_load_power: Arc>, + + // Config reload notification + pub config_reload_notify: Arc, + + // Backpressure for D-Bus signal emission + pub signal_semaphore: Arc, +} + +impl Default for SharedState { + fn default() -> Self { + Self { + connected: Arc::new(RwLock::new(false)), + battery_soc: Arc::new(RwLock::new(0.0)), + battery_power: Arc::new(RwLock::new(0.0)), + battery_state: Arc::new(RwLock::new("idle".to_string())), + solar_power: Arc::new(RwLock::new(0.0)), + ac_input_power: Arc::new(RwLock::new(0.0)), + ac_load_power: Arc::new(RwLock::new(0.0)), + config_reload_notify: Arc::new(Notify::new()), + signal_semaphore: Arc::new(Semaphore::new(10)), + } + } +} + +#[derive(Clone)] +pub struct StatusInterface { + state: SharedState, +} + +impl StatusInterface { + fn new(state: SharedState) -> Self { + Self { state } + } +} + +#[derive(Clone)] +pub struct BatteryInterface { + state: SharedState, +} + +impl BatteryInterface { + fn new(state: SharedState) -> Self { + Self { state } + } +} + +#[derive(Clone)] +pub struct SolarInterface { + state: SharedState, +} + +impl SolarInterface { + fn new(state: SharedState) -> Self { + Self { state } + } +} + +#[derive(Clone)] +pub struct AcInputInterface { + state: SharedState, +} + +impl AcInputInterface { + fn new(state: SharedState) -> Self { + Self { state } + } +} + +#[derive(Clone)] +pub struct AcLoadInterface { + state: SharedState, +} + +impl AcLoadInterface { + fn new(state: SharedState) -> Self { + Self { state } + } +} + +#[derive(Clone)] +pub struct ConfigInterface { + #[allow(dead_code)] + state: SharedState, +} + +impl ConfigInterface { + fn new(state: SharedState) -> Self { + Self { state } + } +} + +// Status interface - Connected property +#[interface(name = "org.craftknight.CamperWidget.Status")] +impl StatusInterface { + #[zbus(property(emits_changed_signal = "true"))] + async fn connected(&self) -> bool { + *self.state.connected.read().await + } +} + +// Battery interface - SoC, Power, State properties +#[interface(name = "org.craftknight.CamperWidget.Battery")] +impl BatteryInterface { + #[zbus(property(emits_changed_signal = "true"))] + async fn soc(&self) -> f64 { + *self.state.battery_soc.read().await + } + + #[zbus(property(emits_changed_signal = "true"))] + async fn power(&self) -> f64 { + *self.state.battery_power.read().await + } + + #[zbus(property(emits_changed_signal = "true"))] + async fn state(&self) -> String { + self.state.battery_state.read().await.clone() + } +} + +// Solar interface - Power property +#[interface(name = "org.craftknight.CamperWidget.Solar")] +impl SolarInterface { + #[zbus(property(emits_changed_signal = "true"))] + async fn power(&self) -> f64 { + *self.state.solar_power.read().await + } +} + +// AC input interface - Power property +#[interface(name = "org.craftknight.CamperWidget.AcInput")] +impl AcInputInterface { + #[zbus(property(emits_changed_signal = "true"))] + async fn power(&self) -> f64 { + *self.state.ac_input_power.read().await + } +} + +// AC load interface - Power property +#[interface(name = "org.craftknight.CamperWidget.AcLoad")] +impl AcLoadInterface { + #[zbus(property(emits_changed_signal = "true"))] + async fn power(&self) -> f64 { + *self.state.ac_load_power.read().await + } +} + +// Config interface for handling configuration reloads +#[interface(name = "org.craftknight.CamperWidget.Config")] +impl ConfigInterface { + /// Reload configuration method + async fn reload(&self) -> zbus::fdo::Result<()> { + self.state.config_reload_notify.notify_one(); + Ok(()) + } +} + +// Start a session bus service and return the connection; keep it alive in main +pub async fn start_service() -> Result<(Connection, SharedState)> { + let shared = SharedState::default(); + + let status_server = StatusInterface::new(shared.clone()); + let battery_server = BatteryInterface::new(shared.clone()); + let solar_server = SolarInterface::new(shared.clone()); + let ac_input_server = AcInputInterface::new(shared.clone()); + let ac_load_server = AcLoadInterface::new(shared.clone()); + let config_server = ConfigInterface::new(shared.clone()); + + let conn = connection::Builder::session()? + .name("org.craftknight.CamperWidget")? + .serve_at(ROOT_OBJECT_PATH, status_server)? + .serve_at(ROOT_OBJECT_PATH, config_server)? + .serve_at(BATTERY_OBJECT_PATH, battery_server)? + .serve_at(SOLAR_OBJECT_PATH, solar_server)? + .serve_at(AC_INPUT_OBJECT_PATH, ac_input_server)? + .serve_at(AC_LOAD_OBJECT_PATH, ac_load_server)? + .build() + .await?; + + Ok((conn, shared)) +} + +/// Start a D-Bus service at a custom address (for integration testing). +pub async fn start_service_at(address: &str) -> Result<(Connection, SharedState)> { + let shared = SharedState::default(); + + let status_server = StatusInterface::new(shared.clone()); + let battery_server = BatteryInterface::new(shared.clone()); + let solar_server = SolarInterface::new(shared.clone()); + let ac_input_server = AcInputInterface::new(shared.clone()); + let ac_load_server = AcLoadInterface::new(shared.clone()); + let config_server = ConfigInterface::new(shared.clone()); + + let conn = connection::Builder::address(address)? + .name("org.craftknight.CamperWidget")? + .serve_at(ROOT_OBJECT_PATH, status_server)? + .serve_at(ROOT_OBJECT_PATH, config_server)? + .serve_at(BATTERY_OBJECT_PATH, battery_server)? + .serve_at(SOLAR_OBJECT_PATH, solar_server)? + .serve_at(AC_INPUT_OBJECT_PATH, ac_input_server)? + .serve_at(AC_LOAD_OBJECT_PATH, ac_load_server)? + .build() + .await?; + + Ok((conn, shared)) +} + +// Property types for individual updates +#[derive(Debug, Clone, PartialEq)] +pub enum PropertyType { + BatterySoc, + BatteryPower, + BatteryState, + SolarPower, + AcInputPower, + AcLoadPower, +} + +// Update connected status and emit signal +pub async fn update_connected_status( + conn: &Connection, + state: &SharedState, + connected: bool, +) -> Result<()> { + let object_server = conn.object_server(); + + let mut guard = state.connected.write().await; + if *guard != connected { + *guard = connected; + // Emit signal for connected status asynchronously + let object_server_clone = object_server.clone(); + match state.signal_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + if let Ok(iface_ref) = object_server_clone + .interface::<_, StatusInterface>(ROOT_OBJECT_PATH) + .await + { + let iface = iface_ref.get_mut().await; + if let Err(e) = iface.connected_changed(iface_ref.signal_emitter()).await { + error!("Connected: Failed to emit signal: {}", e); + } + } else { + error!("Connected: Failed to get interface at {}", ROOT_OBJECT_PATH); + } + }); + } + Err(_) => { + warn!("Signal emission backpressure: dropping signal for Connected"); + } + } + } + + Ok(()) +} + +// Update battery state from a string directly (used by Batteries topic) +pub async fn update_battery_state_str( + conn: &Connection, + state: &SharedState, + state_str: &str, +) -> Result<()> { + let object_server = conn.object_server(); + let mut guard = state.battery_state.write().await; + if *guard != state_str { + *guard = state_str.to_string(); + let object_server_clone = object_server.clone(); + match state.signal_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + if let Ok(iface_ref) = object_server_clone + .interface::<_, BatteryInterface>(BATTERY_OBJECT_PATH) + .await + { + let iface = iface_ref.get_mut().await; + if let Err(e) = iface.state_changed(iface_ref.signal_emitter()).await { + error!("BatteryState: Failed to emit signal: {}", e); + } + } + }); + } + Err(_) => { + warn!("Signal emission backpressure: dropping signal for BatteryState"); + } + } + } + Ok(()) +} + +// Update a single DBus property immediately +pub async fn update_single_property( + conn: &Connection, + state: &SharedState, + property_type: &PropertyType, + value: f64, +) -> Result<()> { + let object_server = conn.object_server(); + + match property_type { + PropertyType::BatterySoc => { + let mut guard = state.battery_soc.write().await; + if (*guard - value).abs() > f64::EPSILON { + *guard = value; + // Emit signal for battery SoC asynchronously + let object_server_clone = object_server.clone(); + match state.signal_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + if let Ok(iface_ref) = object_server_clone + .interface::<_, BatteryInterface>(BATTERY_OBJECT_PATH) + .await + { + let iface = iface_ref.get_mut().await; + if let Err(e) = iface.soc_changed(iface_ref.signal_emitter()).await + { + error!("BatterySoc: Failed to emit signal: {}", e); + } + } else { + error!( + "BatterySoc: Failed to get interface at {}", + BATTERY_OBJECT_PATH + ); + } + }); + } + Err(_) => { + warn!("Signal emission backpressure: dropping signal for BatterySoc"); + } + } + } + } + PropertyType::BatteryPower => { + let mut guard = state.battery_power.write().await; + if (*guard - value).abs() > f64::EPSILON { + *guard = value; + // Emit signal for battery power asynchronously + let object_server_clone = object_server.clone(); + match state.signal_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + if let Ok(iface_ref) = object_server_clone + .interface::<_, BatteryInterface>(BATTERY_OBJECT_PATH) + .await + { + let iface = iface_ref.get_mut().await; + if let Err(e) = + iface.power_changed(iface_ref.signal_emitter()).await + { + error!("BatteryPower: Failed to emit signal: {}", e); + } + } else { + error!( + "BatteryPower: Failed to get interface at {}", + BATTERY_OBJECT_PATH + ); + } + }); + } + Err(_) => { + warn!("Signal emission backpressure: dropping signal for BatteryPower"); + } + } + } + } + PropertyType::BatteryState => { + let state_str = if value > 5.0 { + "charging".to_string() + } else if value < -5.0 { + "discharging".to_string() + } else { + "idle".to_string() + }; + + let mut guard = state.battery_state.write().await; + if *guard != state_str { + *guard = state_str.clone(); + // Emit signal for battery state asynchronously + let object_server_clone = object_server.clone(); + match state.signal_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + if let Ok(iface_ref) = object_server_clone + .interface::<_, BatteryInterface>(BATTERY_OBJECT_PATH) + .await + { + let iface = iface_ref.get_mut().await; + if let Err(e) = + iface.state_changed(iface_ref.signal_emitter()).await + { + error!("BatteryState: Failed to emit signal: {}", e); + } + } else { + error!( + "BatteryState: Failed to get interface at {}", + BATTERY_OBJECT_PATH + ); + } + }); + } + Err(_) => { + warn!("Signal emission backpressure: dropping signal for BatteryState"); + } + } + } + } + PropertyType::SolarPower => { + let mut guard = state.solar_power.write().await; + if (*guard - value).abs() > f64::EPSILON { + *guard = value; + // Emit signal for solar power asynchronously + let object_server_clone = object_server.clone(); + match state.signal_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + if let Ok(iface_ref) = object_server_clone + .interface::<_, SolarInterface>(SOLAR_OBJECT_PATH) + .await + { + let iface = iface_ref.get_mut().await; + if let Err(e) = + iface.power_changed(iface_ref.signal_emitter()).await + { + error!("SolarPower: Failed to emit signal: {}", e); + } + } else { + error!( + "SolarPower: Failed to get interface at {}", + SOLAR_OBJECT_PATH + ); + } + }); + } + Err(_) => { + warn!("Signal emission backpressure: dropping signal for SolarPower"); + } + } + } + } + PropertyType::AcInputPower => { + let mut guard = state.ac_input_power.write().await; + if (*guard - value).abs() > f64::EPSILON { + *guard = value; + let object_server_clone = object_server.clone(); + match state.signal_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + if let Ok(iface_ref) = object_server_clone + .interface::<_, AcInputInterface>(AC_INPUT_OBJECT_PATH) + .await + { + let iface = iface_ref.get_mut().await; + if let Err(e) = + iface.power_changed(iface_ref.signal_emitter()).await + { + error!("AcInputPower: Failed to emit signal: {}", e); + } + } else { + error!( + "AcInputPower: Failed to get interface at {}", + AC_INPUT_OBJECT_PATH + ); + } + }); + } + Err(_) => { + warn!("Signal emission backpressure: dropping signal for AcInputPower"); + } + } + } + } + PropertyType::AcLoadPower => { + let mut guard = state.ac_load_power.write().await; + if (*guard - value).abs() > f64::EPSILON { + *guard = value; + let object_server_clone = object_server.clone(); + match state.signal_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + if let Ok(iface_ref) = object_server_clone + .interface::<_, AcLoadInterface>(AC_LOAD_OBJECT_PATH) + .await + { + let iface = iface_ref.get_mut().await; + if let Err(e) = + iface.power_changed(iface_ref.signal_emitter()).await + { + error!("AcLoadPower: Failed to emit signal: {}", e); + } + } else { + error!( + "AcLoadPower: Failed to get interface at {}", + AC_LOAD_OBJECT_PATH + ); + } + }); + } + Err(_) => { + warn!("Signal emission backpressure: dropping signal for AcLoadPower"); + } + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_shared_state_initialization() { + let state = SharedState::default(); + assert!(!(*state.connected.read().await)); + assert_eq!(*state.battery_soc.read().await, 0.0); + assert_eq!(*state.battery_power.read().await, 0.0); + assert_eq!(*state.battery_state.read().await, "idle"); + assert_eq!(*state.solar_power.read().await, 0.0); + assert_eq!(*state.ac_input_power.read().await, 0.0); + assert_eq!(*state.ac_load_power.read().await, 0.0); + } + + #[tokio::test] + async fn test_property_updates() { + let state = SharedState::default(); + + // Test connected status update + { + let mut guard = state.connected.write().await; + *guard = true; + } + assert!(*state.connected.read().await); + + // Test battery property updates + { + let mut soc_guard = state.battery_soc.write().await; + *soc_guard = 75.0; + } + { + let mut power_guard = state.battery_power.write().await; + *power_guard = -15.0; + } + { + let mut state_guard = state.battery_state.write().await; + *state_guard = "discharging".to_string(); + } + + assert_eq!(*state.battery_soc.read().await, 75.0); + assert_eq!(*state.battery_power.read().await, -15.0); + assert_eq!(*state.battery_state.read().await, "discharging"); + + // Test solar power update + { + let mut solar_guard = state.solar_power.write().await; + *solar_guard = 120.0; + } + assert_eq!(*state.solar_power.read().await, 120.0); + + // Test AC input power update + { + let mut ac_input_guard = state.ac_input_power.write().await; + *ac_input_guard = 82.0; + } + assert_eq!(*state.ac_input_power.read().await, 82.0); + + // Test AC load power update + { + let mut ac_load_guard = state.ac_load_power.write().await; + *ac_load_guard = 77.0; + } + assert_eq!(*state.ac_load_power.read().await, 77.0); + } + + #[tokio::test] + async fn test_interface_creation() { + let state = SharedState::default(); + + let _status_interface = StatusInterface::new(state.clone()); + let _battery_interface = BatteryInterface::new(state.clone()); + let _solar_interface = SolarInterface::new(state.clone()); + let _ac_input_interface = AcInputInterface::new(state.clone()); + let _ac_load_interface = AcLoadInterface::new(state.clone()); + let _config_interface = ConfigInterface::new(state.clone()); + + // Test that interfaces can be created without panicking + // If we get here, creation succeeded + } + + #[test] + fn test_property_change_detection() { + // Test floating point comparison logic without async + let value1: f64 = 50.0; + let value2: f64 = 50.1; + + assert!((value1 - 50.0_f64).abs() < f64::EPSILON); + assert!((value2 - 50.1_f64).abs() < f64::EPSILON); + assert!((value1 - value2).abs() > f64::EPSILON); + } + + #[tokio::test] + async fn test_battery_state_mapping() { + // Test the battery state mapping logic from main.rs + let test_cases = vec![ + (10.0, "charging"), + (-10.0, "discharging"), + (0.0, "idle"), + (3.0, "idle"), // Below threshold + (-3.0, "idle"), // Above negative threshold + ]; + + for (power, expected_state) in test_cases { + let direction = if power > 5.0 { + "charging".to_string() + } else if power < -5.0 { + "discharging".to_string() + } else { + "idle".to_string() + }; + + assert_eq!( + direction, expected_state, + "Power {power} should map to {expected_state}" + ); + } + } +} diff --git a/service/src/lib.rs b/service/src/lib.rs new file mode 100644 index 0000000..56bf445 --- /dev/null +++ b/service/src/lib.rs @@ -0,0 +1,4 @@ +pub mod cache; +pub mod config; +pub mod dbus; +pub mod victron_mqtt; diff --git a/service/src/main.rs b/service/src/main.rs new file mode 100644 index 0000000..51f7ff7 --- /dev/null +++ b/service/src/main.rs @@ -0,0 +1,238 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use camper_widget_refresh::cache::{read_serial_cache, write_serial_cache}; +use camper_widget_refresh::config::{Config, listen_for_config_changes, load_config}; +use camper_widget_refresh::dbus::{self, start_service, update_connected_status}; +use camper_widget_refresh::victron_mqtt::{ + build_mqtt_options, disconnect_mqtt, pick_first_available, + read_values_and_update_properties_immediately, wait_for_serial, +}; +use rumqttc::AsyncClient; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; +use tokio::time; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +const MAX_CONFIG_LISTENER_BACKOFF_SECS: u64 = 30; + +async fn refresh_data( + cfg: &Config, + conn: &zbus::Connection, + dbus_state: &dbus::SharedState, +) -> Result<()> { + let (host, port) = pick_first_available(&cfg.endpoints).await?; + info!("Connecting to MQTT {}:{} as {}", host, port, cfg.client_id); + + // Set connected to true when we found a reachable host + update_connected_status(conn, dbus_state, true).await?; + + let mqttoptions = build_mqtt_options(cfg, &host, port); + let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + + // Try cache for serial first + let mut serial: Option = match read_serial_cache().await { + Ok(Some(s)) => { + info!("Using cached serial: {}", s); + Some(s) + } + Ok(None) => None, + Err(e) => { + warn!("Failed reading cache: {}", e); + None + } + }; + + if serial.is_none() { + info!("Waiting for serial on N/+/system/0/Serial..."); + let s = wait_for_serial(&client, &mut eventloop).await?; + // Long TTL 30 days + if let Err(e) = write_serial_cache(&s, Duration::from_secs(30 * 24 * 3600)).await { + warn!("Failed to write cache: {}", e); + } + serial = Some(s); + } + + let serial = serial.expect("serial should be set"); + + // Read values and update DBus properties immediately as each MQTT message arrives + let result = read_values_and_update_properties_immediately( + &client, + &mut eventloop, + &serial, + conn, + dbus_state, + ) + .await; + + disconnect_mqtt(&client).await; + + match result? { + true => { + info!("Completed refresh cycle - all properties updated"); + } + false => { + warn!("Refresh cycle completed with partial data - some MQTT topics timed out"); + } + } + + Ok(()) +} + +fn spawn_config_listener( + conn: zbus::Connection, + config_state: Arc>, + shared_state: dbus::SharedState, + token: CancellationToken, +) -> JoinHandle<()> { + tokio::spawn(async move { + if let Err(e) = listen_for_config_changes(&conn, config_state, &shared_state, token).await { + error!("Config change listener failed: {}", e); + } + }) +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing early so debug logs from config loading are visible + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing::subscriber::set_global_default( + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(false) + .finish(), + ); + + let initial_cfg = load_config().await; + + // Start D-Bus service and keep connection/state alive + let (dbus_conn, dbus_state) = start_service().await?; + + // Create shared config state + let config_state = Arc::new(RwLock::new(initial_cfg)); + + // Graceful shutdown via CancellationToken + let shutdown_token = CancellationToken::new(); + let ctrl_c_token = shutdown_token.clone(); + tokio::spawn(async move { + if let Err(e) = tokio::signal::ctrl_c().await { + error!("Failed to listen for ctrl-c: {}", e); + return; + } + info!("Received shutdown signal, initiating graceful shutdown"); + ctrl_c_token.cancel(); + }); + + info!( + "Starting camper-widget-refresh with refresh interval: {} seconds", + config_state.read().await.refresh_interval_seconds + ); + + // Spawn the config change listener with supervision + let mut config_listener_handle = spawn_config_listener( + dbus_conn.clone(), + config_state.clone(), + dbus_state.clone(), + shutdown_token.child_token(), + ); + let mut config_backoff_secs: u64 = 1; + + // Run initial refresh + { + let cfg = config_state.read().await; + if let Err(e) = refresh_data(&cfg, &dbus_conn, &dbus_state).await { + warn!("Initial refresh failed: {}", e); + // Set connected to false when refresh fails + if let Err(update_err) = update_connected_status(&dbus_conn, &dbus_state, false).await { + warn!("Failed to update connected status: {}", update_err); + } + } + } + + // Run refresh loop with dynamic interval + while !shutdown_token.is_cancelled() { + // Supervise config listener — respawn with backoff if it died + if config_listener_handle.is_finished() && !shutdown_token.is_cancelled() { + error!( + "Config listener exited unexpectedly, respawning in {}s", + config_backoff_secs + ); + time::sleep(Duration::from_secs(config_backoff_secs)).await; + config_backoff_secs = (config_backoff_secs * 2).min(MAX_CONFIG_LISTENER_BACKOFF_SECS); + config_listener_handle = spawn_config_listener( + dbus_conn.clone(), + config_state.clone(), + dbus_state.clone(), + shutdown_token.child_token(), + ); + } + + let refresh_interval = { + let cfg = config_state.read().await; + Duration::from_secs(cfg.refresh_interval_seconds) + }; + + // Race sleep against shutdown + tokio::select! { + _ = time::sleep(refresh_interval) => {} + _ = shutdown_token.cancelled() => break, + } + + let cfg = config_state.read().await; + match refresh_data(&cfg, &dbus_conn, &dbus_state).await { + Ok(()) => { + // Successful refresh — reset config listener backoff + config_backoff_secs = 1; + } + Err(e) => { + warn!("Refresh failed: {}", e); + if let Err(update_err) = + update_connected_status(&dbus_conn, &dbus_state, false).await + { + warn!("Failed to update connected status: {}", update_err); + } + } + } + } + + info!("Shutting down cleanly"); + Ok(()) +} + +#[cfg(test)] +mod tests { + + // parse_numeric_value tests moved to victron_mqtt.rs + + #[test] + fn test_battery_direction_mapping() { + // Test the direction mapping logic + let test_cases = vec![ + (10.0, "charging"), + (-10.0, "discharging"), + (0.0, "idle"), + (3.0, "idle"), // Below threshold + (-3.0, "idle"), // Above negative threshold + (5.1, "charging"), // Just above threshold + (-5.1, "discharging"), // Just below negative threshold + ]; + + for (power, expected) in test_cases { + let direction = if power > 5.0 { + "charging".to_string() + } else if power < -5.0 { + "discharging".to_string() + } else { + "idle".to_string() + }; + + assert_eq!( + direction, expected, + "Power {power} should map to {expected}" + ); + } + } +} diff --git a/service/src/victron_mqtt.rs b/service/src/victron_mqtt.rs new file mode 100644 index 0000000..d72ddb3 --- /dev/null +++ b/service/src/victron_mqtt.rs @@ -0,0 +1,576 @@ +use std::time::Duration; + +use crate::config::Config; +use crate::dbus::{PropertyType, SharedState, update_battery_state_str, update_single_property}; +use anyhow::{Result, anyhow}; +use rumqttc::{AsyncClient, Event, EventLoop, Incoming, MqttOptions, QoS}; +use tokio::{net::TcpStream, select, time}; +use tracing::{debug, info, warn}; +use zbus::Connection; + +// Topic-to-property mapping structure (for simple 1:1 topic→property mappings) +#[derive(Debug, Clone)] +pub struct TopicPropertyMapping { + pub topic_pattern: String, + pub property_type: PropertyType, +} + +impl TopicPropertyMapping { + pub fn new(topic_pattern: String, property_type: PropertyType) -> Self { + Self { + topic_pattern, + property_type, + } + } +} + +// Collection of all topic mappings for a given serial +pub struct MqttPropertyMappings { + mappings: Vec, + batteries_topic: String, // system/0/Batteries — yields SoC, Power, State +} + +impl MqttPropertyMappings { + pub fn new(serial: &str) -> Self { + Self { + mappings: vec![ + TopicPropertyMapping::new( + format!("N/{serial}/solarcharger/277/Yield/Power"), + PropertyType::SolarPower, + ), + TopicPropertyMapping::new( + format!("N/{serial}/system/0/Ac/Grid/L1/Power"), + PropertyType::AcInputPower, + ), + TopicPropertyMapping::new( + format!("N/{serial}/system/0/Ac/Consumption/L1/Power"), + PropertyType::AcLoadPower, + ), + ], + batteries_topic: format!("N/{serial}/system/0/Batteries"), + } + } + + // Find mapping for a given topic (simple 1:1 mappings only) + pub fn find_mapping(&self, topic: &str) -> Option<&TopicPropertyMapping> { + self.mappings.iter().find(|m| m.topic_pattern == topic) + } + + // Check if topic is the composite Batteries topic + pub fn is_batteries_topic(&self, topic: &str) -> bool { + self.batteries_topic == topic + } + + // Get all topic patterns for subscription + pub fn get_subscription_topics(&self) -> Vec<&str> { + let mut topics: Vec<&str> = self + .mappings + .iter() + .map(|m| m.topic_pattern.as_str()) + .collect(); + topics.push(&self.batteries_topic); + topics + } +} + +/// Parse the system/0/Batteries JSON payload. +/// Returns (soc, power, state_string) from the first battery entry. +pub fn parse_batteries_payload(payload: &str) -> Option<(f64, f64, String)> { + let json: serde_json::Value = serde_json::from_str(payload).ok()?; + let batteries = json.get("value")?.as_array()?; + let battery = batteries.first()?; + let soc = battery.get("soc")?.as_f64()?; + let power = battery.get("power")?.as_f64()?; + let state_num = battery.get("state")?.as_i64().unwrap_or(0); + let state_str = match state_num { + 1 => "charging", + 2 => "discharging", + _ => "idle", + }; + Some((soc, power, state_str.to_string())) +} + +// Parse numeric value from MQTT payload (moved from main.rs) +fn parse_numeric_value(s: Option<&String>) -> f64 { + if let Some(raw) = s { + // First try direct number parsing + if let Ok(v) = raw.trim().parse::() { + return v; + } + // Try JSON object with {"value": number} + if let Ok(val) = serde_json::from_str::(raw) + && let Some(v) = val.get("value") + { + if let Some(n) = v.as_f64() { + return n; + } + if let Some(s) = v.as_str() + && let Ok(n) = s.trim().parse::() + { + return n; + } + } + } + 0.0 +} + +// Returns Some(serial) when topic is like "N/{serial}/system/0/Serial" +pub fn extract_serial_from_topic(topic: &str) -> Option { + if topic.starts_with("N/") && topic.ends_with("/system/0/Serial") { + let parts: Vec<&str> = topic.split('/').collect(); + if parts.len() >= 3 { + return Some(parts[1].to_string()); + } + } + None +} + +pub async fn disconnect_mqtt(client: &AsyncClient) { + if let Err(e) = client.disconnect().await { + warn!("MQTT disconnect failed: {}", e); + } +} + +pub fn build_mqtt_options(cfg: &Config, host: &str, port: u16) -> MqttOptions { + let mut opts = MqttOptions::new(&cfg.client_id, host, port); + opts.set_keep_alive(Duration::from_secs(10)); + if let (Some(u), Some(p)) = (&cfg.username, &cfg.password) { + opts.set_credentials(u, p); + } + opts +} + +pub async fn pick_first_available(endpoints: &[(String, u16)]) -> Result<(String, u16)> { + for (host, port) in endpoints { + let addr = (host.as_str(), *port); + let attempt = time::timeout(Duration::from_secs(2), TcpStream::connect(addr)).await; + match attempt { + Ok(Ok(stream)) => { + // Successfully connected; close and return this endpoint + drop(stream); + return Ok((host.clone(), *port)); + } + _ => { + // Try next + } + } + } + Err(anyhow!("No reachable MQTT endpoint from config")) +} + +pub async fn wait_for_serial(client: &AsyncClient, eventloop: &mut EventLoop) -> Result { + // Subscribe to N/+/system/0/Serial + client + .subscribe("N/+/system/0/Serial", QoS::AtLeastOnce) + .await?; + loop { + match eventloop.poll().await? { + Event::Incoming(Incoming::Publish(p)) => { + let topic = p.topic.clone(); + if let Some(serial) = extract_serial_from_topic(&topic) { + info!("Detected serial from topic: {}", serial); + return Ok(serial); + } + } + _ => { + // Ignore other events + } + } + } +} + +/// Read values and update DBus properties immediately as each message arrives. +/// Returns `Ok(true)` if all topics were received, `Ok(false)` on timeout with partial data. +pub async fn read_values_and_update_properties_immediately( + client: &AsyncClient, + eventloop: &mut EventLoop, + serial: &str, + conn: &Connection, + dbus_state: &SharedState, +) -> Result { + let mappings = MqttPropertyMappings::new(serial); + let topics = mappings.get_subscription_topics(); + + // Subscribe to all topics + for topic in &topics { + client.subscribe(*topic, QoS::AtLeastOnce).await?; + } + debug!( + "Subscribed to {} topics for serial={}", + topics.len(), + serial + ); + + let mut received_topics = std::collections::HashSet::new(); + let total_topics = topics.len(); + + // We'll wait up to 30 seconds for all values + let timeout = time::sleep(Duration::from_secs(30)); + tokio::pin!(timeout); + + let all_received = loop { + select! { + _ = &mut timeout => { + warn!("Timeout while waiting for all values, received {} of {}", received_topics.len(), total_topics); + break false; + } + ev = eventloop.poll() => { + match ev? { + Event::Incoming(Incoming::Publish(p)) => { + let topic = p.topic.clone(); + let payload_str = String::from_utf8_lossy(&p.payload).to_string(); + + if let Some(mapping) = mappings.find_mapping(&topic) { + let value = parse_numeric_value(Some(&payload_str)); + update_single_property(conn, dbus_state, &mapping.property_type, value).await?; + received_topics.insert(topic.clone()); + info!("Updated {:?} with value {} ({} of {} topics received)", + mapping.property_type, value, received_topics.len(), total_topics); + } else if mappings.is_batteries_topic(&topic) + && let Some((soc, power, state_str)) = parse_batteries_payload(&payload_str) + { + update_single_property(conn, dbus_state, &PropertyType::BatterySoc, soc).await?; + update_single_property(conn, dbus_state, &PropertyType::BatteryPower, power).await?; + update_battery_state_str(conn, dbus_state, &state_str).await?; + received_topics.insert(topic.clone()); + info!("Updated battery from Batteries topic: soc={}, power={}, state={} ({} of {} topics received)", + soc, power, state_str, received_topics.len(), total_topics); + } + + if received_topics.len() >= total_topics { + info!("All {} properties updated successfully", total_topics); + break true; + } + } + _ => { + // Ignore other events (ConnAck, SubAck, etc.) + } + } + } + } + }; + + Ok(all_received) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::net::TcpListener; + + #[test] + fn test_extract_serial_from_topic() { + assert_eq!( + extract_serial_from_topic("N/abc123/system/0/Serial"), + Some("abc123".to_string()) + ); + assert_eq!( + extract_serial_from_topic("N/xyz/system/0/Serial"), + Some("xyz".to_string()) + ); + assert_eq!( + extract_serial_from_topic("N//system/0/Serial"), + Some("".to_string()) + ); + assert_eq!(extract_serial_from_topic("N/abc123/system/0/Other"), None); + assert_eq!(extract_serial_from_topic("foo/bar"), None); + } + + #[tokio::test] + async fn test_pick_first_available_picks_open_second() { + // Start a listener for the second endpoint only + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let endpoints = vec![ + ("127.0.0.1".to_string(), 1), // very likely closed + (addr.ip().to_string(), addr.port()), + ]; + + let chosen = pick_first_available(&endpoints).await.unwrap(); + assert_eq!(chosen, (addr.ip().to_string(), addr.port())); + + drop(listener); + } + + #[tokio::test] + async fn test_pick_first_available_prefers_first_when_both_open() { + let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr1 = listener1.local_addr().unwrap(); + let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr2 = listener2.local_addr().unwrap(); + + let endpoints = vec![ + (addr1.ip().to_string(), addr1.port()), + (addr2.ip().to_string(), addr2.port()), + ]; + + let chosen = pick_first_available(&endpoints).await.unwrap(); + assert_eq!(chosen, (addr1.ip().to_string(), addr1.port())); + + drop(listener1); + drop(listener2); + } + + #[test] + fn test_parse_numeric_value_direct() { + let value = "123.45".to_string(); + let input = Some(&value); + assert_eq!(parse_numeric_value(input), 123.45); + } + + #[test] + fn test_parse_numeric_value_json() { + let value = r#"{"value": 67.89}"#.to_string(); + let input = Some(&value); + assert_eq!(parse_numeric_value(input), 67.89); + } + + #[test] + fn test_parse_numeric_value_json_string() { + let value = r#"{"value": "90.12"}"#.to_string(); + let input = Some(&value); + assert_eq!(parse_numeric_value(input), 90.12); + } + + #[test] + fn test_parse_numeric_value_invalid() { + let value = "invalid".to_string(); + let input = Some(&value); + assert_eq!(parse_numeric_value(input), 0.0); + } + + #[test] + fn test_parse_numeric_value_none() { + let input = None; + assert_eq!(parse_numeric_value(input), 0.0); + } + + #[test] + fn test_parse_numeric_value_whitespace() { + let value = " 42.5 ".to_string(); + let input = Some(&value); + assert_eq!(parse_numeric_value(input), 42.5); + } + + #[test] + fn test_topic_property_mapping() { + let mappings = MqttPropertyMappings::new("test123"); + let topics = mappings.get_subscription_topics(); + + assert_eq!(topics.len(), 4); + assert!(topics.contains(&"N/test123/solarcharger/277/Yield/Power")); + assert!(topics.contains(&"N/test123/system/0/Ac/Grid/L1/Power")); + assert!(topics.contains(&"N/test123/system/0/Ac/Consumption/L1/Power")); + assert!(topics.contains(&"N/test123/system/0/Batteries")); + + // Test finding solar mapping + let solar_mapping = mappings.find_mapping("N/test123/solarcharger/277/Yield/Power"); + assert!(solar_mapping.is_some()); + assert_eq!( + solar_mapping.unwrap().property_type, + PropertyType::SolarPower + ); + + // Test finding AC input mapping + let ac_input_mapping = mappings.find_mapping("N/test123/system/0/Ac/Grid/L1/Power"); + assert!(ac_input_mapping.is_some()); + assert_eq!( + ac_input_mapping.unwrap().property_type, + PropertyType::AcInputPower + ); + + // Test finding AC load mapping + let ac_load_mapping = mappings.find_mapping("N/test123/system/0/Ac/Consumption/L1/Power"); + assert!(ac_load_mapping.is_some()); + assert_eq!( + ac_load_mapping.unwrap().property_type, + PropertyType::AcLoadPower + ); + + // Batteries topic is handled specially, not in find_mapping + assert!( + mappings + .find_mapping("N/test123/system/0/Batteries") + .is_none() + ); + assert!(mappings.is_batteries_topic("N/test123/system/0/Batteries")); + } + + // --- parse_numeric_value: fallback chain and design decisions --- + + #[test] + fn test_parse_numeric_value_negative() { + let value = "-123.45".to_string(); + assert_eq!(parse_numeric_value(Some(&value)), -123.45); + } + + #[test] + fn test_parse_numeric_value_json_negative() { + let value = r#"{"value": -67.89}"#.to_string(); + assert_eq!(parse_numeric_value(Some(&value)), -67.89); + } + + #[test] + fn test_parse_numeric_value_json_null() { + let value = r#"{"value": null}"#.to_string(); + assert_eq!(parse_numeric_value(Some(&value)), 0.0); + } + + #[test] + fn test_parse_numeric_value_json_missing_value_key() { + let value = r#"{"power": 42}"#.to_string(); + assert_eq!(parse_numeric_value(Some(&value)), 0.0); + } + + #[test] + fn test_parse_numeric_value_empty_string() { + let value = "".to_string(); + assert_eq!(parse_numeric_value(Some(&value)), 0.0); + } + + #[test] + fn test_parse_numeric_value_json_with_extra_fields() { + let value = r#"{"value": 42, "unit": "W"}"#.to_string(); + assert_eq!(parse_numeric_value(Some(&value)), 42.0); + } + + #[test] + fn test_parse_numeric_value_json_non_numeric_string() { + let value = r#"{"value": "n/a"}"#.to_string(); + assert_eq!(parse_numeric_value(Some(&value)), 0.0); + } + + // --- extract_serial_from_topic: boundary cases --- + + #[test] + fn test_extract_serial_empty_topic() { + assert_eq!(extract_serial_from_topic(""), None); + } + + #[test] + fn test_extract_serial_data_topic_rejected() { + // Data topics must not be mistaken for serial discovery topics + assert_eq!( + extract_serial_from_topic("N/abc/battery/278/Dc/0/Power"), + None + ); + } + + // --- MqttPropertyMappings: Victron device mapping design --- + + #[test] + fn test_mapping_find_returns_none_for_unknown_topic() { + let mappings = MqttPropertyMappings::new("serial1"); + assert!(mappings.find_mapping("N/serial1/unknown/0/Foo").is_none()); + } + + #[test] + fn test_mapping_solar_power_exists() { + let mappings = MqttPropertyMappings::new("s1"); + let solar = mappings + .find_mapping("N/s1/solarcharger/277/Yield/Power") + .expect("solar mapping should exist"); + assert_eq!(solar.property_type, PropertyType::SolarPower); + } + + #[test] + fn test_mapping_different_serials_dont_cross_match() { + let mappings_a = MqttPropertyMappings::new("AAA"); + let mappings_b = MqttPropertyMappings::new("BBB"); + // Simple mappings from serial AAA must not match in serial BBB + for topic in mappings_a.get_subscription_topics() { + assert!( + mappings_b.find_mapping(topic).is_none(), + "serial BBB should not match topic from serial AAA: {topic}" + ); + if mappings_a.is_batteries_topic(topic) { + assert!( + !mappings_b.is_batteries_topic(topic), + "serial BBB batteries topic should not match serial AAA: {topic}" + ); + } + } + } + + #[test] + fn test_parse_batteries_payload_charging() { + let payload = r#"{"value":[{"active_battery_service":true,"current":1.8,"id":"com.victronenergy.battery.ttyS7","instance":279,"name":"Shunt","power":48.13,"soc":67.1,"state":1,"voltage":26.74}]}"#; + let (soc, power, state) = parse_batteries_payload(payload).unwrap(); + assert!((soc - 67.1).abs() < 0.01); + assert!((power - 48.13).abs() < 0.01); + assert_eq!(state, "charging"); + } + + #[test] + fn test_parse_batteries_payload_discharging() { + let payload = r#"{"value":[{"soc":50.0,"power":-334.78,"state":2}]}"#; + let (soc, power, state) = parse_batteries_payload(payload).unwrap(); + assert!((soc - 50.0).abs() < 0.01); + assert!((power - (-334.78)).abs() < 0.01); + assert_eq!(state, "discharging"); + } + + #[test] + fn test_parse_batteries_payload_idle() { + let payload = r#"{"value":[{"soc":100.0,"power":0.0,"state":0}]}"#; + let (soc, _power, state) = parse_batteries_payload(payload).unwrap(); + assert!((soc - 100.0).abs() < 0.01); + assert_eq!(state, "idle"); + } + + #[test] + fn test_parse_batteries_payload_invalid() { + assert!(parse_batteries_payload("not json").is_none()); + assert!(parse_batteries_payload(r#"{"value":[]}"#).is_none()); + assert!(parse_batteries_payload(r#"{"value":"nope"}"#).is_none()); + } + + #[test] + fn test_mapping_topics_embed_serial() { + let serial = "mySerial42"; + let mappings = MqttPropertyMappings::new(serial); + for topic in mappings.get_subscription_topics() { + assert!( + topic.contains(serial), + "topic should contain serial: {topic}" + ); + } + } + + // --- build_mqtt_options: configuration policy --- + + #[test] + fn test_build_mqtt_options_applies_credentials_when_present() { + let cfg = Config { + endpoints: vec![("127.0.0.1".to_string(), 1883)], + username: Some("user".to_string()), + password: Some("pass".to_string()), + client_id: "test-client".to_string(), + refresh_interval_seconds: 60, + }; + let opts = build_mqtt_options(&cfg, "127.0.0.1", 1883); + let login = opts.credentials().expect("credentials should be set"); + assert_eq!(login.username, "user"); + assert_eq!(login.password, "pass"); + assert_eq!(opts.keep_alive(), Duration::from_secs(10)); + } + + #[test] + fn test_build_mqtt_options_skips_credentials_when_absent() { + let cfg = Config { + endpoints: vec![("127.0.0.1".to_string(), 1883)], + username: None, + password: None, + client_id: "anon-client".to_string(), + refresh_interval_seconds: 60, + }; + let opts = build_mqtt_options(&cfg, "127.0.0.1", 1883); + assert!( + opts.credentials().is_none(), + "anonymous mode should have no credentials" + ); + assert_eq!(opts.client_id(), "anon-client"); + assert_eq!(opts.keep_alive(), Duration::from_secs(10)); + } +} diff --git a/service/tests/dbus_integration.rs b/service/tests/dbus_integration.rs new file mode 100644 index 0000000..53ac25b --- /dev/null +++ b/service/tests/dbus_integration.rs @@ -0,0 +1,325 @@ +use std::time::Duration; + +use anyhow::Result; +use camper_widget_refresh::dbus::{ + PropertyType, start_service_at, update_connected_status, update_single_property, +}; +use futures_util::StreamExt; +use tokio::time::timeout; +use zbus::{Connection, connection, proxy}; + +const TIMEOUT: Duration = Duration::from_secs(2); + +// ---------- Client-side proxy definitions ---------- + +#[proxy( + interface = "org.craftknight.CamperWidget.Status", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget" +)] +trait Status { + #[zbus(property)] + fn connected(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.Battery", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget/Battery" +)] +trait Battery { + #[zbus(property)] + fn soc(&self) -> zbus::Result; + + #[zbus(property)] + fn power(&self) -> zbus::Result; + + #[zbus(property)] + fn state(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.Solar", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget/Solar" +)] +trait Solar { + #[zbus(property)] + fn power(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.AcInput", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget/AcInput" +)] +trait AcInput { + #[zbus(property)] + fn power(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.AcLoad", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget/AcLoad" +)] +trait AcLoad { + #[zbus(property)] + fn power(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.Config", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget" +)] +trait Config { + fn reload(&self) -> zbus::Result<()>; +} + +// ---------- Test fixture ---------- + +struct DbusTestFixture { + _daemon: dbus_launch::Daemon, + address: String, +} + +impl DbusTestFixture { + fn new() -> Self { + let daemon = dbus_launch::Launcher::daemon() + .launch() + .expect("failed to launch dbus-daemon — is dbus-daemon installed?"); + let address = daemon.address().to_string(); + Self { + _daemon: daemon, + address, + } + } + + fn address(&self) -> &str { + &self.address + } + + async fn client_connection(&self) -> Result { + let conn = connection::Builder::address(self.address())? + .build() + .await?; + Ok(conn) + } +} + +// ---------- Tests ---------- + +#[tokio::test] +async fn test_default_property_values() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (_conn, _state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let status = StatusProxy::new(&client).await?; + let battery = BatteryProxy::new(&client).await?; + let solar = SolarProxy::new(&client).await?; + let ac_input = AcInputProxy::new(&client).await?; + let ac_load = AcLoadProxy::new(&client).await?; + + assert!(!status.connected().await?); + assert_eq!(battery.soc().await?, 0.0); + assert_eq!(battery.power().await?, 0.0); + assert_eq!(battery.state().await?, "idle"); + assert_eq!(solar.power().await?, 0.0); + assert_eq!(ac_input.power().await?, 0.0); + assert_eq!(ac_load.power().await?, 0.0); + + Ok(()) +} + +#[tokio::test] +async fn test_connected_status_update() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (conn, state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let status = StatusProxy::new(&client).await?; + + assert!(!status.connected().await?); + + // Subscribe to changes before mutating + let mut stream = status.receive_connected_changed().await; + + update_connected_status(&conn, &state, true).await?; + + // Wait for a property-changed signal with the expected value. + // The proxy may deliver a cached/initial signal first, so loop until + // we see the value we expect or timeout. + let deadline = tokio::time::Instant::now() + TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + panic!("timed out waiting for connected=true signal"); + } + let signal = timeout(remaining, stream.next()).await?; + let changed = signal.expect("stream ended unexpectedly"); + if changed.get().await? { + break; // got the expected value + } + } + + Ok(()) +} + +#[tokio::test] +async fn test_battery_soc_update() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (conn, state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + + let mut stream = battery.receive_soc_changed().await; + + update_single_property(&conn, &state, &PropertyType::BatterySoc, 85.5).await?; + + let signal = timeout(TIMEOUT, stream.next()).await?; + assert!(signal.is_some()); + assert!((signal.unwrap().get().await? - 85.5).abs() < f64::EPSILON); + + Ok(()) +} + +#[tokio::test] +async fn test_battery_power_and_state_derivation() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (conn, state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + + let mut state_stream = battery.receive_state_changed().await; + + // Positive power above threshold -> charging + update_single_property(&conn, &state, &PropertyType::BatteryPower, 12.0).await?; + update_single_property(&conn, &state, &PropertyType::BatteryState, 12.0).await?; + + let signal = timeout(TIMEOUT, state_stream.next()).await?; + assert!(signal.is_some()); + assert_eq!(signal.unwrap().get().await?, "charging"); + + // Negative power below threshold -> discharging + update_single_property(&conn, &state, &PropertyType::BatteryPower, -12.0).await?; + update_single_property(&conn, &state, &PropertyType::BatteryState, -12.0).await?; + + let signal = timeout(TIMEOUT, state_stream.next()).await?; + assert!(signal.is_some()); + assert_eq!(signal.unwrap().get().await?, "discharging"); + + // Small power within threshold -> idle + update_single_property(&conn, &state, &PropertyType::BatteryPower, 3.0).await?; + update_single_property(&conn, &state, &PropertyType::BatteryState, 3.0).await?; + + let signal = timeout(TIMEOUT, state_stream.next()).await?; + assert!(signal.is_some()); + assert_eq!(signal.unwrap().get().await?, "idle"); + + Ok(()) +} + +#[tokio::test] +async fn test_solar_power_update() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (conn, state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let solar = SolarProxy::new(&client).await?; + + let mut stream = solar.receive_power_changed().await; + + update_single_property(&conn, &state, &PropertyType::SolarPower, 250.0).await?; + + let signal = timeout(TIMEOUT, stream.next()).await?; + assert!(signal.is_some()); + assert!((signal.unwrap().get().await? - 250.0).abs() < f64::EPSILON); + + Ok(()) +} + +#[tokio::test] +async fn test_ac_input_power_update() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (conn, state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let ac_input = AcInputProxy::new(&client).await?; + + let mut stream = ac_input.receive_power_changed().await; + + update_single_property(&conn, &state, &PropertyType::AcInputPower, 82.0).await?; + + let signal = timeout(TIMEOUT, stream.next()).await?; + assert!(signal.is_some()); + assert!((signal.unwrap().get().await? - 82.0).abs() < f64::EPSILON); + + Ok(()) +} + +#[tokio::test] +async fn test_ac_load_power_update() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (conn, state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let ac_load = AcLoadProxy::new(&client).await?; + + let mut stream = ac_load.receive_power_changed().await; + + update_single_property(&conn, &state, &PropertyType::AcLoadPower, 77.0).await?; + + let signal = timeout(TIMEOUT, stream.next()).await?; + assert!(signal.is_some()); + assert!((signal.unwrap().get().await? - 77.0).abs() < f64::EPSILON); + + Ok(()) +} + +#[tokio::test] +async fn test_config_reload_method() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (_conn, state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let config = ConfigProxy::new(&client).await?; + + // Spawn a listener for the notify signal + let notify = state.config_reload_notify.clone(); + let listener = tokio::spawn(async move { notify.notified().await }); + + // Call reload via D-Bus + config.reload().await?; + + // The listener should complete within the timeout + timeout(TIMEOUT, listener).await??; + + Ok(()) +} + +#[tokio::test] +async fn test_property_change_signal_emitted() -> Result<()> { + let fixture = DbusTestFixture::new(); + let (conn, state) = start_service_at(fixture.address()).await?; + + let client = fixture.client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + + // Subscribe to soc property changes before making updates + let mut stream = battery.receive_soc_changed().await; + + // Update the property + update_single_property(&conn, &state, &PropertyType::BatterySoc, 50.0).await?; + + // Should receive the change signal + let signal = timeout(TIMEOUT, stream.next()).await?; + assert!(signal.is_some()); + let changed = signal.unwrap(); + assert!((changed.get().await? - 50.0).abs() < f64::EPSILON); + + Ok(()) +} diff --git a/service/tests/mqtt_to_dbus_e2e.rs b/service/tests/mqtt_to_dbus_e2e.rs new file mode 100644 index 0000000..3aebc40 --- /dev/null +++ b/service/tests/mqtt_to_dbus_e2e.rs @@ -0,0 +1,641 @@ +//! End-to-end tests: MQTT → Service → D-Bus pipeline. +//! +//! Each test spins up an embedded `rumqttd` broker and an isolated D-Bus daemon, +//! then exercises the real service code path from MQTT publish through to D-Bus +//! property reads. + +use std::collections::HashMap; +use std::net::{SocketAddr, TcpStream}; +use std::thread; +use std::time::Duration; + +use anyhow::Result; +use camper_widget_refresh::config::Config; +use camper_widget_refresh::dbus::start_service_at; +use camper_widget_refresh::victron_mqtt::{ + build_mqtt_options, read_values_and_update_properties_immediately, wait_for_serial, +}; +use rumqttc::{AsyncClient, EventLoop, MqttOptions, QoS}; +use rumqttd::{Broker, ConnectionSettings, RouterConfig, ServerSettings}; +use zbus::{Connection, connection, proxy}; + +// ---------- Client-side proxy definitions (same pattern as dbus_integration.rs) ---------- + +#[proxy( + interface = "org.craftknight.CamperWidget.Status", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget" +)] +trait Status { + #[zbus(property)] + fn connected(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.Battery", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget/Battery" +)] +trait Battery { + #[zbus(property)] + fn soc(&self) -> zbus::Result; + + #[zbus(property)] + fn power(&self) -> zbus::Result; + + #[zbus(property)] + fn state(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.Solar", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget/Solar" +)] +trait Solar { + #[zbus(property)] + fn power(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.AcInput", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget/AcInput" +)] +trait AcInput { + #[zbus(property)] + fn power(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.craftknight.CamperWidget.AcLoad", + default_service = "org.craftknight.CamperWidget", + default_path = "/org/craftknight/camper_widget/AcLoad" +)] +trait AcLoad { + #[zbus(property)] + fn power(&self) -> zbus::Result; +} + +// ---------- Test fixture ---------- + +struct E2ETestFixture { + _dbus_daemon: dbus_launch::Daemon, + dbus_address: String, + mqtt_port: u16, + _broker_handle: thread::JoinHandle<()>, +} + +impl E2ETestFixture { + fn new() -> Self { + // 1. Launch isolated D-Bus daemon + let daemon = dbus_launch::Launcher::daemon() + .launch() + .expect("failed to launch dbus-daemon — is dbus-daemon installed?"); + let dbus_address = daemon.address().to_string(); + + // 2. Pick a random free port for the MQTT broker + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("failed to bind ephemeral port"); + let mqtt_port = listener.local_addr().unwrap().port(); + drop(listener); + + // 3. Build rumqttd config programmatically + let mut server_settings = HashMap::new(); + server_settings.insert( + "test".to_string(), + ServerSettings { + name: "test".to_string(), + listen: SocketAddr::from(([127, 0, 0, 1], mqtt_port)), + tls: None, + next_connection_delay_ms: 0, + connections: ConnectionSettings { + connection_timeout_ms: 5000, + max_payload_size: 2048, + max_inflight_count: 100, + auth: None, + external_auth: None, + dynamic_filters: true, + }, + }, + ); + let broker_config = rumqttd::Config { + router: RouterConfig { + max_connections: 10, + max_outgoing_packet_count: 200, + max_segment_size: 10240, + max_segment_count: 10, + ..Default::default() + }, + v4: Some(server_settings), + ..Default::default() + }; + + // 4. Spawn broker in a dedicated OS thread (blocking call) + let broker_handle = thread::spawn(move || { + let mut broker = Broker::new(broker_config); + broker.start().expect("broker failed to start"); + }); + + // 5. Wait for broker to be ready + Self::wait_for_broker(mqtt_port, Duration::from_secs(2)); + + Self { + _dbus_daemon: daemon, + dbus_address, + mqtt_port, + _broker_handle: broker_handle, + } + } + + fn dbus_address(&self) -> &str { + &self.dbus_address + } + + fn mqtt_port(&self) -> u16 { + self.mqtt_port + } + + async fn dbus_client_connection(&self) -> Result { + let conn = connection::Builder::address(self.dbus_address())? + .build() + .await?; + Ok(conn) + } + + fn create_publisher_client(&self, id: &str) -> (AsyncClient, EventLoop) { + let opts = MqttOptions::new(id, "127.0.0.1", self.mqtt_port()); + AsyncClient::new(opts, 10) + } + + fn test_config(&self) -> Config { + Config { + endpoints: vec![("127.0.0.1".to_string(), self.mqtt_port())], + username: None, + password: None, + client_id: "e2e-service-client".to_string(), + refresh_interval_seconds: 60, + } + } + + fn wait_for_broker(port: u16, timeout: Duration) { + let start = std::time::Instant::now(); + loop { + if TcpStream::connect(SocketAddr::from(([127, 0, 0, 1], port))).is_ok() { + return; + } + if start.elapsed() > timeout { + panic!( + "MQTT broker did not become ready within {}ms on port {}", + timeout.as_millis(), + port + ); + } + thread::sleep(Duration::from_millis(50)); + } + } +} + +// ---------- Helper: publish Victron topics ---------- + +const TEST_SERIAL: &str = "e2e_test_serial"; + +/// Victron battery state codes +const VICTRON_STATE_IDLE: i64 = 0; +const VICTRON_STATE_CHARGING: i64 = 1; +const VICTRON_STATE_DISCHARGING: i64 = 2; + +/// Build a Batteries JSON payload matching the Victron format. +fn batteries_json(soc: f64, power: f64, state: i64) -> String { + format!( + r#"{{"value":[{{"soc":{},"power":{},"state":{}}}]}}"#, + soc, power, state + ) +} + +async fn publish_all_topics( + client: &AsyncClient, + soc: f64, + power: f64, + battery_state: i64, + solar: f64, + ac_input: f64, + ac_load: f64, +) -> Result<()> { + client + .publish( + format!("N/{TEST_SERIAL}/system/0/Batteries"), + QoS::AtLeastOnce, + false, + batteries_json(soc, power, battery_state), + ) + .await?; + client + .publish( + format!("N/{TEST_SERIAL}/solarcharger/277/Yield/Power"), + QoS::AtLeastOnce, + false, + solar.to_string(), + ) + .await?; + client + .publish( + format!("N/{TEST_SERIAL}/system/0/Ac/Grid/L1/Power"), + QoS::AtLeastOnce, + false, + format!(r#"{{"value":{ac_input}}}"#), + ) + .await?; + client + .publish( + format!("N/{TEST_SERIAL}/system/0/Ac/Consumption/L1/Power"), + QoS::AtLeastOnce, + false, + format!(r#"{{"value":{ac_load}}}"#), + ) + .await?; + Ok(()) +} + +// ---------- Tests ---------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mqtt_serial_discovery() -> Result<()> { + let fixture = E2ETestFixture::new(); + + // Create a publisher that will announce the serial + let (pub_client, mut pub_eventloop) = fixture.create_publisher_client("serial-pub"); + tokio::spawn(async move { while (pub_eventloop.poll().await).is_ok() {} }); + + // Create service client for serial discovery + let cfg = fixture.test_config(); + let opts = build_mqtt_options(&cfg, "127.0.0.1", fixture.mqtt_port()); + let (svc_client, mut svc_eventloop) = AsyncClient::new(opts, 10); + + // Spawn wait_for_serial first (it subscribes then polls for the serial topic) + let svc_client_clone = svc_client.clone(); + let discovery = + tokio::spawn(async move { wait_for_serial(&svc_client_clone, &mut svc_eventloop).await }); + + // Give the service client time to connect and subscribe + tokio::time::sleep(Duration::from_millis(500)).await; + + // Now publish the serial announcement + pub_client + .publish( + format!("N/{TEST_SERIAL}/system/0/Serial"), + QoS::AtLeastOnce, + false, + TEST_SERIAL, + ) + .await?; + + let discovered = tokio::time::timeout(Duration::from_secs(5), discovery).await???; + assert_eq!(discovered, TEST_SERIAL); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mqtt_all_properties_to_dbus() -> Result<()> { + let fixture = E2ETestFixture::new(); + + // Start D-Bus service + let (conn, dbus_state) = start_service_at(fixture.dbus_address()).await?; + + // Create publisher + let (pub_client, mut pub_eventloop) = fixture.create_publisher_client("props-pub"); + tokio::spawn(async move { while (pub_eventloop.poll().await).is_ok() {} }); + + // Create service MQTT client + let cfg = fixture.test_config(); + let opts = build_mqtt_options(&cfg, "127.0.0.1", fixture.mqtt_port()); + let (svc_client, mut svc_eventloop) = AsyncClient::new(opts, 10); + + // Spawn the service reader that subscribes and updates D-Bus properties + let conn_clone = conn.clone(); + let dbus_state_clone = dbus_state.clone(); + let reader = tokio::spawn(async move { + read_values_and_update_properties_immediately( + &svc_client, + &mut svc_eventloop, + TEST_SERIAL, + &conn_clone, + &dbus_state_clone, + ) + .await + }); + + // Give the reader time to subscribe + tokio::time::sleep(Duration::from_millis(300)).await; + + // Publish Batteries JSON + solar + AC topics + publish_all_topics( + &pub_client, + 78.9, + 25.5, + VICTRON_STATE_CHARGING, + 310.0, + 82.0, + 77.0, + ) + .await?; + + // Wait for the reader to finish (all topics received) + let all_received = tokio::time::timeout(Duration::from_secs(10), reader).await???; + assert!(all_received, "expected all topics to be received"); + + // Small delay for signal propagation + tokio::time::sleep(Duration::from_millis(100)).await; + + // Read D-Bus properties via proxies + let client = fixture.dbus_client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + let solar = SolarProxy::new(&client).await?; + + assert!((battery.soc().await? - 78.9).abs() < f64::EPSILON); + assert!((battery.power().await? - 25.5).abs() < f64::EPSILON); + assert!((solar.power().await? - 310.0).abs() < f64::EPSILON); + assert_eq!(battery.state().await?, "charging"); + + let ac_input = AcInputProxy::new(&client).await?; + let ac_load = AcLoadProxy::new(&client).await?; + assert!((ac_input.power().await? - 82.0).abs() < f64::EPSILON); + assert!((ac_load.power().await? - 77.0).abs() < f64::EPSILON); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mqtt_battery_state_charging() -> Result<()> { + let fixture = E2ETestFixture::new(); + let (conn, dbus_state) = start_service_at(fixture.dbus_address()).await?; + + let (pub_client, mut pub_eventloop) = fixture.create_publisher_client("charge-pub"); + tokio::spawn(async move { while (pub_eventloop.poll().await).is_ok() {} }); + + let cfg = fixture.test_config(); + let opts = build_mqtt_options(&cfg, "127.0.0.1", fixture.mqtt_port()); + let (svc_client, mut svc_eventloop) = AsyncClient::new(opts, 10); + + let conn_clone = conn.clone(); + let dbus_state_clone = dbus_state.clone(); + let reader = tokio::spawn(async move { + read_values_and_update_properties_immediately( + &svc_client, + &mut svc_eventloop, + TEST_SERIAL, + &conn_clone, + &dbus_state_clone, + ) + .await + }); + + tokio::time::sleep(Duration::from_millis(300)).await; + publish_all_topics( + &pub_client, + 60.0, + 15.0, + VICTRON_STATE_CHARGING, + 200.0, + 100.0, + 90.0, + ) + .await?; + + let all_received = tokio::time::timeout(Duration::from_secs(10), reader).await???; + assert!(all_received); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let client = fixture.dbus_client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + assert_eq!(battery.state().await?, "charging"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mqtt_battery_state_discharging() -> Result<()> { + let fixture = E2ETestFixture::new(); + let (conn, dbus_state) = start_service_at(fixture.dbus_address()).await?; + + let (pub_client, mut pub_eventloop) = fixture.create_publisher_client("discharge-pub"); + tokio::spawn(async move { while (pub_eventloop.poll().await).is_ok() {} }); + + let cfg = fixture.test_config(); + let opts = build_mqtt_options(&cfg, "127.0.0.1", fixture.mqtt_port()); + let (svc_client, mut svc_eventloop) = AsyncClient::new(opts, 10); + + let conn_clone = conn.clone(); + let dbus_state_clone = dbus_state.clone(); + let reader = tokio::spawn(async move { + read_values_and_update_properties_immediately( + &svc_client, + &mut svc_eventloop, + TEST_SERIAL, + &conn_clone, + &dbus_state_clone, + ) + .await + }); + + tokio::time::sleep(Duration::from_millis(300)).await; + publish_all_topics( + &pub_client, + 45.0, + -20.0, + VICTRON_STATE_DISCHARGING, + 0.0, + 0.0, + 50.0, + ) + .await?; + + let all_received = tokio::time::timeout(Duration::from_secs(10), reader).await???; + assert!(all_received); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let client = fixture.dbus_client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + assert_eq!(battery.state().await?, "discharging"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mqtt_battery_state_idle() -> Result<()> { + let fixture = E2ETestFixture::new(); + let (conn, dbus_state) = start_service_at(fixture.dbus_address()).await?; + + let (pub_client, mut pub_eventloop) = fixture.create_publisher_client("idle-pub"); + tokio::spawn(async move { while (pub_eventloop.poll().await).is_ok() {} }); + + let cfg = fixture.test_config(); + let opts = build_mqtt_options(&cfg, "127.0.0.1", fixture.mqtt_port()); + let (svc_client, mut svc_eventloop) = AsyncClient::new(opts, 10); + + let conn_clone = conn.clone(); + let dbus_state_clone = dbus_state.clone(); + let reader = tokio::spawn(async move { + read_values_and_update_properties_immediately( + &svc_client, + &mut svc_eventloop, + TEST_SERIAL, + &conn_clone, + &dbus_state_clone, + ) + .await + }); + + tokio::time::sleep(Duration::from_millis(300)).await; + publish_all_topics(&pub_client, 80.0, 2.0, VICTRON_STATE_IDLE, 50.0, 0.0, 30.0).await?; + + let all_received = tokio::time::timeout(Duration::from_secs(10), reader).await???; + assert!(all_received); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let client = fixture.dbus_client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + assert_eq!(battery.state().await?, "idle"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mqtt_json_payload_format() -> Result<()> { + let fixture = E2ETestFixture::new(); + let (conn, dbus_state) = start_service_at(fixture.dbus_address()).await?; + + let (pub_client, mut pub_eventloop) = fixture.create_publisher_client("json-pub"); + tokio::spawn(async move { while (pub_eventloop.poll().await).is_ok() {} }); + + let cfg = fixture.test_config(); + let opts = build_mqtt_options(&cfg, "127.0.0.1", fixture.mqtt_port()); + let (svc_client, mut svc_eventloop) = AsyncClient::new(opts, 10); + + let conn_clone = conn.clone(); + let dbus_state_clone = dbus_state.clone(); + let reader = tokio::spawn(async move { + read_values_and_update_properties_immediately( + &svc_client, + &mut svc_eventloop, + TEST_SERIAL, + &conn_clone, + &dbus_state_clone, + ) + .await + }); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Publish Batteries with JSON value format + solar with {"value": } + AC topics + pub_client + .publish( + format!("N/{TEST_SERIAL}/system/0/Batteries"), + QoS::AtLeastOnce, + false, + batteries_json(225.5, 30.0, VICTRON_STATE_CHARGING), + ) + .await?; + pub_client + .publish( + format!("N/{TEST_SERIAL}/solarcharger/277/Yield/Power"), + QoS::AtLeastOnce, + false, + r#"{"value": 450.0}"#, + ) + .await?; + pub_client + .publish( + format!("N/{TEST_SERIAL}/system/0/Ac/Grid/L1/Power"), + QoS::AtLeastOnce, + false, + r#"{"value": 120.0}"#, + ) + .await?; + pub_client + .publish( + format!("N/{TEST_SERIAL}/system/0/Ac/Consumption/L1/Power"), + QoS::AtLeastOnce, + false, + r#"{"value": 95.0}"#, + ) + .await?; + + let all_received = tokio::time::timeout(Duration::from_secs(10), reader).await???; + assert!(all_received, "expected all topics to be received"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let client = fixture.dbus_client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + let solar = SolarProxy::new(&client).await?; + let ac_input = AcInputProxy::new(&client).await?; + let ac_load = AcLoadProxy::new(&client).await?; + + assert!((battery.soc().await? - 225.5).abs() < f64::EPSILON); + assert!((battery.power().await? - 30.0).abs() < f64::EPSILON); + assert!((solar.power().await? - 450.0).abs() < f64::EPSILON); + assert!((ac_input.power().await? - 120.0).abs() < f64::EPSILON); + assert!((ac_load.power().await? - 95.0).abs() < f64::EPSILON); + + Ok(()) +} + +#[ignore] // Takes ~30s due to internal timeout; run with `cargo test -- --ignored` +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mqtt_timeout_with_partial_data() -> Result<()> { + let fixture = E2ETestFixture::new(); + let (conn, dbus_state) = start_service_at(fixture.dbus_address()).await?; + + let (pub_client, mut pub_eventloop) = fixture.create_publisher_client("partial-pub"); + tokio::spawn(async move { while (pub_eventloop.poll().await).is_ok() {} }); + + let cfg = fixture.test_config(); + let opts = build_mqtt_options(&cfg, "127.0.0.1", fixture.mqtt_port()); + let (svc_client, mut svc_eventloop) = AsyncClient::new(opts, 10); + + let conn_clone = conn.clone(); + let dbus_state_clone = dbus_state.clone(); + let reader = tokio::spawn(async move { + read_values_and_update_properties_immediately( + &svc_client, + &mut svc_eventloop, + TEST_SERIAL, + &conn_clone, + &dbus_state_clone, + ) + .await + }); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Only publish Batteries (omit solar + AC) — 1 of 4 topics + pub_client + .publish( + format!("N/{TEST_SERIAL}/system/0/Batteries"), + QoS::AtLeastOnce, + false, + batteries_json(55.0, 10.0, VICTRON_STATE_CHARGING), + ) + .await?; + + // Should return false after the 30s internal timeout + let all_received = tokio::time::timeout(Duration::from_secs(35), reader).await???; + assert!(!all_received, "expected timeout with partial data"); + + // The received battery properties should be set on D-Bus + let client = fixture.dbus_client_connection().await?; + let battery = BatteryProxy::new(&client).await?; + + assert!((battery.soc().await? - 55.0).abs() < f64::EPSILON); + assert!((battery.power().await? - 10.0).abs() < f64::EPSILON); + // Solar should still be at default (0.0) + let solar = SolarProxy::new(&client).await?; + assert!((solar.power().await? - 0.0).abs() < f64::EPSILON); + + Ok(()) +} diff --git a/tests/qml/tst_icon_utils.qml b/tests/qml/tst_icon_utils.qml new file mode 100644 index 0000000..4b0b67b --- /dev/null +++ b/tests/qml/tst_icon_utils.qml @@ -0,0 +1,91 @@ +import QtQuick 6.0 +import QtTest 1.0 +import "../../package/contents/ui" as Ui + +TestCase { + name: "IconUtils" + + Ui.IconUtils { id: icons } + + // getBatteryIcon — boundary and edge cases + function test_battery_undefined() { + compare(icons.getBatteryIcon(undefined), "\uf244") + } + + function test_battery_null() { + compare(icons.getBatteryIcon(null), "\uf244") + } + + function test_battery_negative() { + compare(icons.getBatteryIcon(-1), "\uf244") + } + + function test_battery_zero() { + compare(icons.getBatteryIcon(0), "\uf244") + } + + function test_battery_low_boundary() { + compare(icons.getBatteryIcon(20), "\uf244") + } + + function test_battery_quarter() { + compare(icons.getBatteryIcon(21), "\uf243") + } + + function test_battery_quarter_boundary() { + compare(icons.getBatteryIcon(40), "\uf243") + } + + function test_battery_half() { + compare(icons.getBatteryIcon(41), "\uf242") + } + + function test_battery_half_boundary() { + compare(icons.getBatteryIcon(60), "\uf242") + } + + function test_battery_three_quarter() { + compare(icons.getBatteryIcon(61), "\uf241") + } + + function test_battery_three_quarter_boundary() { + compare(icons.getBatteryIcon(80), "\uf241") + } + + function test_battery_full() { + compare(icons.getBatteryIcon(81), "\uf240") + } + + function test_battery_full_100() { + compare(icons.getBatteryIcon(100), "\uf240") + } + + // getDirectionIcon — all states + function test_direction_charge() { + compare(icons.getDirectionIcon("charge"), "\uf185") + } + + function test_direction_discharge() { + compare(icons.getDirectionIcon("discharge"), "\uf0e7") + } + + function test_direction_idle() { + compare(icons.getDirectionIcon("idle"), "\uf186") + } + + function test_direction_unknown() { + compare(icons.getDirectionIcon("bogus"), "") + } + + function test_direction_empty() { + compare(icons.getDirectionIcon(""), "") + } + + function test_direction_null() { + compare(icons.getDirectionIcon(null), "") + } + + function test_direction_undefined() { + compare(icons.getDirectionIcon(undefined), "") + } +} diff --git a/tests/qml/tst_value_formatting.qml b/tests/qml/tst_value_formatting.qml new file mode 100644 index 0000000..78d39dc --- /dev/null +++ b/tests/qml/tst_value_formatting.qml @@ -0,0 +1,76 @@ +import QtQuick 6.0 +import QtTest 1.0 +import "../../package/contents/ui" as Ui + +TestCase { + name: "ValueFormatting" + + Ui.FormatUtils { id: fmt } + + // formatSoc + function test_soc_normal() { + compare(fmt.formatSoc(75.3), "75%") + } + + function test_soc_zero() { + compare(fmt.formatSoc(0), "0%") + } + + function test_soc_full() { + compare(fmt.formatSoc(100), "100%") + } + + function test_soc_rounds_up() { + compare(fmt.formatSoc(49.6), "50%") + } + + function test_soc_negative() { + compare(fmt.formatSoc(-1), "--") + } + + function test_soc_undefined() { + compare(fmt.formatSoc(undefined), "--") + } + + function test_soc_null() { + compare(fmt.formatSoc(null), "--") + } + + function test_soc_nan() { + compare(fmt.formatSoc(NaN), "--") + } + + // formatPower + function test_power_connected() { + compare(fmt.formatPower(true, 123.7), "124W") + } + + function test_power_connected_zero() { + compare(fmt.formatPower(true, 0), "0W") + } + + function test_power_disconnected() { + compare(fmt.formatPower(false, 50), "--") + } + + // formatSolar + function test_solar_normal() { + compare(fmt.formatSolar(200.4), "200W") + } + + function test_solar_zero() { + compare(fmt.formatSolar(0), "0W") + } + + function test_solar_negative() { + compare(fmt.formatSolar(-1), "--") + } + + function test_solar_undefined() { + compare(fmt.formatSolar(undefined), "--") + } + + function test_solar_nan() { + compare(fmt.formatSolar(NaN), "--") + } +} -- cgit v1.2.3