Files
Panama/config/dot/quickshell/services/Battery.qml
T
Gabriel Brown 3c359f3f7e Notice the battery, and the machine it is or is not in
Panama had no idea whether it was running on a laptop. No upower, no
battery, no lid, no AC: hypridle.conf says "This is a desktop" in its
own header, and that was true of the code as well as the machine.

panama-hw answers hardware questions one at a time, exits 0 or 1, and
prints nothing, so scripts, services and contracts all ask the same
way. The definition the rest of the laptop work hangs on is one line:
clamshell is lid-closed AND an external monitor. A machine with no
mains supply at all reports as being on wall power, because a desktop
cannot run out of it.

The battery service follows Vitals: sysfs through FileView, an
availability flag, and no subprocess on the timer. Globbing is the one
thing QML cannot do -- a battery is BAT0 or BAT1 or CMB0, mains is AC
or ADP1 or ACAD -- so panama-battery resolves the names once and the
shell reads the files directly after. Nothing falls back to a
plausible zero: a desktop shows no indicator, no card, and no charge
limit control where the firmware has no ceiling.

Also repairs two contracts that were already failing and had not been
noticed, because only the full suite runs them. The dependency
scanner treated line-initial variable assignments, case labels,
comments and heredoc bodies as commands, and `count`, `host`, `cancel`
and `import` are all real binaries on Fedora, so `command -v` could
not filter them out. It now drops comments and heredoc bodies and
requires a command to be followed by whitespace. Verified it still
catches a genuinely undeclared dependency rather than passing quietly.
The launcher command contract had not been told about the fourteen
commands added earlier today.
2026-08-21 21:43:14 -04:00

215 lines
7.8 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
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()
}