pragma Singleton // ───────────────────────────────────────────────────────────────────────────── // Battery and power source. // // GNOME put this in the system menu; here it is a bar indicator and a card on // the Power page. The steady state is pure sysfs reads, following Vitals.qml: // no subprocess runs on the timer. // // Which files to read is the one thing QML cannot work out for itself, because // it cannot glob -- a battery is BAT0 on most machines, BAT1 on some, and the // mains supply is AC, AC0, ADP1 or ACAD depending on firmware. So // scripts/panama-battery resolves the names once at startup and this reads // them directly from then on. // // `available` is the flag every consumer gates on, exactly as Vitals exposes // gpuAvailable. A desktop has no battery and the correct behavior there is // that nothing appears at all -- which is why nothing here falls back to a // plausible-looking zero. // // One deliberate omission: no time-to-empty estimate. The kernel's own figure // swings wildly under load and computing one from a discharge rate produces a // confident number that is usually wrong, which is worse than no number. // ───────────────────────────────────────────────────────────────────────────── import Quickshell import Quickshell.Io import QtQuick import qs.config Singleton { id: root // Percent charged, 0-100. Meaningless unless `available`. property real percent: 0 // "Charging" | "Discharging" | "Full" | "Not charging" | "Unknown" property string status: "Unknown" // On wall power. Independent of charging: a full battery on the charger is // not charging but is very much on AC, and the idle timings care about the // wall rather than the current. True on a machine with no mains supply at // all, because a desktop cannot run out of power. property bool acOnline: true // False until a battery has actually been read. property bool available: false // The firmware charge ceiling, and whether this machine has one at all. // Not every laptop does, and the Power page hides the control rather than // offering one that would lie. property int chargeLimit: 0 property bool chargeLimitSupported: false readonly property bool charging: root.status === "Charging" readonly property bool low: root.available && !root.acOnline && root.percent <= Settings.batteryLowPercent readonly property bool critical: root.available && !root.acOnline && root.percent <= Settings.batteryCriticalPercent readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-battery" // Resolved once. Empty means this machine has none. property string batteryPath: "" property string mainsPath: "" property string thresholdPath: "" property bool located: false signal acChanged(bool online) function refresh(): void { if (!root.located) { locate.running = true; return; } capacityFile.reload(); statusFile.reload(); if (root.mainsPath !== "") onlineFile.reload(); if (root.thresholdPath !== "") thresholdFile.reload(); } function setChargeLimit(percent: int): void { if (!root.chargeLimitSupported || applyLimit.running) return; applyLimit.command = [root.helperPath, "set-threshold", String(percent)]; applyLimit.running = true; } // A battery moves a percentage point every few minutes; a charger being // unplugged is the only fast transition, and 20s catches it well inside // the time any timing decision matters. Small file reads, no subprocess. Timer { interval: 20000 running: root.available || !root.located repeat: true triggeredOnStart: true onTriggered: root.refresh() } Process { id: locate command: [root.helperPath, "paths"] stdout: StdioCollector { onStreamFinished: { root.located = true; try { const paths = JSON.parse(text); root.batteryPath = String(paths.battery ?? ""); root.mainsPath = String(paths.mains ?? ""); root.thresholdPath = String(paths.threshold ?? ""); root.chargeLimitSupported = root.thresholdPath !== ""; } catch (error) { console.warn("Battery: could not read the sysfs paths:", error); root.available = false; return; } if (root.batteryPath === "") { root.available = false; return; } root.refresh(); } } } Process { id: applyLimit // Read back rather than trusting the write: some firmware clamps the // value or ignores it entirely. onExited: root.refresh() } FileView { id: capacityFile path: root.batteryPath === "" ? "" : root.batteryPath + "/capacity" printErrors: false onLoaded: { const value = parseInt(text().trim(), 10); if (isFinite(value)) { root.percent = Math.max(0, Math.min(100, value)); root.available = true; } } // It was there and stopped reading: a removable pack, or a path that // moved. Drop availability rather than showing the last number // forever, and re-resolve on the next tick. onLoadFailed: { root.available = false; root.located = false; } } FileView { id: statusFile path: root.batteryPath === "" ? "" : root.batteryPath + "/status" printErrors: false onLoaded: root.status = text().trim() || "Unknown" onLoadFailed: root.status = "Unknown" } FileView { id: onlineFile path: root.mainsPath === "" ? "" : root.mainsPath + "/online" printErrors: false onLoaded: { const online = text().trim() === "1"; if (online !== root.acOnline) { root.acOnline = online; // The idle timings differ by power source and hypridle has no // concept of one, so somebody has to say when it changed. root.acChanged(online); } } } FileView { id: thresholdFile path: root.thresholdPath printErrors: false onLoaded: { const value = parseInt(text().trim(), 10); if (isFinite(value)) root.chargeLimit = value; } onLoadFailed: root.chargeLimitSupported = false; } // The charge ceiling is a preference the firmware has to be told about. // Written the same way IdleLock regenerates hypridle: watch for the value // changing, debounce, then push it -- and only when it actually differs // from what the hardware reports, so a settled slider does not ask for a // password on every unrelated preference write. Connections { target: DesktopPreferences function onRevisionChanged(): void { if (root.chargeLimitSupported) limitSync.restart(); } } Timer { id: limitSync interval: 600 onTriggered: { const wanted = DesktopPreferences.get("batteryChargeLimit"); if (wanted !== root.chargeLimit) root.setChargeLimit(wanted); } } Component.onCompleted: root.refresh() }