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 18, 2024
1 parent 2b5ad3c commit 4d31fd4
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 3 deletions.
158 changes: 155 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,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 = {}
Expand All @@ -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):
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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
20 changes: 20 additions & 0 deletions test/widgets/test_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 4d31fd4

Please sign in to comment.