From 4d31fd4f42697a0892a7d9df97722bbdb54760d9 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 | 158 +++++++++++++++++- test/widgets/docs_screenshots/ss_battery.py | 2 + .../docs_screenshots/ss_batteryicon.py | 2 + test/widgets/test_battery.py | 20 +++ 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/libqtile/widget/battery.py b/libqtile/widget/battery.py index d038915d52..ef21b1e5c0 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,39 @@ 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, + ) + + +def connected_to_thunderbolt(): + 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 thunderbolt_smart_charge() -> tuple[int, int]: + # 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 connected_to_thunderbolt(): + return (40, 50) + return (0, 90) class _LinuxBattery(_Battery, configurable.Configurable): @@ -191,6 +226,17 @@ class _LinuxBattery(_Battery, configurable.Configurable): None, "Name of file with the current power draw in /sys/class/power_supply/battery_name", ), + ( + "charge_controller", + # hopefully a reasonable threshold to not kill peoples' batteries + lambda: (0, 90), + "A function that takes no arguments and returns (start, end) charge thresholds", + ), + ( + "force_charge", + False, + "Whether or not to ignore the result of charge_controller()", + ), ] filenames: dict = {} @@ -214,6 +260,7 @@ 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 def _get_battery_name(self): if os.path.isdir(self.BAT_DIR): @@ -267,7 +314,37 @@ def _get_param(self, name) -> tuple[str, str]: raise RuntimeError("Unable to read status for {}".format(name)) + def set_battery_charge_thresholds(self, start, end): + if not self.charge_threshold_supported: + return + + 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.charge_controller() + if self.force_charge: + charge_start_threshold = 0 + charge_end_threshold = 100 + self.set_battery_charge_thresholds(charge_start_threshold, charge_end_threshold) stat = self._get_param("status_file")[0] if stat == "Full": @@ -309,11 +386,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 +503,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 diff --git a/test/widgets/docs_screenshots/ss_battery.py b/test/widgets/docs_screenshots/ss_battery.py index 3a859570d8..8e7df3b169 100644 --- a/test/widgets/docs_screenshots/ss_battery.py +++ b/test/widgets/docs_screenshots/ss_battery.py @@ -32,6 +32,8 @@ def widget(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) monkeypatch.setattr("libqtile.widget.battery.load_battery", dummy_load_battery(loaded_bat)) yield libqtile.widget.battery.Battery diff --git a/test/widgets/docs_screenshots/ss_batteryicon.py b/test/widgets/docs_screenshots/ss_batteryicon.py index c38f77a600..c30050658a 100644 --- a/test/widgets/docs_screenshots/ss_batteryicon.py +++ b/test/widgets/docs_screenshots/ss_batteryicon.py @@ -32,6 +32,8 @@ def widget(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) monkeypatch.setattr("libqtile.widget.battery.load_battery", dummy_load_battery(loaded_bat)) diff --git a/test/widgets/test_battery.py b/test/widgets/test_battery.py index be39986ca8..a33f384677 100644 --- a/test/widgets/test_battery.py +++ b/test/widgets/test_battery.py @@ -35,6 +35,8 @@ def test_text_battery_charging(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) with monkeypatch.context() as manager: @@ -51,6 +53,8 @@ def test_text_battery_discharging(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) with monkeypatch.context() as manager: @@ -67,6 +71,8 @@ def test_text_battery_full(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) with monkeypatch.context() as manager: @@ -90,6 +96,8 @@ def test_text_battery_empty(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) with monkeypatch.context() as manager: @@ -111,6 +119,8 @@ def test_text_battery_empty(monkeypatch): percent=0.0, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) with monkeypatch.context() as manager: @@ -127,6 +137,8 @@ def test_text_battery_not_charging(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) with monkeypatch.context() as manager: @@ -143,6 +155,8 @@ def test_text_battery_unknown(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) with monkeypatch.context() as manager: @@ -159,6 +173,8 @@ def test_text_battery_hidden(monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) with monkeypatch.context() as manager: @@ -233,12 +249,16 @@ def test_battery_background(fake_qtile, fake_window, monkeypatch): percent=0.5, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) low = BatteryStatus( state=BatteryState.DISCHARGING, percent=0.1, power=15.0, time=1729, + charge_start_threshold=0, + charge_end_threshold=100, ) low_background = "ff0000"