Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ install: install-dirs

install-ppd:
$(call install_python_script,tuned-ppd.py,$(DESTDIR)$(SBINDIR)/tuned-ppd)
$(call install_python_script,tuned/ppd/powerprofilesctl,$(DESTDIR)$(BINDIR)/powerprofilesctl)
install -Dpm 0644 tuned/ppd/tuned-ppd.service $(DESTDIR)$(UNITDIR)/tuned-ppd.service
install -Dpm 0644 tuned/ppd/ppd.conf $(DESTDIR)$(SYSCONFDIR)/tuned/ppd.conf
$(foreach bus, $(PPD_BUS_NAMES), \
Expand Down
25 changes: 25 additions & 0 deletions profiles/cachyos-balanced-battery/tuned.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#
# tuned configuration
#

[main]
summary=CachyOS balanced battery profile
include=cachyos-common

[cpu]
governor=schedutil|powersave
energy_perf_bias=powersave|normal
energy_performance_preference=balance_power

[acpi]
platform_profile=balanced|low-power

[video]
radeon_powersave=dpm-balanced, auto
panel_power_savings=2

[amd_x3d]
# On dual-CCD 3D V-Cache CPUs prefer the high-frequency CCD for general
# desktop use where clock speed matters more than raw cache capacity.
# No-op on other hardware.
mode=frequency
354 changes: 354 additions & 0 deletions tuned/ppd/powerprofilesctl
Original file line number Diff line number Diff line change
@@ -0,0 +1,354 @@
#!/usr/bin/python3

import argparse
import os
import signal
import subprocess
import sys
from gi.repository import Gio, GLib

PP_NAME = "org.freedesktop.UPower.PowerProfiles"
PP_PATH = "/org/freedesktop/UPower/PowerProfiles"
PP_IFACE = "org.freedesktop.UPower.PowerProfiles"
PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"


def get_proxy():
bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
return Gio.DBusProxy.new_sync(
bus, Gio.DBusProxyFlags.NONE, None, PP_NAME, PP_PATH, PROPERTIES_IFACE, None
)


def command(func):
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except GLib.Error as error:
sys.stderr.write(
f"Failed to communicate with power-profiles-daemon: {error}\n"
)
sys.exit(1)
except ValueError as error:
sys.stderr.write(f"Error: {error}\n")
sys.exit(1)

return wrapper


@command
def _version(_args):
client_version = "0.23"
try:
proxy = get_proxy()
daemon_ver = proxy.Get("(ss)", PP_IFACE, "Version")
except GLib.Error:
daemon_ver = "unknown"
print(f"client: {client_version}\ndaemon: {daemon_ver}")


@command
def _set_profile(args):
proxy = get_proxy()
proxy.Set(
"(ssv)", PP_IFACE, "ActiveProfile", GLib.Variant.new_string(args.profile[0])
)


@command
def _get(_args):
proxy = get_proxy()
profile = proxy.Get("(ss)", PP_IFACE, "ActiveProfile")
print(profile)


@command
def _set_battery_aware(args):
enable = args.enable
disable = args.disable
if enable is False and disable is True:
raise ValueError("enable or disable is required")
if enable is True and disable is False:
raise ValueError("can't set both enable and disable")
enable = enable if enable is not None else not disable
Comment on lines +69 to +73
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enable is always a boolean here because the argparse flag uses action="store_true" (default False), so enable is not None is always true and the else not disable branch is dead code. If the intent is to require exactly one of --enable/--disable, consider using add_mutually_exclusive_group(required=True) with a single destination (e.g. dest="enable" with store_true/store_false) so the logic is simpler and validation happens in argparse.

Suggested change
if enable is False and disable is True:
raise ValueError("enable or disable is required")
if enable is True and disable is False:
raise ValueError("can't set both enable and disable")
enable = enable if enable is not None else not disable
# Require exactly one of --enable or --disable
if enable == disable:
raise ValueError("exactly one of --enable or --disable is required")

Copilot uses AI. Check for mistakes.
proxy = get_proxy()
proxy.Set("(ssv)", PP_IFACE, "BatteryAware", GLib.Variant.new_boolean(enable))


def get_profiles_property(prop):
proxy = get_proxy()
return proxy.Get("(ss)", PP_IFACE, prop)


def get_profile_choices():
try:
return [profile["Profile"] for profile in get_profiles_property("Profiles")]
except GLib.Error:
return []


@command
def _list(_args):
profiles = get_profiles_property("Profiles")
reason = get_proxy().Get("(ss)", PP_IFACE, "PerformanceDegraded")
degraded = reason != ""
active = get_proxy().Get("(ss)", PP_IFACE, "ActiveProfile")

index = 0
for profile in reversed(profiles):
if index > 0:
print("")
marker = "*" if profile["Profile"] == active else " "
print(f'{marker} {profile["Profile"]}:')
if "Driver" in profile:
print(f' Driver:\t{profile["Driver"]}')
if profile["Profile"] == "performance":
print(" Degraded: ", f"yes ({reason})" if degraded else "no")
index += 1


@command
def _list_holds(_args):
holds = get_profiles_property("ActiveProfileHolds")

index = 0
for hold in holds:
if index > 0:
print("")
print("Hold:")
print(" Profile: ", hold["Profile"])
print(" Application ID: ", hold["ApplicationId"])
print(" Reason: ", hold["Reason"])
index += 1


@command
def _launch(args):
reason = args.reason
profile = args.profile
appid = args.appid
if not args.arguments:
raise ValueError("No command to launch")
if not args.appid:
appid = args.arguments[0]
if not profile:
profile = "performance"
if not reason:
reason = f"Running {args.appid}"
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When defaulting reason, this uses args.appid, but appid may have just been inferred from args.arguments[0]. This can produce Running None even though appid was computed. Use the resolved appid variable when building the default reason.

Suggested change
reason = f"Running {args.appid}"
reason = f"Running {appid}"

Copilot uses AI. Check for mistakes.
ret = 0
bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
proxy = Gio.DBusProxy.new_sync(
bus, Gio.DBusProxyFlags.NONE, None, PP_NAME, PP_PATH, PP_IFACE, None
)
cookie = proxy.HoldProfile("(sss)", profile, reason, appid)

with subprocess.Popen(args.arguments) as launched_app:
def receive_signal(signum, _stack):
launched_app.send_signal(signum)

redirected_signals = [
signal.SIGTERM,
signal.SIGINT,
signal.SIGABRT,
]

for sig in redirected_signals:
signal.signal(sig, receive_signal)

try:
launched_app.wait()
ret = launched_app.returncode
except KeyboardInterrupt:
ret = launched_app.returncode

for sig in redirected_signals:
signal.signal(sig, signal.SIG_DFL)

proxy.ReleaseProfile("(u)", cookie)

Comment on lines +145 to +168
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HoldProfile(...) is acquired before subprocess.Popen(...). If Popen raises (e.g., command not found) or another exception occurs before ReleaseProfile, the profile hold will leak. Wrap the launched process section in a try/finally that always calls ReleaseProfile (and consider handling OSError/FileNotFoundError to return a clean exit code).

Suggested change
with subprocess.Popen(args.arguments) as launched_app:
def receive_signal(signum, _stack):
launched_app.send_signal(signum)
redirected_signals = [
signal.SIGTERM,
signal.SIGINT,
signal.SIGABRT,
]
for sig in redirected_signals:
signal.signal(sig, receive_signal)
try:
launched_app.wait()
ret = launched_app.returncode
except KeyboardInterrupt:
ret = launched_app.returncode
for sig in redirected_signals:
signal.signal(sig, signal.SIG_DFL)
proxy.ReleaseProfile("(u)", cookie)
try:
with subprocess.Popen(args.arguments) as launched_app:
def receive_signal(signum, _stack):
launched_app.send_signal(signum)
redirected_signals = [
signal.SIGTERM,
signal.SIGINT,
signal.SIGABRT,
]
for sig in redirected_signals:
signal.signal(sig, receive_signal)
try:
launched_app.wait()
ret = launched_app.returncode
except KeyboardInterrupt:
ret = launched_app.returncode
finally:
for sig in redirected_signals:
signal.signal(sig, signal.SIG_DFL)
except (OSError, FileNotFoundError):
# Command could not be executed (e.g. not found); use a conventional
# "command not found" exit code instead of leaking the profile hold.
ret = 127
finally:
proxy.ReleaseProfile("(u)", cookie)

Copilot uses AI. Check for mistakes.
if ret < 0:
os.kill(os.getpid(), -ret)
return

sys.exit(ret)


@command
def _query_battery_aware(_args):
result = get_profiles_property("BatteryAware")
print(f"Dynamic changes from charger and battery events: {result}")


@command
def _list_actions(_args):
actions = get_profiles_property("ActionsInfo")
for action in actions:
for key in action:
print(f"{key}: {action[key]}")
if action != actions[-1]:
print("")


@command
def _configure_action(args):
action = args.action[0]
enable = args.enable
disable = args.disable
if enable is False and disable is True:
raise argparse.ArgumentError(
argument="action", message="enable or disable is required"
)
if enable is True and disable is False:
raise argparse.ArgumentError(
argument="action", message="can't set both enable and disable"
)
Comment on lines +198 to +204
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_configure_action raises argparse.ArgumentError with argument="action" (a string). ArgumentError expects an argparse Action object, so this will raise a TypeError and bypass the @command error handling (resulting in a traceback). Prefer ValueError (caught by the decorator) or call parser.error(...)/use a mutually-exclusive group with required=True so argparse handles validation.

Suggested change
raise argparse.ArgumentError(
argument="action", message="enable or disable is required"
)
if enable is True and disable is False:
raise argparse.ArgumentError(
argument="action", message="can't set both enable and disable"
)
raise ValueError("enable or disable is required")
if enable is True and disable is False:
raise ValueError("can't set both enable and disable")

Copilot uses AI. Check for mistakes.
print(f"action: {action}, enable: {enable}")
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This print(...) in configure-action looks like leftover debug output and will add unexpected stdout on success (unlike the other subcommands). Consider removing it or only emitting output under an explicit verbosity/debug flag.

Suggested change
print(f"action: {action}, enable: {enable}")

Copilot uses AI. Check for mistakes.
bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
proxy = Gio.DBusProxy.new_sync(
bus, Gio.DBusProxyFlags.NONE, None, PP_NAME, PP_PATH, PP_IFACE, None
)
proxy.SetActionEnabled("(sb)", action, enable)


def get_parser():
parser = argparse.ArgumentParser(
epilog="Use \u201cpowerprofilesctl COMMAND --help\u201d to get detailed help for individual commands",
)
subparsers = parser.add_subparsers(help="Individual command help", dest="command")
parser_list = subparsers.add_parser("list", help="List available power profiles")
parser_list.set_defaults(func=_list)
parser_list_holds = subparsers.add_parser(
"list-holds", help="List current power profile holds"
)
parser_list_holds.set_defaults(func=_list_holds)
parser_list_actions = subparsers.add_parser(
"list-actions", help="List available power profile actions"
)
parser_list_actions.set_defaults(func=_list_actions)
parser_get = subparsers.add_parser(
"get", help="Print the currently active power profile"
)
parser_get.set_defaults(func=_get)
parser_set = subparsers.add_parser(
"set", help="Set the currently active power profile"
)
parser_set.add_argument(
"profile",
nargs=1,
help="Profile to use for set command",
choices=get_profile_choices(),
)
Comment on lines +235 to +240
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using choices=get_profile_choices() triggers a D-Bus call while building the parser. If the service isn't reachable at startup, choices becomes empty and powerprofilesctl set … fails with an argparse “invalid choice” error instead of the intended D-Bus communication error. Consider removing choices= and validating the profile inside _set_profile (or lazily resolving choices only for shell completion generation).

Copilot uses AI. Check for mistakes.
parser_set.set_defaults(func=_set_profile)
parser_set_action = subparsers.add_parser(
"configure-action", help="Configure the action to be taken for the profile"
)
parser_set_action.add_argument(
"action",
nargs=1,
help="action to change for configure-action",
)
parser_set_action.add_argument(
"--enable",
action="store_true",
help="enable action",
)
parser_set_action.add_argument(
"--disable",
action="store_false",
help="disable action",
)
parser_set_action.set_defaults(func=_configure_action)
parser_set_battery_aware = subparsers.add_parser(
"configure-battery-aware",
help="Turn on or off dynamic changes from battery level or power adapter",
)
parser_set_battery_aware.add_argument(
"--enable",
action="store_true",
help="enable battery aware",
)
parser_set_battery_aware.add_argument(
"--disable",
action="store_false",
help="disable battery aware",
)
parser_set_battery_aware.set_defaults(func=_set_battery_aware)
parser_query_battery_aware = subparsers.add_parser(
"query-battery-aware",
help="Query if dynamic changes from battery level or power adapter are enabled",
)
parser_query_battery_aware.set_defaults(func=_query_battery_aware)
parser_launch = subparsers.add_parser(
"launch",
help="Launch a command while holding a power profile",
description="Launch the command while holding a power profile, "
"either performance, or power-saver. By default, the profile hold "
"is for the performance profile, but it might not be available on "
"all systems. See the list command for a list of available profiles.",
)
parser_launch.add_argument(
"arguments",
nargs="*",
help="Command to launch",
)
parser_launch.add_argument(
"--profile", "-p", required=False, help="Profile to use for launch command"
)
parser_launch.add_argument(
"--reason", "-r", required=False, help="Reason to use for launch command"
)
parser_launch.add_argument(
"--appid", "-i", required=False, help="AppId to use for launch command"
)
parser_launch.set_defaults(func=_launch)
parser_version = subparsers.add_parser(
"version", help="Print version information and exit"
)
parser_version.set_defaults(func=_version)

if not os.getenv("PPD_COMPLETIONS_GENERATION"):
return parser

try:
import shtab

shtab.add_argument_to(parser, ["--print-completion"])
except ImportError:
pass

return parser


def check_unknown_args(args, unknown_args, cmd):
if cmd != "launch":
return False

for idx, unknown_arg in enumerate(unknown_args):
arg = args[idx]
if arg == cmd:
return True
if unknown_arg == arg:
return False

return True


def main():
parser = get_parser()
args, unknown = parser.parse_known_args()
if not args.command:
args.func = _list

if check_unknown_args(sys.argv[1:], unknown, args.command):
args.arguments += unknown
unknown = []

if unknown:
msg = argparse._("unrecognized arguments: %s")
parser.error(msg % " ".join(unknown))

args.func(args)


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion tuned/ppd/ppd.conf
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ performance=cachyos-gaming

[battery]
# PPD = TuneD
balanced=cachyos-powersave
balanced=cachyos-balanced-battery