Monthly full-charge automation for Linux laptops, developed and tested on Fedora.
Lithium-ion batteries last longer when kept around 80 %, but they should still be cycled to 100 % occasionally so that the firmware fuel gauge stays calibrated. This project keeps your charge threshold at 80 % every day, except for one day per month (the 1st by default), when it raises the threshold to 100 %, lets the battery fill at your own pace, and then drops back to 80 % the moment a full charge is complete.
No timers tied to specific times of day. No polling loops. The system reacts to real events: AC plug/unplug, battery capacity change, daily rollover at midnight, and system boot.
git clone https://github.com/thevirtueye/lithium-cycle
cd lithium-cycle
./tests/test-dryrun.sh # sanity-check the logic (no install yet)
sudo ./install.sh # install
sudo lithium-cycle status # see the current stateThat's it. From now on the system runs itself.
Two requirements: your operating system must be a modern Linux distro, and your laptop firmware must expose a writable charge threshold to the kernel.
Your laptop must expose this file:
/sys/class/power_supply/BAT0/charge_control_end_threshold
Quick check:
ls /sys/class/power_supply/BAT*/charge_control_end_thresholdIf the file exists, you're good. If it doesn't, the installer will print a list of things to try (loading the right kernel module for your laptop brand). Rough vendor compatibility:
| Vendor | Support |
|---|---|
| Lenovo ThinkPad | Yes (thinkpad_acpi) |
| Lenovo IdeaPad | Yes on most recent models (ideapad_laptop) |
| ASUS ROG / ZenBook | Yes on recent models (asus_wmi) |
| Huawei MateBook | Yes (huawei_wmi) |
| Framework | Yes (native kernel support) |
| System76 | Generally yes |
| Dell | Hit-or-miss, depends on model |
| HP | Almost never (firmware doesn't expose it) |
| Acer / MSI | Case by case |
| Apple Mac | No |
Single-battery laptops only. Dual-battery setups (some ThinkPad business
models) work for whichever battery you configure as BATTERY=BAT0 (or
BAT1); the other is left at firmware defaults.
Three different things can trigger the script. They all converge on the same idempotent function:
- udev fires on every
power_supplyevent (cable plugged/unplugged, capacity tick). - A systemd timer fires every day at 00:01 to handle the date
rolling over to a new month.
Persistent=truecovers the case where the laptop was off at that exact minute — the missed run is caught up at the next boot. - The service unit runs once at boot, so the system is always in a correct state regardless of when you turn the laptop on.
Each trigger calls the same logic:
- Has this month already been marked as "full charge done"? → Set threshold to 80 %. Done.
- Otherwise, if we're on or past day 1 of the current month:
- If capacity is already ≥ 100 % → mark this month as done, drop threshold back to 80 %.
- Else → set threshold to 100 % and let the battery fill.
State is persisted in /var/lib/lithium-cycle/state as
last_full_charge=YYYY-MM. The full-charge window stays open across days
until 100 % is actually reached — so if you forget to plug in on the 1st,
the system picks up on the 2nd, 3rd, etc.
Edit /etc/lithium-cycle/lithium-cycle.conf:
| Variable | Default | Meaning |
|---|---|---|
BATTERY |
BAT0 |
Battery device under /sys/class/power_supply/ |
NORMAL_THRESHOLD |
80 |
Daily charge cap |
FULL_THRESHOLD |
100 |
Cap during the monthly full-charge window |
FULL_CHARGE_DAY |
1 |
Day of the month to open the window (1–28) |
COMPLETION_CAPACITY |
100 |
Capacity at which the full charge counts as done (1–100) |
After editing, apply changes with:
sudo systemctl restart lithium-cycle.serviceThe config file is never overwritten by reinstalling — your edits
survive sudo ./install.sh being run again.
sudo lithium-cycle status # one-shot, human-readable summary
journalctl -t lithium-cycle # all script log lines
journalctl -t lithium-cycle -f # follow logs live
systemctl list-timers lithium-cycle.timer
cat /sys/class/power_supply/BAT0/charge_control_end_threshold
cat /var/lib/lithium-cycle/stateYou can also force an evaluation manually at any time:
sudo systemctl start lithium-cycle.serviceTwo test layers ship with the project.
Logic test (no installation required). Runs the script against a fake
/sys sandbox and replays 27 scenarios in a couple of seconds:
./tests/test-dryrun.sh
./tests/test-dryrun.sh -v # verboseLive integration checklist. Validates udev, systemd, real /sys
writes, state persistence, and reboot behaviour on your actual laptop
after sudo ./install.sh. See tests/LIVE-TESTING.md.
sudo ./uninstall.shThe uninstaller asks before removing /etc/lithium-cycle (config) and
/var/lib/lithium-cycle (state). The kernel keeps whatever charge
threshold value was last written until reboot or another tool changes it,
so if you want to reset to "always 100 %" or "always 80 %", do:
echo 100 | sudo tee /sys/class/power_supply/BAT0/charge_control_end_threshold
# or
echo 80 | sudo tee /sys/class/power_supply/BAT0/charge_control_end_thresholdcharge_control_end_threshold doesn't exist.
The installer prints diagnostic suggestions. The most common fix is
loading the right kernel module: sudo modprobe thinkpad_acpi for
ThinkPads, asus_wmi for ASUS, ideapad_laptop for IdeaPads, etc. If
none work, your firmware probably doesn't expose this API.
Threshold doesn't change after I plug in the cable.
Check the udev rule is loaded: sudo udevadm control --reload-rules,
then trigger manually with
sudo udevadm trigger --subsystem-match=power_supply --action=change
and inspect journalctl -t lithium-cycle.
lithium-cycle status says timer is inactive.
Re-enable: sudo systemctl enable --now lithium-cycle.timer.
KDE Plasma's "Charge limit" panel shows a different value.
Plasma stores its own preference and does not always reflect the live
/sys value. The live value (what the firmware actually enforces) is
the authoritative one. Plasma typically does not re-apply its preference
in real time, so this mismatch is cosmetic.
Logs don't persist across reboots.
Some distros set the journal to volatile by default. Make it persistent:
sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald.
"Read-only file system" or permission errors writing to /sys.
The systemd unit has aggressive sandboxing. The ReadWritePaths= line
in /etc/systemd/system/lithium-cycle.service must include
/sys/class/power_supply.
The service runs as root, because writing to
/sys/class/power_supply/.../charge_control_end_threshold requires it.
The systemd unit applies aggressive sandboxing:
ProtectSystem=strictwith explicitReadWritePathsPrivateNetwork=true,IPAddressDeny=any— no network at allCapabilityBoundingSet=empty,NoNewPrivileges=trueMemoryDenyWriteExecute=trueSystemCallFilter=@system-service(no privileged syscalls)
The script itself validates every config value before touching /sys,
uses atomic writes for the state file, and never interpolates user input
into paths.
You can audit the sandbox profile with:
systemd-analyze security lithium-cycle.serviceThis project is released under the MIT License.
Free to use for educational and research purposes. Please credit the author where applicable.
Created by Alberto Cirillo — 2026