Files
noctalia-shell/Services/System/SystemStatService.qml
T
2025-12-16 00:31:11 +01:00

785 lines
24 KiB
QML

pragma Singleton
import Qt.labs.folderlistmodel
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
// Configuration
readonly property int minimumIntervalMs: 250
readonly property int defaultIntervalMs: 3000
function normalizeInterval(value) {
return Math.max(minimumIntervalMs, value || defaultIntervalMs);
}
// Public values
property real cpuUsage: 0
property real cpuTemp: 0
property real gpuTemp: 0
property bool gpuAvailable: false
property string gpuType: "" // "amd", "intel", "nvidia"
property real memGb: 0
property real memPercent: 0
property var diskPercents: ({})
property real rxSpeed: 0
property real txSpeed: 0
// 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: 0
property real prevTxBytes: 0
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 of all cores/sensors
property var intelTempValues: []
property int intelTempFilesChecked: 0
property int intelTempMaxFiles: 20 // Will test up to temp20_input
// GPU temperature detection
// On dual-GPU systems, we prioritize discrete GPUs over integrated GPUs
// Priority: NVIDIA (opt-in) > AMD dGPU > Intel Arc > AMD iGPU
// Note: NVIDIA requires opt-in because nvidia-smi wakes the dGPU on laptops, draining battery
readonly property var supportedTempGpuSensorNames: ["amdgpu", "xe"]
property string gpuTempHwmonPath: ""
property var foundGpuSensors: [] // [{hwmonPath, type, hasDedicatedVram}]
property int gpuVramCheckIndex: 0
// --------------------------------------------
Component.onCompleted: {
Logger.i("SystemStat", "Service started with custom polling intervals");
// Kickoff the cpu name detection for temperature
cpuTempNameReader.checkNext();
// Kickoff the gpu sensor detection for temperature
gpuTempNameReader.checkNext();
}
// Re-run GPU detection when NVIDIA opt-in setting changes
Connections {
target: Settings.data.systemMonitor
function onEnableNvidiaGpuChanged() {
Logger.i("SystemStat", "NVIDIA opt-in setting changed, re-detecting GPUs");
restartGpuDetection();
}
}
function restartGpuDetection() {
// Reset GPU state
root.gpuAvailable = false;
root.gpuType = "";
root.gpuTempHwmonPath = "";
root.gpuTemp = 0;
root.foundGpuSensors = [];
root.gpuVramCheckIndex = 0;
// Restart GPU detection
gpuTempNameReader.currentIndex = 0;
gpuTempNameReader.checkNext();
}
// --------------------------------------------
// Timer for CPU usage
Timer {
id: cpuUsageTimer
interval: root.normalizeInterval(Settings.data.systemMonitor.cpuPollingInterval)
repeat: true
running: true
triggeredOnStart: true
onIntervalChanged: {
if (running) {
restart();
}
}
onTriggered: cpuStatFile.reload()
}
// Timer for CPU temperature
Timer {
id: cpuTempTimer
interval: root.normalizeInterval(Settings.data.systemMonitor.tempPollingInterval)
repeat: true
running: true
triggeredOnStart: true
onIntervalChanged: {
if (running) {
restart();
}
}
onTriggered: updateCpuTemperature()
}
// Timer for memory stats
Timer {
id: memoryTimer
interval: root.normalizeInterval(Settings.data.systemMonitor.memPollingInterval)
repeat: true
running: true
triggeredOnStart: true
onIntervalChanged: {
if (running) {
restart();
}
}
onTriggered: memInfoFile.reload()
}
// Timer for disk usage
Timer {
id: diskTimer
interval: root.normalizeInterval(Settings.data.systemMonitor.diskPollingInterval)
repeat: true
running: true
triggeredOnStart: true
onIntervalChanged: {
if (running) {
restart();
}
}
onTriggered: dfProcess.running = true
}
// Timer for network speeds
Timer {
id: networkTimer
interval: root.normalizeInterval(Settings.data.systemMonitor.networkPollingInterval)
repeat: true
running: true
triggeredOnStart: true
onIntervalChanged: {
if (running) {
restart();
}
}
onTriggered: netDevFile.reload()
}
// Timer for GPU temperature
Timer {
id: gpuTempTimer
interval: root.normalizeInterval(Settings.data.systemMonitor.gpuPollingInterval)
repeat: true
running: root.gpuAvailable
triggeredOnStart: true
onIntervalChanged: {
if (running) {
restart();
}
}
onTriggered: updateGpuTemperature()
}
// --------------------------------------------
// 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'
// "-x efivarfs' skips efivarfs mountpoints, for which the `statfs` syscall may cause system-wide stuttering
Process {
id: dfProcess
command: ["df", "--output=target,pcent", "-x", "efivarfs"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split('\n');
const newPercents = {};
// Start from line 1 (skip header)
for (var i = 1; i < lines.length; i++) {
const parts = lines[i].trim().split(/\s+/);
if (parts.length >= 2) {
const target = parts[0];
const percent = parseInt(parts[1].replace(/[^0-9]/g, '')) || 0;
newPercents[target] = percent;
}
}
root.diskPercents = newPercents;
}
}
}
// --------------------------------------------
// --------------------------------------------
// CPU Temperature
// It's more complex.
// ----
// #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower"
FileView {
id: cpuTempNameReader
property int currentIndex: 0
printErrors: false
function checkNext() {
if (currentIndex >= 16) {
// Check up to hwmon10
Logger.w("No supported temperature sensor found");
return;
}
//Logger.i("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.i("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();
});
}
}
// --------------------------------------------
// --------------------------------------------
// GPU Temperature
// On dual-GPU systems (e.g., Intel iGPU + NVIDIA dGPU, or AMD APU + AMD dGPU),
// we scan all hwmon entries, then select the best GPU based on priority.
// ----
// #1 - Scan all hwmon entries to find GPU sensors
FileView {
id: gpuTempNameReader
property int currentIndex: 0
printErrors: false
function checkNext() {
if (currentIndex >= 16) {
// Finished scanning all hwmon entries
// Only check nvidia-smi if user has explicitly enabled NVIDIA monitoring (opt-in)
// because nvidia-smi wakes up the dGPU on laptops, draining battery
if (Settings.data.systemMonitor.enableNvidiaGpu) {
Logger.d("SystemStat", `Found ${root.foundGpuSensors.length} sysfs GPU sensor(s), checking nvidia-smi (opt-in enabled)`);
nvidiaSmiCheck.running = true;
} else {
Logger.d("SystemStat", `Found ${root.foundGpuSensors.length} sysfs GPU sensor(s), skipping nvidia-smi (opt-in disabled)`);
root.gpuVramCheckIndex = 0;
checkNextGpuVram();
}
return;
}
gpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`;
gpuTempNameReader.reload();
}
onLoaded: {
const name = text().trim();
if (root.supportedTempGpuSensorNames.includes(name)) {
// Collect this GPU sensor, don't stop - continue scanning for more
const hwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`;
const gpuType = name === "amdgpu" ? "amd" : "intel";
root.foundGpuSensors.push({
"hwmonPath": hwmonPath,
"type": gpuType,
"hasDedicatedVram": false // Will be checked later for AMD
});
Logger.d("SystemStat", `Found ${name} GPU sensor at ${hwmonPath}`);
}
// Continue scanning regardless of whether we found a match
currentIndex++;
Qt.callLater(() => {
checkNext();
});
}
onLoadFailed: function (error) {
currentIndex++;
Qt.callLater(() => {
checkNext();
});
}
}
// ----
// #2 - Read GPU sensor value (AMD/Intel via sysfs)
FileView {
id: gpuTempReader
printErrors: false
onLoaded: {
const data = text().trim();
root.gpuTemp = Math.round(parseInt(data) / 1000.0);
}
}
// ----
// #3 - Check if nvidia-smi is available (for NVIDIA GPUs)
Process {
id: nvidiaSmiCheck
command: ["which", "nvidia-smi"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text.trim().length > 0) {
// Add NVIDIA as a GPU option (always discrete, highest priority)
root.foundGpuSensors.push({
"hwmonPath": "",
"type": "nvidia",
"hasDedicatedVram": true // NVIDIA is always discrete
});
Logger.d("SystemStat", "Found NVIDIA GPU (nvidia-smi available)");
}
// After NVIDIA check, check VRAM for AMD GPUs to distinguish dGPU from iGPU
root.gpuVramCheckIndex = 0;
checkNextGpuVram();
}
}
}
// ----
// #4 - Check VRAM for AMD GPUs to distinguish dGPU from iGPU
// dGPUs have dedicated VRAM, iGPUs don't (use system RAM)
FileView {
id: gpuVramChecker
printErrors: false
onLoaded: {
// File exists and has content = dGPU with dedicated VRAM
const vramSize = parseInt(text().trim());
if (vramSize > 0) {
root.foundGpuSensors[root.gpuVramCheckIndex].hasDedicatedVram = true;
Logger.d("SystemStat", `GPU at ${root.foundGpuSensors[root.gpuVramCheckIndex].hwmonPath} has dedicated VRAM (dGPU)`);
}
root.gpuVramCheckIndex++;
Qt.callLater(() => {
checkNextGpuVram();
});
}
onLoadFailed: function (error) {
// File doesn't exist = iGPU (no dedicated VRAM)
// hasDedicatedVram is already false by default
root.gpuVramCheckIndex++;
Qt.callLater(() => {
checkNextGpuVram();
});
}
}
// ----
// #4 - Read GPU temperature via nvidia-smi (NVIDIA only)
Process {
id: nvidiaTempProcess
command: ["nvidia-smi", "--query-gpu=temperature.gpu", "--format=csv,noheader,nounits"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const temp = parseInt(text.trim());
if (!isNaN(temp)) {
root.gpuTemp = temp;
}
}
}
}
// -------------------------------------------------------
// -------------------------------------------------------
// 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 / 1048576).toFixed(1); // 1024*1024 = 1048576
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) {
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 * 1024) {
const kb = bytesPerSecond / 1024;
if (kb < 10) {
let formatted = kb.toFixed(1) + "KB";
if (formatted.length > 5) {
formatted = kb.toFixed(1) + "K";
}
return formatted;
} else {
let formatted = Math.round(kb) + "KB";
if (formatted.length > 5) {
formatted = Math.round(kb) + "K";
}
return formatted;
}
} else if (bytesPerSecond < 1024 * 1024 * 1024) {
const mb = bytesPerSecond / (1024 * 1024);
let formatted = mb.toFixed(1) + "MB";
if (formatted.length > 5) {
formatted = mb.toFixed(1) + "M";
if (formatted.length > 5) {
formatted = Math.round(mb) + "M";
}
}
return formatted;
} else {
const gb = bytesPerSecond / (1024 * 1024 * 1024);
let formatted = gb.toFixed(1) + "GB";
if (formatted.length > 5) {
formatted = gb.toFixed(1) + "G";
if (formatted.length > 5) {
formatted = Math.round(gb) + "G";
}
}
return formatted;
}
}
// -------------------------------------------------------
// Compact speed formatter for vertical bar display
function formatCompactSpeed(bytesPerSecond) {
if (!bytesPerSecond || bytesPerSecond <= 0)
return "0";
const units = ["", "K", "M", "G"];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value = value / 1024.0;
unitIndex++;
}
// Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded)
if (unitIndex < units.length - 1 && value >= 100) {
value = value / 1024.0;
unitIndex++;
}
const display = Math.round(value).toString();
return display + units[unitIndex];
}
// -------------------------------------------------------
// Smart formatter for memory values (GB) that prevents elision
// Tries to keep within 5 chars when possible, rounds if needed
function formatMemoryGb(memGb) {
// memGb is already a string from toFixed(1), convert to number
const value = parseFloat(memGb);
if (isNaN(value))
return "0G";
// Try with 1 decimal and "G"
let formatted = value.toFixed(1) + "G";
// If longer than 5 chars (e.g., "123.4G"), round to integer
if (formatted.length > 5) {
formatted = Math.round(value) + "G";
}
return formatted;
}
// -------------------------------------------------------
// 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.i("SystemStat", `Averaged ${root.intelTempValues.length} CPU thermal sensors: ${root.cpuTemp}°C`)
} else {
Logger.w("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();
}
// -------------------------------------------------------
// Function to check VRAM for each AMD GPU to determine if it's a dGPU
function checkNextGpuVram() {
// Skip non-AMD GPUs (NVIDIA and Intel Arc are always discrete)
while (root.gpuVramCheckIndex < root.foundGpuSensors.length) {
const gpu = root.foundGpuSensors[root.gpuVramCheckIndex];
if (gpu.type === "amd") {
// Check for dedicated VRAM at hwmonPath/device/mem_info_vram_total
gpuVramChecker.path = `${gpu.hwmonPath}/device/mem_info_vram_total`;
gpuVramChecker.reload();
return;
}
// Skip non-AMD GPUs
root.gpuVramCheckIndex++;
}
// All VRAM checks complete, now select the best GPU
selectBestGpu();
}
// -------------------------------------------------------
// Function to select the best GPU based on priority
// Priority: NVIDIA > AMD dGPU > Intel Arc > AMD iGPU
function selectBestGpu() {
if (root.foundGpuSensors.length === 0) {
Logger.d("SystemStat", "No GPU temperature sensor found");
return;
}
let best = null;
for (var i = 0; i < root.foundGpuSensors.length; i++) {
const gpu = root.foundGpuSensors[i];
// NVIDIA is always highest priority (always discrete)
if (gpu.type === "nvidia") {
best = gpu;
break;
}
// AMD dGPU is second priority
if (gpu.type === "amd" && gpu.hasDedicatedVram) {
best = gpu;
break;
}
// Intel Arc is third priority (always discrete)
if (gpu.type === "intel" && !best) {
best = gpu;
}
// AMD iGPU is lowest priority (fallback)
if (gpu.type === "amd" && !gpu.hasDedicatedVram && !best) {
best = gpu;
}
}
if (best) {
root.gpuTempHwmonPath = best.hwmonPath;
root.gpuType = best.type;
root.gpuAvailable = true;
const gpuDesc = best.type === "nvidia" ? "NVIDIA" : (best.type === "intel" ? "Intel Arc" : (best.hasDedicatedVram ? "AMD dGPU" : "AMD iGPU"));
Logger.i("SystemStat", `Selected ${gpuDesc} for temperature monitoring at ${best.hwmonPath || "nvidia-smi"}`);
}
}
// -------------------------------------------------------
// Function to update GPU temperature
function updateGpuTemperature() {
if (root.gpuType === "nvidia") {
nvidiaTempProcess.running = true;
} else if (root.gpuType === "amd" || root.gpuType === "intel") {
gpuTempReader.path = `${root.gpuTempHwmonPath}/temp1_input`;
gpuTempReader.reload();
}
}
}