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:
@@ -14,9 +14,43 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
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"
|
title: "Power & Lock"
|
||||||
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
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 {
|
SettingsCard {
|
||||||
title: "Idle behaviour"
|
title: "Idle behaviour"
|
||||||
subtitle: IdleLock.managed
|
subtitle: IdleLock.managed
|
||||||
|
|||||||
+85
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# panama-power-profile reads and sets the system power profile.
|
||||||
|
#
|
||||||
|
# Runs against a stubbed busctl. The real daemon is a SYSTEM service shared with
|
||||||
|
# everything else on the machine, and a test that flipped the daily driver into
|
||||||
|
# power-saver and crashed before restoring would leave it there.
|
||||||
|
#
|
||||||
|
# The parsing is the fragile part. busctl renders the Profiles property flat:
|
||||||
|
#
|
||||||
|
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
|
||||||
|
#
|
||||||
|
# so profile names and driver names sit in the same stream. A pattern loose
|
||||||
|
# enough to match both reports the driver as an extra profile -- and on this
|
||||||
|
# machine the driver is literally called "tuned", which reads exactly like a
|
||||||
|
# plausible fourth profile.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-power-profile"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'power profile contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stub_dir="$(mktemp -d /tmp/panama-power.XXXXXX)"
|
||||||
|
trap 'rm -rf "$stub_dir"' EXIT
|
||||||
|
|
||||||
|
cat >"$stub_dir/busctl" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Records set-property calls so the test can assert what was written.
|
||||||
|
case "${1:-}" in
|
||||||
|
status)
|
||||||
|
[[ "${PANAMA_POWER_FAKE:-up}" == "down" ]] && exit 1
|
||||||
|
exit 0 ;;
|
||||||
|
get-property)
|
||||||
|
case "${5:-}" in
|
||||||
|
ActiveProfile) printf 's "performance"\n' ;;
|
||||||
|
PerformanceDegraded) printf 's "%s"\n' "${PANAMA_POWER_DEGRADED:-}" ;;
|
||||||
|
Profiles)
|
||||||
|
if [[ "${PANAMA_POWER_FAKE:-up}" == "empty" ]]; then
|
||||||
|
printf 'v aa{sv} 0\n'
|
||||||
|
else
|
||||||
|
printf 'v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" s "balanced" "Driver" s "tuned" 2 "Profile" s "performance" "Driver" s "tuned"\n'
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
exit 0 ;;
|
||||||
|
set-property)
|
||||||
|
printf '%s\n' "${!#}" >>"$PANAMA_POWER_SET_LOG"
|
||||||
|
exit 0 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
STUB
|
||||||
|
chmod +x "$stub_dir/busctl"
|
||||||
|
|
||||||
|
export PANAMA_POWER_SET_LOG="$stub_dir/sets.log"
|
||||||
|
: >"$PANAMA_POWER_SET_LOG"
|
||||||
|
|
||||||
|
run() { PATH="$stub_dir:$PATH" "$helper" "$@"; }
|
||||||
|
|
||||||
|
# ── Parsing ──────────────────────────────────────────────────────────────────
|
||||||
|
out="$(run list)"
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
|
||||||
|
|
||||||
|
[[ "$(jq -r '.profiles | length' <<<"$out")" == "3" ]] \
|
||||||
|
|| fail "expected exactly three profiles; a fourth usually means the Driver value was parsed as one: $out"
|
||||||
|
|
||||||
|
jq -e '.profiles == ["power-saver", "balanced", "performance"]' >/dev/null <<<"$out" \
|
||||||
|
|| fail "profiles were parsed wrongly or reordered: $out"
|
||||||
|
|
||||||
|
jq -e '.profiles | index("tuned") == null' >/dev/null <<<"$out" \
|
||||||
|
|| fail 'the driver name "tuned" was reported as a profile'
|
||||||
|
|
||||||
|
[[ "$(jq -r '.active' <<<"$out")" == "performance" ]] \
|
||||||
|
|| fail "the active profile was not read: $out"
|
||||||
|
|
||||||
|
# ── Degradation is surfaced, since it makes the active profile a lie ─────────
|
||||||
|
out="$(PANAMA_POWER_DEGRADED="lap-detected" run list)"
|
||||||
|
[[ "$(jq -r '.degraded' <<<"$out")" == "lap-detected" ]] \
|
||||||
|
|| fail "a degraded performance state was not reported: $out"
|
||||||
|
|
||||||
|
# ── Setting ──────────────────────────────────────────────────────────────────
|
||||||
|
run set balanced
|
||||||
|
[[ "$(tail -1 "$PANAMA_POWER_SET_LOG")" == "balanced" ]] \
|
||||||
|
|| fail "set did not write the requested profile: $(cat "$PANAMA_POWER_SET_LOG")"
|
||||||
|
|
||||||
|
before="$(wc -l <"$PANAMA_POWER_SET_LOG")"
|
||||||
|
run set 'evil; rm -rf /' 2>/dev/null
|
||||||
|
[[ "$(wc -l <"$PANAMA_POWER_SET_LOG")" == "$before" ]] \
|
||||||
|
|| fail 'a profile name with shell metacharacters reached the system service'
|
||||||
|
|
||||||
|
# ── No daemon, and a daemon with nothing to offer, are both states ───────────
|
||||||
|
out="$(PANAMA_POWER_FAKE=down run list)"
|
||||||
|
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|
||||||
|
|| fail "a missing power daemon must be reported with a reason: $out"
|
||||||
|
|
||||||
|
out="$(PANAMA_POWER_FAKE=empty run list)"
|
||||||
|
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|
||||||
|
|| fail "a daemon offering no profiles must be reported, not shown as an empty card: $out"
|
||||||
|
|
||||||
|
printf 'power profile contract: PASS\n'
|
||||||
Reference in New Issue
Block a user