add full proj
This commit is contained in:
290
services/SysStats.qml
Normal file
290
services/SysStats.qml
Normal file
@@ -0,0 +1,290 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
// System metrics gathered by reading /proc and /sys directly from QML via
|
||||
// FileView, polled by a Timer. No persistent helper process and no per-tick
|
||||
// subprocess churn (awk/df/cat). Two exceptions that have no virtual-file
|
||||
// equivalent: hardware discovery (one-shot at startup) and disk usage
|
||||
// (df, refreshed infrequently).
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// --- live values, updated each tick ---
|
||||
property real cpu: 0 // %
|
||||
property real cpuFreq: 0 // MHz, max across all cores
|
||||
property real mem: 0 // %
|
||||
property int temp: 0 // °C
|
||||
property int disk: 0 // % used on /
|
||||
property string iface: "" // active interface name
|
||||
property real rxRate: 0 // bytes/sec down
|
||||
property real txRate: 0 // bytes/sec up
|
||||
property int battery: -1 // %, -1 if none
|
||||
property string batteryStatus: "unknown"
|
||||
|
||||
// per-core utilization, 0..100, index = core number
|
||||
property var coreLoads: []
|
||||
property int coreCount: 0
|
||||
|
||||
readonly property bool hasBattery: battery >= 0
|
||||
|
||||
// poll interval in seconds
|
||||
readonly property int interval: 1
|
||||
|
||||
// discovered sysfs paths (filled once by discover.sh)
|
||||
property string tempPath: ""
|
||||
property string batPath: ""
|
||||
|
||||
// --- previous-sample state for delta computations ---
|
||||
property double _prevCpuTotal: 0
|
||||
property double _prevCpuIdle: 0
|
||||
property var _prevCoreTotal: []
|
||||
property var _prevCoreIdle: []
|
||||
property double _prevRx: 0
|
||||
property double _prevTx: 0
|
||||
property string _prevIface: ""
|
||||
property int _tick: 0
|
||||
|
||||
// ---- file readers ------------------------------------------------------
|
||||
// blockLoading makes reload() synchronous so text() is fresh on the
|
||||
// same tick. These are tiny virtual files, so the cost is negligible.
|
||||
|
||||
FileView { id: statView; path: "/proc/stat"; blockLoading: true }
|
||||
FileView { id: cpuInfoView; path: "/proc/cpuinfo"; blockLoading: true }
|
||||
FileView { id: memView; path: "/proc/meminfo"; blockLoading: true }
|
||||
FileView { id: routeView; path: "/proc/net/route"; blockLoading: true }
|
||||
FileView {
|
||||
id: tempView
|
||||
path: root.tempPath
|
||||
blockLoading: true
|
||||
}
|
||||
FileView {
|
||||
id: batCapView
|
||||
path: root.batPath ? root.batPath + "/capacity" : ""
|
||||
blockLoading: true
|
||||
}
|
||||
FileView {
|
||||
id: batStatusView
|
||||
path: root.batPath ? root.batPath + "/status" : ""
|
||||
blockLoading: true
|
||||
}
|
||||
// network counters: path follows the active interface
|
||||
FileView { id: rxView; blockLoading: true }
|
||||
FileView { id: txView; blockLoading: true }
|
||||
|
||||
// ---- parsing -----------------------------------------------------------
|
||||
|
||||
// Utilization from a /proc/stat "cpu..." field array, given the previous
|
||||
// total/idle for that line. Returns { load, total, idle }.
|
||||
function _cpuLineLoad(f, prevTotal, prevIdle) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < f.length; i++) total += f[i];
|
||||
const idle = f[3] + f[4]; // idle + iowait
|
||||
const dt = total - prevTotal;
|
||||
const di = idle - prevIdle;
|
||||
let load = 0;
|
||||
if (prevTotal !== 0 && dt > 0)
|
||||
load = Math.max(0, Math.min(100, (1 - di / dt) * 100));
|
||||
return { load: load, total: total, idle: idle };
|
||||
}
|
||||
|
||||
function _parseCpu() {
|
||||
const text = statView.text();
|
||||
if (!text) return;
|
||||
const lines = text.split("\n");
|
||||
const loads = [];
|
||||
const prevT = root._prevCoreTotal.slice();
|
||||
const prevI = root._prevCoreIdle.slice();
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const ln = lines[li];
|
||||
if (!ln.startsWith("cpu")) break; // cpu lines lead /proc/stat
|
||||
const parts = ln.trim().split(/\s+/);
|
||||
const f = parts.slice(1).map(Number);
|
||||
if (f.length < 5) continue;
|
||||
if (parts[0] === "cpu") { // aggregate -> overall %
|
||||
const r = root._cpuLineLoad(f, root._prevCpuTotal, root._prevCpuIdle);
|
||||
root.cpu = r.load;
|
||||
root._prevCpuTotal = r.total;
|
||||
root._prevCpuIdle = r.idle;
|
||||
} else { // "cpuN" -> per-core
|
||||
const idx = parseInt(parts[0].slice(3));
|
||||
if (isNaN(idx)) continue;
|
||||
const r = root._cpuLineLoad(f, prevT[idx] || 0, prevI[idx] || 0);
|
||||
loads[idx] = Math.round(r.load); // integer %: stable, lets the redraw guard below short-circuit
|
||||
prevT[idx] = r.total;
|
||||
prevI[idx] = r.idle;
|
||||
}
|
||||
}
|
||||
root._prevCoreTotal = prevT;
|
||||
root._prevCoreIdle = prevI;
|
||||
// coreLoads is a var (array): reassigning always fires the change
|
||||
// signal and re-evaluates every bar binding. Only assign when a core's
|
||||
// value actually changed so unchanged ticks trigger no redraw.
|
||||
if (!_sameLoads(loads, root.coreLoads)) {
|
||||
root.coreLoads = loads;
|
||||
root.coreCount = loads.length;
|
||||
}
|
||||
}
|
||||
|
||||
function _sameLoads(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Highest current per-core frequency from /proc/cpuinfo's "cpu MHz" lines.
|
||||
function _parseFreq() {
|
||||
const text = cpuInfoView.text();
|
||||
if (!text) return;
|
||||
let max = 0;
|
||||
const lines = text.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].startsWith("cpu MHz")) continue;
|
||||
const v = parseFloat(lines[i].split(":")[1]);
|
||||
if (!isNaN(v) && v > max) max = v;
|
||||
}
|
||||
root.cpuFreq = max;
|
||||
}
|
||||
|
||||
function _parseMem() {
|
||||
const text = memView.text();
|
||||
if (!text) return;
|
||||
let mt = 0, ma = 0;
|
||||
const lines = text.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const ln = lines[i];
|
||||
if (ln.startsWith("MemTotal:")) mt = parseInt(ln.split(/\s+/)[1]);
|
||||
else if (ln.startsWith("MemAvailable:")) { ma = parseInt(ln.split(/\s+/)[1]); break; }
|
||||
}
|
||||
if (mt > 0) root.mem = (mt - ma) / mt * 100;
|
||||
}
|
||||
|
||||
function _parseTemp() {
|
||||
if (!root.tempPath) { root.temp = 0; return; }
|
||||
const text = tempView.text();
|
||||
if (!text) return;
|
||||
const v = parseInt(text.trim());
|
||||
if (!isNaN(v)) root.temp = Math.round(v / 1000);
|
||||
}
|
||||
|
||||
function _parseBattery() {
|
||||
if (!root.batPath) { root.battery = -1; root.batteryStatus = "unknown"; return; }
|
||||
const cap = batCapView.text();
|
||||
const st = batStatusView.text();
|
||||
if (cap) { const v = parseInt(cap.trim()); if (!isNaN(v)) root.battery = v; }
|
||||
if (st) root.batteryStatus = st.trim() || "unknown";
|
||||
}
|
||||
|
||||
// Active interface = device of the default route (destination 00000000).
|
||||
function _activeIface() {
|
||||
const text = routeView.text();
|
||||
if (!text) return "";
|
||||
const lines = text.split("\n");
|
||||
for (let i = 1; i < lines.length; i++) { // skip header
|
||||
const cols = lines[i].trim().split(/\s+/);
|
||||
if (cols.length >= 2 && cols[1] === "00000000") return cols[0];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function _parseNet() {
|
||||
const ifc = root._activeIface();
|
||||
root.iface = ifc;
|
||||
if (!ifc) { root.rxRate = 0; root.txRate = 0; root._prevIface = ""; return; }
|
||||
|
||||
const base = "/sys/class/net/" + ifc + "/statistics/";
|
||||
rxView.path = base + "rx_bytes";
|
||||
txView.path = base + "tx_bytes";
|
||||
rxView.reload();
|
||||
txView.reload();
|
||||
|
||||
const rx = parseInt((rxView.text() || "0").trim());
|
||||
const tx = parseInt((txView.text() || "0").trim());
|
||||
if (ifc === root._prevIface && root._prevRx > 0) {
|
||||
root.rxRate = Math.max(0, (rx - root._prevRx) / root.interval);
|
||||
root.txRate = Math.max(0, (tx - root._prevTx) / root.interval);
|
||||
} else {
|
||||
root.rxRate = 0;
|
||||
root.txRate = 0;
|
||||
}
|
||||
root._prevRx = rx;
|
||||
root._prevTx = tx;
|
||||
root._prevIface = ifc;
|
||||
}
|
||||
|
||||
function _tickOnce() {
|
||||
statView.reload();
|
||||
cpuInfoView.reload();
|
||||
memView.reload();
|
||||
routeView.reload();
|
||||
if (root.tempPath) tempView.reload();
|
||||
if (root.batPath) { batCapView.reload(); batStatusView.reload(); }
|
||||
|
||||
_parseCpu();
|
||||
_parseFreq();
|
||||
_parseMem();
|
||||
_parseTemp();
|
||||
_parseBattery();
|
||||
_parseNet();
|
||||
|
||||
// disk has no virtual file; refresh via df every 30 ticks.
|
||||
if (root._tick % 30 === 0) dfProc.running = true;
|
||||
root._tick++;
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: root.interval * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root._tickOnce()
|
||||
}
|
||||
|
||||
// ---- one-shot hardware discovery --------------------------------------
|
||||
|
||||
Process {
|
||||
id: discover
|
||||
command: ["bash", Quickshell.shellPath("services/discover.sh")]
|
||||
running: true
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: line => {
|
||||
if (!line) return;
|
||||
const sp = line.indexOf(" ");
|
||||
if (sp < 0) return;
|
||||
const key = line.slice(0, sp);
|
||||
const val = line.slice(sp + 1).trim();
|
||||
if (key === "TEMP") root.tempPath = val;
|
||||
else if (key === "BAT") root.batPath = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- disk usage (df, low cadence) -------------------------------------
|
||||
|
||||
Process {
|
||||
id: dfProc
|
||||
command: ["df", "-P", "/"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = (this.text || "").trim().split("\n");
|
||||
if (lines.length < 2) return;
|
||||
const cols = lines[lines.length - 1].trim().split(/\s+/);
|
||||
if (cols.length >= 5) {
|
||||
const v = parseInt(cols[4].replace("%", ""));
|
||||
if (!isNaN(v)) root.disk = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable byte rate with kB as the smallest unit, e.g. "1.2M", "8K".
|
||||
function fmtRate(bytes) {
|
||||
const u = ["K", "M", "G"];
|
||||
let i = 0, v = bytes / 1024; // start in kB
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
||||
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + u[i];
|
||||
}
|
||||
}
|
||||
59
services/discover.sh
Executable file
59
services/discover.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot hardware discovery for the Quickshell bar.
|
||||
# Prints stable sysfs paths the QML side then reads directly via FileView,
|
||||
# so no per-tick subprocess is needed. Output (one key per line):
|
||||
#
|
||||
# TEMP <path to hwmon/thermal temp*_input>
|
||||
# BAT <path to power_supply battery dir>
|
||||
#
|
||||
# Re-run only when hardware topology might change (rare); paths are stable
|
||||
# across a boot.
|
||||
|
||||
set -u
|
||||
|
||||
# CPU package temperature sensor (coretemp / k10temp / zenpower ...).
|
||||
discover_temp_input() {
|
||||
local hw name lbl base
|
||||
for hw in /sys/class/hwmon/hwmon*; do
|
||||
name=$(cat "$hw/name" 2>/dev/null) || continue
|
||||
case "$name" in
|
||||
coretemp|k10temp|zenpower)
|
||||
# Prefer the package/Tctl/Tdie label if present.
|
||||
for lbl in "$hw"/temp*_label; do
|
||||
[ -e "$lbl" ] || continue
|
||||
case "$(cat "$lbl" 2>/dev/null)" in
|
||||
"Package id 0"|Tctl|Tdie)
|
||||
base="${lbl%_label}"
|
||||
echo "${base}_input"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ -e "$hw/temp1_input" ] && { echo "$hw/temp1_input"; return 0; }
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# Fallback: x86_pkg_temp thermal zone, else first thermal zone.
|
||||
local z t
|
||||
for z in /sys/class/thermal/thermal_zone*; do
|
||||
t=$(cat "$z/type" 2>/dev/null)
|
||||
[ "$t" = "x86_pkg_temp" ] && { echo "$z/temp"; return 0; }
|
||||
done
|
||||
[ -e /sys/class/thermal/thermal_zone0/temp ] && echo /sys/class/thermal/thermal_zone0/temp
|
||||
}
|
||||
|
||||
# Main system battery (skip peripherals like wacom/mouse which set scope=Device).
|
||||
discover_battery() {
|
||||
local ps t scope
|
||||
for ps in /sys/class/power_supply/*; do
|
||||
t=$(cat "$ps/type" 2>/dev/null)
|
||||
[ "$t" = "Battery" ] || continue
|
||||
scope=$(cat "$ps/scope" 2>/dev/null)
|
||||
[ "$scope" = "Device" ] && continue
|
||||
echo "$ps"
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
printf 'TEMP %s\n' "$(discover_temp_input)"
|
||||
printf 'BAT %s\n' "$(discover_battery)"
|
||||
1
services/qmldir
Normal file
1
services/qmldir
Normal file
@@ -0,0 +1 @@
|
||||
singleton SysStats 1.0 SysStats.qml
|
||||
Reference in New Issue
Block a user