Skip to content

Commit

Permalink
battery: add "fancy" charge control
Browse files Browse the repository at this point in the history
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 <tycho@tycho.pizza>
  • Loading branch information
tych0 committed Feb 19, 2024
1 parent 3a61a27 commit effc931
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 3 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
Qtile x.xx.x, released XXXX-XX-XX:
* features
- The Battery widget now supports dynamic charge control, allowing for
protecting battery life.
* bugfixes
- Fix groups marked with `persist=False` not being deleted when their last window is moved to another group.

Expand Down
180 changes: 177 additions & 3 deletions libqtile/widget/battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,6 +71,8 @@ class BatteryState(Enum):
("percent", float),
("power", float),
("time", int),
("charge_start_threshold", int),
("charge_end_threshold", int),
],
)

Expand Down Expand Up @@ -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):
Expand All @@ -191,6 +226,20 @@ class _LinuxBattery(_Battery, configurable.Configurable):
None,
"Name of file with the current power draw in /sys/class/power_supply/battery_name",
),
(
"charge_controller",
None,
"""
A function that takes no arguments and returns (start, end) charge
thresholds, e.g. ``lambda: (0, 90)``; set to None to disable smart
charging.
""",
),
(
"force_charge",
False,
"Whether or not to ignore the result of charge_controller() and charge to 100%",
),
]

filenames: dict = {}
Expand All @@ -214,6 +263,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):
Expand Down Expand Up @@ -267,7 +317,40 @@ 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 = 0
charge_end_threshold = 100
if self.charge_controller is not None and self.charge_threshold_supported:
(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":
Expand Down Expand Up @@ -309,11 +392,94 @@ 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%) under user defined conditions, and provides some
implementations for doing so. For example, to only charge the battery to
90%, use:
.. code-block:: python
Battery(..., charge_controller: lambda (0, 90))
The battery widget also supplies some charging algorithms. To only charge
the battery between 40-50% while connected to a thunderbolt docking
station, but 90% all other times, use:
.. code-block:: python
from libqtile.widget.battery import thunderbolt_smart_charge
Battery(..., charge_controller: thunderbolt_smart_charge)
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
Expand Down Expand Up @@ -359,6 +525,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
Expand Down
2 changes: 2 additions & 0 deletions test/widgets/docs_screenshots/ss_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test/widgets/docs_screenshots/ss_batteryicon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down

0 comments on commit effc931

Please sign in to comment.