// 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 }; }