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.
This commit is contained in:
@@ -84,6 +84,39 @@ Singleton {
|
||||
label: "Graphics",
|
||||
detail: "Show graphics usage beside the workspace indicator"
|
||||
},
|
||||
// Only ever visible on a machine that has a battery: the indicator
|
||||
// gates on Battery.available as well as this, the way the graphics
|
||||
// field gates on Vitals.gpuAvailable.
|
||||
{
|
||||
key: "showBattery", type: "bool", def: true, group: "vitals",
|
||||
label: "Battery",
|
||||
detail: "Show the charge level in the bar, on machines that have a battery"
|
||||
},
|
||||
|
||||
// ── Battery ─────────────────────────────────────────────────────────
|
||||
// The two points at which the desktop starts telling you. Low is a
|
||||
// quiet mention; critical is the one that interrupts, so it is
|
||||
// published at a priority Do Not Disturb does not silence.
|
||||
{
|
||||
key: "batteryLowPercent", type: "int", def: 20, min: 5, max: 50, step: 5,
|
||||
unit: "%", group: "battery",
|
||||
label: "Warn at",
|
||||
detail: "Mention the battery once it drops this low"
|
||||
},
|
||||
{
|
||||
key: "batteryCriticalPercent", type: "int", def: 5, min: 1, max: 25, step: 1,
|
||||
unit: "%", group: "battery",
|
||||
label: "Urgent at",
|
||||
detail: "Interrupt at this level, even during Do Not Disturb"
|
||||
},
|
||||
// Only offered where the firmware exposes a ceiling; the Power page
|
||||
// hides the control entirely otherwise. 100 means charge to full.
|
||||
{
|
||||
key: "batteryChargeLimit", type: "int", def: 100, min: 50, max: 100, step: 5,
|
||||
unit: "%", group: "battery",
|
||||
label: "Stop charging at",
|
||||
detail: "Charging to less than full is easier on the battery over years"
|
||||
},
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -38,6 +38,11 @@ Singleton {
|
||||
readonly property bool showCpu: DesktopPreferences.get("showCpu")
|
||||
readonly property bool showMemory: DesktopPreferences.get("showMemory")
|
||||
readonly property bool showGpu: DesktopPreferences.get("showGpu")
|
||||
readonly property bool showBattery: DesktopPreferences.get("showBattery")
|
||||
|
||||
// ── Battery ─────────────────────────────────────────────────────────────
|
||||
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
|
||||
readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent")
|
||||
|
||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||
// widget hides itself if the path is missing rather than showing zeros.
|
||||
|
||||
@@ -116,4 +116,27 @@ Pill {
|
||||
glyph: "\u{F03F2}" // md-cellphone-link
|
||||
color: Theme.cyan
|
||||
}
|
||||
|
||||
// Battery. Absent entirely on a desktop: `available` is false until a
|
||||
// battery has actually been read, so this is not a zero that looks like a
|
||||
// flat cell. Same shape as the graphics field in VitalsWidget, which gates
|
||||
// on both the preference and the hardware.
|
||||
StatusGlyph {
|
||||
visible: Settings.showBattery && Battery.available
|
||||
glyph: {
|
||||
if (Battery.charging)
|
||||
return "\u{F0084}"; // md-battery_charging
|
||||
const level = Math.round(Battery.percent / 10) * 10;
|
||||
if (level >= 100) return "\u{F0079}"; // md-battery
|
||||
if (level <= 0) return "\u{F008E}"; // md-battery_outline
|
||||
// md-battery_10 .. md-battery_90 are consecutive from F007A.
|
||||
return String.fromCodePoint(0xF007A + (level / 10) - 1);
|
||||
}
|
||||
color: {
|
||||
if (Battery.critical) return Theme.danger;
|
||||
if (Battery.low) return Theme.warn;
|
||||
if (Battery.charging) return Theme.ok;
|
||||
return Theme.fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,44 @@ SettingsPage {
|
||||
title: "Power & Lock"
|
||||
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
||||
|
||||
// Absent on a desktop, in full. `available` is false until a battery has
|
||||
// actually been read, so this is not an empty card claiming 0%.
|
||||
SettingsCard {
|
||||
visible: Battery.available
|
||||
title: "Battery"
|
||||
subtitle: Battery.acOnline
|
||||
? "On wall power."
|
||||
: "On battery. The timings below switch to their battery values automatically."
|
||||
|
||||
TextRow {
|
||||
label: "Charge"
|
||||
value: Math.round(Battery.percent) + "%"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "State"
|
||||
value: {
|
||||
if (Battery.charging)
|
||||
return "Charging";
|
||||
if (Battery.status === "Full")
|
||||
return "Full";
|
||||
if (Battery.acOnline)
|
||||
return "Plugged in, not charging";
|
||||
return "On battery";
|
||||
}
|
||||
divider: Battery.chargeLimitSupported
|
||||
}
|
||||
|
||||
// Only where the firmware actually has a ceiling. A machine whose
|
||||
// kernel exposes nothing gets no control at all, rather than one that
|
||||
// would accept a value and change nothing.
|
||||
SliderRow {
|
||||
visible: Battery.chargeLimitSupported
|
||||
setting: "batteryChargeLimit"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// The same profiles GNOME's Power panel offers. Not a stored preference --
|
||||
// the daemon owns it, it survives Panama restarts, and anything else on the
|
||||
// system can change it, so a copy here would drift.
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The battery, for the shell and the Power page.
|
||||
#
|
||||
# panama-battery paths resolve which sysfs files to watch
|
||||
# panama-battery status one JSON reading, for scripts and contracts
|
||||
# panama-battery set-threshold N cap charging at N% (needs root)
|
||||
#
|
||||
# `paths` exists so the shell does not have to poll a subprocess. Globbing is
|
||||
# the one thing QML cannot do -- a battery is BAT0 on most machines, BAT1 on
|
||||
# some, CMB0 on a few, and the mains supply is AC, AC0, ADP1 or ACAD depending
|
||||
# on the firmware -- so this resolves the names once and the shell reads the
|
||||
# files directly from then on, the way Vitals.qml reads procfs.
|
||||
#
|
||||
# Which machine has what is panama-hw's question, so the search lives there and
|
||||
# this asks it rather than keeping a second copy of the answer.
|
||||
#
|
||||
# Charge thresholds are a root write to a sysfs attribute, and the only part of
|
||||
# this that needs privilege. It goes through panama-sudo so the prompt names
|
||||
# what is being changed, rather than asking for a password with polkit's
|
||||
# generic "run a program as another user".
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
HW="$PANAMA_PATH/bin/panama-hw"
|
||||
SYS="${PANAMA_HW_SYS:-/sys}"
|
||||
|
||||
battery_dir() {
|
||||
[[ -x "$HW" ]] || return 1
|
||||
"$HW" battery-path 2>/dev/null
|
||||
}
|
||||
|
||||
# The mains supply, if the machine has one. A desktop has none, and that is
|
||||
# not an error: panama-hw's `ac` predicate treats "no mains at all" as being on
|
||||
# wall power, and the shell falls back to the same assumption.
|
||||
mains_dir() {
|
||||
local supply
|
||||
for supply in "$SYS"/class/power_supply/*; do
|
||||
[[ -r "$supply/type" ]] || continue
|
||||
[[ "$(cat "$supply/type" 2>/dev/null)" == "Mains" ]] || continue
|
||||
printf '%s\n' "$supply"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
read_int() {
|
||||
local file="$1" value
|
||||
[[ -r "$file" ]] || return 1
|
||||
value="$(cat "$file" 2>/dev/null)" || return 1
|
||||
[[ "$value" =~ ^[0-9]+$ ]] || return 1
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
cmd_paths() {
|
||||
local battery mains threshold=""
|
||||
battery="$(battery_dir)" || battery=""
|
||||
mains="$(mains_dir)" || mains=""
|
||||
|
||||
# Only report the threshold file when it exists AND is writable through
|
||||
# root -- a machine whose kernel exposes a read-only stub would otherwise
|
||||
# get a control that silently does nothing.
|
||||
if [[ -n "$battery" && -r "$battery/charge_control_end_threshold" ]]; then
|
||||
threshold="$battery/charge_control_end_threshold"
|
||||
fi
|
||||
|
||||
printf '{"battery":"%s","mains":"%s","threshold":"%s"}\n' \
|
||||
"$battery" "$mains" "$threshold"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
local battery mains capacity="" state="Unknown" online=1 threshold=0
|
||||
battery="$(battery_dir)" || battery=""
|
||||
mains="$(mains_dir)" || mains=""
|
||||
|
||||
if [[ -n "$battery" ]]; then
|
||||
capacity="$(read_int "$battery/capacity")" || capacity=""
|
||||
[[ -r "$battery/status" ]] && state="$(cat "$battery/status" 2>/dev/null)"
|
||||
threshold="$(read_int "$battery/charge_control_end_threshold")" || threshold=0
|
||||
fi
|
||||
if [[ -n "$mains" ]]; then
|
||||
online="$(read_int "$mains/online")" || online=0
|
||||
fi
|
||||
|
||||
printf '{"available":%s,"percent":%s,"status":"%s","acOnline":%s,"chargeLimit":%s}\n' \
|
||||
"$([[ -n "$capacity" ]] && echo true || echo false)" \
|
||||
"${capacity:-0}" "$state" \
|
||||
"$([[ "$online" == "1" ]] && echo true || echo false)" \
|
||||
"$threshold"
|
||||
}
|
||||
|
||||
cmd_set_threshold() {
|
||||
local value="${1:-}" battery file
|
||||
[[ "$value" =~ ^[0-9]+$ ]] || { echo 'set-threshold needs a percentage' >&2; return 2; }
|
||||
(( value >= 50 && value <= 100 )) || { echo 'threshold must be between 50 and 100' >&2; return 2; }
|
||||
|
||||
battery="$(battery_dir)" || { echo 'no battery on this machine' >&2; return 1; }
|
||||
file="$battery/charge_control_end_threshold"
|
||||
[[ -e "$file" ]] || { echo 'this machine cannot set a charge threshold' >&2; return 1; }
|
||||
|
||||
# tee rather than a redirect: the redirect is performed by the calling
|
||||
# shell, which is not the one holding root.
|
||||
"$PANAMA_PATH/bin/panama-sudo" \
|
||||
--reason "Capping battery charging at ${value}% to reduce wear" \
|
||||
-- sh -c "printf '%s\n' '$value' | tee '$file' >/dev/null" || return 1
|
||||
|
||||
# Read it back rather than reporting success from the write's exit code:
|
||||
# some firmware silently clamps or ignores the value.
|
||||
read_int "$file"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
paths) cmd_paths ;;
|
||||
status) cmd_status ;;
|
||||
set-threshold) shift; cmd_set_threshold "$@" ;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
usage: panama-battery [paths|status|set-threshold <50-100>]
|
||||
|
||||
paths JSON: which sysfs files hold the battery, mains and threshold
|
||||
status JSON: one reading of charge, state, power source and limit
|
||||
set-threshold cap charging at N percent (asks for a password)
|
||||
USAGE
|
||||
;;
|
||||
*) echo "panama-battery: unknown command: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -17,7 +17,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo 'usage: panama-remind add "<20m|1h30m|9:30>" "<text>" | list | pick | cancel <unit>' >&2
|
||||
echo 'usage: panama-remind add "<20m, 1h30m, or 9:30>" "<text>"' >&2
|
||||
echo ' panama-remind list, pick, or cancel <unit>' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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()
|
||||
}
|
||||
@@ -29,6 +29,7 @@ Singleton {
|
||||
"appearance": "appearance",
|
||||
"clock": "appearance",
|
||||
"vitals": "appearance",
|
||||
"battery": "power",
|
||||
"typography": "appearance",
|
||||
"themes": "appearance",
|
||||
"titlebar": "appearance",
|
||||
|
||||
Reference in New Issue
Block a user