Add power profiles to Power & Lock

The same Power Saver / Balanced / Performance choice GNOME's Power panel
offers, and the daemon behind it was already running here -- it simply
had no control anywhere in Panama. This machine has been sitting on
"performance" with nothing to say so.

Talks to the net.hadess.PowerProfiles interface rather than to a binary.
Fedora 44 implements it with tuned-ppd instead of power-profiles-daemon,
and powerprofilesctl is not installed at all, so anything shelling out
to that command would have found nothing while the service was right
there. Setting a profile needs no privileges: the daemon accepts a
property write from the active session user.

Not a stored preference. The daemon owns the profile, it survives Panama
restarts, and anything else on the system can change it, so a copy in
settings.json would drift -- the same reasoning as monitor brightness.

PerformanceDegraded is surfaced because it is what makes the setting a
lie: a thermally throttled machine reports "performance" while behaving
otherwise, and that is worth saying out loud.

The contract stubs busctl, because the real daemon is a system service
shared with everything else on the machine and a test that flipped the
daily driver to power-saver and then died would leave it there. It pins
the parsing in particular: busctl renders the Profiles property flat, so
profile names and driver names arrive in one stream, and a pattern loose
enough to match both reports the driver as an extra profile. On this
machine the driver is called "tuned", which reads exactly like a
plausible fourth profile -- verified the contract catches that, and
catches an unvalidated name reaching the system service.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 10:46:03 -04:00
parent 5562323eb8
commit 2e8292599a
4 changed files with 329 additions and 0 deletions
@@ -14,9 +14,43 @@ import qs.services
SettingsPage {
id: root
// Probing the power daemon is a D-Bus round trip, so it happens when the
// page opens rather than at shell startup.
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
title: "Power & Lock"
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
// 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.
SettingsCard {
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
title: "Power profile"
subtitle: PowerProfiles.degraded !== ""
? "Performance is limited right now: " + PowerProfiles.degraded
: (PowerProfiles.available
? "Applies to the whole system and persists across sessions."
: PowerProfiles.lastError)
Repeater {
model: PowerProfiles.profiles
SettingRow {
id: profileRow
required property var modelData
required property int index
label: PowerProfiles.label(profileRow.modelData)
detail: PowerProfiles.detail(profileRow.modelData)
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
divider: profileRow.index < PowerProfiles.profiles.length - 1
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
onActivated: PowerProfiles.set(profileRow.modelData)
}
}
}
SettingsCard {
title: "Idle behaviour"
subtitle: IdleLock.managed
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# System power profile, via the PowerProfiles D-Bus API.
#
# GNOME's Power panel offers Balanced / Performance / Power Saver; this is the
# same daemon behind it. On Fedora 44 the implementation is tuned-ppd rather
# than power-profiles-daemon, but it serves the same net.hadess.PowerProfiles
# interface, which is why this talks to the interface rather than to either
# binary -- powerprofilesctl is not even installed here.
#
# Setting a profile needs no privileges: the daemon accepts a property write
# from the active session user.
#
# Usage:
# panama-power-profile list -> {"profiles":[...],"active":"...","degraded":"..."}
# panama-power-profile set <name>
#
# PerformanceDegraded is reported because it is the one thing that makes the
# choice a lie: a thermally throttled laptop reports "performance" while
# behaving otherwise, and GNOME surfaces exactly this. It is an empty string
# when nothing is wrong.
set -uo pipefail
readonly BUS_NAME=net.hadess.PowerProfiles
readonly OBJECT=/net/hadess/PowerProfiles
emit_error() {
printf '{"profiles":[],"active":"","degraded":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v busctl >/dev/null 2>&1 || emit_error 'busctl is not available'
property() {
busctl get-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" "$1" 2>/dev/null
}
cmd_list() {
# A machine with no power-profiles daemon is a normal state -- plenty of
# desktops have none -- so it is reported rather than treated as a failure.
busctl status "$BUS_NAME" >/dev/null 2>&1 \
|| emit_error 'No power profile service is running. GNOME uses power-profiles-daemon; Fedora ships tuned-ppd.'
local active degraded profiles
active="$(property ActiveProfile | sed 's/^s //; s/"//g')"
degraded="$(property PerformanceDegraded | sed 's/^s //; s/"//g')"
# Profiles is an array of dicts, which busctl renders flat:
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
# so each profile is the string following its own "Profile" marker. Matching
# the marker matters: "Driver" values sit in the same stream, and on this
# machine the driver is called "tuned", which a looser pattern happily
# reports as a fourth profile that does not exist.
profiles="$(property Profiles \
| grep -oE '"Profile" s "[a-z-]+"' \
| sed 's/.*s "//; s/"$//' \
| awk '!seen[$0]++')"
[[ -n "$profiles" ]] || emit_error 'The power profile service reported no profiles.'
jq -cn \
--arg active "$active" \
--arg degraded "$degraded" \
--argjson profiles "$(printf '%s\n' "$profiles" | jq -Rn '[inputs | select(length > 0)]')" \
'{profiles: $profiles, active: $active, degraded: $degraded, error: ""}'
}
cmd_set() {
local profile="${1:-}"
# Constrained rather than passed through: this reaches a system service.
[[ "$profile" =~ ^[a-z-]+$ ]] || {
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
return 2
}
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
| head -2 >&2
return 0
}
case "${1:-list}" in
list) cmd_list ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-power-profile [list|set <profile>]\n' >&2; exit 2 ;;
esac
@@ -0,0 +1,107 @@
pragma Singleton
// The system power profile: power-saver, balanced, or performance.
//
// The same daemon GNOME's Power panel drives. On Fedora 44 the implementation
// is tuned-ppd rather than power-profiles-daemon, but it serves the same
// net.hadess.PowerProfiles interface -- so this talks to the interface, not to
// either binary. powerprofilesctl is not installed here at all.
//
// Not a stored preference. The profile lives in the daemon, survives Panama
// restarts, and can be changed by anything else on the system; keeping a copy
// in settings.json would mean restoring a value the daemon had moved past.
// Same reasoning as monitor brightness.
//
// Read on demand and after each change. The daemon does emit PropertiesChanged,
// but subscribing to it would mean holding a bus connection open for a value
// that changes only when someone chooses it.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-power-profile"
property var profiles: []
property string active: ""
property bool scanned: false
property bool busy: false
property string lastError: ""
// Non-empty when the machine cannot actually deliver the profile it is set
// to -- thermal throttling, or a laptop running on battery. Worth showing,
// because otherwise "performance" is a claim the hardware is not honouring.
property string degraded: ""
readonly property bool available: root.profiles.length > 0
// Presentation lives here rather than in the page so the Control Center and
// Settings cannot disagree about what a profile is called.
function label(profile: string): string {
switch (profile) {
case "power-saver": return "Power Saver";
case "balanced": return "Balanced";
case "performance": return "Performance";
default: return profile;
}
}
function detail(profile: string): string {
switch (profile) {
case "power-saver": return "Reduces performance to save energy and run quieter";
case "balanced": return "Standard behaviour, scaling up only when needed";
case "performance": return "Holds higher clocks, using more power and making more noise";
default: return "";
}
}
function refresh(): void {
if (!query.running)
query.running = true;
}
function set(profile: string): void {
if (root.busy || profile === root.active)
return;
root.busy = true;
apply.command = [root.helperPath, "set", profile];
apply.running = true;
}
Process {
id: query
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : [];
root.active = String(parsed.active ?? "");
root.degraded = String(parsed.degraded ?? "");
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.profiles = [];
root.lastError = "Could not read the power profile helper's output.";
console.warn("PowerProfiles: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
Process {
id: apply
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
// Re-read rather than assuming: the daemon may refuse, or may land on a
// different profile than the one asked for.
onExited: {
root.busy = false;
root.refresh();
}
}
}