From 6d5bb74637e7bf9a18dc81478f48bdddf1df474e Mon Sep 17 00:00:00 2001 From: "Manuel A. Fernandez Montecelo" Date: Wed, 29 Oct 2025 17:46:09 +0100 Subject: [PATCH] New property for time estimate to CriticalPowerAction (time-to-action) Analog to time-to-empty, but taking into account that time-to-action happens before that, so it estimates the time until the system stops working normally and it's instead forced to suspend/power-off/etc., when the battery is almost depleted. It makes the calculations necessary based on the configured values, whether they are time-based, or percentage-based. This property is only valid for /org/freedesktop/UPower/devices/DisplayDevice and not for actual batteries, because it is a property of the system as a whole and not physical batteries. In most systems (e.g. laptops) they would be the same, since there is only one battery; but depending on the system, there can be multiple batteries and they can deplete at different rates, so the per-battery calculation would not be useful. Also, similar to time-to-empty, it's only valid when the system is Discharging. Add also relevant integration test: - test_time_to_action --- dbus/org.freedesktop.UPower.Device.xml | 19 +++ libupower-glib/up-device.c | 28 +++++ src/linux/integration-test.py | 162 +++++++++++++++++++++++++ src/linux/up-device-supply.c | 1 + src/up-daemon.c | 13 +- 5 files changed, 221 insertions(+), 2 deletions(-) diff --git a/dbus/org.freedesktop.UPower.Device.xml b/dbus/org.freedesktop.UPower.Device.xml index e99bb32..b8f7e64 100644 --- a/dbus/org.freedesktop.UPower.Device.xml +++ b/dbus/org.freedesktop.UPower.Device.xml @@ -97,6 +97,10 @@ method return sender=:1.386 -> dest=:1.477 reply_serial=2 string "time-to-empty" variant int64 0 ) + dict entry( + string "time-to-action" + variant int64 0 + ) dict entry( string "time-to-full" variant int64 0 @@ -596,6 +600,21 @@ method return sender=:1.386 -> dest=:1.477 reply_serial=2 + + + + + Number of seconds until the CriticalPowerAction is triggered. + Is set to 0 if unknown. + + This property is only valid if the property + type + has the value "battery" and the device is "/org/freedesktop/UPower/devices/DisplayDevice". + + + + + diff --git a/libupower-glib/up-device.c b/libupower-glib/up-device.c index bbf1122..2f4c07c 100644 --- a/libupower-glib/up-device.c +++ b/libupower-glib/up-device.c @@ -83,6 +83,7 @@ enum { PROP_VOLTAGE, PROP_LUMINOSITY, PROP_TIME_TO_EMPTY, + PROP_TIME_TO_ACTION, PROP_TIME_TO_FULL, PROP_PERCENTAGE, PROP_TEMPERATURE, @@ -379,6 +380,11 @@ up_device_to_text (UpDevice *device) g_string_append_printf (string, " time to full: %s\n", time_str); g_free (time_str); } + if (up_exported_device_get_time_to_action (priv->proxy_device) > 0) { + time_str = up_device_to_text_time_to_string (up_exported_device_get_time_to_action (priv->proxy_device)); + g_string_append_printf (string, " time to action: %s\n", time_str); + g_free (time_str); + } if (up_exported_device_get_time_to_empty (priv->proxy_device) > 0) { time_str = up_device_to_text_time_to_string (up_exported_device_get_time_to_empty (priv->proxy_device)); g_string_append_printf (string, " time to empty: %s\n", time_str); @@ -612,6 +618,7 @@ static void up_device_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) { UpDevice *device = UP_DEVICE (object); + gboolean is_display = FALSE; if (device->priv->proxy_device == NULL) { GValue *v; @@ -691,6 +698,12 @@ up_device_set_property (GObject *object, guint prop_id, const GValue *value, GPa case PROP_TIME_TO_EMPTY: up_exported_device_set_time_to_empty (device->priv->proxy_device, g_value_get_int64 (value)); break; + case PROP_TIME_TO_ACTION: + /* set this property only for DisplayDevice */ + is_display = (g_strcmp0 ("/org/freedesktop/UPower/devices/DisplayDevice", up_device_get_object_path (device)) == 0); + if (is_display) + up_exported_device_set_time_to_action (device->priv->proxy_device, g_value_get_int64 (value)); + break; case PROP_TIME_TO_FULL: up_exported_device_set_time_to_full (device->priv->proxy_device, g_value_get_int64 (value)); break; @@ -832,6 +845,9 @@ up_device_get_property (GObject *object, guint prop_id, GValue *value, GParamSpe case PROP_TIME_TO_EMPTY: g_value_set_int64 (value, up_exported_device_get_time_to_empty (device->priv->proxy_device)); break; + case PROP_TIME_TO_ACTION: + g_value_set_int64 (value, up_exported_device_get_time_to_action (device->priv->proxy_device)); + break; case PROP_TIME_TO_FULL: g_value_set_int64 (value, up_exported_device_get_time_to_full (device->priv->proxy_device)); break; @@ -1192,6 +1208,18 @@ up_device_class_init (UpDeviceClass *klass) g_param_spec_int64 ("time-to-empty", NULL, NULL, 0, G_MAXINT64, 0, G_PARAM_READWRITE)); + /** + * UpDevice:time-to-action: + * + * The amount of time until the CriticalPowerAction is triggered. + * + * Since: 1.90.11 + **/ + g_object_class_install_property (object_class, + PROP_TIME_TO_ACTION, + g_param_spec_int64 ("time-to-action", NULL, NULL, + 0, G_MAXINT64, 0, + G_PARAM_READWRITE)); /** * UpDevice:time-to-full: * diff --git a/src/linux/integration-test.py b/src/linux/integration-test.py index c9cc834..1f231ae 100755 --- a/src/linux/integration-test.py +++ b/src/linux/integration-test.py @@ -6042,6 +6042,168 @@ class Tests(dbusmock.DBusTestCase): os.rmdir(conf_d_dir_path) os.rmdir(base_dir.name) + def test_time_to_action(self): + """Test time-to-action property with different configurations to ensure calculations work as expected""" + + def inner_helper_check_general_and_energy(): + # get devices + devs = self.proxy.EnumerateDevices() + self.assertEqual(len(devs), 1) + bat0_up = devs[0] + + # check general state + self.assertDevs({"battery_BAT0": {"State": UP_DEVICE_STATE_DISCHARGING}}) + self.assertEqual( + self.get_dbus_display_property("State"), UP_DEVICE_STATE_DISCHARGING + ) + self.assertEqual( + self.get_dbus_display_property("WarningLevel"), UP_DEVICE_LEVEL_NONE + ) + self.assertEqual(self.get_dbus_display_property("IsPresent"), True) + + # energy is in uWh, charge_* is in uAh, energy=charge*voltage + self.assertAlmostEqual(self.get_dbus_dev_property(bat0_up, "Voltage"), 12.0) + self.assertAlmostEqual( + self.get_dbus_dev_property(bat0_up, "EnergyFullDesign"), 132.0 + ) + self.assertAlmostEqual( + self.get_dbus_display_property("EnergyFullDesign"), 0.0 + ) + self.assertAlmostEqual(self.get_dbus_display_property("EnergyFull"), 120.0) + self.assertAlmostEqual(self.get_dbus_display_property("Energy"), 96.0) + self.assertAlmostEqual(self.get_dbus_display_property("Percentage"), 80.0) + self.assertAlmostEqual(self.get_dbus_display_property("EnergyRate"), 24.0) + + # (96/24)*3600, 4h in seconds + self.assertAlmostEqual(self.get_dbus_display_property("TimeToEmpty"), 14400) + + def inner_helper_check_log_line(check_string): + self.daemon_log.check_line( + check_string, timeout=UP_DAEMON_ACTION_DELAY + 0.5 + ) + + self.testbed.add_device( + "power_supply", + "BAT0", + None, + [ + "type", + "Battery", + "present", + "1", + "status", + "Discharging", + "charge_now", + "8000000", + "charge_full", + "10000000", + "charge_full_design", + "11000000", + "percentage", + "80", + "voltage_now", + "12000000", + "current_now", + "2000000", + ], + [], + ) + + base_dir = tempfile.TemporaryDirectory(delete=False, prefix="UPower-") + # UPower.conf.d dir is mandaory + conf_d_dir_path = os.path.join(base_dir.name, "UPower.conf.d") + conf_d_dir = os.mkdir(path=conf_d_dir_path) + + # Combination of {Percentage,Time}{Low,Critical,Action} are all needed + # in the "main config file" to avoid falling back to defaults, and for + # the config fragments to be able to override and work as expected (they + # keys in config fragments are assumed valid if, and only if, they have + # been seen previously in the main config file) + config = tempfile.NamedTemporaryFile( + delete=False, mode="w", dir=base_dir.name, suffix=".conf" + ) + config.write("[UPower]\n") + config.write("AllowRiskyCriticalPowerAction=true\n") + config.write("CriticalPowerAction=Ignore\n") + config.write("UsePercentageForPolicy=true\n") + config.write("PercentageLow=15.0\n") + config.write("PercentageCritical=10.0\n") + config.write("PercentageAction=5.0\n") + config.write("TimeLow=300\n") + config.write("TimeCritical=200\n") + config.write("TimeAction=100\n") + config.close() + + ############################### + # Test #1, PercentageAction=5.0 + ############################### + self.start_daemon(cfgfile=config.name, warns=True) + # check warning message to ensure that we're using the expected config: + # AllowRiskyCriticalPowerAction=true and CriticalPowerAction=Ignore + inner_helper_check_log_line( + 'The "Ignore" CriticalPowerAction setting is considered risky:' + ) + inner_helper_check_general_and_energy() + # specific time-to-action test: 4h-5%, in seconds + self.assertAlmostEqual(self.get_dbus_display_property("TimeToAction"), 13680) + self.stop_daemon() + + # Time-based instead of percentage-based, overriding main config + config2 = tempfile.NamedTemporaryFile( + delete=False, mode="w", dir=conf_d_dir_path, prefix="30-", suffix=".conf" + ) + config2.write("[UPower]\n") + config2.write("AllowRiskyCriticalPowerAction=true\n") + config2.write("CriticalPowerAction=Suspend\n") + config2.write("UsePercentageForPolicy=false\n") + config2.write("TimeAction=60\n") + config2.close() + + ############################### + # Test #2, TimeAction=60 + ############################### + self.start_daemon(cfgfile=config.name, warns=True) + # check warning message to ensure that we're using the expected config: + # AllowRiskyCriticalPowerAction=true and CriticalPowerAction=Suspend + inner_helper_check_log_line( + 'The "Suspend" CriticalPowerAction setting is considered risky:' + ) + inner_helper_check_general_and_energy() + # specific time-to-action test: 4h-1min, in seconds + self.assertAlmostEqual(self.get_dbus_display_property("TimeToAction"), 14340) + self.stop_daemon() + + # Percentage-based again, overriding both main config and lower-prio + # config fragments + config3 = tempfile.NamedTemporaryFile( + delete=False, mode="w", dir=conf_d_dir_path, prefix="60-", suffix=".conf" + ) + config3.write("[UPower]\n") + config3.write("AllowRiskyCriticalPowerAction=false\n") + config3.write("CriticalPowerAction=Ignore\n") + config3.write("UsePercentageForPolicy=true\n") + config3.write("PercentageAction=1.0\n") + config3.close() + + ############################### + # Test #3, PercentageAction=1.0 + ############################### + self.start_daemon(cfgfile=config.name, warns=True) + # check warning message to ensure that we're using the expected config: + # AllowRiskyCriticalPowerAction=false and CriticalPowerAction=Ignore + inner_helper_check_log_line('The system will perform "HybridSleep" instead.') + inner_helper_check_general_and_energy() + # specific time-to-action test: 4h-1%, in seconds + self.assertAlmostEqual(self.get_dbus_display_property("TimeToAction"), 14256) + self.stop_daemon() + + # final cleanup + os.unlink(config.name) + os.unlink(config2.name) + os.unlink(config3.name) + os.rmdir(conf_d_dir_path) + os.rmdir(base_dir.name) + # # Helper methods # diff --git a/src/linux/up-device-supply.c b/src/linux/up-device-supply.c index 889367f..5c4b6e9 100644 --- a/src/linux/up-device-supply.c +++ b/src/linux/up-device-supply.c @@ -102,6 +102,7 @@ up_device_supply_reset_values (UpDeviceSupply *supply) "energy-rate", (gdouble) 0.0, "voltage", (gdouble) 0.0, "time-to-empty", (gint64) 0, + "time-to-action", (gint64) 0, "time-to-full", (gint64) 0, "percentage", (gdouble) 0.0, "temperature", (gdouble) 0.0, diff --git a/src/up-daemon.c b/src/up-daemon.c index 969db56..f74cf82 100644 --- a/src/up-daemon.c +++ b/src/up-daemon.c @@ -61,6 +61,7 @@ struct UpDaemonPrivate gdouble energy_full; gdouble energy_rate; gint64 time_to_empty; + gint64 time_to_action; gint64 time_to_full; gboolean charge_threshold_enabled; @@ -150,6 +151,7 @@ up_daemon_update_display_battery (UpDaemon *daemon) gdouble energy_full_total = 0.0; gdouble energy_rate_total = 0.0; gint64 time_to_empty_total = 0; + gint64 time_to_action_total = 0; gint64 time_to_full_total = 0; gboolean is_present_total = FALSE; gboolean charge_threshold_enabled_total = FALSE; @@ -309,9 +311,13 @@ out: /* calculate a quick and dirty time remaining value * NOTE: Keep in sync with per-battery estimation code! */ if (energy_rate_total > 0) { - if (state_total == UP_DEVICE_STATE_DISCHARGING) + if (state_total == UP_DEVICE_STATE_DISCHARGING) { time_to_empty_total = SECONDS_PER_HOUR * (energy_total / energy_rate_total); - else if (state_total == UP_DEVICE_STATE_CHARGING) + if (daemon->priv->use_percentage_for_policy) + time_to_action_total = time_to_empty_total * (1.0 - (daemon->priv->action_percentage / 100.0)); + else + time_to_action_total = time_to_empty_total - daemon->priv->action_time; + } else if (state_total == UP_DEVICE_STATE_CHARGING) time_to_full_total = SECONDS_PER_HOUR * ((energy_full_total - energy_total) / energy_rate_total); } @@ -325,6 +331,7 @@ out: daemon->priv->energy_full == energy_full_total && daemon->priv->energy_rate == energy_rate_total && daemon->priv->time_to_empty == time_to_empty_total && + daemon->priv->time_to_action == time_to_action_total && daemon->priv->time_to_full == time_to_full_total && daemon->priv->percentage == percentage_total && daemon->priv->charge_threshold_enabled == charge_threshold_enabled_total && @@ -337,6 +344,7 @@ out: daemon->priv->energy_full = energy_full_total; daemon->priv->energy_rate = energy_rate_total; daemon->priv->time_to_empty = time_to_empty_total; + daemon->priv->time_to_action = time_to_action_total; daemon->priv->time_to_full = time_to_full_total; daemon->priv->percentage = percentage_total; @@ -351,6 +359,7 @@ out: "energy-full", energy_full_total, "energy-rate", energy_rate_total, "time-to-empty", time_to_empty_total, + "time-to-action", time_to_action_total, "time-to-full", time_to_full_total, "percentage", percentage_total, "is-present", is_present_total,