Files
Claude c562586216 fix(gui): "CPU-Temperatur" statt Zonenname in Klammern
Die Zeile zeigt nur noch den Wert in Grad. Damit die neue Beschriftung
stimmt, filtert sysstat.js die Zonen enger: nur soc-thermal, bigcore0/1
und littlecore. GPU-, NPU- und Center-Zone konnten vorher den Hoechstwert
stellen, obwohl "CPU" daran steht.

Doku: Kapitel 07 + Changelog.
2026-08-17 22:29:02 +02:00

154 lines
5.9 KiB
JavaScript

// Systemwerte für die Kopfzeile: Plattenbelegung, CPU-Last, RAM, Temperatur.
// Bewusst ohne Shell-Aufrufe (kein df/top) — alles aus /proc bzw. statfs, damit
// die GUI im Sekundentakt fragen darf, ohne Prozesse zu starten.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DISK_PATH = path.join(__dirname, 'recordings');
// ---- CPU ----------------------------------------------------------------
// Auslastung braucht zwei Messpunkte. Ein eigener Ticker hält den Wert bereit,
// sonst wäre die erste Antwort nach jedem Start immer 0 %.
let prevCpu = null;
let cpuPct = 0;
function sampleCpu() {
try {
const line = fs.readFileSync('/proc/stat', 'utf8').split('\n', 1)[0];
const v = line.trim().split(/\s+/).slice(1).map(Number).filter(n => Number.isFinite(n));
if (v.length < 5) return;
const idle = v[3] + v[4]; // idle + iowait
const total = v.reduce((a, b) => a + b, 0);
if (prevCpu) {
const dTotal = total - prevCpu.total;
const dIdle = idle - prevCpu.idle;
if (dTotal > 0) cpuPct = Math.max(0, Math.min(100, (1 - dIdle / dTotal) * 100));
}
prevCpu = { total, idle };
} catch { /* ohne /proc bleibt der letzte Wert stehen */ }
}
sampleCpu();
setInterval(sampleCpu, 2000).unref();
// ---- Temperatur ---------------------------------------------------------
// Gemeldet wird die CPU-Temperatur: wärmste der Kern-Zonen (bigcore0/1,
// littlecore) plus soc-thermal. GPU, NPU und die WLAN-Karte bleiben draußen —
// die GUI beschriftet den Wert als „CPU-Temperatur“, also darf auch nur CPU
// drinstecken.
const TEMP_ZONES = (() => {
try {
return fs.readdirSync('/sys/class/thermal')
.filter(d => /^thermal_zone\d+$/.test(d))
.map(d => {
const dir = `/sys/class/thermal/${d}`;
let type = d;
try { type = fs.readFileSync(`${dir}/type`, 'utf8').trim(); } catch { }
return { type, file: `${dir}/temp` };
})
.filter(z => /^(soc|bigcore\d*|littlecore)-thermal$/i.test(z.type));
} catch { return []; }
})();
function readTemp() {
let best = null;
for (const z of TEMP_ZONES) {
try {
const c = Number(fs.readFileSync(z.file, 'utf8').trim()) / 1000;
if (Number.isFinite(c) && (!best || c > best.c)) best = { c, zone: z.type };
} catch { }
}
return best;
}
// ---- RAM ----------------------------------------------------------------
// MemAvailable statt MemFree: Cache zählt nicht als belegt, sonst stünde das
// Board dauernd auf 95 %.
function readMem() {
try {
const txt = fs.readFileSync('/proc/meminfo', 'utf8');
const kb = k => {
const m = txt.match(new RegExp(`^${k}:\\s+(\\d+) kB`, 'm'));
return m ? Number(m[1]) * 1024 : null;
};
const total = kb('MemTotal');
const avail = kb('MemAvailable') ?? kb('MemFree');
if (!total || avail == null) return null;
return { totalBytes: total, freeBytes: avail, usedBytes: total - avail, usedPct: (1 - avail / total) * 100 };
} catch { return null; }
}
// ---- Aufnahmen ----------------------------------------------------------
// Summe aller Audiodateien unter recordings/. Der Scan ist billig (readdir +
// stat, keine Lesevorgänge), wird aber gepuffert, damit ein Poll im
// Sekundentakt nicht dauernd über das Verzeichnis läuft.
const AUDIO_EXT = /\.(mp3|wav|m4a|opus|ogg|flac|aac)$/i;
const REC_SCAN_TTL_MS = 60_000;
let recCache = { bytes: 0, files: 0, at: 0 };
let recScan = null;
async function scanRecordings() {
let bytes = 0, files = 0;
try {
const entries = await fs.promises.readdir(DISK_PATH, { withFileTypes: true, recursive: true });
for (const e of entries) {
if (!e.isFile() || !AUDIO_EXT.test(e.name)) continue;
try {
const st = await fs.promises.stat(path.join(e.parentPath || e.path || DISK_PATH, e.name));
bytes += st.size; files++;
} catch { /* zwischenzeitlich gelöscht */ }
}
} catch { return recCache; }
recCache = { bytes, files, at: Date.now() };
return recCache;
}
function recordingsSize() {
const fresh = Date.now() - recCache.at < REC_SCAN_TTL_MS;
if (!fresh && !recScan) recScan = scanRecordings().finally(() => { recScan = null; });
// Beim allerersten Aufruf gibt es noch keine Zahl — dann auf den Scan warten,
// sonst zeigt die Anzeige direkt nach dem Start einmal 0 % Belegung.
return recCache.at === 0 ? recScan : recCache;
}
// ---- Platte -------------------------------------------------------------
// Zwei Sichtweisen auf denselben Datenträger:
// partition: was df sagt (inklusive System, Modelle, node_modules)
// budget: der Platz, um den es dem Nutzer geht — freier Platz PLUS die
// Aufnahmen, die schon daliegen. Das ist genau das, was ein frisch
// aufgesetztes System dieser Ausbaustufe für Aufnahmen übrig
// hätte. Die Prozentzahl im Ring = Aufnahmen / Budget.
async function readDisk() {
let st;
try { st = fs.statfsSync(fs.existsSync(DISK_PATH) ? DISK_PATH : __dirname); } catch { return null; }
const bs = st.bsize;
const totalBytes = st.blocks * bs;
const freeBytes = st.bavail * bs;
const usedBytes = (st.blocks - st.bfree) * bs;
const partDenom = usedBytes + freeBytes;
const rec = await recordingsSize();
const recBytes = rec ? rec.bytes : 0;
const budgetBytes = freeBytes + recBytes;
return {
totalBytes, freeBytes, usedBytes,
partPct: partDenom > 0 ? (usedBytes / partDenom) * 100 : 0,
recBytes, recFiles: rec ? rec.files : 0,
budgetBytes,
usedPct: budgetBytes > 0 ? (recBytes / budgetBytes) * 100 : 0
};
}
export async function status() {
const t = readTemp();
return {
cpu: { pct: Math.round(cpuPct * 10) / 10, cores: os.cpus().length, load1: Math.round(os.loadavg()[0] * 100) / 100 },
ram: readMem(),
disk: await readDisk(),
temp: t ? { c: Math.round(t.c * 10) / 10, zone: t.zone } : null
};
}