Table of Contents
PkgPower
CPU package power-limit manager originally written for my personal laptop (ThinkPad P16 Gen 5, Core Ultra 7 365, Fedora Linux + KDE Plasma 6). Created 2026-10-06.
A small bash script + systemd unit + udev rule + Plasma 6 panel applet that caps CPU package power (PL1/PL2) via the kernel's native RAPL interface. Replaces the unmaintained undervolt Python utility: only its power-limit writes (-p1/-p2, MSR 0x610) ever had effect — voltage offsets are locked on 10th-gen+ Intel CPUs and silently no-op.
How it works
- Interface: RAPL via powercap sysfs.
/sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw= PL1 (long/sustained),constraint_1_power_limit_uw= PL2 (short/burst). Units are microwatts. - Stock firmware limits on this machine: 55 W / 55 W.
- Current mode persists in
/var/lib/pkgpower/modeand is re-applied by a boot unit, a udev rule (AC plug/unplug), and the panel applet. - One-writer rule: all limit writes go through
/usr/local/bin/pkgpower; the applet only selects a mode. Never write raw wattages from two places (the TLP/powerdevil double-writer problem).
| Mode | PL1/PL2 (W) | Use |
|---|---|---|
perf | 55 / 55 | stock, on wall power |
auto (default) | 25/35 on AC, 15/15 on battery | daily |
eco | 15 / 15 | quiet / battery |
max | 10 / 10 | maximum endurance |
Installation
1. The script: /usr/local/bin/pkgpower
#!/usr/bin/env bash # pkgpower — set CPU package power limits via RAPL (native, no MSR hacking) # pkgpower re-apply current mode # pkgpower set MODE MODE = perf | auto | eco | max set -euo pipefail PC=/sys/class/powercap/intel-rapl:0 STATE=/var/lib/pkgpower/mode on_ac() { grep -q 1 /sys/class/power_supply/*/online 2>/dev/null; } # only AC adapters have "online" set_limits() { echo $(( $1 * 1000000 )) > "$PC/constraint_0_power_limit_uw" # long/sustained echo $(( $2 * 1000000 )) > "$PC/constraint_1_power_limit_uw" # short/burst } apply() { local m; m=$(cat "$STATE" 2>/dev/null || echo auto) case "$m" in perf) set_limits 55 55 ;; eco) set_limits 15 15 ;; max) set_limits 10 10 ;; auto) if on_ac; then set_limits 25 35; else set_limits 15 15; fi ;; *) echo "invalid mode in $STATE: $m" >&2; exit 1 ;; esac printf 'mode=%s readback: PL1=%dW PL2=%dW\n' "$m" \ $(( $(<"$PC/constraint_0_power_limit_uw") / 1000000 )) \ $(( $(<"$PC/constraint_1_power_limit_uw") / 1000000 )) } case "${1:-apply}" in set) case "${2:-}" in perf|auto|eco|max) printf '%s\n' "$2" > "$STATE"; apply ;; *) echo "usage: pkgpower set perf|auto|eco|max" >&2; exit 1 ;; esac ;; *) apply ;; esac
Either save it to a scratch file and install from that directory:
sudo install -Dm755 -o root -g root pkgpower /usr/local/bin/pkgpower
or paste directly into sudoedit /usr/local/bin/pkgpower and then:
sudo chown root:root /usr/local/bin/pkgpower sudo chmod 755 /usr/local/bin/pkgpower
Finish with the state directory and a first run:
sudo mkdir -p /var/lib/pkgpower echo auto | sudo tee /var/lib/pkgpower/mode bash -n /usr/local/bin/pkgpower # silent = clean paste ls -l /usr/local/bin/pkgpower # want: -rwxr-xr-x 1 root root sudo /usr/local/bin/pkgpower # applies mode + prints readback
RAPL-lock test: if the readback reports 55/55 after setting a lower mode, the firmware has locked package RAPL and only firmware presets remain (KDE power profiles). Not the case on panther — writes stick.
2. Privileges (sudoers)
Lets the panel applet (user session, no TTY) run the script without a password prompt. Create with sudo visudo -f /etc/sudoers.d/pkgpower (replace john with the actual username):
john ALL=(root) NOPASSWD: /usr/local/bin/pkgpower
Safe because the script is root-owned 755 and only accepts whitelisted mode tokens. The file must stay root-owned and not user-writable — the NOPASSWD rule trusts it; edit only via sudoedit.
3. Boot unit + udev rule
/etc/systemd/system/pkgpower.service:
[Unit] Description=Apply pkgpower mode (CPU package power limits) [Service] Type=oneshot ExecStart=/usr/local/bin/pkgpower [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now pkgpower
/etc/udev/rules.d/99-pkgpower.rules:
ACTION=="change", SUBSYSTEM=="power_supply", RUN+="/usr/local/bin/pkgpower"
sudo udevadm control --reload-rules
Keep the boot unit even with the udev rule: udev coldplug fires add events at boot, not change.
Plasma 6 panel applet
File layout:
~/.local/share/plasma/plasmoids/org.panther.pkgpower/
├── metadata.json
└── contents/
└── ui/
└── main.qml
metadata.json (Plasma 6 format — the old metadata.desktop is gone):
{
"KPackageStructure": "Plasma/Applet",
"X-Plasma-API-Minimum-Version": "6.0",
"KPlugin": {
"Id": "org.panther.pkgpower",
"Name": "Power Modes",
"Description": "Switch CPU package power limits",
"Icon": "battery-good",
"Version": "1.0",
"License": "MIT",
"Authors": [{ "Name": "John" }]
}
}
contents/ui/main.qml — the whole applet (verified on panther, Fedora Plasma 6):
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.plasmoid
import org.kde.plasma.plasma5support as Plasma5Support
import org.kde.plasma.components 3.0 as PC3
import org.kde.kirigami as Kirigami
PlasmoidItem {
id: root
readonly property var modes: [
{ id: "perf", label: "Stock · 55/55 W", icon: "battery-full" },
{ id: "auto", label: "Auto · 25/35 AC, 15 bat.", icon: "battery-good" },
{ id: "eco", label: "Eco · 15 W", icon: "battery-low" },
{ id: "max", label: "Max Eco · 10 W", icon: "battery-caution" }
]
property int current: 1
// Plasma 6: DataSource lives in org.kde.plasma.plasma5support now
Plasma5Support.DataSource { id: runner; engine: "executable" }
function exec(cmd) { runner.disconnectSource(cmd); runner.connectSource(cmd) }
// poll current mode so the icon stays honest
Plasma5Support.DataSource {
id: status
engine: "executable"
interval: 3000
connectedSources: ["cat /var/lib/pkgpower/mode 2>/dev/null || echo auto"]
onNewData: (source, data) => {
const i = root.modes.findIndex(m => m.id === String(data.stdout ?? "").trim())
if (i >= 0) root.current = i
}
}
compactRepresentation: Component {
Item {
Layout.minimumWidth: ico.implicitWidth
Layout.minimumHeight: ico.implicitHeight
Kirigami.Icon { // Plasma 6: PlasmaCore.IconItem was removed; Kirigami.Icon replaces it
id: ico; anchors.fill: parent
source: root.modes[root.current].icon
active: area.containsMouse
}
MouseArea { id: area; anchors.fill: parent; hoverEnabled: true
onClicked: root.expanded = !root.expanded }
}
}
fullRepresentation: Component {
ColumnLayout {
Layout.minimumWidth: Kirigami.Units.gridUnit * 16
spacing: Kirigami.Units.smallSpacing
Repeater {
model: root.modes
delegate: PC3.Button {
required property var modelData
required property int index
Layout.fillWidth: true
icon.name: modelData.icon
text: modelData.label
checkable: true // without this, `checked` has no visual effect
checked: index === root.current
onClicked: {
root.exec("sudo /usr/local/bin/pkgpower set " + modelData.id)
root.expanded = false
}
}
}
}
}
}
Test, then add to the panel:
sudo dnf install plasma-sdk plasmoidviewer -a ~/.local/share/plasma/plasmoids/org.panther.pkgpower # happy → right-click panel → Add Widgets → "Power Modes" # after edits to an installed copy: systemctl restart --user plasma-plasmashell.service # versioned reinstall: kpackagetool6 --type Plasma/Applet --upgrade ~/.local/share/plasma/plasmoids/org.panther.pkgpower
Misc
plasmoidviewerconsole noise (DeclarativeDropArea overrides, isScreenUiReady errors, nullptr disconnect) comes from KDE's stock desktopcontainment inside the viewer, not from this applet. Judge only file:-home-… lines.- Clicking mode buttons in the viewer is live fire (NOPASSWD sudo); the icon flips within ~3 s (poll interval).
Verification
sudo dnf install kernel-tools stress-ng sudo turbostat --Summary --interval 5 # terminal 1 — watch PkgWatt stress-ng --cpu $(nproc) --timeout 60s # terminal 2
PkgWatt should pin at the cap. Quick checks without load: cat /sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw (µW), or legacy undervolt -r readback.
Troubleshooting
- Readback reverts to 55/55 — firmware locked package RAPL; only firmware presets remain.
- Cap mysteriously reverts later —
thermaldor a firmware power-profile switch rewrote it (switching KDE power profiles is the usual trigger); re-runsudo pkgpower. - Plasma 6 API drift (both hit during development):
PlasmaCore.IconItemwas removed → useKirigami.Icon.DataSourceis no longer inorg.kde.plasma.core→ importorg.kde.plasma.plasma5supportand usePlasma5Support.DataSource.SvgItem/FrameSvgItemnow live inorg.kde.ksvg. A “X is not a type” QML error almost always means Plasma 5-era code. - Generic “document” icon — the icon name does not resolve in the active theme. The lookup chain is Breeze → hicolor only, never into other installed themes (e.g. Adwaita, which is where
power-profile-*-symboliclives on this box). Verify names withfind /usr/share/icons/breeze -name 'NAME*' ''. Beware: multiple ''-namepredicates in onefindare ANDed (match nothing) — filter with grep instead. - Stale mode in applet — 3 s poll interval; wait it out.
- Buttons do nothing in viewer — confirm the
executabledataengine exists (plasmaengineexplorer, from plasma-sdk). - udev rule silently misfires — check the journal for SELinux denials.
- Changing wattages — edit the
casetable in the script; everything else follows. - Lighter-touch alternatives:
/sys/devices/system/cpu/intel_pstate/max_perf_pct(frequency cap, %) and/sys/devices/system/cpu/intel_pstate/no_turbo(turbo switch). A low PL2 already tames bursty surging without killing turbo entirely.
