A DKMS kernel driver for SupTronics UPS HAT boards on Raspberry Pi, distributed by Geekworm. Covers the X120x series and experimentally the X728, X729, and X708.
This driver is an independent community project — developed on my own time and hardware, not affiliated with or endorsed by SupTronics or Geekworm — though Geekworm links to it from their official wiki pages for these boards (e.g. the X1206 page).
Provides native Linux power supply integration equivalent to a laptop battery — battery icon in the taskbar, accurate state of charge (SoC), clean undervoltage shutdown, and selectable Long Life battery preservation mode. No custom scripts, no daemons, no polling loops.
If you just want to get up and running quickly, here is everything you need in one place.
Requirements: Raspberry Pi OS Bookworm or later (64-bit
recommended), fully updated — run sudo apt update && sudo apt full-upgrade first. You need kernel 6.3 or newer; check with uname -r (a fully-updated Bookworm is on 6.6 or 6.12). The driver builds via
DKMS against your running kernel, so there are no pre-built binaries to
match. (For maintainers: the 6.3 floor comes from the driver's use of
the modern one-arg i2c .probe, the sys-off handler framework, and the
void i2c .remove.)
Clone the repository:
git clone https://github.com/mor-lock/x120x-dkms.git
cd x120x-dkmsNow run the install command for your board — it sets the battery pack capacity for you. Copy-paste the one that matches:
| Board | Cells | Install command |
|---|---|---|
| X1200 | 2× 18650 | sudo bash install.sh --battery-mah 6000 |
| X1201 | 2× 18650 | sudo bash install.sh --battery-mah 6000 |
| X1202 | 4× 18650 | sudo bash install.sh --battery-mah 12000 |
| X1203 | External Li-ion | sudo bash install.sh --battery-mah <your_capacity> |
| X1205 | 2× 21700 | sudo bash install.sh --battery-mah 10000 |
| X1206 | 4× 21700 | sudo bash install.sh --battery-mah 20000 |
| X1207 | 1× 21700 (PoE) | sudo bash install.sh --battery-mah 5000 |
| X1208 | 1× 21700 + NVMe | sudo bash install.sh --battery-mah 5000 |
| X1209 | External Li-ion | sudo bash install.sh --battery-mah <your_capacity> |
| X708 (experimental) | External Li-ion | sudo bash install.sh --board x708 --battery-mah <your_capacity> |
| X728 V1.x (experimental) | 2× 18650 | sudo bash install.sh --board x728v1 --battery-mah 6000 |
| X728 V2.x (experimental) | 2× 18650 | sudo bash install.sh --board x728v2 --battery-mah 6000 |
| X729 (experimental) | 2× 18650 | sudo bash install.sh --board x729 --battery-mah 6000 |
The table assumes 3000 mAh 18650 cells and 5000 mAh 21700 cells — check
the mAh printed on your actual cells and multiply by the cell count if
yours differ. For the external-pack boards (X1203, X1209, and the
experimental X708) replace <your_capacity> with your pack's total
capacity in mAh. The X728/X708/X729 rows are experimental and
untested — see Experimental board support before relying on them.
Fast is the default, so it is omitted above; to start in Long Life
from the outset, add --charge-mode longlife (see Battery conservation
mode).
The driver defaults to Fast mode — it charges to 100%, which is right for almost every UPS install. A battery-preserving Long Life mode can be enabled at any time after install — see Battery conservation mode.
Then reboot:
sudo rebootOn a Raspberry Pi 5 the installer also stages two required bootloader
settings — POWER_OFF_ON_HALT=1 and PSU_MAX_CURRENT=5000 — which take
effect at that same reboot; see Required bootloader settings (Raspberry
Pi 5) below for what they do and the --skip-eeprom opt-out.
After the reboot, a battery icon appears in the desktop taskbar and the
battery shows up as a standard Linux power supply. The easiest way to
see full details is gnome-power-statistics:
sudo apt install gnome-power-manager
gnome-power-statisticsThis shows live battery percentage, voltage, energy, charge rate, and history graphs — all read directly from the driver via UPower. No configuration needed.
For a quick command-line view:
upower -i /org/freedesktop/UPower/devices/battery_x120x_batteryThat is all that is needed for a fully working installation. The rest of this document covers the driver interface, hardware details, and advanced configuration in depth.
No icon after rebooting? See Troubleshooting below.
If something is not working after installing and rebooting, find your symptom below. Every command is safe to copy-paste.
First check that the driver loaded:
dmesg | grep x120xHealthy output looks like:
x120x: loading out-of-tree module taints kernel.
x120x 1-0036: MAX1704x at 0x36 version 0x000
x120x 1-0036: x120x UPS ready (battery=x120x-battery ac=x120x-ac charger=x120x-charger hwmon=hwmon3)
- Nothing at all — the device-tree overlay is not loading. Check
that
dtoverlay=x120xis present in/boot/firmware/config.txt(under the[all]section), and that the reboot actually happened — reboot again if unsure. - Probe or I²C errors (e.g.
MAX1704xnot found) — the board is not making contact. Power down, re-seat the Pi firmly on the UPS board's pogo pins, and confirm you passed the right--board.
Check whether the power-supply devices are present:
ls /sys/class/power_supply/If you see x120x-battery, x120x-ac, and x120x-charger, the driver
is fine — this is a desktop/UPower display issue. Confirm UPower sees
the battery:
upower -eThen log out and back in; some desktop panels also need the battery indicator enabled in their panel/applet settings.
The MAX17043 fuel gauge needs a little time to converge after first power-up — give it a few minutes. If it stays at 0% with the charger connected, the cells may have been deep-discharged; see Dead battery detection and the deep-discharge recovery notes.
This is almost always the GPIO6 AC-detect line floating at boot — see
GPIO6 pull-up. If the charger LED is lit and ac_online stays 0
across reboots (with gpio=6=pu in config.txt), suspect a failed
board — see Incident 2 for the field-failure signature.
Almost always missing kernel headers. Install the ones matching your running kernel and reinstall:
sudo apt install linux-headers-$(uname -r)See Step 1 of the manual installation for details.
If none of the above helps, open an issue and include the output of:
dmesg | grep x120x
dkms status
cat /proc/device-tree/model # Pi model
cat /etc/os-release # OS versionplus which UPS board you have.
Or just run tools/collect-debug.sh from a checkout (no root required) —
it gathers all of the above, plus the driver's sysfs state, into a single
paste-ready block.
All X120x models share an identical software interface and are fully supported by this driver:
| Model | Pi compatibility | Connection | Battery |
|---|---|---|---|
| X1200 | Raspberry Pi 5 | Pogo pins | 2× 18650 |
| X1201 | Raspberry Pi 5 | Pogo pins | 2× 18650 (thin) |
| X1202 | Raspberry Pi 5 | Pogo pins | 4× 18650 |
| X1203 | Raspberry Pi 5 | Pogo pins | External Li-ion |
| X1205 | Raspberry Pi 5 | Pogo pins | 2× 21700 |
| X1206 | Raspberry Pi 5 | Pogo pins | 4× 21700 |
| X1207 | Raspberry Pi 5 | 40-pin header + pogo¹ | 1× 21700 (PoE) |
| X1208 | Raspberry Pi 5 | 40-pin header + pogo¹ | 1× 21700 + NVMe |
| X1209 | Raspberry Pi 5/4B/3B+/3B | 40-pin header + pogo² | External Li-ion |
¹ Connects via the 40-pin GPIO header. A single additional pogo pin carries the power button signal to the Pi 5's PSW through-hole.
² Connects via the 40-pin GPIO header. An optional pogo pin enables the power button function on Pi 5; not required on Pi 4/3.
Configurations confirmed working on real hardware. To add a row, file a hardware test report — reports for other boards, Pi models, kernels, and 32-bit armhf are very welcome.
| Board | Pi | Driver | Kernel | Arch | Reporter |
|---|---|---|---|---|---|
| X1206 | Raspberry Pi 5 | v0.4.6 | 6.12.x | aarch64 | maintainer |
The driver includes untested, experimental support for older Geekworm
UPS HAT boards that share the same MAX17043 fuel gauge and GPIO6 AC-detect
interface. These boards additionally require a GPIO pulse to cut power after
OS shutdown — without it the UPS stays on indefinitely after poweroff.
| Board | Pi support | Power-off GPIO | Charge control |
|---|---|---|---|
| X728 V2.x | All Pi models | GPIO26 | GPIO16 (V2.5 only) |
| X728 V1.x | All Pi models | GPIO13 | None |
| X708 | Pi 4/3 only | GPIO13 | None (GPIO16 = fan speed) |
| X729 | All Pi models | GPIO26 | None |
To install for an X728 V2.x board:
sudo bash install.sh --battery-mah 6000 --board x728v2
sudo rebootAvailable board variants: x120x (default), x728v2, x728v1, x708, x729.
Important notes for experimental boards:
- Long Life mode is only available on boards with charge control (X120x and
X728 V2.5). On all other boards a
Long Lifewrite is rejected andcharge_typealways readsFast. - The power-off GPIO pulse is registered via a sys-off handler
(
SYS_OFF_MODE_POWER_OFF_PREPARE) and fires aftersystemctl poweroff. The DT overlay must provide thepower-off-gpiosproperty for this to work — without it a warning is logged and the UPS will not cut power automatically after shutdown. - The DS1307 RTC on X728/X729 is handled by the existing mainline
rtc-ds1307kernel driver, not this driver. Adddtoverlay=i2c-rtc,ds1307toconfig.txtto enable it. - GPIO16 on the X708 controls fan speed, not charging. This driver never touches GPIO16 on X708.
- None of these boards have been tested by the author. Reports and feedback from users with this hardware are very welcome.
Architecture note: The driver has been developed and tested on
Raspberry Pi OS 64-bit (aarch64). The X1209 also supports Pi 4B,
Pi 3B+, and Pi 3B, which can run 32-bit Raspberry Pi OS (armhf).
The driver contains no architecture-specific code and should build and
run correctly on armhf — the DKMS build system will compile for
whatever kernel is running — but this has not been tested. Reports
from armhf users are welcome.
- X703 — ultra-thin single-cell UPS for Pi 4 only. Connects via test pins rather than the 40-pin header. No I²C fuel gauge or GPIO interface accessible from the Pi. Software shutdown not supported.
- X735 — power management and PWM fan controller, not a UPS. Has no battery fuel gauge and no I²C interface. Nothing for this driver to interface with.
- X-UPS1 — a universal stackable UPS with 12V/5V dual output and no Raspberry Pi GPIO integration. No I²C fuel gauge interface.
After loading, three devices appear under /sys/class/power_supply/:
/sys/class/power_supply/x120x-battery/
status Charging | Discharging | Not charging | Full | Unknown
health Good | Dead | Unknown
present 1 if battery detected
manufacturer SupTronics
model_name X120x (or X728, X708, X729 on experimental boards)
voltage_now cell voltage in µV
voltage_max_design 4200000 µV (4.20 V — full charge)
voltage_min_design 3200000 µV (3.20 V — safe shutdown floor)
capacity 0-100 %
capacity_level Critical (<5%) | Low (<10%) | Normal | Full (≥95%) | Unknown
charge_now current charge in µAh
charge_full total pack capacity in µAh (from battery_mah)
charge_full_design same as charge_full
charge_empty 0
energy_now current energy in µWh
energy_full total pack energy in µWh
energy_full_design same as energy_full
energy_empty 0
power_now instantaneous power in µW (+ charging, − discharging)
technology Li-ion
scope System
/sys/class/power_supply/x120x-ac/
online 1 = mains present, 0 = on battery
/sys/class/power_supply/x120x-charger/
online 1 = mains present
status Charging | Not charging | Discharging
charge_type Fast | Long Life (writeable)
charge_control_start_threshold SoC % to resume charging in Long Life mode (writeable, default 75)
charge_control_end_threshold SoC % to stop charging in Long Life mode (writeable, default 80)
A hwmon device is also registered under /sys/class/hwmon/:
/sys/class/hwmon/hwmonN/ (N assigned by kernel at load time)
name x120x
in0_input cell voltage in mV (read-only)
in0_label "cell_voltage"
curr1_input charge/discharge current in mA, signed (read-only)
curr1_label "battery_current"
power1_input charge/discharge power in µW, signed (read-only)
power1_label "battery_power"
energy1_input stored energy in µJ (read-only)
energy1_label "battery_energy"
Sign convention for curr1_input and power1_input: positive = charging,
negative = discharging.
The hwmon interface makes the driver visible to standard monitoring tools without any configuration:
# lm-sensors
sensors
sensors | grep -A6 x120x
# Direct sysfs read — find the hwmon index first
N=$(grep -rl x120x /sys/class/hwmon/*/name 2>/dev/null | grep -o 'hwmon[0-9]*' | head -1)
cat /sys/class/hwmon/$N/in0_input # voltage, mV
cat /sys/class/hwmon/$N/curr1_input # current, mA (+ charging, - discharging)
cat /sys/class/hwmon/$N/power1_input # power, µW
cat /sys/class/hwmon/$N/energy1_input # stored energy, µJPrometheus node_exporter with --collector.hwmon (enabled by default)
exposes these as:
node_hwmon_in_volts{chip="x120x",sensor="in0"}
node_hwmon_curr_amps{chip="x120x",sensor="curr1"}
node_hwmon_power_watt{chip="x120x",sensor="power1"}
node_hwmon_energy_joules{chip="x120x",sensor="energy1"}
Notes on derived channels: in0_input (voltage) is a direct hardware
reading from the MAX17043 VCELL register. The remaining three channels are
derived: power1_input is computed from the rate of change of SoC ×
pack capacity × nominal voltage; curr1_input is further derived as
power ÷ voltage; energy1_input is SoC% × pack energy capacity. The
MAX17043 does not measure current directly. Values are accurate during
steady charge/discharge but lag during rapid transitions and at very low
SoC before the fuel gauge model has converged.
UPower reads these devices automatically:
upower -e
upower -i /org/freedesktop/UPower/devices/battery_x120x_batteryLithium-ion cells wear out in two ways: cycle aging (charge and
discharge cycles) and calendar aging (time spent sitting at high
state of charge, especially near 100%). A UPS battery sees very few
cycles — it charges once and then sits on mains for weeks or months
between outages — so calendar aging at full charge is the dominant wear
mechanism for always-on systems. Conservation mode slows it by holding
the battery at a lower resting state of charge. Note, though, that on a
standby UPS slower aging does not translate into more backup runtime
(see Choosing a profile: runtime vs. longevity) — which is why Fast
is the default, and conservation mode is aimed mainly at frequently
cycled builds.
The driver supports two charge modes, selectable via charge_type:
Fast(default) — charges to 100%, disables the charger, and re-enables it once SoC falls to 95%. This 100%/95% hysteresis band lets the pack drain down a little (the X1206 has a small standby draw on the battery rail) before topping up, instead of micro-cycling against the full-charge cutoff. Cells rest at or near full voltage, so calendar aging continues at its normal rate. Best when the priority is maximum backup capacity at the moment an outage begins.Long Life— charges tocharge_control_end_threshold(default 80%), disables the charger, and re-enables it atcharge_control_start_threshold(default 75%). Cells spend their idle life at a noticeably lower voltage, where calendar aging is dramatically reduced. The trade-off is about 20% less runtime during an outage (~1.3 h on a full X1206); the benefit is that the cells retain meaningfully more of their original capacity after several years. Best for a frequently cycled build (e.g. a portable unit), where cycle aging dominates and trimming the top of the charge greatly extends cell life — or, on a UPS, only when the pack is oversized relative to your worst outage or deferring the eventual replacement matters more than runtime. See Choosing a profile: runtime vs. longevity below — on a standby UPS, slower aging does not automatically mean more runtime years later, becauseLong Lifealso starts every outage at a lower charge.
Enable and disable conservation mode from the command line:
# Enable conservation mode (charges to 80%, resumes at 75%)
echo "Long Life" | sudo tee /sys/class/power_supply/x120x-charger/charge_type
# Disable conservation mode (charges to 100%)
echo "Fast" | sudo tee /sys/class/power_supply/x120x-charger/charge_type
# Check current mode
cat /sys/class/power_supply/x120x-charger/charge_type
# Adjust thresholds (example: stop at 85%, resume at 70%)
echo 70 | sudo tee /sys/class/power_supply/x120x-charger/charge_control_start_threshold
echo 85 | sudo tee /sys/class/power_supply/x120x-charger/charge_control_end_thresholdNote:
charge_control_start_thresholdandcharge_control_end_thresholdalways report the Long Life band (default 75 / 80), regardless of the active mode — the standard sysfs interface has no way to express the Fast band. InFastmode those two values are inert: charging follows the fixed 100% / 95% band described above. So seeing75/80there while inFastis expected, not a misconfiguration.
The default thresholds (75% / 80%) match the recommendation of TLP, the widely-used Linux power management tool, and are a commonly accepted balance between battery longevity and available backup capacity.
The default thresholds can also be changed permanently via module
parameters in /etc/modprobe.d/x120x.conf:
options x120x battery_mah=20000 conservation_start=75 conservation_end=80
Quick answer — pick by how often the battery actually gets used:
| Your setup | Profile |
|---|---|
| Standby UPS — runs on mains, discharges only during occasional outages | Fast (default) |
| Frequently cycled (e.g. portable) — runs off the battery away from mains and recharges when docked, cycling most days | Long Life |
Most x120x installs are the first case, so Fast is the default — and
on a standby UPS, limiting charge to 80% mostly just throws away backup
time. But if the pack is cycled hard, Long Life can double or triple
its lifespan. The rest of this section explains why the right answer
hinges entirely on cycling, and what each profile costs.
Lithium-ion cells age by two largely independent mechanisms:
- Calendar aging — damage from time spent sitting at a high state of charge, regardless of use. Runs faster the closer to full (and the warmer) the cell sits.
- Cycle aging — damage per charge/discharge cycle, concentrated at the voltage extremes, especially the top of the charge.
Which one dominates is set entirely by how often you cycle. A standby UPS cycles a few times a year, so calendar aging dominates. A portable build, run off the pack away from mains and recharged when docked, may cycle daily, so cycle aging dominates. Each profile targets one of these — which is why the right choice flips depending on your use.
On a standby UPS, calendar aging dominates, and the usual "more capacity
now vs. longer life later" framing is incomplete. It overlooks one
fact: in Long Life mode the battery does not only age more slowly — it
also starts every outage a full 20 percentage points lower. Slower
aging has to first overcome that head start before it yields any extra
usable runtime, and for a UPS sized close to its actual backup needs,
it often never does within the life of the device.
The model below estimates usable runtime at the start of an outage, as a function of years in service, for both profiles. Usable runtime is the energy available between the resting state of charge and the 2% shutdown floor — where the driver actually powers off — scaled by the capacity the cells have retained to that point.
Assumptions (read these before trusting the numbers). These are a model, not measurements of your hardware, and the ranking is only as good as the assumptions behind it:
- Load: ~5 W continuous (a Raspberry Pi 5 with NVMe at idle). Runtime scales inversely with load — double the load, halve every number.
- Pack: an X1206 with 4× 21700 cells, fresh full→empty ≈ 7 h. Runtime scales linearly with pack capacity; a smaller pack shifts every row down proportionally but does not change the ranking.
- Shutdown floor: 2% SoC — where the driver's UPower
PercentageActionfires the clean OS shutdown.- Starting charge: the X1206 has a small standby drain (~20 mW of board/gauge quiescent draw on the battery rail), so a full pack slowly loses charge and tops back up — a shallow sawtooth:
Fastbetween 100% and 95% (recharging roughly weekly),Long Lifebetween 80% and 75% (at rest, a Fast-held pack relaxes to ~4.18 V). The table uses the top of each band; since the pack spends its time evenly across the band, a typical outage starts ~mid-band (~2.5 points / ~0.15 h lower).- Calendar aging: assumed 3%/yr capacity loss at full charge (~100% SoC) and 2%/yr at 80% SoC, at a moderate ~25 °C. These are illustrative midpoints from general Li-ion NMC literature, not measured for any specific cell. Real rates vary widely with cell quality and, especially, temperature — calendar aging roughly doubles per +10 °C, so a pack running warm (e.g. in the Pi's exhaust) ages far faster than this and both columns shrink.
- Cycle aging is small but not quite zero — that standby sawtooth is a few full-equivalent cycles per year. It adds marginally more wear under
Fast(cycling at 95–100%, the harshest region) than underLong Life(75–80%), but it is dwarfed by the calendar-aging difference above, so calendar aging still dominates. A genuinely cycled build (e.g. portable) is a different regime — see Frequently cycled builds below.- Runtime is treated as proportional to the state-of-charge span, ignoring the nonlinear "knee" near the bottom of the discharge curve.
These rates assume moderate-quality NMC, not any particular cell.
Cell quality shifts the result but rarely the ranking. The "more
runtime" comparison is driven by geometry — Long Life gives up ~20%
of its usable span up front and must claw it back through slower aging —
so what matters is how fast the cells age relative to that handicap:
- Premium cells (e.g. Molicel) age slowly in absolute terms, so the
year-0 runtime gap — which is pure starting charge, not aging —
persists for longer.
Fastwins more decisively and the crossover pushes well past year 30. - Budget or hot-running cells age fast, eroding both columns
quickly and pulling the crossover in. With genuinely poor cells (or
a pack baking in the Pi's exhaust),
Long Lifecan edge ahead in the early teens.
What would change the model itself is chemistry, not brand: the
numbers bake in NMC at 4.2 V. LiFePO₄ cells have far flatter calendar
aging and much weaker sensitivity to storage charge, which shrinks the
Long Life benefit toward nothing and makes Fast win harder still.
Under the moderate-NMC assumptions (base case: 3%/yr vs 2%/yr):
| Years in service | Fast (~100%) |
Long Life (hold 80%) |
More runtime |
|---|---|---|---|
| 0 | 6.2 h | 4.9 h | Fast (+1.3 h) |
| 5 | 5.3 h | 4.5 h | Fast (+0.9 h) |
| 10 | 4.6 h | 4.0 h | Fast (+0.5 h) |
| 15 | 3.9 h | 3.6 h | Fast (+0.3 h) |
| 20 | 3.4 h | 3.3 h | Fast (+0.1 h) |
| 25 | 2.9 h | 3.0 h | Long Life (+0.1 h) |
(Runtime to the driver's 2% shutdown. The year-0 Fast figure of 6.2 h is the measured value from the Incident 1 full discharge, not just a model output; the later years scale it by assumed capacity retention.)
The counter-intuitive result: Fast delivers more usable runtime
than Long Life for more than two decades. The lower starting charge
in Long Life costs ~1.3 h of runtime up front (~20% of the usable
span), and the slower aging does not repay that until the curves cross
at around year 22 — by which point the pack is well past a routine
replacement anyway. The crossover is sensitive to the aging gap: if
Long Life ages much more slowly than assumed (e.g. 1.5%/yr) it pulls
in toward year 15; if the benefit is smaller (2.5%/yr) Fast wins past
year 40. In none of these cases does Long Life win at year 10.
What Long Life does buy is capacity retention, not runtime: at
year 10 the 80%-held pack retains ~82% of its original capacity versus
~74% for the full-charge (Fast) pack. That defers the eventual
replacement; it does not give you a longer outage on any given day.
So on a UPS the two profiles trade off cleanly: Fast wins on
runtime for the realistic life of the device, while Long Life only
wins on capacity retention — worthwhile if the pack is oversized
relative to your worst outage, or if postponing the eventual (cheap)
replacement matters more than per-outage runtime. For most standby
installs that is not a good trade, which is why Fast is the default.
The exception is a Pi that genuinely cycles the pack often — most realistically a portable build, where the unit runs off the battery away from mains and is recharged whenever it is docked. A pack cycled most days is a different regime entirely: cycle aging now dominates, and it is heavily concentrated at the top of the charge. Taking an NMC cell all the way to 4.2 V means:
- the cathode is fully delithiated and under maximum lattice strain, so it micro-cracks a little more each cycle;
- electrolyte oxidation accelerates sharply above ~4.0–4.1 V, growing resistive films on the cathode; and
- the graphite anode is fully lithiated, raising the risk of lithium plating (permanent capacity loss), worst when charging fast or cold.
Long Life stops before that zone, and because cycle life is strongly
nonlinear in the charge window, trimming the top buys a lot:
| Charge ceiling | Approx. cycle life (to 80% capacity) |
|---|---|
| 4.2 V (100%) | baseline (1×) |
| 4.1 V (~90%) | ~1.5–2× |
4.0 V (~80%, Long Life default) |
~2–3× |
| 3.9 V (~70%) | ~3–4× |
(Approximate NMC figures; exact numbers vary by cell.) So a pack that cycles daily can last two to three times as many cycles before it wears out — a benefit that lands immediately and compounds on every cycle, not the decades-away payoff of the calendar case. This is the same reason laptops, phones and EVs cap charging at 80% by default: they are battery-cycling devices, not standby reserves.
The trade is still real — Long Life gives up ~20% of per-charge
runtime — but here you are paying it to roughly triple the pack's
lifespan, rather than (as on a standby UPS) getting almost nothing back.
And because the charge mode can be switched at runtime, a portable user
can flip to Fast before a long outing when full capacity is needed,
then back to Long Life for everyday use.
Whichever you choose, both profiles disable the charger once the
pack reaches its ceiling rather than holding it on a continuous float —
so both avoid the single worst calendar-aging stressor (sitting pinned
at 4.2 V indefinitely). The only difference between them is the resting
state of charge. If you are unsure, you almost certainly have a standby
UPS: leave it on the default Fast, keep the cells cool, never let them
deep-discharge (see Incident 1), and a replacement — if ever needed —
is cheap and infrequent.
Back to the standby-UPS numbers: the model's year-0 row is not just theory — it matches a real full-depth discharge logged during the Incident 1 outage (before the undervoltage shutdown existed, so the pack drained all the way down). On an X1206 (4× 21700) at ~5 W idle load, from a full start:
| Milestone | Time on battery |
|---|---|
| Down to 50% | ~4.2 h |
10% — driver flags capacity_level=Low |
~5.9 h |
| 2% — clean OS shutdown fires | ~6.2 h |
| 0% — fully empty (no shutdown was in place) | ~7.0 h |
(Incident 1's total 10.3 h on battery includes ~3.3 h spent below fuel-gauge 0%, after the 7.0 h in the table above ends.)
Note the curve: the first half drains slowly on the flat part of the
discharge (~4 h to 50%), then collapses — the bottom half is gone in
about two hours. The driver's clean shutdown fires at 2% SoC (~6.2 h
here); the 10% Low-battery warning lands at ~5.9 h, only ~18 minutes
earlier, because the bottom drains so fast. The lesson: most of the
runtime is in the top half, so starting lower hurts disproportionately.
Because Long Life begins every outage at 80% instead of ~100%, it
enters that drain a full 20 points down and reaches the 2% shutdown well
over an hour sooner — ~4.9 h instead of ~6.2 h, roughly 1.3 h less
ride-through, immediately, on every outage. And as the table above
shows, that lost time is not repaid by slower aging until ~year 22. So
on a pack sized close to its job (here ~6 h against typical 2–5 h
outages), limiting to 80% sheds backup time you are actually using, with
no practical payback — which is exactly why Fast is the default.
It is sometimes assumed that over years of always-on operation Long Life
ends up giving more outage runtime — the idea being that its cells age
less, so the retained capacity offsets the lower starting charge. For
the realistic life of a UPS this is not true. Long Life also starts
every outage a full 20 points lower, and that head start is not repaid by
slower aging until well over two decades in; until then a 100%-held
(Fast) battery delivers more usable runtime despite aging faster (see
the year-by-year model above). On a standby UPS, Long Life's real
benefit is deferred cell replacement, not better outage protection —
which is why Fast is the default.
Lithium-ion cells have a safe operating voltage range of approximately 3.0–4.2 V per cell. When a cell discharges below ~3.0 V the chemistry becomes unstable: copper current collectors begin to dissolve into the electrolyte and redeposit as dendrites on the anode. This is irreversible — the cell permanently loses capacity and internal resistance rises sharply. In severe cases the cell will no longer accept charge at all.
This is the most common cause of the "battery charged to 100% but powers off immediately when unplugged" reports on the Geekworm wiki. The user ran the battery flat, plugged the charger back in, but the cells had already been destroyed by deep discharge and cannot recover.
The driver reports capacity_level=Critical when SoC drops below 5%
and the system is on battery (ac_online=0). When mains power is
present, even at 0% SoC, capacity_level is never reported as Critical
— the battery is charging and shutting down would cause a livelock on
recovery from a deep discharge event. UPower then fires
warning-level: action when SoC reaches its PercentageAction
threshold, which the installer sets to 2% — well above the 3.20 V
floor. This causes systemd-logind to initiate a clean OS shutdown.
The install script configures the whole chain automatically:
HandleLowBattery=poweroff in logind.conf, and
UsePercentageForPolicy=true, PercentageAction=2 and
CriticalPowerAction=PowerOff in UPower.conf.
With the driver installed, the shutdown sequence on a prolonged outage is:
grid power lost
↓
system runs on battery
↓
SoC drops to 5% → capacity_level=Critical → UPower warning-level: low
↓
SoC drops to 2% → UPower warning-level: action → logind: systemctl poweroff
↓
clean OS shutdown
↓
UPS cuts power to Pi — cells preserved well above 3.0 V
↓
grid restored → Pi boots automatically
Without the driver, there is no automatic shutdown. The Pi runs until the UPS hardware cuts power at its own low-voltage threshold, which may be at or below the cell damage threshold.
If cells have already been deep-discharged and destroyed, the driver
detects this automatically. When the system is on grid power, the
cell voltage remains below 3.10 V for 10 minutes with no meaningful
voltage rise (less than 10 mV/h), and SoC is at or below 2%, the
battery health is reported as Dead:
cat /sys/class/power_supply/x120x-battery/health
# DeadUPower surfaces this as health: dead and desktop environments will
display a battery warning. A kernel log entry is also emitted:
x120x 1-0036: battery appears dead: 3050 mV on grid for 600 s with <10 mV/h rise
The health flag clears automatically if the condition resolves — for example after replacing the cells.
The charge mode (Fast or Long Life) is persisted across reboots
automatically. The installer installs a udev rule that fires whenever
charge_type is written and updates conservation_mode_default in
/etc/modprobe.d/x120x.conf. On next boot the driver reads this
parameter and starts in the last-used mode.
The persistence files installed are:
/usr/local/lib/x120x-persist-mode.sh— shell script called by udev/etc/udev/rules.d/90-x120x-persist.rules— udev rule
No action is required from the user — write Long Life once and it
will remain across reboots until explicitly changed back to Fast.
The conservation mode interface integrates natively with desktop environments via UPower:
- GNOME 48+ — "Preserve battery health" toggle in Settings → Power
- KDE Plasma — charge threshold controls in Power Management
When the toggle is enabled, UPower writes Long Life to charge_type
automatically. The full chain — desktop toggle → UPower → sysfs →
driver → GPIO16 → hardware — works without any custom userspace code.
TLP and any other tool that writes to the standard
charge_control_start_threshold and charge_control_end_threshold
sysfs files will also work automatically.
On headless systems, systemd-logind initiates a clean shutdown when
UPower's PercentageAction threshold is reached, which the installer
sets to 2% SoC.
The driver reports capacity_level=Critical at 5% SoC, which triggers
UPower's low battery warning. The actual shutdown fires at 2% when
UPower escalates to warning-level: action.
The install script enables this automatically by setting the following
in /etc/systemd/logind.conf:
HandleLowBattery=poweroffTo disable it, change the line to:
HandleLowBattery=ignoreThe installer also configures /etc/UPower/UPower.conf:
UsePercentageForPolicy=true— act on battery percentage; a UPS HAT reports no time-to-empty estimate for UPower to use.PercentageAction=2— fire the PowerOff action at 2% SoC. Debian/ RPi-OS shipPercentageAction=0, which would only act at 0% — no margin above the 3.20 V floor; the installer overrides it to 2%.CriticalPowerAction=PowerOff— the defaultHybridSleeprequires swap space and hangs indefinitely on a Raspberry Pi.NoPollBatteries=true— the driver sends UPower a notification on every meaningful state change and on a 30-second heartbeat. UPower polling the kernel independently on its own timer causes race conditions that produce spurious0%/unknownentries in the history files and corrupt the gnome-power-statistics rate and charge graphs. Disabling polling eliminates these artefacts.
All X120x boards share an identical GPIO interface:
| Signal | GPIO | Direction | Description |
|---|---|---|---|
| I²C SDA | GPIO2 | in/out | MAX17043 fuel gauge data |
| I²C SCL | GPIO3 | out | MAX17043 fuel gauge clock |
| AC present | GPIO6 | input | High = mains OK, low = on battery |
| Charge ctrl | GPIO16 | output | Low = charging enabled, high = disabled |
These boards share GPIO2/3 (I²C) and GPIO6 (AC detect) with the X120x series, but add a power-off GPIO and differ in charge control:
| Signal | X728 V2.x / X729 | X728 V1.x / X708 | Description |
|---|---|---|---|
| I²C SDA/SCL | GPIO2 / GPIO3 | GPIO2 / GPIO3 | MAX17043 fuel gauge |
| AC present | GPIO6 | GPIO6 | High = mains OK, low = on battery |
| Power-off | GPIO26 | GPIO13 | Pulse high ~3 s to cut UPS power |
| Charge ctrl | GPIO16 (V2.5 only) | — | Low = enabled, high = disabled |
| Fan speed | — | GPIO16 (X708) | High = fast, low = slow (not used by driver) |
The power-off GPIO must be pulsed by the driver after OS shutdown to
tell the UPS to cut power — without it the UPS stays on indefinitely.
On X120x boards this is handled by POWER_OFF_ON_HALT=1 in the Pi 5
bootloader EEPROM instead.
The X120x boards drive GPIO6 high when mains power is present and
actively pull it low on power loss. Without a software pull-up, GPIO6
can float low at boot before the UPS hardware has finished
initialising — causing the driver to falsely report ac_online=0 even
when the charger is connected. This is particularly likely when the
PSU is overloaded at boot (e.g. simultaneously charging the UPS battery
and powering other USB devices), which can cause the input voltage to
sag and delay or prevent GPIO6 assertion.
The installer adds gpio=6=pu to config.txt to apply a software
pull-up. This ensures GPIO6 reads high by default until the hardware
actively drives it low, eliminating false AC-lost readings at boot.
If you installed the driver manually, add this line to
/boot/firmware/config.txt (or /boot/config.txt on older systems):
gpio=6=pu
After a genuine deep discharge event the MAX17043 fuel gauge may report 0% SoC on the next boot. The driver handles this correctly:
- 0% SoC is treated as a valid reading, not implausible — a quick-start command (which resets the fuel gauge's SoC estimation) is not issued, avoiding a reset at the worst possible moment.
- The charger (GPIO16) is forced on at probe and whenever SoC is at or below the resume threshold; between the resume and stop thresholds it holds its previous state. The battery therefore starts charging immediately on every boot regardless of saved state.
capacity_level=Criticalis never reported when mains power is present, preventing UPower from triggering a shutdown loop while the battery is recovering.- The
gpio=6=pupull-up ensures AC is detected correctly even if the PSU voltage sagged during the outage.
Without these fixes, a deep discharge followed by a power restoration can result in a livelock: the Pi boots, UPower immediately fires a critical battery shutdown, the Pi reboots, and the cycle repeats until the battery is exhausted.
Note on register layout: The MAX17043 registers on these boards are
mapped differently from the datasheet. VCELL is at register 0x02
and SOC is at 0x04, as confirmed by SupTronics' published software.
This driver follows the observed hardware behaviour.
The fuel gauge default I²C address is 0x36. The driver probes
0x36, 0x55, 0x32, 0x62 in order to cover all known board revisions.
On a Raspberry Pi 5, two bootloader EEPROM settings are required for the
driver's core behaviour. install.sh configures them automatically —
it is idempotent and only stages a change when a value is missing or
wrong — so most users never touch this. Pass --skip-eeprom to opt out
and manage them yourself.
POWER_OFF_ON_HALT=1— the Pi fully depowers the SoC when Linux halts, so the UPS can cut and restore power to restart it cleanly when mains returns. Without it the Pi stays partially powered after shutdown and the UPS cannot restart it. Caveat: this also disables RTC-alarm and power-button wake from a halted state — intended for a UPS install, where the UPS performs the power cycling.PSU_MAX_CURRENT=5000— tells the Pi its supply can deliver 5 A, removing firmware current-limiting and suppressing spurious low-power warnings when drawing high current through the UPS board. Caveat: this assumes a genuinely 5 A-capable supply.
rpi-eeprom-config --apply only stages the update on the boot
partition; the bootloader flashes it early during the next boot, so it
lands with the same reboot as the driver install — no separate reboot is
needed.
To configure them manually (or in a scripted setup), the non-interactive one-liner keeps every other setting and sets just these two:
conf=$(mktemp)
sudo rpi-eeprom-config > "$conf"
sed -i -e '/^POWER_OFF_ON_HALT=/d' -e '/^PSU_MAX_CURRENT=/d' "$conf"
printf 'POWER_OFF_ON_HALT=1\nPSU_MAX_CURRENT=5000\n' >> "$conf"
sudo rpi-eeprom-config --apply "$conf"
rm -f "$conf"Or edit interactively (prefix EDITOR=nano or EDITOR=vim if you like):
sudo rpi-eeprom-config -eEither way, reboot afterwards for the bootloader to flash the update.
Clone the repository and run the install script:
git clone https://github.com/mor-lock/x120x-dkms.git
cd x120x-dkms
sudo bash install.shThe script handles everything — including the Pi 5 bootloader settings — and tells you what it is doing at each step. Reboot when it finishes.
Optional arguments configure the driver at install time:
| Option | Default | Description |
|---|---|---|
--battery-mah N |
1000 |
Total pack capacity in mAh. Multiply per-cell capacity by number of cells. |
--charge-mode MODE |
fast |
Initial charge mode: fast or longlife. Persisted across reboots. See Getting started for guidance on which to choose. |
--board VARIANT |
x120x |
Board variant. See Experimental board support for details. Variants other than x120x are untested. |
--skip-eeprom |
(off) | Do not modify Pi 5 bootloader EEPROM settings (POWER_OFF_ON_HALT, PSU_MAX_CURRENT); configure them manually — see Required bootloader settings. |
Examples:
# X1206 with four 5000 mAh 21700 cells
sudo bash install.sh --battery-mah 20000
# X1205 with two 5000 mAh 21700 cells
sudo bash install.sh --battery-mah 10000
# Portable build cycled most days — Long Life to extend cell lifespan
sudo bash install.sh --battery-mah 20000 --charge-mode longlife
# Show available options
sudo bash install.sh --helpIf omitted the default (1000 mAh) is used and can be changed
later by editing /etc/modprobe.d/x120x.conf and rebooting.
To remove the driver and all changes made by the installer:
sudo bash uninstall.sh
sudo rebootThe uninstall script removes:
- The DKMS kernel module (all installed kernel versions)
- The DKMS source tree from
/usr/src/ - The device tree overlay from
/boot/firmware/overlays/ - The
dtoverlay=x120xandgpio=6=pulines fromconfig.txt /etc/modprobe.d/x120x.conf- The charge mode persistence script and udev rule
- The marker-wrapped block that the installer added to
/etc/systemd/logind.conf(delimited by# >>> x120x-dkms: logind-low-battery (do not edit) >>>...# <<< x120x-dkms: logind-low-battery <<<) - The marker-wrapped block that the installer added to
/etc/UPower/UPower.conf(delimited by# >>> x120x-dkms: upower-pi-tweaks (do not edit) >>>...# <<< x120x-dkms: upower-pi-tweaks <<<) - Any bare lines left over from older (pre-marker) installer versions
The following are intentionally left unchanged:
- The
dkmsandlinux-headers-$(uname -r)packages — removing them could break other DKMS modules on the system. - Bootloader EEPROM settings (
POWER_OFF_ON_HALT,PSU_MAX_CURRENT) — set by the installer on a Pi 5, but system-level and possibly relied upon by other software. To revert them, runsudo rpi-eeprom-config -eand remove the relevant lines manually. - Lines outside the installer's marker block in
logind.confandUPower.conf. In particular, previously commented-out keys (such as a deliberate#HandleLowBattery=ignore) are never uncommented — the installer has no way to tell whether a comment was its own or yours, and silently reactivating a setting you had intentionally disabled would be surprising. If you had pre-existing values in those files, review them manually after uninstall.
If you prefer to understand each step or the install script is not suitable for your setup, follow these instructions.
sudo apt update
sudo apt install dkms linux-headers-$(uname -r)dkms manages the kernel module and rebuilds it automatically after
kernel updates. linux-headers-$(uname -r) provides headers that
match the currently running kernel exactly, which is what DKMS needs
to compile the module.
Note: older Raspberry Pi OS releases used a single metapackage
raspberrypi-kernel-headers. On Bookworm and later this metapackage may pull headers for a different kernel than the one you booted with, which causes DKMS builds to fail withkernel headers ... cannot be found. Use the kernel-specific package shown above to avoid that.
DKMS expects the source under /usr/src/<name>-<version>/:
sudo cp -r . /usr/src/x120x-0.4.7sudo dkms add x120x/0.4.7
sudo dkms build x120x/0.4.7
sudo dkms install x120x/0.4.7You will see compiler output scroll past — this is normal. The build
takes about a minute on a Raspberry Pi 5. It should end with
DKMS: install completed.
Verify the module is installed:
dkms statusYou should see x120x/0.4.7, <kernel-version>, aarch64: installed.
The overlay tells the kernel how the board is wired (I²C address, GPIO assignments) so the driver can claim the hardware correctly.
dtc -@ -I dts -O dtb -o x120x.dtbo x120x-overlay.dts# Raspberry Pi 5 (Raspberry Pi OS Bookworm):
sudo cp x120x.dtbo /boot/firmware/overlays/
# Raspberry Pi 4 and earlier:
sudo cp x120x.dtbo /boot/overlays/Open the boot configuration file:
# Raspberry Pi 5:
sudo nano /boot/firmware/config.txt
# Raspberry Pi 4 and earlier:
sudo nano /boot/config.txtAdd these lines at the end of the file:
[all]
dtoverlay=x120x
The [all] section header ensures the overlay is applied on all Pi
models. Without it, any [cm4] or [cm5] conditional blocks earlier
in the file will prevent the overlay from loading on a Pi 5.
Save and exit (Ctrl+O, Enter, Ctrl+X in nano).
On a Pi 5, set the two required bootloader EEPROM settings — see Required bootloader settings (Raspberry Pi 5) above for what they do and why. The non-interactive one-liner keeps every other setting:
conf=$(mktemp)
sudo rpi-eeprom-config > "$conf"
sed -i -e '/^POWER_OFF_ON_HALT=/d' -e '/^PSU_MAX_CURRENT=/d' "$conf"
printf 'POWER_OFF_ON_HALT=1\nPSU_MAX_CURRENT=5000\n' >> "$conf"
sudo rpi-eeprom-config --apply "$conf"
rm -f "$conf"(Pi 4 and Pi 3 users skip this step.)
The driver reports capacity_level=Critical when SoC drops below 5%.
UPower escalates to warning-level: action at 2% SoC (the
PercentageAction threshold the installer sets), which triggers a clean
OS shutdown via logind. To enable this, add the following to
/etc/systemd/logind.conf:
sudo nano /etc/systemd/logind.confAdd or update:
HandleLowBattery=poweroffTo disable this behaviour at any time, change the value to ignore.
The install script does this automatically.
sudo rebootAfter the reboot, check that everything is working:
# Confirm the overlay loaded and the driver initialised
dmesg | grep x120x
# Check the three power_supply devices exist
ls /sys/class/power_supply/
# Read live values
cat /sys/class/power_supply/x120x-battery/capacity
cat /sys/class/power_supply/x120x-battery/voltage_now
cat /sys/class/power_supply/x120x-ac/online
# Full UPower view
upower -i /org/freedesktop/UPower/devices/battery_x120x_batteryExpected output from dmesg | grep x120x:
x120x: loading out-of-tree module taints kernel.
x120x 1-0036: MAX1704x at 0x36 version 0x000
x120x 1-0036: x120x UPS ready (battery=x120x-battery ac=x120x-ac charger=x120x-charger hwmon=hwmon3)
The "taints kernel" message is normal for any out-of-tree module.
voltage_now is reported in µV — divide by 1,000,000 for volts.
A healthy fully charged cell reads approximately 4,150,000 (4.15 V).
If you cannot or do not want to use the device tree overlay, the driver
can be loaded manually. I²C readings (capacity and voltage) will work
but ac_online will always read 0 because GPIO6 cannot be claimed
without the overlay on kernel 6.12+.
sudo modprobe x120xTo load automatically at boot without the overlay, add x120x to
/etc/modules.
The installer and uninstaller logic is covered by a small shell test
suite under tests/. The tests sed-extract the individual functions,
mock the external commands (rpi-eeprom-config, the device-tree model,
sysfs paths), and assert on the resulting files and logs — nothing
touches the real system, so they run unprivileged:
bash tests/test-install.sh # bootloader EEPROM staging
bash tests/test-ini-blocks.sh # logind/UPower block round-trip, config.txt
bash tests/test-args.sh # argument parsing
bash tests/test-persist.sh # charge-mode persistence script
bash tests/test-collect-debug.sh # diagnostics collectorOr run the whole suite at once:
make testCI runs all of them, plus bash -n and shellcheck -S warning on the
scripts, a module compile-check (KCFLAGS=-Werror), and a device-tree
overlay compile, on every push and pull request.
(These are the same checks as Step 10 of the manual installation, collected here for quick reference.)
# Kernel log
dmesg | grep x120x
# sysfs directly
cat /sys/class/power_supply/x120x-battery/capacity
cat /sys/class/power_supply/x120x-battery/voltage_now
cat /sys/class/power_supply/x120x-battery/capacity_level
cat /sys/class/power_supply/x120x-ac/online
cat /sys/class/power_supply/x120x-charger/charge_type
# Via UPower
upower -i /org/freedesktop/UPower/devices/battery_x120x_battery
# Test conservation mode toggle
echo "Long Life" | sudo tee /sys/class/power_supply/x120x-charger/charge_type
echo "Fast" | sudo tee /sys/class/power_supply/x120x-charger/charge_type| Parameter | Default | Description |
|---|---|---|
i2c_bus |
1 |
I²C bus number |
i2c_addrs |
0x36,0x55,0x32,0x62 |
Fuel gauge addresses to probe |
gpio_ac |
6 |
BCM GPIO for AC-present |
gpio_charge_ctrl |
16 |
BCM GPIO for charge control |
battery_mah |
1000 |
Total pack capacity in mAh |
conservation_start |
75 |
SoC % at which charging resumes in Long Life mode |
conservation_end |
80 |
SoC % at which charging stops in Long Life mode |
conservation_mode_default |
0 |
Start in Long Life mode (1) or Fast mode (0). Updated automatically on every charge_type sysfs write and persisted to modprobe.d by a udev rule. |
board |
x120x |
Board variant: x120x, x728v2, x728v1, x708, x729. Set by installer. Variants other than x120x are experimental. |
The install script writes these to /etc/modprobe.d/x120x.conf. To
change them after installation, edit that file and reboot:
# /etc/modprobe.d/x120x.conf
options x120x battery_mah=20000
Set battery_mah to your total pack capacity — number of cells
multiplied by per-cell capacity. For example, an X1206 with four
5000 mAh cells: battery_mah=20000.
x120x-dkms/
├── README.md
├── LICENSE
├── SECURITY.md — vulnerability reporting policy
├── RELEASING.md — on-hardware release checklist
├── .gitignore — build-artifact ignore rules
├── Makefile — DKMS build entry point
├── dkms.conf — DKMS package definition
├── install.sh — installer (see Installation)
├── uninstall.sh — uninstaller (see Uninstallation)
├── x120x-overlay.dts — device tree overlay source
├── suptronics,x120x.yaml — DT binding schema (upstreaming)
├── src/
│ ├── x120x.c — the kernel driver
│ └── Kbuild
├── tests/ — shell test suite (see Testing)
│ ├── test-install.sh
│ ├── test-ini-blocks.sh
│ ├── test-args.sh
│ ├── test-persist.sh
│ └── test-collect-debug.sh
├── tools/
│ └── collect-debug.sh — one-shot diagnostics paste (see Troubleshooting)
└── .github/
├── dependabot.yml — weekly GitHub Actions updates
├── workflows/ci.yml — CI: shell, module build, overlay
└── ISSUE_TEMPLATE/
├── bug_report.yml
├── hardware_report.yml
└── config.yml
Many users of these boards run Python scripts that access GPIO6 and GPIO16 directly to monitor AC state and control charging. Once the kernel driver is loaded, it claims exclusive ownership of these GPIOs through the kernel descriptor API. Any userspace script directly accessing these pins will fail or conflict with the driver.
Scripts that read GPIO6 to detect grid loss can be replaced with a simple sysfs read:
# Old approach — direct GPIO access (will fail with driver loaded)
# pinctrl get 6
# gpio_value = open("/sys/class/gpio/gpio6/value").read()
# New approach — read from driver via sysfs
cat /sys/class/power_supply/x120x-ac/online
# 1 = mains present, 0 = on batteryIn Python:
def ac_online():
with open('/sys/class/power_supply/x120x-ac/online') as f:
return f.read().strip() == '1'UPower also publishes AC state over D-Bus if your application already uses UPower.
GPIO16 is reserved by the driver and cannot be accessed from userspace while the driver is loaded. This is intentional — the driver manages it safely with proper locking and hysteresis.
In practice there should be little need to control GPIO16 directly:
- Fast mode — the driver automatically stops charging at 100% and floats the battery, resuming at 95%. No script needed to prevent micro-cycling.
- Long Life mode — the driver manages hysteresis between the configured thresholds (default 75%/80%). Equivalent to what GPIO16 scripts were trying to achieve, but implemented correctly in the kernel with mutex protection.
- Charge mode is selectable and persistent via sysfs:
# Enable Long Life mode (stop at 80%, resume at 75%)
echo "Long Life" | sudo tee /sys/class/power_supply/x120x-charger/charge_type
# Adjust thresholds
echo 90 | sudo tee /sys/class/power_supply/x120x-charger/charge_control_end_threshold
echo 85 | sudo tee /sys/class/power_supply/x120x-charger/charge_control_start_thresholdScripts that read the MAX17043 fuel gauge over I²C directly will continue to work — the driver does not prevent I²C reads from userspace. However, reading from sysfs is simpler and requires no I²C library:
cat /sys/class/power_supply/x120x-battery/capacity # 0-100 %
cat /sys/class/power_supply/x120x-battery/voltage_now # µV
cat /sys/class/power_supply/x120x-battery/status # Charging | Discharging | ...Scripts that poll AC state and call shutdown when power is lost
can be removed entirely. The driver reports capacity_level=Critical
below 5% SoC, and UPower's PercentageAction (set to 2% SoC by the
installer) then causes systemd-logind to initiate a clean shutdown
automatically — no script required. This works identically on headless
and desktop installations.
This driver exposes raw hardware values. For applications requiring sophisticated battery protection — layered shutdown logic, deep-discharge detection, voltage oscillation analysis, or event logging — a userspace daemon can read directly from the sysfs nodes above and implement whatever safety policy is needed.
This driver follows the conventions of
drivers/power/supply/max17040_battery.c in the mainline kernel.
Upstreaming is a future goal once the driver has proven itself in
production use.
This driver was developed on hardware running unattended, always-on. Two real power incidents — plus one field-discovered driver bug — exposed failure modes that no lab test would have found, and drove significant hardening of the driver.
A companion daemon running on the same system reads the driver's sysfs nodes continuously, logs every reading to a SQLite database, and implements layered shutdown logic on top of what the driver exposes. All power data cited in the incidents below — SoC, voltage, AC state, charge state, shutdown events, and PSU power draw — comes from that database. The driver surfaces the raw hardware values; the daemon records and acts on them.
A grid outage began at 17:20 UTC on 2026-03-05. At the time, there was no software undervoltage shutdown in place — it had been assumed that the UPS hardware would cut power before the cells could be damaged. That assumption was wrong: the X120x UPS hardware has no automatic undervoltage cutoff. It simply powers the Pi until the cells are physically unable to sustain the load.
The system ran on battery for 10.3 hours with nothing to stop it. The fuel gauge saturated at 0% SoC when the cell voltage reached 3.25V — from that point on, voltage was the only reliable signal. By 02:39 UTC the voltage had fallen below 3.0V, the point at which irreversible electrochemical damage begins in lithium-ion cells. The Pi ran until 03:38 UTC when the supply rail collapsed at 2.54V.
When grid power returned at 08:58 UTC, the battery had been destroyed. The cells could no longer hold a charge above ~2.99V despite being on grid for 26+ hours. Post-mortem analysis of the voltage data confirmed a characteristic oscillation signature — rapid ±20 mV swings at the fuel gauge output, a known pattern when the MAX17043 is alternating reads across cell groups that can no longer hold voltage.
Analysis of the power database from the incident produced several findings that informed the driver design:
- The fuel gauge saturates at 0% SoC while voltage is still 3.25V — well above the damage threshold. Once SoC hits 0%, voltage is the only reliable indicator of remaining capacity.
- Cell damage begins at approximately 3.0V, confirmed by the onset of voltage oscillation in the data. Sixty-three oscillations of >15mV within intervals of <30 seconds were recorded in the first 100 sub-3.0V readings — a distinct signature not seen during healthy discharge.
- A destroyed battery on grid shows a characteristic plateau: voltage
rises only ~165mV over 26 hours (surface charge only), never enters
a
CHARGINGstate, and settles around 2.99V. Healthy cells charge from 2.8V to 4.1V within 2–3 hours. - The gap between the 10% SoC shutdown trigger and the 3.20V voltage trigger is approximately 14 minutes. If the SoC-based trigger fails, the voltage backstop is the last line of defence before cell damage.
The core lesson was that the X120x hardware provides no undervoltage protection — software must supply it entirely. This shaped several additions to the driver:
Dead battery detection — when the system is on grid and the cell
voltage remains below 3.10V for 10 minutes with no meaningful rise
(less than 10mV/h), the driver reports health=Dead via the
x120x-battery/health sysfs node. UPower surfaces this as
health: dead and desktop environments display a battery warning.
A kernel log entry is also emitted. This allows the operator to
identify destroyed cells and replace them before relying on the UPS
for protection again.
Capacity level reporting — the driver reports capacity_level
accurately throughout the discharge curve, giving UPower and logind
the information needed to trigger a clean shutdown via the standard
HandleLowBattery=poweroff path before the cells reach a dangerous
voltage. Without this, UPower has no basis on which to act.
The incident made clear that the X120x hardware provides no
undervoltage protection whatsoever — software must supply it entirely.
The install script configures the complete shutdown chain automatically:
the driver reports capacity_level=Critical at low SoC, UPower
escalates this to warning-level: action, and logind calls
systemctl poweroff — all without any additional daemon or script.
No extra userspace software is required beyond what the installer
sets up.
A grid outage began at 10:26:50 UTC on 2026-03-29 with the battery at 82% SoC / 4.04 V. The system ran on battery normally, discharging at the expected rate.
Grid was restored approximately 1 hour after the outage began
(confirmed by the uptime of a desktop machine on the same circuit), but
the X1206 never detected the return — ac_online remained 0 for the
remainder of the discharge. Because the companion daemon saw no grid,
charging never resumed. The system continued draining as if the outage
was still in progress.
The companion daemon's shutdown mechanism worked correctly:
shutdown_armed fired at 14:29:28 UTC at 10.0% SoC / 3.59 V, and
shutdown_initiated followed 15 seconds later exactly as designed.
At that point the grid had already been back for approximately 3 hours,
and the cells should have been charging throughout that window. They
were not, because the board was silently failing to assert GPIO6.
When the system rebooted after shutdown, ac_online was still 0
despite the charger being connected. The
system entered a livelock: it booted, UPower immediately read
capacity_level=Critical on the near-empty battery, logind called
systemctl poweroff, the UPS cut and then restored power, and the
cycle repeated. This drained the cells further on every cycle.
The livelock ran across three dates — 2026-03-29 (2 cycles from the
initial recovery attempt), 2026-03-30 (11 cycles), and 2026-04-02 (5
cycles, the last confirmed shutdown voltage 3.15 V) — for a total of
18 forced shutdowns before the board was replaced. The database
records no ac_online=1 after the original outage, because the board
was never able to drive GPIO6 high again.
X1206 hardware failure — GPIO6 output stage. Forensic analysis of
the power database confirms that ac_online never returned true
after the 10:26:50 UTC grid loss, with the grid independently confirmed
as restored roughly an hour later. The GPIO6 output stage on the board
had failed silently during normal operation: not at boot, not under
load stress, but mid-session while the system was running. This is
a harder failure mode than a boot-time marginal-PSU scenario — the
board stopped driving its own AC-present signal while everything else
appeared functional.
The v0.3.0 driver fixes (GPIO6 pull-up, capacity_level=Critical
suppressed on AC, always-on charger at probe) mitigated the livelock
mechanism by protecting against a floating GPIO6 at boot. They could
not compensate for a board whose output stage had permanently failed.
Board replacement was the correct and necessary remedy.
With ac_online=0 and the battery at near-zero SoC, the livelock
chain on every boot was:
- UPower read
capacity_level=Criticaland firedwarning-level: actionimmediately — before the driver had finished probing. - logind received the action and called
systemctl poweroff. - The UPS cut power, then restored it (auto-restart on halt).
- The cycle repeated.
gpio=6=pu pull-up in config.txt — the installer now adds a
software pull-up on GPIO6. The UPS hardware actively drives GPIO6 low
on power loss and high when AC is present. The pull-up ensures the pin
reads high (AC present) by default during boot, before the hardware has
finished asserting the signal. This protects against GPIO6 floating
low during the boot window; it cannot compensate for a board whose
output stage has failed entirely.
capacity_level=Critical only reported on battery — the driver
previously reported capacity_level=Critical whenever SoC dropped
below 5%, regardless of AC state. On a nearly-dead battery with AC
present, this caused UPower to trigger a shutdown loop during recovery.
The driver now only reports Critical when ac_online=0 — when mains
is present, even at 0% SoC, the battery is charging and shutting down
would cause exactly the livelock described above.
Charger always enabled at probe — the driver explicitly drives GPIO16 low (charger enabled) at probe time, regardless of any previously saved state. A battery that has been deeply discharged starts charging immediately on every boot.
Charger default changed to always-on — the charge hysteresis logic previously only re-enabled the charger when SoC dropped below the resume threshold. The start threshold has been removed: the charger is now enabled whenever SoC is below the stop threshold, defaulting to on in all uncertain or low-SoC states. (v0.4.4 later restored the hysteresis band — the charger is still forced on at probe and at or below the resume threshold, but in-band readings now hold state; see the changelog.)
0% SoC no longer treated as implausible — the driver previously issued a MAX17043 quick-start command when the initial SoC reading was 0%, treating it as a fuel gauge convergence failure. After deep discharge the battery is genuinely at 0% — issuing a quick-start resets the fuel gauge's SoC model at the worst possible moment. The plausibility floor has been lowered to 0%.
The faulty board was replaced with a new X1206 on 2026-04-07. The existing Molicel INR-21700-P50B cells (4 × 5000 mAh, 20 Ah pack) were reinstalled — deeply depleted by the livelock cycles but undamaged, as the repeated shutdowns had kept the voltage above the cell damage threshold throughout. The power supply was also replaced with a multi-port GaN charger (Anker Prime 160 W) giving the Pi and the mobile router independent ports with separate overcurrent protection, eliminating any shared-PSU load concern at boot.
The new board's first reading, at 18:20:53 UTC on 2026-04-07, showed
ac_online=1 immediately — GPIO6 asserting correctly from the first
moment — with soc_pct=0.01% and bat_v=3.34 V. The v0.3.0/v0.4.0
recovery path worked exactly as designed: capacity_level=Critical was
suppressed because ac_online=1, UPower did not trigger a shutdown, and
the charger was on from the first probe. Zero livelock cycles occurred.
At 20:36:20 UTC, a brief grid_change: true → null → true transition
lasting ~0.5 s was recorded — this corresponds to the v0.4.0 driver
module being reloaded during installation. Charging continued without
interruption.
The cells charged from 0.01% / 3.34 V to 99.6% / 4.22 V in approximately 6.7 hours, consistent with the X1206's 3 A charge ceiling (~15 W) applied to a 20 Ah pack. PSU draw measured via the driver's hwmon interface held steady at ~16.7 W throughout the bulk charge phase (battery charging plus Pi idle consumption), dropping to ~5.9 W once the cells reached full charge and the charger switched to float.
| Milestone | Time (UTC, 2026-04-07/08) | SoC | Cell voltage |
|---|---|---|---|
| First valid reading | 18:20:53 | 0.01% | 3.34 V |
| Charging begins | 18:33:24 | 0.32% | 3.48 V |
| 10% | 20:16:52 | 10.0% | 3.74 V |
| 50% | 22:02:38 | 50.0% | 3.94 V |
| 80% | 23:21:27 | 80.1% | 4.09 V |
| Full (~100%) | 01:02:05+1 | 99.6% | 4.22 V |
The healthy charge profile — smooth SoC rise, voltage climbing steadily from 3.34 V to 4.22 V, no oscillation, no plateau — confirmed that the new cells were undamaged.
X1206 GPIO6 output stage failure is silent and undetectable in
software. The board continued to appear functional in every other
respect: the fuel gauge was readable over I²C, and the system ran
normally on battery. Only the AC-present signal was
wrong, and only the power database — recording ac_online=0 throughout
a period when grid was independently confirmed as restored — revealed
the failure.
If ac_online remains 0 after a grid outage despite the charger
LED indicating input power, and the pattern persists across multiple
reboots with the v0.3.0+ driver and gpio=6=pu in place, the board
itself should be suspected and replaced. The driver cannot work around
a permanently failed GPIO6 output stage.
The system fan on the host Pi 5 had been audibly revving for an extended
period. CPU temperature was a benign 65.9 °C and the SoC was not
thermally throttled (vcgencmd get_throttled reported 0x0), but the
fan's PWM cooling device was sitting at cur_state=2/4 continuously,
indicating sustained cooling demand driven by compute load rather than
silicon heat.
uptime reported a load average of 7.01 on a 4-core Pi 5 — fully
saturated. ps showed three processes consuming the bulk of the CPU:
345 91.7 systemd-udevd
818657 87.1 (udev-worker)
818612 83.6 (udev-worker)
udevadm monitor --kernel revealed a continuous flood of change
uevents from /sys/class/power_supply/x120x-charger, advancing the
kernel SEQNUM counter by roughly 820 events per second. Over the
21 h uptime preceding the diagnosis, the system had emitted approximately
62 million uevents on this single device — every one of them carrying
identical property values, and every one of them woken up udev to scan
the rules database and re-evaluate the same hook chain.
Uninitialised chrg_changed stack variable in x120x_poll_work.
The poll work function declared three booleans on entry:
bool bat_changed, ac_changed, chrg_changed;In the I²C error paths all three were set to false before the
goto notify jump. In the happy path bat_changed and ac_changed
were assigned unconditionally from the new vs. cached comparisons, but
chrg_changed was only assigned to true inside the conservation-mode
hysteresis block when want_inhibit != chip->charger_inhibited — i.e.
only when GPIO16 actually needed to flip. In the steady state this
branch is rarely taken, so the variable was read at the notify site with
whatever garbage the stack happened to contain.
The compiler-generated stack frame produced a truthy value on most
invocations, causing power_supply_changed(chip->charger) to fire every
poll. This kicked off a tight feedback loop via the supplied_to
notification chain:
power_supply_changed(charger)schedulespower_supply_changed_work- The kernel walks supplicants — the battery is supplied by the charger
- The battery's
external_power_changedcallback fires - That callback calls
mod_delayed_work(system_wq, &chip->work, 0), kickingx120x_poll_workto run immediately - The poll reads I²C, finds no real state change, but reads the
uninitialised
chrg_changedas truthy and firespower_supply_changed(charger)again - Goto 1
bpftrace-confirmed rates during the incident:
| Function | Calls / second |
|---|---|
x120x_poll_work |
~405 (vs. the intended 2 Hz) |
power_supply_changed_work |
~412 |
power_supply_changed(charger) |
~423 |
kernel uevent_seqnum growth |
~820 |
The poll loop was running 200× faster than designed, each iteration re-triggering the loop on a stack-resident phantom.
The bug was latent from v0.4.1, where the polling work function was
restructured to take snapshots of conservation_mode and capacity_pct
under the chip mutex (see that release's changelog). The refactor
introduced the unconditional read of chrg_changed at the notify site
without ensuring the variable was initialised on every path leading
there. GCC's -Wmaybe-uninitialized does not fire on this case because
the variable is assigned on the failing path (via the
if (want_inhibit != chip->charger_inhibited) branch), just not on
every path.
Default-initialise bat_changed, ac_changed, and chrg_changed
at declaration. All three booleans now default to false, so the
notify site reads true only when an explicit assignment marked a real
state change. Defensive initialisation of all three (not just the
one that bit us) prevents the same class of bug from reappearing the
next time a path is added to the function.
Sustained fan noise without a hot SoC means a software bug, not a
thermal one. At 65 °C the Pi 5's silicon is well inside its comfort
envelope; the fan curve responds to total CPU load, not just core
temperature. If the fan is loud while vcgencmd measure_temp reports
something benign, the first place to look is uptime and the top of
ps. In this case the load average pointed at udev within seconds —
and udevadm monitor --kernel exposed the storm in another two.
/sys/kernel/uevent_seqnum is an underused diagnostic. Reading it
twice with a delay gives the kernel-wide uevent rate in a single
shell pipeline:
s1=$(cat /sys/kernel/uevent_seqnum); sleep 2; \
s2=$(cat /sys/kernel/uevent_seqnum); echo $(( (s2-s1) / 2 ))/secA healthy idle system reports 0/sec. Anything higher than the low
tens, sustained, is a misbehaving driver.
A second user (issue #2)
hit the same bug independently on a Geekworm X1209 + X1002 NVMe
expansion board running v0.4.2, on the same day the author diagnosed
it on an X1206. Their symptom was different — no audible fan, but the
attached Samsung 970 Evo NVMe was heatsoaking to 70–75 °C at idle
versus a normal ~51 °C on v0.4.1. The mechanism is the same: the
udev rule 90-x120x-persist.rules runs
/usr/local/lib/x120x-persist-mode.sh on every change event, and at
~820 events per second the constant fork+exec plus small writes
keep the NVMe controller pinned in its highest active power state and
the PCIe link out of L1 substates. After upgrading to v0.4.3 their
NVMe settled back to 51–52 °C within ~15 minutes and
uevent_seqnum delta reported 0/sec.
The same reporter noted that their v0.4.1 idle NVMe temperature
(58–61 °C) was elevated above the clean v0.4.3 baseline
(51–52 °C) by ~7–10 °C. This is consistent with the uninitialised
chrg_changed reading as intermittently truthy on v0.4.1's stack
frame layout — same bug, but a lower duty cycle than the
always-truthy pathology v0.4.2 happened to produce. v0.4.3's explicit
= false initialiser makes the variable deterministically falsy on
every entry, so the baseline should now match a system that never had
the bug.
This second data point matters because it widens the symptom set
documented for this incident: the same bug can present as fan noise
on a host with mediocre case airflow and no NVMe, as silent NVMe heat
on a host with a stack expansion board, or — in principle — as
elevated power draw and slightly shortened battery runtime on any
host. Future reports that don't match the original "loud fan" shape
should still trigger the same diagnostic (uevent_seqnum delta) as
the first step.
Driver
set_propertyemits a synchronouspower_supply_changed()on the charger after acharge_typewrite (outsidechip->lock), so the udev charge-mode persistence runs on the write itself rather than waiting for the next poll/heartbeat cycle to emit the charger uevent.
Installer
install_ini_blockno longer accumulates a leading blank line before its marker block on reinstall — a repeat install is now byte-identical.
Documentation
- Getting started restructured for first-time users (newbie-first requirements, commands in execution order); charge-mode selection moved to Battery conservation mode.
- Section cross-references converted from italics to in-page GitHub anchor links.
- Note that
charge_control_*_thresholdreports the Long Life band even inFastmode (Fast uses a fixed 100% / 95% band the standard sysfs interface cannot express), so75/80there in Fast is expected. - New "Tested hardware" matrix (one confirmed row) inviting reports via the hardware-report issue template.
- Requirements now state the minimum kernel (6.3+), surfaced by the
build-ltsCI job when 5.15 failed to compile.
CI
- Module compile-checks (
KCFLAGS=-Werror, never loaded): abuildmatrix against generic and newest-HWE headers, abuild-ltsjob against an older supported LTS kernel (Ubuntu 24.04, 6.8) in a container, and abuild-armhfjob that verifies the 32-bit cross toolchain (a real armhf build needs an armhf kernel tree, reported by users). Newoverlayjob compilesx120x-overlay.dtswithdtc. - New
staticjob runsmake W=1(kernel extra-warnings and kernel-doc, any warning fails) andsparse(make C=1) over the driver. - Shell job now also verifies every README in-page anchor link resolves to a heading (GitHub slug rules), next to the repository-layout tree check.
Project
- Issue templates: a bug report mirroring the Troubleshooting checklist, and a hardware test report for experimental boards (X728 / X708 / X729) and armhf builds; blank issues stay enabled. Dependabot keeps the pinned GitHub Actions fresh weekly.
SECURITY.md(private vulnerability reporting, latest-release support, best-effort response) andRELEASING.md(the on-hardware release checklist codified from the v0.4.6 process).
Tooling
tools/collect-debug.sh— a no-root one-shot diagnostics collector (Pi model, OS, kernel,dkms status,dmesg, power_supply devices, sysfs values, module params,modprobe.d) into one paste-ready block; runs cleanly whether or not the driver is loaded. Referenced from Troubleshooting and the bug template, with a test covering the driver-present and driver-absent paths.
Build
- New
make testtarget runs the whole shell suite (documented in the Testing section).
Installer
install.shnow stages the two required Pi 5 bootloader EEPROM settings (POWER_OFF_ON_HALT=1,PSU_MAX_CURRENT=5000) itself, rather than only warning if they were missing. The read-modify-apply is idempotent (no EEPROM write when both are already correct), rewrites a differing prior value with no special case, and leaves every other key untouched.--applyonly stages the update; the bootloader flashes it at the next boot, so it lands with the reboot the installer requests. A successful-but-empty config read is guarded so it can never stage a config that wipes the rest of the EEPROM.--skip-eepromopts out.- Temp files are tracked in a single cleanup list removed by one EXIT trap, so nothing leaks if the script is killed mid-run.
Documentation
- Getting started restructured around the automatic bootloader setup:
two steps (install, monitor) with a single reboot, a per-board
install-command table for every supported board (a real capacity
where the cells are fixed,
<your_capacity>only for external-pack boards), and an OS-requirements note. - New Troubleshooting section (symptom → check → fix) for the common novice failure modes.
## Required bootloader settings (Raspberry Pi 5)is now the single canonical reference for the settings, what they do, their caveats, and the manual one-liner.- Intro reframed as an independent community project — not affiliated with or endorsed by SupTronics/Geekworm, though linked from Geekworm's wiki pages.
- New Testing section documenting how to run the suite locally.
Tests
- New unprivileged shell test suite under
tests/— functions sed-extracted, externals mocked, assertions on files and logs: test-install.sh—configure_bootloader()against a mockedrpi-eeprom-configand device-tree model path.test-ini-blocks.sh— theinstall_ini_block/remove_ini_blockround-trip, theclean_legacy_*helpers, and the config.txt[all]-orphan perl.test-args.sh—install.shargument parsing.test-persist.sh— the charge-mode persistence script.- GitHub Actions runs
bash -n,shellcheck -S warning, and every suite on each push and pull request.
Security (repo-audit hardening)
- Manual bootloader-config steps use
mktempinstead of a fixed/tmp/bootconf.txt, closing a local TOCTOU on the root-consumed file. - Installer Step 7 anchors its config.txt "already present" checks, so a
commented-out or prefixed
dtoverlay=x120x/gpio=6=puline no longer suppresses the append. - Installer runs
chmod -R go-won the copied DKMS source tree, so root builds from sources a non-root user cannot alter; the uninstaller narrows its orphan-cleanup glob tox120x-[0-9]*. - Driver:
set_propertytakeschip->lockaround the charge-threshold writes and rejects a band-inverting value with-EINVAL; probe clampsbattery_mahto[1, 500000]to avoid an integer overflow in the ENERGY_FULL property. - Driver: the poll work item is now initialized before any power_supply
is registered, closing a use-before-init window where a deferred
external_power_changedevent could callmod_delayed_work()on an uninitialized work item. - Driver: the
conservation_start,conservation_end, andconservation_mode_defaultmodule parameters are now0444(read-only at runtime). Load-timemodprobe.dconfiguration is unchanged; runtime threshold changes go through the validating, lockingcharge_control_*_thresholdsysfs properties, and the charge mode followscharge_typewrites. - CI drops to
permissions: contents: readand pinsactions/checkoutto a commit SHA.
Note
- The version was bumped to v0.4.6 for the installer and documentation
work; the kernel module also carries the threshold-locking and
battery_mah-clamp fixes listed under Security.
Installer
install.shnow setsUsePercentageForPolicy=trueandPercentageAction=2inUPower.conf, in addition toCriticalPowerAction=PowerOff. Previously the installer relied on the distribution default forPercentageAction; Debian/Raspberry Pi OS ship it as 0, so UPower's PowerOff action only fired at 0% SoC (≈3.3 V, almost no margin above the 3.20 V floor) rather than the documented 2%. UPower (GLibGKeyFile) honours the last value, so the appendedPercentageAction=2overrides the distro default.- Kernel module is unchanged from v0.4.4; version bumped to keep the package, DKMS, and module versions in lockstep.
Kernel driver
- Charging now resumes at the lower threshold again, restoring a proper
hysteresis band: Fast stops at 100% and resumes at 95%; Long
Life stops at
conservation_end(80%) and resumes atconservation_start(75%); the charger holds its state in between. The v0.3.0 "always-on" change had re-enabled the charger as soon as SoC fell below the stop threshold, so the pack was topped back up almost immediately and never rested below ~100% (Fast) or ~80% (Long Life) — defeating Long Life's longevity benefit and making the true self-discharge rate impossible to observe (the charger masked it). - Deep-discharge safety is preserved:
charger_inhibitedstill startsfalse(charging on) and any SoC at or below the resume threshold forces the charger on, so the charger is still always on at boot, after a deep discharge, and in any low/uncertain-SoC state — only an in-band reading holds the previous state. conservation_startis used by the hysteresis again (it had been exposed over sysfs but ignored).
Kernel driver
- Initialise
bat_changed,ac_changed, andchrg_changedtofalseat declaration inx120x_poll_work. Previouslychrg_changedwas declared without an initialiser and only assigned totrueinside the conservation-mode hysteresis block; in the steady state (the common case) the variable was read uninitialised at the notify site and the compiler-generated stack value was truthy often enough to firepower_supply_changed(chip->charger)on most poll cycles. Combined with thesupplied_topropagation chain back into the battery'sexternal_power_changedcallback (which immediately reschedules the poll work), this turned a 2 Hz poll into a ~400 Hz feedback loop and produced approximately 820changeuevents per second on/sys/class/power_supply/x120x-charger. The flood saturatedsystemd-udevdand two worker processes at ~90% CPU each. See Real-world incidents — Incident 3 for the full diagnosis.
Installer
set -euo pipefailso unset variables and pipeline failures abort the install rather than continuing silently.--battery-mahis now validated as a positive integer at parse time. The value is interpolated directly into/etc/modprobe.d/x120x.conf, so any non-numeric, empty, negative, zero, or shell-injection-shaped input is rejected before anything is written. Leading zeros are normalised away to avoid any octal interpretation downstream.- The device tree overlay is now compiled into a root-owned
mktemp -d(mode 700, cleaned up onEXIT) rather than the source directory, and copied from there into/boot/firmware/overlays/. If the source tree happens to live on a path an unprivileged user can write to, compiling in place opened a brief TOCTOU window betweendtcfinishing andcprunning. A private tmpdir closes it. logind.confandUPower.confedits are wrapped in marker blocks delimited by# >>> x120x-dkms: <tag> (do not edit) >>>/# <<< x120x-dkms: <tag> <<<. The installer:- never comments out lines it did not write;
- relies on the systemd / UPower INI rule that the last matching key in a section wins, so appending our block at the bottom overrides any earlier user setting without disturbing it;
- creates the
[Login]/[UPower]section header on a minimal config file before writing the block; - is idempotent — a second install replaces the existing block in place rather than appending a duplicate;
- cleans up bare lines left behind by pre-v0.4.2 installers
(via
clean_legacy_logind/clean_legacy_upower) so an upgrade from an older install doesn't accumulate dead comments.
Uninstaller
- Regression fix: the uninstaller no longer uncomments any line.
Previous versions ran
sed -i 's/^#HandleLowBattery=/HandleLowBattery=/'and the two analogous lines forUPower.conf, intending to "restore" what the installer had commented out. But the installer commented blindly without recording which lines were originally uncommented, so a user who had deliberately written e.g.#HandleLowBattery=ignoreto disable that policy would silently have it reactivated on uninstall. All three restoration steps are removed. - Marker-wrapped blocks are removed by
remove_ini_block; lines outside the markers are never touched. - Legacy line-by-line cleanup is retained (factored into the same
clean_legacy_logind/clean_legacy_upowerhelpers used by the installer) so users upgrading from older installer versions still get cleaned up correctly. set -euo pipefail.
Kernel driver
pm_power_offlegacy function pointer replaced withdevm_register_sys_off_handler(SYS_OFF_MODE_POWER_OFF_PREPARE, ...). Only applies to the experimental X728/X708/X729 board variants (X120x does not use this path). Benefits:- PREPARE-mode handlers may sleep, so the 3-second power-off pulse
now uses
msleep(3000)instead of amdelay(3000)busy-wait. - The sys-off API supports stacking; we no longer unconditionally clobber a power-off handler that another driver may have installed.
devmcleanup tears down the handler automatically on unbind, sox120x_removeis simpler and the static globalx120x_poweroff_chipis gone.
- PREPARE-mode handlers may sleep, so the 3-second power-off pulse
now uses
- GPIO16 (charge-control) state is now cached in
chip->charger_inhibited, and the entire read-modify-write of the cached flag plus the hardware GPIO is performed underchip->lock. This closes a race wherex120x_poll_work's hysteresis decision could in principle disagree with a concurrentcharge_typewrite from sysfs, briefly inhibiting charging based on stale state for one poll tick. The same change also removes three unlocked reads of the hardware GPIO from the sysfsget_propertycallbacks; they now read the cached value, which is by definition consistent withconservation_modebecause both are written under the same lock. - README: the manual-install instructions now use
linux-headers-$(uname -r)(matching the running kernel) rather thanraspberrypi-kernel-headers, which can pull stale headers on Bookworm and cause DKMS builds to fail. - Boot-log noise eliminated: when the DT overlay is present the
module init function no longer races against it. Previously, after
i2c_add_driverreturned (with the DT-instantiated client at 0x36 already bound), the manual probe loop would try to register a duplicate client at 0x36, get EBUSY (logged by the i2c subsystem), fall through to 0x55, succeed in creating a phantom client, and produce three scary lines in dmesg (Failed to register i2c client x120x at 0x36 (-16),1-0055: failed to read chip version: -121, and1-0055: probe with driver x120x failed with error -121).probe()now sets a flag on success;x120x_initchecks the flag immediately afteri2c_add_driverreturns and skips the manual fallback entirely if a DT binding already happened. Cosmetic only — the driver was functional before — but stops people thinking their install is broken.
Uninstaller
- Uninstaller now discovers every installed version of the driver via
dkms statusand removes them all, rather than relying on a single hardcoded version string. Fixes a case whereuninstall.shleft the kernel module installed if it had been built against a different version than the uninstaller expected. - Orphaned
/usr/src/x120x-*source trees are cleaned up even if DKMS no longer tracks them.
Installer
apt-get updateis now run beforeapt-get installso that a Pi with a stale package index does not fail to finddkmsor the kernel headers package.- Removed a duplicated Step 10 block that re-installed the charge-mode persistence script and udev rule a second time. Functionally harmless (same content written to the same files), but cleaned up for clarity.
Kernel driver
- The polling work function now snapshots
conservation_modeandcapacity_pctunder the chip mutex and uses the local copies in the subsequent hysteresis region, rather than reading thechipfields a second time outside the lock. Fixes a minor correctness issue where the hysteresis decision could in principle race against a concurrentcharge_typewrite from sysfs.
hwmon device registration
- Driver now registers a hwmon device (
x120x) alongside the existingpower_supplydevices at probe time - Exposes four channels via
/sys/class/hwmon/hwmonN/:in0_input— cell voltage in mV (direct hardware reading, labelcell_voltage)curr1_input— charge/discharge current in mA, signed (derived, labelbattery_current)power1_input— charge/discharge power in µW, signed (derived, labelbattery_power)energy1_input— stored energy in µJ (derived, labelbattery_energy)
- Sign convention: positive = charging, negative = discharging
- Compatible with
sensors(lm-sensors), Prometheusnode_exporter(--collector.hwmon, enabled by default), collectd, Grafana, and any other tool that reads the standard Linux hwmon sysfs interface - node_exporter exposes
node_hwmon_in_volts,node_hwmon_curr_amps,node_hwmon_power_watt,node_hwmon_energy_jouleslabelledchip="x120x"with no additional configuration - hwmon registration failure is non-fatal — the
power_supplyinterface remains the primary ABI and the driver continues normally if hwmon registration fails
Rate estimation fix
- Fixed a bug where
energy_rate_uw(and thereforePOWER_SUPPLY_PROP_POWER_NOW, and all hwmon power/current channels) was permanently zero - Root cause:
chip->capacity_256was overwritten withnew_256before the rate estimator comparednew_256 != chip->capacity_256— the comparison was always equal so no rate was ever computed - Fix: snapshot
old_256 = chip->capacity_256before the update - UPower's displayed
energy-ratewas unaffected because UPower computes its own rate from consecutiveenergy_nowpolls independently of the driver;power_nowand all hwmon derived channels were the affected paths - Added spike rejection: when the SoC register is stuck for >90 s and then jumps multiple LSBs in a single tick, the resulting rate estimate would be a large transient spike (large ΔE ÷ clamped dt). The driver now detects this condition (real dt > 90 s clamp window) and retains the previous rate estimate rather than emitting the spike
Deep discharge recovery hardening
capacity_level=Criticalonly reported when on battery (ac_online=0) — on AC the battery is charging; reporting Critical caused UPower to trigger an immediate shutdown livelock after a deep discharge event- 0% SoC no longer treated as implausible — quick-start command not issued on a genuinely empty battery, avoiding a fuel gauge reset during recovery
- Charger (GPIO16) explicitly forced low at probe — charging starts immediately on every boot regardless of any previously latched state
- Charger default changed to always-on: the resume threshold is removed; the charger is enabled whenever SoC is below the stop threshold, defaulting to on in all uncertain or low-SoC states
gpio=6=puadded toconfig.txtby installer — prevents GPIO6 floating low at boot before the X1206 hardware asserts the AC-present signal, eliminating falseac_online=0readings after a power outage or PSU overload at boot
UPower history and graph fixes
NoPollBatteries=trueset inUPower.confby installer — eliminates spurious0%/unknownhistory entries caused by UPower polling the kernel independently of driver notifications- Battery status during Fast-mode float is reported as
Full(on AC at ≥95% SoC); UPower history stays populated via the 30-second heartbeat below rather than by faking a discharge power_nowreported as0when SoC is stable for >90 s — the driver cannot measure true self-discharge, so it reports zero rather than a fabricated floor- 30-second heartbeat
power_supply_changed()notification — keeps UPower history recording active during extended stable float periods - AC state change no longer resets the rate tracking window — rate computation is continuous across grid transitions, eliminating transition spikes in the rate graph
- Experimental support for Geekworm X728 V2.x/V1.x, X708, X729 via
--boardparameter ininstall.sh pm_power_offhook pulses the power-off GPIO on these boards after OS shutdown so the UPS cuts power automatically
Additional power_supply properties
manufacturer,model_name,charge_now,charge_full,charge_empty,voltage_max_design,voltage_min_designaddedenergy_now,energy_full,energy_emptycomputed from SoC and pack capacity
Dead battery detection
- Driver reports
health=Deadwhen cell voltage remains below 3.10 V on grid for ≥ 10 minutes with no meaningful voltage rise (<10 mV/h) and SoC ≤ 2% — detects cells destroyed by deep discharge - Kernel log entry emitted on confirmation; clears automatically if voltage recovers
Migration guide
- Added guide for users migrating from existing GPIO scripts
- Native Linux kernel driver for the full SupTronics X120x UPS HAT series (X1200–X1209)
- Registers three
power_supplydevices:x120x-battery,x120x-ac,x120x-charger - Full UPower integration — battery icon, percentage, voltage, energy, charge rate, time-to-empty/full, battery health
- Fast mode — charges to 100%, then floats with 95% recharge threshold to prevent micro-cycling
- Long Life mode — configurable conservation hysteresis (default 75%/80%) to extend cell lifespan
- Charge mode persisted across reboots via udev rule
- Clean undervoltage shutdown via UPower/logind at 2% SoC
- DKMS packaging — survives kernel updates automatically
- Device tree overlay for GPIO descriptor API (kernel 6.12+)
install.shwith--battery-mahand--charge-modeoptions
Copyright (C) 2026 Edvard Fielding mor-lock@users.noreply.github.com
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY — INCLUDING BUT NOT LIMITED TO LOSS OF DATA, HARDWARE DAMAGE, FINANCIAL LOSS, OR CONSEQUENTIAL DAMAGES OF ANY KIND — WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THIS SOFTWARE OR THE USE OR MISUSE THEREOF.
This driver interacts directly with battery hardware. Incorrect operation, misconfiguration, or use on unsupported hardware may result in improper charging behaviour, failure to shut down before battery exhaustion, or hardware damage. You are solely responsible for validating correct operation on your specific hardware before relying on this driver for any purpose.
USE AT YOUR OWN RISK.
This project is an independent personal contribution, developed in my own time on my own hardware. It is not affiliated with or endorsed by SupTronics, Geekworm, or my employer.
GPL v2