From 79547f093fc85fc1d60dbec95c3acf9cf8b8de47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavol=20=C5=BD=C3=A1=C4=8Dik?= Date: Mon, 2 Dec 2024 18:51:04 +0100 Subject: [PATCH 1/7] tuned-ppd: Fix UPower signal handler initialization Make sure that we only initialize the UPower signal handler only if such a handler is not already running. This also reorders the order of operations in the initialization procedure, skipping redundant initial TuneD profile changes. --- tuned/ppd/controller.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tuned/ppd/controller.py b/tuned/ppd/controller.py index 4376c1e9..060e514e 100644 --- a/tuned/ppd/controller.py +++ b/tuned/ppd/controller.py @@ -103,21 +103,29 @@ def __init__(self, bus, tuned_interface): self._tuned_interface = tuned_interface self._cmd = commands() self._terminate = threading.Event() + self._battery_handler = None + self._on_battery = False self.initialize() - def upower_changed(self, interface, changed, invalidated): - properties = dbus.Interface(self.proxy, dbus.PROPERTIES_IFACE) - self._on_battery = bool(properties.Get(UPOWER_DBUS_INTERFACE, "OnBattery")) - log.info("Battery status: " + ("DC (battery)" if self._on_battery else "AC (charging)")) + def _upower_changed(self, interface, changed, invalidated): + self._on_battery = bool(self._upower_properties.Get(UPOWER_DBUS_INTERFACE, "OnBattery")) + log.info("Battery status changed: " + ("DC (battery)" if self._on_battery else "AC (charging)")) tuned_profile = self._config.ppd_to_tuned_battery[self._base_profile] if self._on_battery else self._config.ppd_to_tuned[self._base_profile] - self._tuned_interface.switch_profile(tuned_profile) + self.switch_profile(tuned_profile) - def setup_battery_signaling(self): + def _setup_battery_signaling(self): + self._on_battery = False + if not self._config.battery_detection: + if self._battery_handler is not None: + self._battery_handler.remove() + self._battery_handler = None + return try: - bus = dbus.SystemBus() - self.proxy = bus.get_object(UPOWER_DBUS_NAME, UPOWER_DBUS_PATH) - self.proxy.connect_to_signal("PropertiesChanged", self.upower_changed) - self.upower_changed(None, None, None) + if self._battery_handler is None: + upower_proxy = self._bus.get_object(UPOWER_DBUS_NAME, UPOWER_DBUS_PATH) + self._upower_properties = dbus.Interface(upower_proxy, dbus.PROPERTIES_IFACE) + self._battery_handler = upower_proxy.connect_to_signal("PropertiesChanged", self._upower_changed) + self._on_battery = bool(self._upower_properties.Get(UPOWER_DBUS_INTERFACE, "OnBattery")) except dbus.exceptions.DBusException as error: log.debug(error) @@ -136,12 +144,10 @@ def initialize(self): self._profile_holds = ProfileHoldManager(self) self._performance_degraded = PerformanceDegraded.NONE self._config = PPDConfig(PPD_CONFIG_FILE) + self._setup_battery_signaling() active_profile = self.active_profile() self._base_profile = active_profile if active_profile != UNKNOWN_PROFILE else self._config.default_profile - self._on_battery = False self.switch_profile(self._base_profile) - if self._config.battery_detection: - self.setup_battery_signaling() def run(self): exports.start() From d5bd033422d35849633691f6aa46be03134aa58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavol=20=C5=BD=C3=A1=C4=8Dik?= Date: Thu, 8 Aug 2024 13:33:41 +0200 Subject: [PATCH 2/7] tuned-ppd: Keep track of active and base profile Instead of querying TuneD each time we want to determine the active TuneD profile, remember its value. Also watch for signals from TuneD, updating the value when the TuneD profile changes (resolves #689). The daemon now also keeps track of the "base" PPD profile, which is restored when all profile holds are released or when tuned-ppd is restarted. For the latter purpose, this profile is also saved in a file. Direct access via two dictionaries (one for AC, one for DC) was clumsy, this commit replaces it with a new class - ProfileMap. --- Makefile | 1 + tuned.spec | 1 + tuned/consts.py | 1 + tuned/ppd/config.py | 76 ++++++++++++++++++++++++----------------- tuned/ppd/controller.py | 74 +++++++++++++++++++++++++++------------ 5 files changed, 101 insertions(+), 52 deletions(-) diff --git a/Makefile b/Makefile index 88bf693e..53858e25 100644 --- a/Makefile +++ b/Makefile @@ -178,6 +178,7 @@ install: install-dirs echo -n > $(DESTDIR)$(TUNED_CFG_DIR)/active_profile echo -n > $(DESTDIR)$(TUNED_CFG_DIR)/profile_mode echo -n > $(DESTDIR)$(TUNED_CFG_DIR)/post_loaded_profile + echo -n > $(DESTDIR)$(TUNED_CFG_DIR)/ppd_base_profile install -Dpm 0644 bootcmdline $(DESTDIR)$(TUNED_CFG_DIR)/bootcmdline install -Dpm 0644 modules.conf $(DESTDIR)$(SYSCONFDIR)/modprobe.d/tuned.conf diff --git a/tuned.spec b/tuned.spec index a9ccef65..71ea4c5d 100644 --- a/tuned.spec +++ b/tuned.spec @@ -504,6 +504,7 @@ fi %config(noreplace) %verify(not size mtime md5) %{_sysconfdir}/tuned/active_profile %config(noreplace) %verify(not size mtime md5) %{_sysconfdir}/tuned/profile_mode %config(noreplace) %verify(not size mtime md5) %{_sysconfdir}/tuned/post_loaded_profile +%config(noreplace) %verify(not size mtime md5) %{_sysconfdir}/tuned/ppd_base_profile %config(noreplace) %{_sysconfdir}/tuned/tuned-main.conf %config(noreplace) %verify(not size mtime md5) %{_sysconfdir}/tuned/bootcmdline %verify(not size mtime md5) %{_sysconfdir}/modprobe.d/tuned.conf diff --git a/tuned/consts.py b/tuned/consts.py index 4606aee1..c8f0332f 100644 --- a/tuned/consts.py +++ b/tuned/consts.py @@ -102,6 +102,7 @@ PPD_DBUS_OBJECT = "/net/hadess/PowerProfiles" PPD_DBUS_INTERFACE = PPD_DBUS_BUS PPD_CONFIG_FILE = "/etc/tuned/ppd.conf" +PPD_BASE_PROFILE_FILE = "/etc/tuned/ppd_base_profile" # After adding new option to tuned-main.conf add here its name with CFG_ prefix # and eventually default value with CFG_DEF_ prefix (default is None) diff --git a/tuned/ppd/config.py b/tuned/ppd/config.py index 1e33016e..510b7928 100644 --- a/tuned/ppd/config.py +++ b/tuned/ppd/config.py @@ -12,8 +12,23 @@ BATTERY_DETECTION_OPTION = "battery_detection" +class ProfileMap: + def __init__(self, ac_map, dc_map): + self._ac_map = ac_map + self._dc_map = dc_map + + def get(self, profile, on_battery): + profile_map = self._dc_map if on_battery else self._ac_map + return profile_map[profile] + + def keys(self, on_battery): + profile_map = self._dc_map if on_battery else self._ac_map + return profile_map.keys() + + class PPDConfig: - def __init__(self, config_file): + def __init__(self, config_file, tuned_interface): + self._tuned_interface = tuned_interface self.load_from_file(config_file) @property @@ -32,10 +47,6 @@ def ppd_to_tuned(self): def tuned_to_ppd(self): return self._tuned_to_ppd - @property - def ppd_to_tuned_battery(self): - return self._ppd_to_tuned_battery - def load_from_file(self, config_file): cfg = ConfigParser() @@ -48,38 +59,41 @@ def load_from_file(self, config_file): if PROFILES_SECTION not in cfg: raise TunedException("Missing profiles section in the configuration file '%s'" % config_file) - self._ppd_to_tuned = dict(cfg[PROFILES_SECTION]) - - if not all(isinstance(mapped_profile, str) for mapped_profile in self._ppd_to_tuned.values()): - raise TunedException("Invalid profile mapping in the configuration file '%s'" % config_file) - - if len(set(self._ppd_to_tuned.values())) != len(self._ppd_to_tuned): - raise TunedException("Duplicate profile mapping in the configuration file '%s'" % config_file) - self._tuned_to_ppd = {v: k for k, v in self._ppd_to_tuned.items()} + profile_dict_ac = dict(cfg[PROFILES_SECTION]) - if PPD_POWER_SAVER not in self._ppd_to_tuned: + if PPD_POWER_SAVER not in profile_dict_ac: raise TunedException("Missing power-saver profile in the configuration file '%s'" % config_file) - if PPD_PERFORMANCE not in self._ppd_to_tuned: + if PPD_PERFORMANCE not in profile_dict_ac: raise TunedException("Missing performance profile in the configuration file '%s'" % config_file) if MAIN_SECTION not in cfg or DEFAULT_PROFILE_OPTION not in cfg[MAIN_SECTION]: raise TunedException("Missing default profile in the configuration file '%s'" % config_file) self._default_profile = cfg[MAIN_SECTION][DEFAULT_PROFILE_OPTION] - if self._default_profile not in self._ppd_to_tuned: - raise TunedException("Unknown default profile '%s'" % self._default_profile) - - if BATTERY_DETECTION_OPTION not in cfg[MAIN_SECTION]: - raise TunedException("Missing battery detection option in the configuration file '%s'" % config_file) - self._ppd_to_tuned_battery = self._ppd_to_tuned - self._battery_detection = cfg.getboolean(MAIN_SECTION, BATTERY_DETECTION_OPTION) - if self._battery_detection: - if BATTERY_SECTION not in cfg: - raise TunedException("Missing battery section in the configuration file '%s'" % config_file) - for k, _v in dict(cfg[PROFILES_SECTION]).items(): - if k in cfg[BATTERY_SECTION].keys(): - self._tuned_to_ppd = self._tuned_to_ppd | {cfg[BATTERY_SECTION][k]:k} - for k, v in dict(cfg[BATTERY_SECTION]).items(): - if k in cfg[PROFILES_SECTION].keys(): - self._ppd_to_tuned_battery = self._ppd_to_tuned_battery | {k:v} + if self._default_profile not in profile_dict_ac: + raise TunedException("Default profile '%s' missing in the profile mapping" % self._default_profile) + + self._battery_detection = cfg.getboolean(MAIN_SECTION, BATTERY_DETECTION_OPTION, fallback=BATTERY_SECTION in cfg) + + if self._battery_detection and BATTERY_SECTION not in cfg: + raise TunedException("Missing battery section in the configuration file '%s'" % config_file) + + profile_dict_dc = profile_dict_ac | dict(cfg[BATTERY_SECTION]) if self._battery_detection else profile_dict_ac + + # Make sure all of the TuneD profiles specified in the configuration file actually exist + unknown_tuned_profiles = (set(profile_dict_ac.values()) | set(profile_dict_dc.values())) - set(self._tuned_interface.profiles()) + if unknown_tuned_profiles: + raise TunedException("Unknown TuneD profiles in the configuration file: " + ", ".join(unknown_tuned_profiles)) + + # Make sure there are no PPD profiles appearing in the battery section which are not defined before + unknown_battery_profiles = set(profile_dict_dc.keys()) - set(profile_dict_ac.keys()) + if unknown_battery_profiles: + raise TunedException("Unknown PPD profiles in the battery section: " + ", ".join(unknown_battery_profiles)) + + # Make sure the profile mapping is injective so it can be reverted + if len(set(profile_dict_ac.values())) != len(profile_dict_ac) or len(set(profile_dict_dc.values())) != len(profile_dict_dc): + raise TunedException("Duplicate profile mapping in the configuration file '%s'" % config_file) + + self._ppd_to_tuned = ProfileMap(profile_dict_ac, profile_dict_dc) + self._tuned_to_ppd = ProfileMap({v: k for k, v in profile_dict_ac.items()}, {v: k for k, v in profile_dict_dc.items()}) diff --git a/tuned/ppd/controller.py b/tuned/ppd/controller.py index 060e514e..1ad8ece2 100644 --- a/tuned/ppd/controller.py +++ b/tuned/ppd/controller.py @@ -1,6 +1,6 @@ from tuned import exports, logs from tuned.utils.commands import commands -from tuned.consts import PPD_CONFIG_FILE +from tuned.consts import PPD_CONFIG_FILE, PPD_BASE_PROFILE_FILE from tuned.ppd.config import PPDConfig, PPD_PERFORMANCE, PPD_POWER_SAVER from enum import StrEnum import threading @@ -105,13 +105,30 @@ def __init__(self, bus, tuned_interface): self._terminate = threading.Event() self._battery_handler = None self._on_battery = False + self._tuned_interface.connect_to_signal("profile_changed", self._tuned_profile_changed) self.initialize() def _upower_changed(self, interface, changed, invalidated): self._on_battery = bool(self._upower_properties.Get(UPOWER_DBUS_INTERFACE, "OnBattery")) log.info("Battery status changed: " + ("DC (battery)" if self._on_battery else "AC (charging)")) - tuned_profile = self._config.ppd_to_tuned_battery[self._base_profile] if self._on_battery else self._config.ppd_to_tuned[self._base_profile] - self.switch_profile(tuned_profile) + self.switch_profile(self._active_profile) + + def _tuned_profile_changed(self, tuned_profile, result, errstr): + if not result: + return + self._profile_holds.clear() + try: + ppd_profile = self._config.tuned_to_ppd.get(tuned_profile, self._on_battery) + except KeyError: + ppd_profile = UNKNOWN_PROFILE + log.warning("TuneD profile changed to an unknown profile '%s'" % tuned_profile) + if self._active_profile != ppd_profile: + log.info("Profile changed to '%s'" % ppd_profile) + self._active_profile = ppd_profile + exports.property_changed("ActiveProfile", self._active_profile) + if ppd_profile != UNKNOWN_PROFILE: + self._base_profile = ppd_profile + self._save_base_profile(ppd_profile) def _setup_battery_signaling(self): self._on_battery = False @@ -140,14 +157,31 @@ def _check_performance_degraded(self): self._performance_degraded = performance_degraded exports.property_changed("PerformanceDegraded", performance_degraded) + def _load_base_profile(self): + return self._cmd.read_file(PPD_BASE_PROFILE_FILE, no_error=True).strip() or None + + def _save_base_profile(self, profile): + self._cmd.write_to_file(PPD_BASE_PROFILE_FILE, profile + "\n") + + def _set_tuned_profile(self, tuned_profile): + active_tuned_profile = self._tuned_interface.active_profile() + if active_tuned_profile == tuned_profile: + return True + log.info("Setting TuneD profile to '%s'" % tuned_profile) + ok, error_msg = self._tuned_interface.switch_profile(tuned_profile) + if not ok: + log.error(str(error_msg)) + return bool(ok) + def initialize(self): + self._active_profile = None self._profile_holds = ProfileHoldManager(self) self._performance_degraded = PerformanceDegraded.NONE - self._config = PPDConfig(PPD_CONFIG_FILE) + self._config = PPDConfig(PPD_CONFIG_FILE, self._tuned_interface) self._setup_battery_signaling() - active_profile = self.active_profile() - self._base_profile = active_profile if active_profile != UNKNOWN_PROFILE else self._config.default_profile + self._base_profile = self._load_base_profile() or self._config.default_profile self.switch_profile(self._base_profile) + self._save_base_profile(self._base_profile) def run(self): exports.start() @@ -167,16 +201,12 @@ def terminate(self): self._terminate.set() def switch_profile(self, profile): - if self.active_profile() == profile: - return - tuned_profile = self._config.ppd_to_tuned_battery[profile] if self._on_battery else self._config.ppd_to_tuned[profile] - log.info("Switching to profile '%s'" % tuned_profile) - self._tuned_interface.switch_profile(tuned_profile) - exports.property_changed("ActiveProfile", profile) - - def active_profile(self): - tuned_profile = self._tuned_interface.active_profile() - return self._config.tuned_to_ppd.get(tuned_profile, UNKNOWN_PROFILE) + if not self._set_tuned_profile(self._config.ppd_to_tuned.get(profile, self._on_battery)): + return False + if self._active_profile != profile: + exports.property_changed("ActiveProfile", profile) + self._active_profile = profile + return True @exports.export("sss", "u") def HoldProfile(self, profile, reason, app_id, caller): @@ -198,21 +228,23 @@ def ProfileReleased(self, cookie): @exports.property_setter("ActiveProfile") def set_active_profile(self, profile): - if profile not in self._config.ppd_to_tuned: + if profile not in self._config.ppd_to_tuned.keys(self._on_battery): raise dbus.exceptions.DBusException("Invalid profile '%s'" % profile) log.debug("Setting base profile to %s" % profile) - self._base_profile = profile self._profile_holds.clear() - self.switch_profile(profile) + if not self.switch_profile(profile): + raise dbus.exceptions.DBusException("Error setting profile %s'" % profile) + self._base_profile = profile + self._save_base_profile(profile) @exports.property_getter("ActiveProfile") def get_active_profile(self): - return self.active_profile() + return self._active_profile @exports.property_getter("Profiles") def get_profiles(self): return dbus.Array( - [{"Profile": profile, "Driver": DRIVER} for profile in self._config.ppd_to_tuned.keys()], + [{"Profile": profile, "Driver": DRIVER} for profile in self._config.ppd_to_tuned.keys(self._on_battery)], signature="a{sv}", ) From 86b1b48f98ed30956223c10782eb2827de71991d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavol=20=C5=BD=C3=A1=C4=8Dik?= Date: Mon, 2 Dec 2024 09:23:36 +0100 Subject: [PATCH 3/7] tuned-ppd: Add docstrings --- tuned/ppd/config.py | 28 ++++++++++ tuned/ppd/controller.py | 118 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/tuned/ppd/config.py b/tuned/ppd/config.py index 510b7928..5b61e02f 100644 --- a/tuned/ppd/config.py +++ b/tuned/ppd/config.py @@ -13,41 +13,69 @@ class ProfileMap: + """ + Mapping of PPD profiles to TuneD profiles or vice versa. + """ def __init__(self, ac_map, dc_map): self._ac_map = ac_map self._dc_map = dc_map def get(self, profile, on_battery): + """ + Returns a TuneD profile corresponding to the given + PPD profile and power supply status (or vice versa). + """ profile_map = self._dc_map if on_battery else self._ac_map return profile_map[profile] def keys(self, on_battery): + """ + Returns the supported PPD or TuneD profiles. + """ profile_map = self._dc_map if on_battery else self._ac_map return profile_map.keys() class PPDConfig: + """ + Configuration for the tuned-ppd daemon. + """ def __init__(self, config_file, tuned_interface): self._tuned_interface = tuned_interface self.load_from_file(config_file) @property def battery_detection(self): + """ + Whether battery detection is enabled. + """ return self._battery_detection @property def default_profile(self): + """ + Default PPD profile to set during initialization. + """ return self._default_profile @property def ppd_to_tuned(self): + """ + Mapping of PPD profiles to TuneD profiles. + """ return self._ppd_to_tuned @property def tuned_to_ppd(self): + """ + Mapping of TuneD profiles to PPD profiles. + """ return self._tuned_to_ppd def load_from_file(self, config_file): + """ + Loads the configuration from the provided file. + """ cfg = ConfigParser() if not os.path.isfile(config_file): diff --git a/tuned/ppd/controller.py b/tuned/ppd/controller.py index 1ad8ece2..b97e03f3 100644 --- a/tuned/ppd/controller.py +++ b/tuned/ppd/controller.py @@ -19,12 +19,19 @@ UPOWER_DBUS_INTERFACE = "org.freedesktop.UPower" class PerformanceDegraded(StrEnum): + """ + Possible reasons for performance degradation. + """ NONE = "" LAP_DETECTED = "lap-detected" HIGH_OPERATING_TEMPERATURE = "high-operating-temperature" class ProfileHold(object): + """ + Class holding information about a single profile hold, + i.e., a temporary profile switch requested by a process. + """ def __init__(self, profile, reason, app_id, watch): self.profile = profile self.reason = reason @@ -32,6 +39,9 @@ def __init__(self, profile, reason, app_id, watch): self.watch = watch def as_dict(self): + """ + Returns the hold information as a Python dictionary. + """ return { "Profile": self.profile, "Reason": self.reason, @@ -40,12 +50,21 @@ def as_dict(self): class ProfileHoldManager(object): + """ + Manager of profile holds responsible for their creation/deletion + and for choosing the effective one. Holds are identified using + integer cookies which are distributed to the hold-requesting processes. + """ def __init__(self, controller): self._holds = {} self._cookie_counter = 0 self._controller = controller - def _callback(self, cookie, app_id): + def _removal_callback(self, cookie, app_id): + """ + Returns the callback to invoke when the process with the given ID + (which requested a hold with the given cookie) disappears. + """ def callback(name): if name == "": log.info("Application '%s' disappeared, releasing hold '%s'" % (app_id, cookie)) @@ -54,11 +73,17 @@ def callback(name): return callback def _effective_hold_profile(self): + """ + Returns the hold to use from the set of all active ones. + """ if any(hold.profile == PPD_POWER_SAVER for hold in self._holds.values()): return PPD_POWER_SAVER return PPD_PERFORMANCE def _cancel(self, cookie): + """ + Cancels the hold saved under the provided cookie. + """ if cookie not in self._holds: return hold = self._holds.pop(cookie) @@ -68,12 +93,18 @@ def _cancel(self, cookie): log.info("Releasing hold '%s': profile '%s' by application '%s'" % (cookie, hold.profile, hold.app_id)) def as_dbus_array(self): + """ + Returns the information about current holds as a DBus-compatible array. + """ return dbus.Array([hold.as_dict() for hold in self._holds.values()], signature="a{sv}") def add(self, profile, reason, app_id, caller): + """ + Adds a new profile hold. + """ cookie = self._cookie_counter self._cookie_counter += 1 - watch = self._controller.bus.watch_name_owner(caller, self._callback(cookie, app_id)) + watch = self._controller.bus.watch_name_owner(caller, self._removal_callback(cookie, app_id)) log.info("Adding hold '%s': profile '%s' by application '%s'" % (cookie, profile, app_id)) self._holds[cookie] = ProfileHold(profile, reason, app_id, watch) exports.property_changed("ActiveProfileHolds", self.as_dbus_array()) @@ -81,9 +112,16 @@ def add(self, profile, reason, app_id, caller): return cookie def has(self, cookie): + """ + Returns True if there is a hold under the given cookie. + """ return cookie in self._holds def remove(self, cookie): + """ + Releases the hold saved under the provided cookie and + sets the next profile. + """ self._cancel(cookie) if len(self._holds) != 0: new_profile = self._effective_hold_profile() @@ -92,11 +130,17 @@ def remove(self, cookie): self._controller.switch_profile(new_profile) def clear(self): + """ + Releases all profile holds. + """ for cookie in list(self._holds.keys()): self._cancel(cookie) class Controller(exports.interfaces.ExportableInterface): + """ + The main tuned-ppd controller, exporting its DBus interface. + """ def __init__(self, bus, tuned_interface): super(Controller, self).__init__() self._bus = bus @@ -109,11 +153,17 @@ def __init__(self, bus, tuned_interface): self.initialize() def _upower_changed(self, interface, changed, invalidated): + """ + The callback to invoke when the power supply changes. + """ self._on_battery = bool(self._upower_properties.Get(UPOWER_DBUS_INTERFACE, "OnBattery")) log.info("Battery status changed: " + ("DC (battery)" if self._on_battery else "AC (charging)")) self.switch_profile(self._active_profile) def _tuned_profile_changed(self, tuned_profile, result, errstr): + """ + The callback to invoke when TuneD signals a profile change. + """ if not result: return self._profile_holds.clear() @@ -131,6 +181,9 @@ def _tuned_profile_changed(self, tuned_profile, result, errstr): self._save_base_profile(ppd_profile) def _setup_battery_signaling(self): + """ + Sets up handling of power supply changes. + """ self._on_battery = False if not self._config.battery_detection: if self._battery_handler is not None: @@ -147,6 +200,9 @@ def _setup_battery_signaling(self): log.debug(error) def _check_performance_degraded(self): + """ + Checks the current performance degradation status and sends a signal if it changed. + """ performance_degraded = PerformanceDegraded.NONE if os.path.exists(NO_TURBO_PATH) and self._cmd.read_file(NO_TURBO_PATH).strip() == "1": performance_degraded = PerformanceDegraded.HIGH_OPERATING_TEMPERATURE @@ -158,12 +214,21 @@ def _check_performance_degraded(self): exports.property_changed("PerformanceDegraded", performance_degraded) def _load_base_profile(self): + """ + Loads and returns the saved PPD base profile. + """ return self._cmd.read_file(PPD_BASE_PROFILE_FILE, no_error=True).strip() or None def _save_base_profile(self, profile): + """ + Saves the given PPD profile into the base profile file. + """ self._cmd.write_to_file(PPD_BASE_PROFILE_FILE, profile + "\n") def _set_tuned_profile(self, tuned_profile): + """ + Sets the TuneD profile to the given one if not already set. + """ active_tuned_profile = self._tuned_interface.active_profile() if active_tuned_profile == tuned_profile: return True @@ -174,6 +239,9 @@ def _set_tuned_profile(self, tuned_profile): return bool(ok) def initialize(self): + """ + Initializes the controller. + """ self._active_profile = None self._profile_holds = ProfileHoldManager(self) self._performance_degraded = PerformanceDegraded.NONE @@ -184,6 +252,9 @@ def initialize(self): self._save_base_profile(self._base_profile) def run(self): + """ + Exports the DBus interface and runs the main daemon loop. + """ exports.start() while not self._cmd.wait(self._terminate, 1): self._check_performance_degraded() @@ -191,16 +262,31 @@ def run(self): @property def bus(self): + """ + DBus interface for communication with other services. + """ return self._bus @property def base_profile(self): + """ + The base PPD profile. This is the profile to restore when + all profile holds are released or when tuned-ppd is restarted. + It may not be equal to the currently active profile. + """ return self._base_profile def terminate(self): + """ + Stops the main loop of the daemon. + """ self._terminate.set() def switch_profile(self, profile): + """ + Sets the currently active profile to the given one, if not already set. + Does not change the base profile. + """ if not self._set_tuned_profile(self._config.ppd_to_tuned.get(profile, self._on_battery)): return False if self._active_profile != profile: @@ -210,6 +296,9 @@ def switch_profile(self, profile): @exports.export("sss", "u") def HoldProfile(self, profile, reason, app_id, caller): + """ + Initiates a profile hold and returns a cookie for referring to it. + """ if profile != PPD_POWER_SAVER and profile != PPD_PERFORMANCE: raise dbus.exceptions.DBusException( "Only '%s' and '%s' profiles may be held" % (PPD_POWER_SAVER, PPD_PERFORMANCE) @@ -218,16 +307,26 @@ def HoldProfile(self, profile, reason, app_id, caller): @exports.export("u", "") def ReleaseProfile(self, cookie, caller): + """ + Releases a held profile with the given cookie. + """ if not self._profile_holds.has(cookie): raise dbus.exceptions.DBusException("No active hold for cookie '%s'" % cookie) self._profile_holds.remove(cookie) @exports.signal("u") def ProfileReleased(self, cookie): + """ + The DBus signal sent when a held profile is released. + """ pass @exports.property_setter("ActiveProfile") def set_active_profile(self, profile): + """ + Sets the base profile to the given one and also makes it active. + If there are any active profile holds, these are cancelled. + """ if profile not in self._config.ppd_to_tuned.keys(self._on_battery): raise dbus.exceptions.DBusException("Invalid profile '%s'" % profile) log.debug("Setting base profile to %s" % profile) @@ -239,10 +338,16 @@ def set_active_profile(self, profile): @exports.property_getter("ActiveProfile") def get_active_profile(self): + """ + Returns the currently active PPD profile. + """ return self._active_profile @exports.property_getter("Profiles") def get_profiles(self): + """ + Returns a DBus array of all available PPD profiles. + """ return dbus.Array( [{"Profile": profile, "Driver": DRIVER} for profile in self._config.ppd_to_tuned.keys(self._on_battery)], signature="a{sv}", @@ -250,12 +355,21 @@ def get_profiles(self): @exports.property_getter("Actions") def get_actions(self): + """ + Returns a DBus array of all available actions (currently there are none). + """ return dbus.Array([], signature="s") @exports.property_getter("PerformanceDegraded") def get_performance_degraded(self): + """ + Returns the current performance degradation status. + """ return self._performance_degraded @exports.property_getter("ActiveProfileHolds") def get_active_profile_holds(self): + """ + Returns a DBus array of active profile holds. + """ return self._profile_holds.as_dbus_array() From 2605fedf5b628e0818902bd0bdf74643e251c672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavol=20=C5=BD=C3=A1=C4=8Dik?= Date: Wed, 18 Sep 2024 13:21:51 +0200 Subject: [PATCH 4/7] tuned-ppd: Add the version property This makes tuned-ppd fully API-compatible with the latest power-profiles-daemon. The property will signify the level of API compatibility with power-profiles-daemon. --- tuned/consts.py | 1 + tuned/ppd/controller.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tuned/consts.py b/tuned/consts.py index c8f0332f..80c15c14 100644 --- a/tuned/consts.py +++ b/tuned/consts.py @@ -103,6 +103,7 @@ PPD_DBUS_INTERFACE = PPD_DBUS_BUS PPD_CONFIG_FILE = "/etc/tuned/ppd.conf" PPD_BASE_PROFILE_FILE = "/etc/tuned/ppd_base_profile" +PPD_API_COMPATIBILITY = "0.23" # After adding new option to tuned-main.conf add here its name with CFG_ prefix # and eventually default value with CFG_DEF_ prefix (default is None) diff --git a/tuned/ppd/controller.py b/tuned/ppd/controller.py index b97e03f3..e8936b1d 100644 --- a/tuned/ppd/controller.py +++ b/tuned/ppd/controller.py @@ -1,6 +1,6 @@ from tuned import exports, logs from tuned.utils.commands import commands -from tuned.consts import PPD_CONFIG_FILE, PPD_BASE_PROFILE_FILE +from tuned.consts import PPD_CONFIG_FILE, PPD_BASE_PROFILE_FILE, PPD_API_COMPATIBILITY from tuned.ppd.config import PPDConfig, PPD_PERFORMANCE, PPD_POWER_SAVER from enum import StrEnum import threading @@ -373,3 +373,7 @@ def get_active_profile_holds(self): Returns a DBus array of active profile holds. """ return self._profile_holds.as_dbus_array() + + @exports.property_getter("Version") + def version(self): + return PPD_API_COMPATIBILITY From f2d49135623a2f74c3b3426d36b729f0e469b4aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavol=20=C5=BD=C3=A1=C4=8Dik?= Date: Tue, 26 Nov 2024 10:38:03 +0100 Subject: [PATCH 5/7] tuned-ppd: Require the presence of the balanced profile To be consistent and make sure tuned-ppd works well with its users no matter the configuration, the balanced profile must be required in the configuration file, similarly to the power-saver and performance profiles. If no default profile is specified in the configuration, this also sets the default profile to balanced, not raising any exceptions like before. --- tuned/ppd/config.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tuned/ppd/config.py b/tuned/ppd/config.py index 5b61e02f..b72b2311 100644 --- a/tuned/ppd/config.py +++ b/tuned/ppd/config.py @@ -3,6 +3,7 @@ import os PPD_POWER_SAVER = "power-saver" +PPD_BALANCED = "balanced" PPD_PERFORMANCE = "performance" MAIN_SECTION = "main" @@ -92,13 +93,17 @@ def load_from_file(self, config_file): if PPD_POWER_SAVER not in profile_dict_ac: raise TunedException("Missing power-saver profile in the configuration file '%s'" % config_file) + if PPD_BALANCED not in profile_dict_ac: + raise TunedException("Missing balanced profile in the configuration file '%s'" % config_file) + if PPD_PERFORMANCE not in profile_dict_ac: raise TunedException("Missing performance profile in the configuration file '%s'" % config_file) if MAIN_SECTION not in cfg or DEFAULT_PROFILE_OPTION not in cfg[MAIN_SECTION]: - raise TunedException("Missing default profile in the configuration file '%s'" % config_file) + self._default_profile = PPD_BALANCED + else: + self._default_profile = cfg[MAIN_SECTION][DEFAULT_PROFILE_OPTION] - self._default_profile = cfg[MAIN_SECTION][DEFAULT_PROFILE_OPTION] if self._default_profile not in profile_dict_ac: raise TunedException("Default profile '%s' missing in the profile mapping" % self._default_profile) From 038dceadc0dfe909344603f071de69e8e3bbd94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavol=20=C5=BD=C3=A1=C4=8Dik?= Date: Mon, 2 Dec 2024 09:13:34 +0100 Subject: [PATCH 6/7] tuned-ppd: Use inotify to check for performance degradation Instead of actively polling for changes in the relevant files, set up inotify handlers which are invoked when the files change. --- tuned.spec | 1 + tuned/ppd/controller.py | 44 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/tuned.spec b/tuned.spec index 71ea4c5d..3bd6b733 100644 --- a/tuned.spec +++ b/tuned.spec @@ -84,6 +84,7 @@ BuildRequires: %{_py}-mock BuildRequires: %{_py}-pyudev Requires: %{_py}-pyudev Requires: %{_py}-linux-procfs, %{_py}-perf +Requires: %{_py}-inotify %if %{without python3} Requires: %{_py}-schedutils %endif diff --git a/tuned/ppd/controller.py b/tuned/ppd/controller.py index e8936b1d..b128c464 100644 --- a/tuned/ppd/controller.py +++ b/tuned/ppd/controller.py @@ -3,6 +3,7 @@ from tuned.consts import PPD_CONFIG_FILE, PPD_BASE_PROFILE_FILE, PPD_API_COMPATIBILITY from tuned.ppd.config import PPDConfig, PPD_PERFORMANCE, PPD_POWER_SAVER from enum import StrEnum +import pyinotify import threading import dbus import os @@ -27,6 +28,21 @@ class PerformanceDegraded(StrEnum): HIGH_OPERATING_TEMPERATURE = "high-operating-temperature" +class PerformanceDegradedEventHandler(pyinotify.ProcessEvent): + """ + Event handler for checking performance degradation. + """ + def __init__(self, controller, path): + super(PerformanceDegradedEventHandler, self).__init__() + self._controller = controller + self._path = path + + def process_IN_MODIFY(self, event): + if event.pathname != self._path: + return + self._controller.check_performance_degraded() + + class ProfileHold(object): """ Class holding information about a single profile hold, @@ -149,6 +165,11 @@ def __init__(self, bus, tuned_interface): self._terminate = threading.Event() self._battery_handler = None self._on_battery = False + self._watch_manager = pyinotify.WatchManager() + self._notifier = pyinotify.ThreadedNotifier(self._watch_manager) + self._inotify_watches = {} + self._no_turbo_supported = os.path.isfile(NO_TURBO_PATH) + self._lap_mode_supported = os.path.isfile(LAP_MODE_PATH) self._tuned_interface.connect_to_signal("profile_changed", self._tuned_profile_changed) self.initialize() @@ -199,7 +220,21 @@ def _setup_battery_signaling(self): except dbus.exceptions.DBusException as error: log.debug(error) - def _check_performance_degraded(self): + def _setup_inotify(self): + """ + Sets up inotify file watches. + """ + self._watch_manager.rm_watch(list(self._inotify_watches.values())) + if self._no_turbo_supported: + self._inotify_watches |= self._watch_manager.add_watch(path=os.path.dirname(NO_TURBO_PATH), + mask=pyinotify.IN_MODIFY, + proc_fun=PerformanceDegradedEventHandler(NO_TURBO_PATH, self)) + if self._lap_mode_supported: + self._inotify_watches |= self._watch_manager.add_watch(path=os.path.dirname(LAP_MODE_PATH), + mask=pyinotify.IN_MODIFY, + proc_fun=PerformanceDegradedEventHandler(LAP_MODE_PATH, self)) + + def check_performance_degraded(self): """ Checks the current performance degradation status and sends a signal if it changed. """ @@ -245,19 +280,24 @@ def initialize(self): self._active_profile = None self._profile_holds = ProfileHoldManager(self) self._performance_degraded = PerformanceDegraded.NONE + self.check_performance_degraded() self._config = PPDConfig(PPD_CONFIG_FILE, self._tuned_interface) self._setup_battery_signaling() self._base_profile = self._load_base_profile() or self._config.default_profile self.switch_profile(self._base_profile) self._save_base_profile(self._base_profile) + self._setup_inotify() def run(self): """ Exports the DBus interface and runs the main daemon loop. """ exports.start() + self._notifier.start() while not self._cmd.wait(self._terminate, 1): - self._check_performance_degraded() + pass + self._watch_manager.rm_watch(list(self._inotify_watches.values())) + self._notifier.stop() exports.stop() @property From 23eb67357172b52ea031d21562e38d6b5e62023d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavol=20=C5=BD=C3=A1=C4=8Dik?= Date: Mon, 2 Dec 2024 09:14:14 +0100 Subject: [PATCH 7/7] tuned-ppd: Enable changing profile via function keys On some Thinkpad laptops, it is possible to change the ACPI platform profile using function keys (e.g., Fn+L). This can now trigger a complete profile change if the functionality is enabled. Resolves #710. --- tuned/ppd/config.py | 12 +++++++ tuned/ppd/controller.py | 76 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/tuned/ppd/config.py b/tuned/ppd/config.py index b72b2311..7e148e10 100644 --- a/tuned/ppd/config.py +++ b/tuned/ppd/config.py @@ -11,6 +11,7 @@ BATTERY_SECTION = "battery" DEFAULT_PROFILE_OPTION = "default" BATTERY_DETECTION_OPTION = "battery_detection" +THINKPAD_FUNCTION_KEYS_OPTION = "thinkpad_function_keys" class ProfileMap: @@ -73,6 +74,15 @@ def tuned_to_ppd(self): """ return self._tuned_to_ppd + @property + def thinkpad_function_keys(self): + """ + Whether to react to changes of ACPI platform profile + done via function keys (e.g., Fn-L) on newer Thinkpad + machines. Experimental feature. + """ + return self._thinkpad_function_keys + def load_from_file(self, config_file): """ Loads the configuration from the provided file. @@ -130,3 +140,5 @@ def load_from_file(self, config_file): self._ppd_to_tuned = ProfileMap(profile_dict_ac, profile_dict_dc) self._tuned_to_ppd = ProfileMap({v: k for k, v in profile_dict_ac.items()}, {v: k for k, v in profile_dict_dc.items()}) + + self._thinkpad_function_keys = cfg.getboolean(MAIN_SECTION, THINKPAD_FUNCTION_KEYS_OPTION, fallback=False) diff --git a/tuned/ppd/controller.py b/tuned/ppd/controller.py index b128c464..6e8a9391 100644 --- a/tuned/ppd/controller.py +++ b/tuned/ppd/controller.py @@ -1,12 +1,14 @@ from tuned import exports, logs from tuned.utils.commands import commands from tuned.consts import PPD_CONFIG_FILE, PPD_BASE_PROFILE_FILE, PPD_API_COMPATIBILITY -from tuned.ppd.config import PPDConfig, PPD_PERFORMANCE, PPD_POWER_SAVER +from tuned.ppd.config import PPDConfig, PPD_PERFORMANCE, PPD_BALANCED, PPD_POWER_SAVER + from enum import StrEnum import pyinotify import threading import dbus import os +import time log = logs.get() @@ -19,6 +21,14 @@ UPOWER_DBUS_PATH = "/org/freedesktop/UPower" UPOWER_DBUS_INTERFACE = "org.freedesktop.UPower" +PLATFORM_PROFILE_PATH = "/sys/firmware/acpi/platform_profile" +PLATFORM_PROFILE_MAPPING = { + "low-power": PPD_POWER_SAVER, + "balanced": PPD_BALANCED, + "performance": PPD_PERFORMANCE +} + + class PerformanceDegraded(StrEnum): """ Possible reasons for performance degradation. @@ -43,6 +53,51 @@ def process_IN_MODIFY(self, event): self._controller.check_performance_degraded() +class PlatformProfileEventHandler(pyinotify.ProcessEvent): + """ + Event handler for switching PPD profiles based on the + ACPI platform profile + + This handler should only invoke a PPD profile change if the + change of the file at PLATFORM_PROFILE_PATH comes from within + the kernel (e.g., when the user presses Fn-L on a Thinkpad laptop). + This is currently detected as the file being modified without + being opened before. + """ + CLOSE_MODIFY_BUFFER = 0.1 + + def __init__(self, controller): + super(PlatformProfileEventHandler, self).__init__() + self._controller = controller + self._file_open = False + self._last_close = 0 + + def process_IN_OPEN(self, event): + if event.pathname != PLATFORM_PROFILE_PATH: + return + self._file_open = True + self._last_close = 0 + + def process_IN_CLOSE_WRITE(self, event): + if event.pathname != PLATFORM_PROFILE_PATH: + return + self._file_open = False + self._last_close = time.time() + + def process_IN_CLOSE_NOWRITE(self, event): + if event.pathname != PLATFORM_PROFILE_PATH: + return + self._file_open = False + + def process_IN_MODIFY(self, event): + if event.pathname != PLATFORM_PROFILE_PATH or self._file_open or self._last_close + self.CLOSE_MODIFY_BUFFER > time.time(): + # Do not invoke a profile change if a modify event comes: + # 1. when the file is open, + # 2. directly after the file is closed (the events may sometimes come in the wrong order). + return + self._controller.check_platform_profile() + + class ProfileHold(object): """ Class holding information about a single profile hold, @@ -168,6 +223,7 @@ def __init__(self, bus, tuned_interface): self._watch_manager = pyinotify.WatchManager() self._notifier = pyinotify.ThreadedNotifier(self._watch_manager) self._inotify_watches = {} + self._platform_profile_supported = os.path.isfile(PLATFORM_PROFILE_PATH) self._no_turbo_supported = os.path.isfile(NO_TURBO_PATH) self._lap_mode_supported = os.path.isfile(LAP_MODE_PATH) self._tuned_interface.connect_to_signal("profile_changed", self._tuned_profile_changed) @@ -233,6 +289,10 @@ def _setup_inotify(self): self._inotify_watches |= self._watch_manager.add_watch(path=os.path.dirname(LAP_MODE_PATH), mask=pyinotify.IN_MODIFY, proc_fun=PerformanceDegradedEventHandler(LAP_MODE_PATH, self)) + if self._platform_profile_supported and self._config.thinkpad_function_keys: + self._inotify_watches |= self._watch_manager.add_watch(path=os.path.dirname(PLATFORM_PROFILE_PATH), + mask=pyinotify.IN_OPEN | pyinotify.IN_MODIFY | pyinotify.IN_CLOSE_WRITE | pyinotify.IN_CLOSE_NOWRITE, + proc_fun=PlatformProfileEventHandler(self)) def check_performance_degraded(self): """ @@ -248,6 +308,20 @@ def check_performance_degraded(self): self._performance_degraded = performance_degraded exports.property_changed("PerformanceDegraded", performance_degraded) + def check_platform_profile(self): + """ + Sets the active PPD profile based on the content of the ACPI platform profile. + """ + platform_profile = self._cmd.read_file(PLATFORM_PROFILE_PATH).strip() + if platform_profile not in PLATFORM_PROFILE_MAPPING: + return + log.debug("Platform profile changed: %s" % platform_profile) + new_profile = PLATFORM_PROFILE_MAPPING[platform_profile] + self._profile_holds.clear() + self.switch_profile(new_profile) + self._base_profile = new_profile + self._save_base_profile(new_profile) + def _load_base_profile(self): """ Loads and returns the saved PPD base profile.