Kernel driver and device tree overlay for the SupTronics X-FAN40 on Raspberry Pi 5.
The X-FAN40 is a 4-pin PWM fan HAT designed for active cooling of M.2 NVMe drives on the Raspberry Pi 5. This project integrates it into the Linux thermal framework so the fan responds to all relevant heat sources without any user-space daemon:
- CPU — handled by the device tree overlay (fragment 3), which adds
cooling maps to the Pi's existing
cpu_thermalzone. No kernel module needed; the thermal framework drives the fan automatically from DT alone. - M.2 NVMe drives and Coral TPU accelerators — handled by the
x-fan40-aux-thermalkernel module, which polls their temperatures directly from/sys/class/nvmeand/sys/class/apex.
| GPIO | Function |
|---|---|
| GPIO13 | PWM output to fan (25 kHz, RP1 PWM0 channel 1) |
| GPIO16 | Tachometer input (2 pulses / revolution, open-drain with pull-up) |
| State | Duty | Speed |
|---|---|---|
| 0 | 0 % | Off |
| 1 | 30 % | Low |
| 2 | 40 % | Medium |
| 3 | 60 % | Medium-high |
| 4 | 80 % | High |
| 5 | 100 % | Full |
The kernel step_wise governor steps through cooling states one at a time
based on trip points. Each cooling map in the DT overlay specifies a trip
temperature and a <min max> state range the governor is allowed to use
once that trip fires:
| Zone | Trip | State range | Meaning |
|---|---|---|---|
| aux (Apex/NVMe) | 55 °C | 1–3 | Fan starts at low and steps up to medium-high |
| aux (Apex/NVMe) | 80 °C | 4–5 | Fan steps up to high and full |
| cpu | 50 °C (cpu_tepid) |
1–2 | Fan starts at low |
| cpu | 67.5 °C | 3–4 | Fan steps up to medium-high |
| cpu | 75 °C | 5–5 | Fan goes to full |
Note: The cpu-thermal zone also contains a
criticaltrip at ~110 °C from the Pi 5 base device tree that triggers an emergency thermal shutdown. x-fan40 does not modify that trip point; the 5 trips above are the only ones relevant to fan control.
- Below the trip temperature — the zone requests state 0 (off).
- At or above the trip temperature — the governor starts at the minimum state for that range and steps up one state per poll cycle while the temperature is still rising, or steps back down while it is falling. It settles at whichever state within the range stabilises the temperature.
- Multiple trip points — when the temperature crosses a second trip, the higher range becomes available and the governor can step into it.
The kernel applies max-wins arbitration across all zones: whichever zone
demands a higher state wins. For example, if the CPU zone wants state 4 but the
aux zone only wants state 2, the fan runs at state 4. The fan_driver sysfs
attribute reports which zone is responsible.
A thermal zone pairs a temperature sensor with one or more cooling devices, a governor, and a set of trip points — temperature thresholds that trigger cooling actions. The Raspberry Pi 5 has two thermal zones when x-fan40 is installed:
| sysfs path | Zone type | Sensor | Managed by |
|---|---|---|---|
thermal_zone0 |
cpu-thermal |
BCM2712 SoC | Pi 5 base DT, extended by fragment 3 |
thermal_zone1 |
x-fan40-aux |
Coral TPU / NVMe max | Fragment 5 + x-fan40-aux-thermal.ko |
cpu-thermal is the Pi 5's pre-existing zone. Its base device tree already defines multiple active trips (at 50 °C, 67.5 °C, and 75 °C) plus a critical trip at ~110 °C for emergency shutdown. Fragment 3 injects two new labeled trip nodes at 67.5 °C and 75 °C alongside the existing unlabeled ones — DT labels are required to reference trips in cooling maps — bringing the total to 7 trip points in that zone (6 active + 1 critical).
x-fan40-aux is a new zone created entirely by this project (fragment 5). It has exactly 2 trip points, both active, because auxiliary devices only need fan assistance; no CPU throttling or emergency shutdown is tied to their temperature.
CPU thermal zone ──────────────────────────────────────────────────┐
▼
Coral TPU ──► /sys/class/apex/apex_N/temp ──┐
├──► x-fan40-aux-thermal.ko
NVMe ──► /sys/class/nvme/nvmeN/... ──┘ (aux thermal zone)
│
▼
kernel max-wins arbitration
│
▼
x-fan (GPIO13)
The CPU zone is configured entirely in the device tree overlay (fragment 3)
— the kernel thermal framework wires it to the fan with no module or daemon
involved. The aux zone is owned by x-fan40-aux-thermal.ko, which polls
Coral TPU and NVMe temperatures and reports the maximum. The framework then
applies max-wins arbitration: whichever zone demands more cooling wins.
No user-space daemon is required.
| File | Purpose |
|---|---|
x-fan40-overlay.dts |
Device tree overlay: PWM, tachometer, CPU cooling maps, aux thermal zone |
x-fan40-aux-thermal.c |
Kernel module: polls Coral TPU and NVMe temperatures, drives aux thermal zone |
Kbuild |
Kernel build descriptor for the module |
Makefile |
Build and install rules |
If you only need CPU-driven fan control and do not have an NVMe or Coral TPU, you can drop in the pre-built overlay without installing any kernel module.
- Download
x-fan40.dtbofrom the latest release. - Copy it to the overlays directory:
sudo cp x-fan40.dtbo /boot/firmware/overlays/- Add the overlay to
/boot/firmware/config.txt:
echo "dtoverlay=x-fan40" | sudo tee -a /boot/firmware/config.txt- Reboot.
DKMS rebuilds the kernel module automatically after kernel updates.
# Install prerequisites
sudo apt install dkms raspberrypi-kernel-headers device-tree-compiler
# Clone and install
git clone https://github.com/dacarson/x-fan40-kernel.git
cd x-fan40-kernel
sudo make install-overlay # installs pre-built overlay and edits config.txt
sudo make install-dkms # registers and builds the module via DKMS
# Reboot to activate the overlay
sudo rebootAfter reboot, load the module and configure it to load automatically:
sudo modprobe x-fan40-aux-thermal
echo "x-fan40-aux-thermal" | sudo tee /etc/modules-load.d/x-fan40-aux.confTo remove:
sudo make uninstall-dkms
sudo make uninstall
sudo reboot# Prerequisites
sudo apt install raspberrypi-kernel-headers device-tree-compiler
# Clone the repository
git clone https://github.com/dacarson/x-fan40-kernel.git
cd x-fan40-kernel
# Build the device tree overlay and kernel module
make
# Install both (requires sudo)
sudo make install
# Reboot to activate the device tree overlay
sudo rebootAfter reboot, load the auxiliary thermal module:
sudo modprobe x-fan40-aux-thermalTo load it automatically at boot:
echo "x-fan40-aux-thermal" | sudo tee /etc/modules-load.d/x-fan40-aux.conf- Raspberry Pi 5
- Raspberry Pi OS (Debian 12 Bookworm or Debian 13 Trixie)
- For build/DKMS:
sudo apt install raspberrypi-kernel-headers device-tree-compiler
x-fan40-aux-thermal.ko polls Coral TPU and NVMe temperatures directly from
their kernel sysfs class entries and feeds the maximum into the kernel thermal
framework as an auxiliary zone.
Coral TPU — discovered via /sys/class/apex/apex_N/temp (up to 2 devices).
NVMe — discovered via /sys/class/nvme/nvmeN/hwmonM/temp1_input (up to 4
drives). The hwmon index M is assigned dynamically by the kernel and is
found automatically.
Both source types are discovered at module load and re-tried on every poll cycle, so devices that appear after the module loads are picked up automatically.
| Parameter | Default | Description |
|---|---|---|
poll_ms |
1000 |
Temperature poll interval in milliseconds |
Override at load time:
sudo modprobe x-fan40-aux-thermal poll_ms=500Or persist in /etc/modprobe.d/x-fan40-aux.conf:
options x-fan40-aux-thermal poll_ms=500
| Attribute | Access | Description |
|---|---|---|
aux_temp |
read | Hottest Apex/NVMe temperature in milli-Celsius — this is what drives the aux thermal zone |
source |
read | Name of the hottest Apex/NVMe device (apex_0, nvme1, etc.) |
fan_state |
read | Current pwm-fan cooling state (0–5) |
fan_driver |
read | Zone responsible for the current fan speed: cpu, a source name (e.g. nvme0), or none |
fan_driver is inferred by comparing the actual fan state against the maximum
state the aux zone could demand at the current aux temperature — if the fan is
running faster than the aux zone requires, the CPU zone must be responsible.
cat /sys/devices/platform/x-fan40-aux-sensor/aux_temp
# → 65000 (65 °C, from nvme0 — drives aux zone)
cat /sys/devices/platform/x-fan40-aux-sensor/source
# → nvme0
cat /sys/devices/platform/x-fan40-aux-sensor/fan_state
# → 4
cat /sys/devices/platform/x-fan40-aux-sensor/fan_driver
# → cpu/sys/class/thermal/thermal_zone0/ (cpu-thermal — Pi 5 base zone, extended by fragment 3)
type → "cpu-thermal"
temp → current CPU temperature in milli-Celsius
trip_point_{0-5}_type → "active" (6 trips: 50 °C, 67.5 °C × 2, 75 °C × 2, plus base DT trips)
trip_point_6_type → "critical" (~110 °C emergency shutdown, from Pi 5 base DT)
/sys/class/thermal/thermal_zone1/ (x-fan40-aux — new zone, created by fragment 5)
type → "x-fan40-aux"
temp → hottest Coral TPU / NVMe temperature in milli-Celsius
trip_point_0_type → "active" (55 °C — fan states 1–3)
trip_point_1_type → "active" (80 °C — fan states 4–5)
The pwm-fan driver registers an hwmon device, so the fan appears alongside
the Pi's built-in sensors under /sys/class/hwmon/:
/sys/class/hwmon/hwmonX/
name → "pwmfan"
fan1_input → current RPM (read, from GPIO16 tachometer)
pwm1 → duty cycle 0-255 (read/write)
pwm1_enable → control mode (read/write)
The fan also appears as a thermal cooling device:
/sys/class/thermal/cooling_deviceN/
type → "pwm-fan"
cur_state → current cooling state 0-5 (read/write)
max_state → 5
The X-FAN40 and the Pi 5's built-in active cooler both appear as pwmfan
hwmon devices. To see the RPM of both at once:
for d in /sys/class/hwmon/hwmon*/; do
name=$(cat "$d/name" 2>/dev/null)
rpm=$(cat "$d/fan1_input" 2>/dev/null || echo "no tach")
echo "$name: $rpm RPM ($d)"
doneThe X-FAN40 is specifically at /sys/devices/platform/x-fan/hwmon/hwmon*/fan1_input.
The x-fan40-aux-thermal module finds it by that path (the x-fan DT node
name is unique). The thermal cooling device is identified by max_state 5,
which differs from the Pi 5 built-in fan (max_state 4).
See ADDING-SOURCES.md for a step-by-step guide.
For DKMS installs:
sudo make uninstall-dkms
sudo make uninstall
sudo rebootFor source/manual installs:
sudo make uninstall
sudo rebootmake uninstall removes the .dtbo file and the dtoverlay=x-fan40 line
from /boot/firmware/config.txt. Remove the module auto-load conf manually
if added:
sudo rm /etc/modules-load.d/x-fan40-aux.confmake fails with syntax error on the .dts file
The device tree compiler needs the C preprocessor to handle #include
directives. Ensure raspberrypi-kernel-headers is installed — the Makefile
runs cpp with the kernel include path automatically.
Module loads but aux_temp sysfs attribute is missing
The DT overlay must be active before the module is loaded. Verify the overlay is active by checking for its sysfs devices instead:
ls /sys/devices/platform/x-fan/hwmon/
ls /sys/devices/platform/x-fan40-aux-sensor/If those paths are missing, check /boot/firmware/config.txt for
dtoverlay=x-fan40 and reboot.
source reads none after module load
If no NVMe drive or Coral TPU is installed, source reading none is
correct — the aux zone has no heat sources to monitor and the fan will be
driven by the CPU zone alone.
If you do have an NVMe or Coral TPU and source still reads none, the
device drivers may not have loaded yet. Check:
ls /sys/class/apex/
ls /sys/class/nvme/The module retries discovery on every poll cycle, so source will update
automatically once the devices appear.
Overlay not working after reboot
On Pi 5 with kernel 6.12, dtoverlay -l does not list boot-time overlays,
so its output cannot confirm whether the overlay applied. Use sysfs instead
(see above). If the sysfs paths are missing, the overlay failed to apply.
Run sudo dtoverlay -v x-fan40 to attempt a manual load and check dmesg
for the error. Common causes on Pi 5 kernel 6.12:
node label 'rp1_pwm1_gpio13' not found— the GPIO13 pinctrl node is defined inline in the overlay (fragment 0) for this reason; if you see this error the overlay version pre-dates the fix.node label 'cpu_trip1' not found— the BCM2712 base DT only exportscpu_tepidas a labeled CPU trip point; fragment 3 now uses that label and defines its own nodes for the higher trip temperatures.
Both are fixed in current source; rebuild and reinstall the overlay.
fan1_input is missing from the hwmon directory
The tachometer entry only appears when the pwm-fan driver successfully
registers an interrupt on GPIO16. Check that the overlay is current:
strings /boot/firmware/overlays/x-fan40.dtbo | grep -E 'interrupts-extended|rp1_tach'You should see interrupts-extended and rp1_tach_gpio16. If you see
fan-tach-gpios instead, rebuild with make clean && make overlay && sudo make install-overlay and reboot.
Note for kernel maintainers: The Raspberry Pi 6.12
pwm-fandriver diverges from mainline. It discovers tachometer inputs viaplatform_irq_count(), which reads theinterrupts/interrupts-extendedDT property. The mainlinefan-tach-gpiosproperty is silently ignored. Useinterrupts-extended = <&gpio 16 IRQ_TYPE_EDGE_FALLING>to wire the tachometer.
Fan does not spin after install
Verify the overlay created a pwm-fan cooling device:
cat /sys/class/thermal/cooling_device*/typeOne entry should read pwm-fan. If absent, the overlay did not load — check
dmesg for device tree errors.