From 2bc1d53b18bdd3068ed5d13c2be14465bc72d952 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 20:50:49 -0400 Subject: [PATCH] SysStat Service: Porting code to JS/QML instead of an external bash --- Modules/Bar/Widgets/SystemMonitor.qml | 2 +- Modules/SidePanel/Cards/SystemMonitorCard.qml | 4 +- Services/SystemStatService.qml | 367 ++++++++++++++++-- 3 files changed, 346 insertions(+), 27 deletions(-) diff --git a/Modules/Bar/Widgets/SystemMonitor.qml b/Modules/Bar/Widgets/SystemMonitor.qml index 6c2346c..e57d599 100644 --- a/Modules/Bar/Widgets/SystemMonitor.qml +++ b/Modules/Bar/Widgets/SystemMonitor.qml @@ -88,7 +88,7 @@ RowLayout { } NText { - text: `${SystemStatService.memoryUsageGb}G` + text: `${SystemStatService.memGb}G` font.family: Settings.data.ui.fontFixed font.pointSize: Style.fontSizeS * scaling font.weight: Style.fontWeightMedium diff --git a/Modules/SidePanel/Cards/SystemMonitorCard.qml b/Modules/SidePanel/Cards/SystemMonitorCard.qml index 2fc18de..9d3154d 100644 --- a/Modules/SidePanel/Cards/SystemMonitorCard.qml +++ b/Modules/SidePanel/Cards/SystemMonitorCard.qml @@ -40,7 +40,7 @@ NBox { height: 68 * scaling } NCircleStat { - value: SystemStatService.memoryUsagePer + value: SystemStatService.memPercent icon: "memory" flat: true contentScale: 0.8 @@ -48,7 +48,7 @@ NBox { height: 68 * scaling } NCircleStat { - value: SystemStatService.diskUsage + value: SystemStatService.diskPercent icon: "hard_drive" flat: true contentScale: 0.8 diff --git a/Services/SystemStatService.qml b/Services/SystemStatService.qml index 4f09c1d..5de99f0 100644 --- a/Services/SystemStatService.qml +++ b/Services/SystemStatService.qml @@ -4,6 +4,7 @@ import QtQuick import Qt.labs.folderlistmodel import Quickshell import Quickshell.Io +import qs.Commons Singleton { id: root @@ -11,12 +12,313 @@ Singleton { // Public values property real cpuUsage: 0 property real cpuTemp: 0 - property real memoryUsageGb: 0 - property real memoryUsagePer: 0 - property real diskUsage: 0 + property real memGb: 0 + property real memPercent: 0 + property real diskPercent: 0 property real rxSpeed: 0 property real txSpeed: 0 + // Configuration + property int sleepDuration: 3000 + + // Internal state for CPU calculation + property var prevCpuStats: null + + // Internal state for network speed calculation + // Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered + // since the computer started, so their value will easily overlfow a 32bit int. + property real prevRxBytes: undefined + property real prevTxBytes: undefined + property real prevTime: 0 + + // Cpu temperature is the most complex + readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"] + property string cpuTempSensorName: "" + property string cpuTempHwmonPath: "" + // For Intel coretemp averaging + property var intelTempValues: [] + property int intelTempFilesChecked: 0 + property int intelTempMaxFiles: 20 // Will test up to temp20_input + + // -------------------------------------------- + Component.onCompleted: { + Logger.log("SystemStat", "Service started with interval:", root.sleepDuration, "ms") + + // Kickoff the cpu name detection for temperature + cpuTempNameReader.checkNext() + } + + // -------------------------------------------- + // Timer for periodic updates + Timer { + id: updateTimer + interval: root.sleepDuration + repeat: true + running: true + triggeredOnStart: true + onTriggered: { + // Trigger all direct system files reads + memInfoFile.reload() + cpuStatFile.reload() + netDevFile.reload() + + // Run df (disk free) one time + dfProcess.running = true + + updateCpuTemperature() + } + } + + // -------------------------------------------- + // FileView components for reading system files + FileView { + id: memInfoFile + path: "/proc/meminfo" + onLoaded: parseMemoryInfo(text()) + } + + FileView { + id: cpuStatFile + path: "/proc/stat" + onLoaded: calculateCpuUsage(text()) + } + + FileView { + id: netDevFile + path: "/proc/net/dev" + onLoaded: calculateNetworkSpeed(text()) + } + + // -------------------------------------------- + // Process to fetch disk usage in percent + // Uses 'df' aka 'disk free' + Process { + id: dfProcess + command: ["df", "--output=pcent", "/"] + running: false + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split('\n') + if (lines.length >= 2) { + const percent = lines[1].replace(/[^0-9]/g, '') + root.diskPercent = parseInt(percent) || 0 + } + } + } + } + + // -------------------------------------------- + // CPU Temperature + // It's more complex. + // ---- + // #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower" + FileView { + id: cpuTempNameReader + property int currentIndex: 0 + + function checkNext() { + if (currentIndex >= 10) { + // Check up to hwmon10 + Logger.warn("No supported temperature sensor found") + return + } + + //Logger.log("SystemStat", "---- Probing: hwmon", currentIndex) + cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name` + cpuTempNameReader.reload() + } + + onLoaded: { + const name = text().trim() + if (root.supportedTempCpuSensorNames.includes(name)) { + root.cpuTempSensorName = name + root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}` + Logger.log("SystemStat", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`) + } else { + currentIndex++ + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNext() + }) + } + } + + onLoadFailed: function (error) { + currentIndex++ + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNext() + }) + } + } + + // ---- + // #2 - Read sensor value + FileView { + id: cpuTempReader + printErrors: false + + onLoaded: { + const data = text().trim() + if (root.cpuTempSensorName === "coretemp") { + // For Intel, collect all temperature values + const temp = parseInt(data) / 1000.0 + //console.log(temp, cpuTempReader.path) + root.intelTempValues.push(temp) + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNextIntelTemp() + }) + } else { + // For AMD sensors (k10temp and zenpower), directly set the temperature + root.cpuTemp = Math.round(parseInt(data) / 1000.0) + } + } + onLoadFailed: function (error) { + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNextIntelTemp() + }) + } + } + + // ------------------------------------------------------- + // Parse memory info from /proc/meminfo + function parseMemoryInfo(text) { + if (!text) + return + + const lines = text.split('\n') + let memTotal = 0 + let memAvailable = 0 + + for (const line of lines) { + if (line.startsWith('MemTotal:')) { + memTotal = parseInt(line.split(/\s+/)[1]) || 0 + } else if (line.startsWith('MemAvailable:')) { + memAvailable = parseInt(line.split(/\s+/)[1]) || 0 + } + } + + if (memTotal > 0) { + const usageKb = memTotal - memAvailable + root.memGb = (usageKb / 1000000).toFixed(1) + root.memPercent = Math.round((usageKb / memTotal) * 100) + } + } + + // ------------------------------------------------------- + // Calculate CPU usage from /proc/stat + function calculateCpuUsage(text) { + if (!text) + return + + const lines = text.split('\n') + const cpuLine = lines[0] + + // First line is total CPU + if (!cpuLine.startsWith('cpu ')) + return + + const parts = cpuLine.split(/\s+/) + const stats = { + "user": parseInt(parts[1]) || 0, + "nice": parseInt(parts[2]) || 0, + "system": parseInt(parts[3]) || 0, + "idle": parseInt(parts[4]) || 0, + "iowait": parseInt(parts[5]) || 0, + "irq": parseInt(parts[6]) || 0, + "softirq": parseInt(parts[7]) || 0, + "steal": parseInt(parts[8]) || 0, + "guest": parseInt(parts[9]) || 0, + "guestNice": parseInt(parts[10]) || 0 + } + const totalIdle = stats.idle + stats.iowait + const total = Object.values(stats).reduce((sum, val) => sum + val, 0) + + if (root.prevCpuStats) { + const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait + const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => sum + val, 0) + + const diffTotal = total - prevTotal + const diffIdle = totalIdle - prevTotalIdle + + if (diffTotal > 0) { + root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1) + } + } + + root.prevCpuStats = stats + } + + // ------------------------------------------------------- + // Calculate RX and TX speed from /proc/net/dev + // Average speed of all interfaces excepted 'lo' + function calculateNetworkSpeed(text) { + if (!text) { + return + } + + const currentTime = Date.now() / 1000 + const lines = text.split('\n') + + let totalRx = 0 + let totalTx = 0 + + for (var i = 2; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) { + continue + } + + const colonIndex = line.indexOf(':') + if (colonIndex === -1) { + continue + } + + const iface = line.substring(0, colonIndex).trim() + if (iface === 'lo') { + continue + } + + const statsLine = line.substring(colonIndex + 1).trim() + const stats = statsLine.split(/\s+/) + + const rxBytes = parseInt(stats[0], 10) || 0 + const txBytes = parseInt(stats[8], 10) || 0 + + totalRx += rxBytes + totalTx += txBytes + } + + // Compute only if we have a previous run to compare to. + if (root.prevTime > 0 && root.prevRxBytes !== undefined) { + const timeDiff = currentTime - root.prevTime + + // Avoid division by zero if time hasn't passed. + if (timeDiff > 0) { + let rxDiff = totalRx - root.prevRxBytes + let txDiff = totalTx - root.prevTxBytes + + // Handle counter resets (e.g., WiFi reconnect), which would cause a negative value. + if (rxDiff < 0) { + rxDiff = 0 + } + if (txDiff < 0) { + txDiff = 0 + } + + root.rxSpeed = Math.round(rxDiff / timeDiff) // Speed in Bytes/s + root.txSpeed = Math.round(txDiff / timeDiff) + } + } + + root.prevRxBytes = totalRx + root.prevTxBytes = totalTx + root.prevTime = currentTime + } + + // ------------------------------------------------------- // Helper function to format network speeds function formatSpeed(bytesPerSecond) { if (bytesPerSecond < 1024) { @@ -30,27 +332,44 @@ Singleton { } } - // Background process emitting one JSON line per sample - Process { - id: reader - running: true - command: ["sh", "-c", Quickshell.shellDir + "/Bin/system-stats.sh"] - stdout: SplitParser { - onRead: function (line) { - try { - const data = JSON.parse(line) - root.cpuUsage = data.cpu - root.cpuTemp = data.cputemp - root.memoryUsageGb = data.memgb - root.memoryUsagePer = data.memper - root.diskUsage = data.diskper - root.rxSpeed = parseFloat(data.rx_speed) || 0 - root.txSpeed = parseFloat(data.tx_speed) || 0 - } catch (e) { - - // ignore malformed lines - } - } + // ------------------------------------------------------- + // Function to start fetching and computing the cpu temperature + function updateCpuTemperature() { + // For AMD sensors (k10temp and zenpower), only use Tctl sensor + // temp1_input corresponds to Tctl (Temperature Control) on these sensors + if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") { + cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input` + cpuTempReader.reload() + } // For Intel coretemp, start averaging all available sensors/cores + else if (root.cpuTempSensorName === "coretemp") { + root.intelTempValues = [] + root.intelTempFilesChecked = 0 + checkNextIntelTemp() } } + + // ------------------------------------------------------- + // Function to check next Intel temperature sensor + function checkNextIntelTemp() { + if (root.intelTempFilesChecked >= root.intelTempMaxFiles) { + // Calculate average of all found temperatures + if (root.intelTempValues.length > 0) { + let sum = 0 + for (var i = 0; i < root.intelTempValues.length; i++) { + sum += root.intelTempValues[i] + } + root.cpuTemp = Math.round(sum / root.intelTempValues.length) + Logger.log("SystemStat", `Averaged ${root.intelTempValues.length} CPU thermal sensors: ${root.cpuTemp}°C`) + } else { + Logger.warn("SystemStat", "No temperature sensors found for coretemp") + root.cpuTemp = 0 + } + return + } + + // Check next temperature file + root.intelTempFilesChecked++ + cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input` + cpuTempReader.reload() + } }