272 lines
10 KiB
QML
272 lines
10 KiB
QML
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
|
|
|
|
// How much of its design capacity the pack still holds, as a whole
|
|
// percent, and how many full cycles it has been through.
|
|
//
|
|
// Both are `null` rather than 0 on the very many machines whose firmware
|
|
// does not report them -- every desktop, and a fair number of laptops that
|
|
// export cycle_count as a permanent zero. Null is the value the Power page
|
|
// renders as an em dash; a zero would render as a dead battery that has
|
|
// never been charged, which is the same class of lie as the time-to-empty
|
|
// estimate this service deliberately does not compute.
|
|
//
|
|
// `var` rather than `int` so that null survives: an int property would
|
|
// coerce it to 0 and put the lie back.
|
|
property var healthPercent: null
|
|
property var cycleCount: null
|
|
|
|
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();
|
|
}
|
|
|
|
// Wear, read through the helper rather than from sysfs directly.
|
|
//
|
|
// It is the one reading here that needs arithmetic across a variable set of
|
|
// files -- energy_full or charge_full, against a design capacity that may
|
|
// not exist, summed over however many packs the machine has -- which is
|
|
// exactly what the helper already does for `status`. Kept off the 20-second
|
|
// poll: health moves over months and cycles over days, so this runs once
|
|
// when the paths resolve and again whenever the Power page asks.
|
|
function refreshHealth(): void {
|
|
if (root.batteryPath === "" || healthQuery.running)
|
|
return;
|
|
healthQuery.running = true;
|
|
}
|
|
|
|
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;
|
|
root.healthPercent = null;
|
|
root.cycleCount = null;
|
|
return;
|
|
}
|
|
root.refresh();
|
|
root.refreshHealth();
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: healthQuery
|
|
command: [root.helperPath, "status"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const state = JSON.parse(this.text);
|
|
// Anything that is not a number -- null, absent, a string
|
|
// from a future field -- is "this machine does not say".
|
|
root.healthPercent = typeof state.healthPercent === "number"
|
|
? state.healthPercent : null;
|
|
root.cycleCount = typeof state.cycleCount === "number"
|
|
? state.cycleCount : null;
|
|
} catch (error) {
|
|
root.healthPercent = null;
|
|
root.cycleCount = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
// The pack that these described is gone; keep no wear figures for
|
|
// a battery that is no longer there.
|
|
root.healthPercent = null;
|
|
root.cycleCount = null;
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|