commit e0877c25a4cad324a08cabc192e2c5f9d6531f5e Author: samjage Date: Sun Mar 22 20:58:49 2026 -0400 Initial commit: Weather && Stats KDE Plasma 6 panel widget diff --git a/contents/config/config.qml b/contents/config/config.qml new file mode 100644 index 0000000..eeba674 --- /dev/null +++ b/contents/config/config.qml @@ -0,0 +1,11 @@ +import QtQuick +import org.kde.plasma.plasmoid +import org.kde.plasma.configuration + +ConfigModel { + ConfigCategory { + name: "General" + icon: "weather-clear" + source: "configGeneral.qml" + } +} diff --git a/contents/config/main.xml b/contents/config/main.xml new file mode 100644 index 0000000..582dcab --- /dev/null +++ b/contents/config/main.xml @@ -0,0 +1,48 @@ + + + + + + 39.84 + + + -82.81 + + + Canal Winchester, OH + + + true + + + false + + + 5 + + + false + + + 80 + + + 3 + + + true + + + true + + + true + + + true + + + diff --git a/contents/stats.sh b/contents/stats.sh new file mode 100644 index 0000000..d9a619d --- /dev/null +++ b/contents/stats.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# CPU temp via hwmon (same source as btop) +# Looks for coretemp (Intel), k10temp/zenpower (AMD), cpu-thermal (ARM) +CTEMP=-1 +for hwmon in /sys/class/hwmon/hwmon*/; do + name=$(cat "${hwmon}name" 2>/dev/null) + if [[ "$name" == "coretemp" || "$name" == "k10temp" || "$name" == "zenpower" || "$name" == "cpu-thermal" ]]; then + # Prefer Package/Tctl label, otherwise take temp1 + for label_file in "${hwmon}"temp*_label; do + label=$(cat "$label_file" 2>/dev/null) + if [[ "$label" == "Package"* || "$label" == "Tctl" || "$label" == "Tccd"* ]]; then + val=$(cat "${label_file/_label/_input}" 2>/dev/null) + if [ -n "$val" ] && [ "$val" -gt 0 ]; then CTEMP=$(( val / 1000 )); break 2; fi + fi + done + val=$(cat "${hwmon}temp1_input" 2>/dev/null) + if [ -n "$val" ] && [ "$val" -gt 0 ]; then CTEMP=$(( val / 1000 )); break; fi + fi +done +# Fallback: highest thermal_zone reading +if [ "$CTEMP" -lt 0 ]; then + best=$(cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | sort -rn | head -1) + [ -n "$best" ] && CTEMP=$(( best / 1000 )) +fi + +# Memory usage % +MEM=$(free | awk '/Mem:/{print int($3/$2*100)}') + +# Auto-detect most active network interface (excluding loopback) +IFACE=$(awk 'NR>2{gsub(/:$/,"",$1); if($1!="lo") print $1, ($2+0)+($10+0)}' /proc/net/dev \ + | sort -k2 -rn | awk 'NR==1{print $1}') +[ -z "$IFACE" ] && IFACE="lo" + +# Helper functions for sampling +get_cpu() { awk 'NR==1{t=0; for(i=2;i<=NF;i++) t+=$i; print t, $6}' /proc/stat; } +get_net() { awk -v d="$IFACE:" '$1==d{print $2, $10}' /proc/net/dev; } + +# First samples +read T1 I1 < <(get_cpu) +read R1 X1 < <(get_net) + +sleep 1 + +# Second samples +read T2 I2 < <(get_cpu) +read R2 X2 < <(get_net) + +# CPU usage % +DT=$(( T2 - T1 )) +CPU=$(( DT > 0 ? (100 * (DT - (I2 - I1))) / DT : 0 )) + +# Network KB/s +DOWN=$(( (R2 - R1) / 1024 )) +UP=$(( (X2 - X1) / 1024 )) + +echo "$CTEMP $CPU $MEM $DOWN $UP" diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml new file mode 100644 index 0000000..346731b --- /dev/null +++ b/contents/ui/configGeneral.qml @@ -0,0 +1,235 @@ +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +Item { + id: root + implicitHeight: col.implicitHeight + + property double cfg_latitude: 0 + property double cfg_longitude: 0 + property string cfg_locationName: "" + property bool cfg_useFahrenheit: true + property bool cfg_showCondition: false + property int cfg_weatherRefresh: 5 + property bool cfg_cpuTempFahrenheit: false + property int cfg_cpuTempThreshold: 80 + property int cfg_statsRefresh: 3 + property bool cfg_showCpuTemp: true + property bool cfg_showCpuUsage: true + property bool cfg_showMemory: true + property bool cfg_showNetwork: true + + component SectionHeader: ColumnLayout { + property string title: "" + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 4 + + QQC2.Label { + text: title + font.bold: true + font.pointSize: 9 + font.letterSpacing: 2 + opacity: 0.5 + } + Rectangle { + Layout.fillWidth: true + height: 1 + opacity: 0.15 + color: "white" + } + } + + ColumnLayout { + id: col + anchors.fill: parent + anchors.margins: 20 + anchors.topMargin: 16 + spacing: 10 + + // ── WEATHER ────────────────────────────────────────────────────────── + + SectionHeader { title: "WEATHER" } + + QQC2.Label { + text: cfg_locationName ? "📍 " + cfg_locationName : "No location set" + opacity: 0.7 + font.italic: !cfg_locationName + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + QQC2.TextField { + id: searchField + Layout.fillWidth: true + placeholderText: "Search for a city..." + onAccepted: doSearch() + } + QQC2.Button { + text: "Search" + onClicked: doSearch() + } + } + + ListView { + id: resultsList + Layout.fillWidth: true + Layout.preferredHeight: Math.min(resultsModel.count * 40, 160) + visible: resultsModel.count > 0 + clip: true + model: ListModel { id: resultsModel } + delegate: QQC2.ItemDelegate { + width: resultsList.width + text: model.name + onClicked: { + cfg_latitude = model.lat + cfg_longitude = model.lon + cfg_locationName = model.name + resultsModel.clear() + searchField.text = "" + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + QQC2.Label { text: "Temperature:" } + QQC2.RadioButton { + text: "°F" + checked: cfg_useFahrenheit + onToggled: if (checked) cfg_useFahrenheit = true + } + QQC2.RadioButton { + text: "°C" + checked: !cfg_useFahrenheit + onToggled: if (checked) cfg_useFahrenheit = false + } + + Item { Layout.fillWidth: true } + + QQC2.Label { text: "Refresh every" } + QQC2.SpinBox { + value: cfg_weatherRefresh + from: 1; to: 60 + onValueChanged: cfg_weatherRefresh = value + } + QQC2.Label { text: "min" } + } + + QQC2.CheckBox { + text: "Show condition text (e.g. \"Partly Cloudy\")" + checked: cfg_showCondition + onToggled: cfg_showCondition = checked + } + + // ── STATS ───────────────────────────────────────────────────────────── + + SectionHeader { title: "STATS" } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + QQC2.Label { text: "CPU Temp:" } + QQC2.RadioButton { + text: "°F" + checked: cfg_cpuTempFahrenheit + onToggled: if (checked) cfg_cpuTempFahrenheit = true + } + QQC2.RadioButton { + text: "°C" + checked: !cfg_cpuTempFahrenheit + onToggled: if (checked) cfg_cpuTempFahrenheit = false + } + + Item { Layout.fillWidth: true } + + QQC2.Label { text: "Refresh every" } + QQC2.SpinBox { + value: cfg_statsRefresh + from: 2; to: 60 + onValueChanged: cfg_statsRefresh = value + } + QQC2.Label { text: "sec" } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + QQC2.Label { text: "Alert when CPU temp exceeds:" } + QQC2.SpinBox { + from: cfg_cpuTempFahrenheit ? 122 : 50 + to: cfg_cpuTempFahrenheit ? 230 : 110 + value: cfg_cpuTempFahrenheit ? Math.round(cfg_cpuTempThreshold * 9/5 + 32) : cfg_cpuTempThreshold + onValueChanged: cfg_cpuTempThreshold = cfg_cpuTempFahrenheit ? Math.round((value - 32) * 5/9) : value + } + QQC2.Label { text: cfg_cpuTempFahrenheit ? "°F" : "°C" } + } + + QQC2.Label { + text: "Visible stats:" + opacity: 0.7 + Layout.topMargin: 2 + } + + GridLayout { + columns: 2 + Layout.fillWidth: true + columnSpacing: 32 + rowSpacing: 2 + + QQC2.CheckBox { + text: "CPU Temperature" + checked: cfg_showCpuTemp + onToggled: cfg_showCpuTemp = checked + } + QQC2.CheckBox { + text: "CPU Usage" + checked: cfg_showCpuUsage + onToggled: cfg_showCpuUsage = checked + } + QQC2.CheckBox { + text: "Memory Usage" + checked: cfg_showMemory + onToggled: cfg_showMemory = checked + } + QQC2.CheckBox { + text: "Network Speed" + checked: cfg_showNetwork + onToggled: cfg_showNetwork = checked + } + } + + Item { Layout.fillHeight: true; Layout.minimumHeight: 16 } + } + + function doSearch() { + var query = searchField.text.trim() + if (query.length < 2) return + var url = "https://geocoding-api.open-meteo.com/v1/search" + + "?name=" + encodeURIComponent(query) + + "&count=8&language=en&format=json" + var req = new XMLHttpRequest() + req.open("GET", url) + req.onreadystatechange = function() { + if (req.readyState === XMLHttpRequest.DONE && req.status === 200) { + var data = JSON.parse(req.responseText) + resultsModel.clear() + if (data.results) { + data.results.forEach(function(r) { + var label = r.name + if (r.admin1) label += ", " + r.admin1 + if (r.country) label += ", " + r.country + resultsModel.append({ name: label, lat: r.latitude, lon: r.longitude }) + }) + } + } + } + req.send() + } +} diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..f954b18 --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,198 @@ +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.plasmoid +import org.kde.plasma.plasma5support as P5Support +import org.kde.plasma.components as PlasmaComponents +import org.kde.kirigami as Kirigami + +PlasmoidItem { + id: root + + // ── Config bindings ─────────────────────────────────────────────────────── + property double lat: Plasmoid.configuration.latitude + property double lon: Plasmoid.configuration.longitude + property bool fahrenheit: Plasmoid.configuration.useFahrenheit + property bool showCondition: Plasmoid.configuration.showCondition || false + property int weatherRefresh: Plasmoid.configuration.weatherRefresh || 5 + property bool cpuFahrenheit: Plasmoid.configuration.cpuTempFahrenheit || false + property int cpuTempThreshold: Plasmoid.configuration.cpuTempThreshold || 80 + property int statsRefresh: Plasmoid.configuration.statsRefresh || 10 + property bool showCpuTemp: Plasmoid.configuration.showCpuTemp !== false + property bool showCpuUsage: Plasmoid.configuration.showCpuUsage !== false + property bool showMemory: Plasmoid.configuration.showMemory !== false + property bool showNetwork: Plasmoid.configuration.showNetwork !== false + + // ── Live state ──────────────────────────────────────────────────────────── + property string weatherIcon: "" + property string temperature: "--" + property string weatherCondition: "" + property int cpuTempRaw: -1 + property int cpuUsage: -1 + property int memUsage: -1 + property int netDown: -1 + property int netUp: -1 + + property string cpuTempDisplay: { + if (cpuTempRaw < 0) return "--" + if (cpuFahrenheit) return Math.round(cpuTempRaw * 9/5 + 32) + "°F" + return cpuTempRaw + "°C" + } + + function padPct(val) { + var s = val + "%" + if (val < 10) return "\u00a0\u00a0" + s + if (val < 100) return "\u00a0" + s + return s + } + + function formatNetSpeed(kbs) { + if (kbs < 0) return "--" + if (kbs < 1000) return kbs + " KB/s" + return (kbs / 1024).toFixed(1) + " MB/s" + } + + // ── Panel display ───────────────────────────────────────────────────────── + preferredRepresentation: fullRepresentation + + fullRepresentation: PlasmaComponents.Label { + Layout.fillHeight: true + Layout.preferredWidth: implicitWidth + 16 + verticalAlignment: Text.AlignVCenter + textFormat: Text.RichText + + text: { + // CPU temp is red when above threshold + var hot = root.cpuTempRaw > 0 && root.cpuTempRaw >= root.cpuTempThreshold + + // Weather section + var s = root.weatherIcon + "\u00a0\u00a0" + root.temperature + "°" + (root.fahrenheit ? "F" : "C") + if (root.showCondition && root.weatherCondition !== "") + s += " " + root.weatherCondition + + // CPU + Memory stats + var cpuMem = [] + if (root.showCpuTemp) { + var tempStr = "\uf2c8\u00a0\u00a0" + root.cpuTempDisplay + cpuMem.push(hot ? "" + tempStr + "" : tempStr) + } + if (root.showCpuUsage) + cpuMem.push("\uf2db\u00a0\u00a0" + (root.cpuUsage >= 0 ? padPct(root.cpuUsage) : "\u00a0--")) + if (root.showMemory) + cpuMem.push("\ue266\u00a0\u00a0" + (root.memUsage >= 0 ? padPct(root.memUsage) : "\u00a0--")) + + var sp = "\u00a0\u00a0\u00a0\u00a0" + var div = "\u00a0\u00a0\u2502\u00a0\u00a0" + + if (cpuMem.length > 0) s += div + cpuMem.join(sp) + + // Network stats + if (root.showNetwork) + s += div + "\uf0ac" + sp + "\uf063\u00a0\u00a0" + formatNetSpeed(root.netDown) + sp + "\uf062\u00a0\u00a0" + formatNetSpeed(root.netUp) + + return s + " " + } + } + + // ── Weather ─────────────────────────────────────────────────────────────── + + function iconForCode(code) { + if (code === 0) return "\ue30d" + if (code <= 3) return "\ue312" + if (code === 45 || code === 48) return "\ue313" + if ([51,53,55,61,63,65].indexOf(code) >= 0) return "\ue318" + if ([71,73,75].indexOf(code) >= 0) return "\ue31a" + return "\ue32e" + } + + function conditionForCode(code) { + var map = { + 0: "Clear", 1: "Mostly Clear", 2: "Partly Cloudy", 3: "Overcast", + 45: "Foggy", 48: "Icy Fog", + 51: "Light Drizzle", 53: "Drizzle", 55: "Heavy Drizzle", + 61: "Light Rain", 63: "Rain", 65: "Heavy Rain", + 71: "Light Snow", 73: "Snow", 75: "Heavy Snow", 77: "Snow Grains", + 80: "Light Showers", 81: "Showers", 82: "Heavy Showers", + 85: "Snow Showers", 86: "Heavy Snow Showers", + 95: "Thunderstorm", 96: "Thunderstorm w/ Hail", 99: "Heavy Thunderstorm" + } + return map[code] || "" + } + + function fetchWeather() { + var unit = root.fahrenheit ? "fahrenheit" : "celsius" + var url = "https://api.open-meteo.com/v1/forecast" + + "?latitude=" + root.lat + + "&longitude=" + root.lon + + "¤t_weather=true" + + "&temperature_unit=" + unit + + "&timezone=auto" + var req = new XMLHttpRequest() + req.open("GET", url) + req.onreadystatechange = function() { + if (req.readyState === XMLHttpRequest.DONE && req.status === 200) { + var cw = JSON.parse(req.responseText).current_weather + root.temperature = Math.round(cw.temperature).toString() + root.weatherIcon = root.iconForCode(cw.weathercode) + root.weatherCondition = root.conditionForCode(cw.weathercode) + } + } + req.send() + } + + // ── System Stats ────────────────────────────────────────────────────────── + + P5Support.DataSource { + id: sysStatsSource + engine: "executable" + connectedSources: [] + onNewData: function(source, data) { + var out = data["stdout"].trim().split(/\s+/) + if (out.length >= 5) { + root.cpuTempRaw = parseInt(out[0]) || -1 + root.cpuUsage = parseInt(out[1]) || -1 + root.memUsage = parseInt(out[2]) || -1 + root.netDown = parseInt(out[3]) + root.netUp = parseInt(out[4]) + } + sysStatsSource.disconnectSource(source) + } + } + + function fetchSysStats() { + var path = Qt.resolvedUrl("../stats.sh").toString().replace("file://", "") + sysStatsSource.connectSource("bash " + path) + } + + // ── Timers ──────────────────────────────────────────────────────────────── + + Timer { + id: weatherTimer + interval: Math.max(1, root.weatherRefresh) * 60000 + repeat: true + running: true + onTriggered: root.fetchWeather() + } + + Timer { + id: statsTimer + interval: Math.max(2, root.statsRefresh) * 1000 + repeat: true + running: true + onTriggered: root.fetchSysStats() + } + + onWeatherRefreshChanged: weatherTimer.restart() + onStatsRefreshChanged: statsTimer.restart() + + Component.onCompleted: { + fetchWeather() + fetchSysStats() + } + + Connections { + target: Plasmoid.configuration + function onLatitudeChanged() { root.fetchWeather() } + function onLongitudeChanged() { root.fetchWeather() } + function onUseFahrenheitChanged() { root.fetchWeather() } + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..aea210c --- /dev/null +++ b/metadata.json @@ -0,0 +1,20 @@ +{ + "KPlugin": { + "Name": "Weather && Stats", + "Description": "Live weather and system stats for the KDE panel", + "Icon": "weather-clear", + "Id": "com.github.samjage.weatherstats", + "Version": "1.0", + "License": "GPL-2.0", + "Category": "System Information", + "Authors": [ + { + "Name": "samjage", + "Email": "" + } + ], + "Website": "https://github.com/samjage/weather-and-stats" + }, + "X-Plasma-API-Minimum-Version": "6.0", + "KPackageStructure": "Plasma/Applet" +}