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 <[email protected]>
  • Loading branch information
tych0 committed Feb 11, 2024
1 parent 2b5ad3c commit c71ee0a
Showing 1 changed file with 134 additions and 1 deletion.
135 changes: 134 additions & 1 deletion 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 @@ -191,6 +192,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 = {}
Expand All @@ -214,6 +221,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):
Expand Down Expand Up @@ -267,7 +277,62 @@ 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

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)

def update_status(self) -> BatteryStatus:
self.set_battery_charge_thresholds()
stat = self._get_param("status_file")[0]

if stat == "Full":
Expand Down Expand Up @@ -313,7 +378,67 @@ def update_status(self) -> BatteryStatus:


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
Expand Down Expand Up @@ -359,6 +484,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

0 comments on commit c71ee0a

Please sign in to comment.