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.)
Hardware assembly — battery orientation, seating the HAT on the Pi — is covered by your board's page on the Geekworm wiki; this guide starts where assembly ends.
Clone the repository:
git clone https://github.com/mor-lock/x120x-dkms.git
cd x120x-dkms(No git? Download and extract the latest release archive instead, and run the same commands from the extracted directory.)
Now 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 | not yet installable — see Experimental board support |
| X728 V1.x (experimental) | 2× 18650 | not yet installable — see Experimental board support |
| X728 V2.x (experimental) | 2× 18650 | not yet installable — see Experimental board support |
| X729 (experimental) | 2× 18650 | not yet installable — see Experimental board support |
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
untes
10000
ted — 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.
Before rebooting, make sure the power supply is connected to the UPS board's own power input, not the Pi's USB-C port. The Pi is powered through the UPS; a charger plugged into the Pi directly will neither charge the battery nor assert AC detection. Your board's page on the Geekworm wiki shows the input's location.
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, the battery shows up as a standard Linux power supply. For a quick command-line view (works on every install, including Lite/headless):
upower -i /org/freedesktop/UPower/devices/battery_x120x_batteryOn desktop installs a battery icon also appears in the taskbar, and
gnome-power-statistics shows live battery percentage, voltage,
energy, charge rate, and history graphs — all read directly from the
driver via UPower. No configuration needed:
sudo apt install gnome-power-manager
gnome-power-statisticsThat 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.- On Ubuntu, if the driver worked and then vanished after an
apt upgrade: the update repopulated/boot/firmware/current/overlays/and deleted the overlay file (lsthere shows nox120x.dtbo) even thoughdtoverlay=x120xis still inconfig.txt. The installer sets up an apt hook that restores it automatically after each update; if you installed before that hook existed, re-runsudo bash install.shonce to install both the overlay and the hook, then reboot.
- On Ubuntu, if the driver worked and then vanished after an
- 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.
First check the cheapest thing: the charger must be plugged into the UPS board's own power input, not the Pi's USB-C port. A supply feeding the Pi directly keeps the Pi running but never charges the battery or asserts AC detection.
Otherwise 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 | OS | Driver | Kernel | Arch | Reporter |
|---|---|---|---|---|---|---|
| X1206 | Raspberry Pi 5 | Raspberry Pi OS | v0.4.8 | 6.12.x | aarch64 | maintainer |
| X1201 V1.1 | Raspberry Pi 5 | Ubuntu 26.04 LTS | v0.4.10 | — | aarch64 | issue #5 |
| X1209 (+ X1002 NVMe board) | Raspberry Pi 5 | Raspberry Pi OS | v0.4.3 | — | — | issue #2 |
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 |
Not yet installable. install.sh refuses --board variants other
than x120x: no per-board device tree overlay ships with this release,
and the x120x overlay has no power-off-gpios property — so the
power-off pulse these boards require after shutdown could never fire,
and the UPS would keep draining the pack indefinitely after poweroff
(the deep-discharge scenario this driver exists to prevent). Refusing
is better than installing a setup that looks complete but silently
lacks its most important safety behaviour. Per-board overlays are
future work and need hardware reports to validate.
If you have one of these boards and want to help develop support, the manual path is:
- Copy
x120x-overlay.dtsand add the board's power-off GPIO to the UPS node —power-off-gpios = <&gpio 26 0>;for X728 V2.x / X729, GPIO 13 for X728 V1.x / X708 (see the table above). - Compile and install the overlay (see Manual installation, steps 5–7).
- Run the normal installation (
sudo bash install.sh --battery-mah …), addboard=x728v2(or your variant) to theoptions x120xline in/etc/modprobe.d/x120x.conf, and reboot.
On success the driver logs power-off handler registered at probe;
please report results via the
hardware test report
template.
Board variants understood by the driver: 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 — and whenever the charge-control GPIO is
absent from the device tree — a
Long Lifewrite is rejected,charge_typealways readsFast, andcharge_typeplus bothcharge_control_*_thresholdfiles are read-only. - 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
The full discussion — calendar vs. cycle aging, why Fast wins for a
standby UPS and Long Life for a frequently cycled build, and the
measured standby sawtooth — has moved to
docs/battery-profiles.md.
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 via a drop-in under
/etc/systemd/logind.conf.d/, 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
Kernel-side backstop (independent of UPower/logind). The chain
above is graceful but entirely userspace, and userspace is not always
there to run it: on systemd < 255 — including Debian 12 (systemd
252) — logind ignores HandleLowBattery (it logs "Unknown key …
ignoring" at boot and does nothing), and UPower may be D-Bus-inactive
on a headless box. So the SoC → UPower → logind path can silently fail
to fire. As a last-ditch defence the driver watches the raw terminal
voltage itself: on battery, a cell at/below 3.1 V held for 20 s
makes it call orderly_poweroff() directly from kernel space — no
UPower, no logind. It still forces capacity_level=Critical, so a
healthy userspace chain acts first (earlier, at the 5 % SoC line); the
voltage floor is calibration-immune, so it fires even if the SoC
estimate reads high. Disable with vfloor_poweroff=0 for
userspace-only operation, or move the floor with vmin_critical_mv.
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.
systemd < 255 (e.g. Debian 12, systemd 252): logind does not understand
HandleLowBattery— it logs "Unknown key 'HandleLowBattery' … ignoring" at boot and never acts on it. On these systems the UPowerCriticalPowerAction=PowerOffpath below drives the shutdown, and the driver's own kernel voltage floor (see the shutdown chain above) is the backstop when UPower is not running. The drop-in is still written — harmless on < 255, correct on ≥ 255.
The install script enables this automatically by writing a drop-in
file, /etc/systemd/logind.conf.d/90-x120x.conf:
[Login]
HandleLowBattery=poweroffA drop-in is used instead of editing /etc/systemd/logind.conf so the
packaged file stays pristine — dpkg never sees it as modified, so a
systemd upgrade never raises a conffile prompt over it — and every
line in the drop-in belongs to the driver. (Systems installed by
older versions carry a marker-wrapped block in logind.conf instead;
reinstalling migrates it to the drop-in automatically.)
To disable the behaviour, override it from a later drop-in (e.g.
/etc/systemd/logind.conf.d/99-local.conf):
[Login]
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. Only x120x is currently installable — other variants are refused until per-board overlays ship. See Experimental board support. |
--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 update, fetch the new version and re-run the installer:
git pull # or: download and extract the new release archive
sudo bash install.sh
sudo rebootSettings from the previous install — pack capacity, charge mode, and board variant — are kept automatically; flags are only needed to change something.
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 logind drop-in
/etc/systemd/logind.conf.d/90-x120x.conf(and thelogind.conf.ddirectory itself, if empty afterwards) - 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 <<<) - On systems installed before the drop-in existed: the marker-wrapped
logind-low-batteryblock in/etc/systemd/logind.conf, and any bare lines left over from even 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.
The full numbered walkthrough — every command install.sh runs, for those who prefer to run each step themselves — has moved to docs/manual-install.md.
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
├── CHANGELOG.md — release history, newest first
├── LICENSE
├── SECURITY.md — vulnerability reporting policy
├── CONTRIBUTING.md — build, test, and PR guidelines
├── RELEASING.md — on-hardware release checklist
├── docs/
│ ├── battery-profiles.md — Fast vs. Long Life, with measured data
│ ├── incidents.md — the field incident write-ups
│ ├── manual-install.md — step-by-step install without install.sh
│ ├── migration.md — replacing direct-GPIO scripts with sysfs
│ └── images/ — README screenshots
│ ├── battery-tray.png — panel battery icon
│ ├── upower-info.png — upower -i output
│ └── power-statistics.png — GNOME Power Statistics window
├── .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)
├── lib/
│ └── common.sh — helpers shared by install.sh/uninstall.sh
├── 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-restore-overlay.sh
│ ├── test-collect-debug.sh
│ ├── test-check-links.sh
│ ├── test-check-versions.sh
│ └── test-driver-params.sh
├── tools/
│ ├── collect-debug.sh — one-shot diagnostics paste (see Troubleshooting)
│ ├── check-links.sh — markdown link checker (run by CI)
│ ├── check-versions.sh — version-string consistency check (run by CI)
│ └── check-layout-tree.sh — this tree is complete (run by CI)
└── .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
The guide to replacing direct-GPIO polling scripts with the driver's sysfs interface has moved to docs/migration.md.
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.
Bug reports, hardware reports for the experimental boards, and patches
are all welcome — see CONTRIBUTING.md for how to
build, run the test suite (make test, unprivileged), and what CI
checks a pull request must pass.
The field incidents behind this driver's design — deep discharge and cell destruction, an undetected grid return, a uevent storm, and a driver wiped by an Ubuntu package update — are written up in full in docs/incidents.md.
The full release history has moved to CHANGELOG.md.
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-2.0-or-later. Every source file carries an SPDX
GPL-2.0-or-later header; the full GPL-2.0 text is in
LICENSE.