diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-07 17:29:48 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-07 17:29:48 +0100 |
| commit | 2eda97537b63d68b2e9ba06500e3fb491894d10c (patch) | |
| tree | 52873ad380cd97f4327765aac24659a2b00079b1 /package | |
feat: camper van energy monitoring widget for Plasma 6main
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 <noreply@anthropic.com>
Diffstat (limited to 'package')
| -rw-r--r-- | package/contents/config/config.qml | 11 | ||||
| -rw-r--r-- | package/contents/config/main.xml | 32 | ||||
| -rw-r--r-- | package/contents/ui/CompactRepresentation.qml | 33 | ||||
| -rw-r--r-- | package/contents/ui/ConfigGeneral.qml | 80 | ||||
| -rw-r--r-- | package/contents/ui/FormatUtils.qml | 31 | ||||
| -rw-r--r-- | package/contents/ui/FullRepresentation.qml | 203 | ||||
| -rw-r--r-- | package/contents/ui/IconUtils.qml | 31 | ||||
| -rw-r--r-- | package/contents/ui/main.qml | 161 | ||||
| -rw-r--r-- | package/metadata.json | 21 |
9 files changed, 603 insertions, 0 deletions
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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0 http://www.kde.org/standards/kcfg/1.0/kcfg.xsd"> + <kcfgfile name=""/> + + <group name="General"> + <entry name="mqttClientId" type="String"> + <label>MQTT client ID</label> + <default>camper-widget-refresh</default> + </entry> + + <entry name="mqttHosts" type="String"> + <label>MQTT hosts (comma separated list)</label> + <default>127.0.0.1:1883</default> + </entry> + + <entry name="mqttUsername" type="String"> + <label>MQTT username</label> + <default></default> + </entry> + + <entry name="mqttPassword" type="String"> + <label>MQTT password</label> + <default></default> + </entry> + + <entry name="refreshIntervalSeconds" type="Int"> + <label>Refresh interval (seconds)</label> + <default>60</default> + <min>1</min> + </entry> + </group> +</kcfg> 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" +} |
