From 0ec6aa0969077eb1423e54e60028e628035852e6 Mon Sep 17 00:00:00 2001 From: Tycho Andersen Date: Sat, 10 Feb 2024 08:15:37 -0700 Subject: [PATCH] battery: add "fancy" charge control Inspired by this talk at FOSDEM: https://fosdem.org/2024/schedule/event/fosdem-2024-2123-from-kernel-api-to-desktop-integration-how-do-we-integrate-battery-charge-limiting-in-the-desktop/ I wrote some "smart charge" logic for qtile. It probably needs better detection of other kinds of docking stations, since all I have is a thunderbolt one. But at least this is a start. Signed-off-by: Tycho Andersen --- libqtile/widget/battery.py | 156 ++++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 3 deletions(-) diff --git a/libqtile/widget/battery.py b/libqtile/widget/battery.py index d038915d52..856f7aba03 100644 --- a/libqtile/widget/battery.py +++ b/libqtile/widget/battery.py @@ -42,6 +42,7 @@ from typing import TYPE_CHECKING, NamedTuple from libqtile import bar, configurable, images +from libqtile.command.base import expose_command from libqtile.images import Img from libqtile.log_utils import logger from libqtile.utils import send_notification @@ -70,6 +71,8 @@ class BatteryState(Enum): ("percent", float), ("power", float), ("time", int), + ("charge_start_threshold", int), + ("charge_end_threshold", int), ], ) @@ -170,7 +173,14 @@ def update_status(self) -> BatteryStatus: else: raise RuntimeError("Could not get remaining battery time!") - return BatteryStatus(state, percent=percent, power=power, time=time) + return BatteryStatus( + state, + percent=percent, + power=power, + time=time, + charge_start_threshold=0, + charge_end_threshold=100, + ) class _LinuxBattery(_Battery, configurable.Configurable): @@ -191,6 +201,12 @@ class _LinuxBattery(_Battery, configurable.Configurable): None, "Name of file with the current power draw in /sys/class/power_supply/battery_name", ), + ( + "default_max_charge", + # hopefully a reasonable threshold to not kill peoples' batteries + 90, + "The default max charge percentage (depends on battery support)", + ), ] filenames: dict = {} @@ -214,6 +230,9 @@ def __init__(self, **config): self.add_defaults(_LinuxBattery.defaults) if isinstance(self.battery, int): self.battery = "BAT{}".format(self.battery) + self.charge_threshold_supported = True + self.override_charge_threshold = False + self.force_charge = False def _get_battery_name(self): if os.path.isdir(self.BAT_DIR): @@ -267,7 +286,63 @@ def _get_param(self, name) -> tuple[str, str]: raise RuntimeError("Unable to read status for {}".format(name)) + def connected_to_thunderbolt(self): + try: + sysfs = "/sys/bus/thunderbolt/devices" + entries = os.listdir(sysfs) + for e in entries: + try: + name = Path(sysfs, e, "device_name").read_text() + except FileNotFoundError: + continue + else: + logger.debug("found dock %s", name) + return True + except OSError: + logger.debug("failed to detect thunderbot %s", exc_info=True) + return False + + def set_battery_charge_thresholds(self): + if not self.charge_threshold_supported: + return (0, 100) + + start = 0 + end = self.default_max_charge + + # if we are thunderbolt docked, set the thresholds to 40/50, per + # https://support.lenovo.com/us/en/solutions/ht078208-how-can-i-increase-battery-life-thinkpad-and-lenovo-vbke-series-notebooks + if self.connected_to_thunderbolt(): + start = 40 + end = 50 + + # unless we have been explicitly asked to charge, then we should JFDI + if self.force_charge: + start = 0 + end = 100 + + battery_dir = "/sys/class/power_supply" + + path = os.path.join(battery_dir, self.battery, "charge_control_start_threshold") + try: + with open(path, "w+") as f: + f.write(str(start)) + except FileNotFoundError: + self.charge_threshold_supported = False + except OSError: + logger.debug("Failed to write %s", path, exc_info=True) + + path = os.path.join(battery_dir, self.battery, "charge_control_end_threshold") + try: + with open(path, "w+") as f: + f.write(str(end)) + except FileNotFoundError: + self.charge_threshold_supported = False + except OSError: + logger.debug("Failed to write %s", path, exc_info=True) + return (start, end) + def update_status(self) -> BatteryStatus: + (charge_start_threshold, charge_end_threshold) = self.set_battery_charge_thresholds() stat = self._get_param("status_file")[0] if stat == "Full": @@ -309,11 +384,78 @@ def update_status(self) -> BatteryStatus: elif power_unit == "uW": power = power / 1e6 - return BatteryStatus(state=state, percent=percent, power=power, time=time) + return BatteryStatus( + state=state, + percent=percent, + power=power, + time=time, + charge_start_threshold=charge_start_threshold, + charge_end_threshold=charge_end_threshold, + ) class Battery(base.ThreadPoolText): - """A text-based battery monitoring widget currently supporting FreeBSD""" + """ + A text-based battery monitoring widget supporting both Linux and FreeBSD. + + The Linux version of this widget has functionality to charge "smartly" + (i.e. not to 100%) when the laptop is connected to a thunderbolt docking + station. To temporarily disable/re-enable this (e.g. if you know you're + going mobile and need to charge) use either: + + .. code-block:: bash + + qtile cmd-obj -o bar top widget battery -f charge_to_full + qtile cmd-obj -o bar top widget battery -f charge_dynamically + + or bind a key to: + + .. code-block:: python + + Key([mod, "shift"], "c", lazy.widget['battery'].charge_to_full()) + Key([mod, "shift"], "x", lazy.widget['battery'].charge_dynamically()) + + note that this functionality requires qtile to be able to write to certain + files in sysfs. The easiest way to persist this across reboots is via a + udev rule that sets g+w and ownership of the relevant files to the `sudo` + group, assuming the user qtile runs as is in that group. + + This is slightly complicated, since the chage_control_{start,end}_threshold + files are not created by the device driver itself, but by the particular + ACPI module for your laptop. If we try to do the chown/chmod when the + device is added in udev, the files won't be present yet. So, we have to do + it when the ACPI module for the laptop is loaded. + + For thinkpads, the udev rule looks like: + + .. code-block:: bash + + cat <<'EOF' | sudo tee /etc/udev/rules.d/99-qtile-battery.rules + ACTION=="add" KERNEL=="thinkpad_acpi" RUN+="/home/tycho/config/bin/qtile-battery" + EOF + + and the qtile-battery script looks like: + + .. code-block:: bash + + #!/bin/bash -eu + + GROUP=sudo + die() { + echo "$@" + exit 1 + } + + set_ownership() { + chgrp "$GROUP" $1 2>&1 + chmod g+w $1 + } + + [ $# -eq 0 ] || die "Usage: $0" + + set_ownership /sys/class/power_supply/BAT*/charge_control_end_threshold + set_ownership /sys/class/power_supply/BAT*/charge_control_start_threshold + """ background: ColorsType | None low_background: ColorsType | None @@ -359,6 +501,14 @@ def _configure(self, qtile, bar): base.ThreadPoolText._configure(self, qtile, bar) + @expose_command() + def charge_to_full(self): + self._battery.force_charge = True + + @expose_command() + def charge_dynamically(self): + self._battery.force_charge = False + @staticmethod def _load_battery(**config): """Function used to load the Battery object