Ultimate Raspberry Pi 5 Silence: Noctua PWM Mod for Pironman 5 Case

The Pironman 5 is a beast of a case, but let’s be honest—those stock fans can get loud. In this video, I walk you through a step-by-step hardware mod to swap them out for ultra-quiet Noctua 40mm fans with full PWM control.

We’ll cover everything from stripping the cables and crimping custom JST ZH headers to jumping the PWM signals to GPIO pins 12 and 18 for precision cooling.

Hardware Requirements:

Software:

The fan control script is open-source! Check out the GitHub repo to monitor your CPU and NVMe temps automatically. (Note: Tested on Ubuntu 25.10).

:link: GitHub - tuffant21/raspberrypi-pwm-temp-to-fan-curve · GitHub

If this helped you quiet down your lab, drop a Like and Subscribe for more Pi hardware mods!

Interesting!

I’ve ordered some noctua 5V fans to try this out myself. I take the existing 4-pin connectors in the pironman 5 max case do not allow for PWM control? (even though it seems they have 4 pins and they are populated?). I was wondering since I have a third fan (see Problem with 2xNVMe HAT in Pironman 5 MAX ) that is also the same fan you are using, a noctua 4cmx1cm 5V that I am using to cool off the two NVME drives that I had to put on top of the pironman 5 max case, in another powered HAT, since the internal HAT provided by the case could not handle my two NVMe drives). This fan is connected to one of the USB 2.0 ports. So I will “join” the two internal fans and will feed them to one of the internal fan connectors and will drive this upper fan to the other connector.
I wil modify –with your permission– your code so that the this third fan is PWM controlled by max temp of the two NVMe drives.

Will post progress!

Oh… seems the other pins are for the RGB headers… I get it now. So the internal fans provided are NOT PWM controlled. Makes sense.

Yep! You got it, yeah they are just 5v power and ground for both the fans and leds. :slightly_smiling_face:

Feel free to mod the code however you want! It has an MIT license and you are more than welcome to bring it into the code repository as well!

Would love to keep up with the progress! Please post back when you have some updates :slightly_smiling_face:

Waiting for the fans to arrive to do the mods and try out the software with modifications. I am gunning at controlling speed of all three fans. AFAIK, the pi 5 has a PWMO block with four independent channels, so theoretically I can control the speed of the three fans (two internal, one on top for the two NVMe).
I also want to read the rpms of the three fans through GPIO wiring as well.

Should be doable with this wiring:

Component Function Noctua Wire Destination (Pi 5 GPIO / Pin)
Internal Fan 1 PWM Blue GPIO 12 (Pin 32)PWM0_CHAN0
Tach Green GPIO 23 (Pin 16)
Internal Fan 2 PWM Blue GPIO 13 (Pin 33)PWM0_CHAN1
Tach Green GPIO 22 (Pin 15)
Top Fan PWM Blue GPIO 18 (Pin 12)PWM0_CHAN2
Tach Green GPIO 24 (Pin 18)

Of course, +5V and GND of the two internal fans go to one of the JST ZH 1.5 mm pitch fan connectors on the case and the +5V and GND of the top fan go to the other internal fan connector.

I will modify the dtoverlay to have three PWM channels.

Code could be something like this (generated with GenAI, need to go line by line to make sure it does what I want… for example, I want a floor fan speed to ensure minimal airflow). I also want to connect either to Glances and then Home Assistant or directly to MQTT as I want to be able to report fan rpms to Home Assistant (and not only the CPU fan).

import time
import signal
import sys
import logging
import subprocess
import re
import os
import lgpio
from rpi_hardware_pwm import HardwarePWM

# --- CONFIGURATION ---
PWM_FREQ = 25000 
# Note: Minimum speed of 25% is enforced in the interpolation function below
CPU_CURVE = [(35, 0), (45, 30), (55, 70), (65, 100)]
NVME_CURVE = [(35, 0), (40, 40), (50, 80), (60, 100)]

# Tachometer Pins
TACH_INT_1 = 23
TACH_INT_2 = 22
TACH_TOP = 24

logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')

class PironmanTripleZone:
    def __init__(self):
        self.h = lgpio.gpiochip_open(0)
        
        # Setup 3 Tach inputs with Pull-ups
        for pin in [TACH_INT_1, TACH_INT_2, TACH_TOP]:
            lgpio.gpio_claim_input(self.h, pin, lgpio.SET_PULL_UP)
        
        self.counts = {"int1": 0, "int2": 0, "top": 0}
        lgpio.callback(self.h, TACH_INT_1, lgpio.FALLING_EDGE, lambda s,g,t,f: self._inc("int1"))
        lgpio.callback(self.h, TACH_INT_2, lgpio.FALLING_EDGE, lambda s,g,t,f: self._inc("int2"))
        lgpio.callback(self.h, TACH_TOP, lgpio.FALLING_EDGE, lambda s,g,t,f: self._inc("top"))

        # Hardware PWM (Chip 0)
        # We now use three distinct channels on the same PWM chip
        self.pwm_int1 = HardwarePWM(pwm_channel=0, hz=PWM_FREQ, chip=0)
        self.pwm_int2 = HardwarePWM(pwm_channel=1, hz=PWM_FREQ, chip=0)
        self.pwm_top  = HardwarePWM(pwm_channel=2, hz=PWM_FREQ, chip=0)
        
        # --- KICKSTART ---
        # High-torque burst to ensure all 3 fans start spinning from cold
        logging.info("Kickstarting 3 fans at 100%...")
        for p in [self.pwm_int1, self.pwm_int2, self.pwm_top]: p.start(100)
        time.sleep(2)

    def _inc(self, zone): self.counts[zone] += 1

    def get_rpm(self, zone):
        self.counts[zone] = 0
        time.sleep(0.5) # Shorter sample window per fan to keep loop responsive
        return self.counts[zone] * 60 

    def get_system_temps(self):
        """Reads CPU and finds max temp of both NVMe drives."""
        # Min temp in case probing fails to avoid low temps
        cpu = 35.0
        nvme_temps = []
        try:
            with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
                cpu = float(f.read()) / 1000.0
        except: pass
        
        for dev in ['/dev/nvme0', '/dev/nvme1']:
            if os.path.exists(dev):
                try:
                    res = subprocess.run(['nvme', 'smart-log', dev], capture_output=True, text=True, timeout=1)
                    m = re.search(r'temperature\s*:\s*(\d+)\s*C', res.stdout, re.IGNORECASE)
                    if m: nvme_temps.append(float(m.group(1)))
                except: pass
        
        # Min temp is 30 in case probing fails to avoid low temps
        max_nvme = max(nvme_temps) if nvme_temps else 30.0
        return cpu, max_nvme

    def interpolate(self, temp, curve):
        """Calculates duty cycle with a strict 25% minimum floor."""
        speed = 0.0
        if temp <= curve[0][0]: speed = 0.0
        elif temp >= curve[-1][0]: speed = 100.0
        else:
            for i in range(len(curve) - 1):
                (t1, d1), (t2, d2) = curve[i], curve[i+1]
                if t1 <= temp <= t2:
                    speed = d1 + (d2 - d1) * (temp - t1) / (t2 - t1)
                    break
        
        # ENFORCE 25% MINIMUM: Even if the curve suggests 0%, 
        # we return 25% to keep airflow moving and prevent stall.
        return max(speed, 25.0)

    def run(self):
        try:
            while True:
                cpu_t, nvme_t = self.get_system_temps()
                
                # Independent calculation for each zone
                cpu_speed = self.interpolate(cpu_t, CPU_CURVE)
                top_speed = self.interpolate(nvme_t, NVME_CURVE)
                
                # Apply PWM
                self.pwm_int1.change_duty_cycle(cpu_speed)
                self.pwm_int2.change_duty_cycle(cpu_speed) # Following CPU
                self.pwm_top.change_duty_cycle(top_speed)
                
                # Measure RPMs
                r1 = self.get_rpm("int1")
                r2 = self.get_rpm("int2")
                rt = self.get_rpm("top")
                
                logging.info(f"CPU: {cpu_t:0.1f}°C | Int1: {r1} RPM | Int2: {r2} RPM")
                logging.info(f"NVMe: {nvme_t:0.1f}°C | Top: {rt} RPM")
                time.sleep(3)
        except KeyboardInterrupt:
            self.stop()

    def stop(self):
        for p in [self.pwm_int1, self.pwm_int2, self.pwm_top]: p.stop()
        lgpio.gpiochip_close(self.h)
        sys.exit(0)

if __name__ == "__main__":
    c = PironmanTripleZone()
    signal.signal(signal.SIGTERM, lambda s, f: c.stop())
    c.run()

Got the fans… I connected all fans to the GPIO header. Why? because that way I do not have to cut or do any soldering… basically using dupont wires (male-female) and I can pin them directly to the fan connector (female) on the 5V noctua fans and then to the GPIO header.

I am now having lots and lots of problems with the pins… This is the pinout:

GROUP1 TOP FAN NVME, CONTROLLED BY THE MAX TEMPERATURE OF BOTH NVME DRIVES
5V Power: Pin 2 (Labeled “+5V”)
Ground: Pin 6 (Labeled “GND”)
PWM (Blue): Pin 32 (Labeled “#12”)
Tach (Green): Pin 22 (Labeled “#25”)

GROUP2 INTERNAL CASE FAN ON TOP CONTROLLED BY CPU TEMP
5V Power: Pin 2 (Labeled “+5V”)
Ground: Pin 9 (Labeled “GND”)
PWM (Blue): Pin 33 (Labeled “#13”)
Tach (Green): Pin 11 (Labeled “#17”)

GROUP3 INTERNAL CASE FAN ON BOTTOM CONTROLLED BY CPU TEMP
5V Power: Pin 4 (Labeled “+5V”)
Ground: Pin 14 (Labeled “GND”)
PWM (Blue): Pin 35 (Labeled “#19”)
Tach (Green): Pin 16 (Labeled “#23”)

And this is what I added at the end of the /boot/firmware/config.txt file:

# --- FAN GROUP 1 (NVMe) ---
# Physical Pin 32 (GPIO 12). Alt Function 4 = PWM0 Channel 0
dtoverlay=pwm,pin=12,func=4

# --- FAN GROUP 2 (Internal Case Top) ---
# Physical Pin 33 (GPIO 13). Alt Function 4 = PWM0 Channel 1
dtoverlay=pwm,pin=13,func=4

# --- FAN GROUP 3 (Internal Case Bottom) ---
# Physical Pin 35 (GPIO 19). Alt Function 3 = PWM1 Channel 1
dtoverlay=pwm,pin=19,func=3

I tried in the overlays to use pwm-2chan but does not work either…

I am a bit stuck. Any pointers? Thank you!

Then hell broke loose, lmao… For the life of me I could not

OK, since I am on a raspberry pi 5 I will try without kernel overlays and will use lgpio.tx_pwm() instead… no need for pwm overlays and rpi-hardware-pwm… Willl report progress later!

I ran into an issue with the dtoverlay when I was coding mine. I had to do the dtoverlay on separate lines than the dtparams for some reason. It sounds like it depends on what os you are running on as well. Are you running Ubuntu or Raspbian?


One thing that I am curious about is if the tach green wire needs to be adc (analog to digital converter). I think the pi has some pins dedicated for adc, but I think you have to connect the ground wire to a specific adc ground pin so the pi can use it as ground reference. Might be something to look into. I would look into it, but it’s 2am here at the moment, ha!


Curious what happened here! Looks like the text got cut off maybe?


What does the output of this command give you?

sudo pinctrl get 12,18

I have been working on the code… I am getting some weird results.

  1. I am trying to use lgpio, which does not require, apparently, kernel overlays, so no modifications to the /boot/firmware/config.txt file (RP1 chip on Raspberry pi 5
  2. Did this code… it has all the features I want.
"""
Pironman Fan Controller (Production Grade)

Controls three PWM fans on Raspberry Pi 5.

Features
--------
• Independent PWM control
• Accurate RPM measurement
• Moving-average RPM smoothing
• Thermal hysteresis
• Startup boost
• Fan stall detection
• Watchdog failsafe
• NVMe multi-drive detection
• Shared-memory stats export
• Systemd friendly

Stats file:
/dev/shm/pironman_stats

Format:
CPU_TEMP,NVME_TEMP,RPM_NVME,RPM_TOP,RPM_BOTTOM
"""

import time
import signal
import logging
import os
import lgpio
import sys
import glob

# --------------------------------------------------
# GPIO CONFIGURATION
# --------------------------------------------------

PWM_PINS = {
    "nvme": 12,
    "top": 13,
    "bottom": 19
}

TACH_PINS = {
    "nvme": 25,
    "top": 17,
    "bottom": 23
}

# --------------------------------------------------
# FAN CHARACTERISTICS
# --------------------------------------------------

PWM_FREQ = 10000
PULSES_PER_REV = 2
MIN_DUTY = 35
SAFE_DUTY = 60
STALL_RPM_THRESHOLD = 200

RPM_SMOOTHING = 3
TEMP_HYSTERESIS = 2

STATS_FILE = "/dev/shm/pironman_stats"

# --------------------------------------------------
# FAN CURVES
# --------------------------------------------------

CPU_CURVE = [
    (40, 25),
    (50, 45),
    (60, 75),
    (70, 100)
]

NVME_CURVE = [
    (35, 30),
    (45, 55),
    (55, 80),
    (65, 100)
]

# --------------------------------------------------
# LOGGING
# --------------------------------------------------

logging.basicConfig(
    level=logging.INFO,
    format="[PIRONMAN] %(asctime)s %(levelname)s: %(message)s"
)

# --------------------------------------------------
# FAN OBJECT
# --------------------------------------------------

class Fan:

    def __init__(self, chip, pwm_gpio, tach_gpio, name):
        self.name = name
        self.h = chip
        self.pwm = pwm_gpio
        self.tach = tach_gpio

        self.pulses = 0
        self.last_time = time.time()
        self.last_duty = MIN_DUTY

        self.rpm_history = []

        lgpio.gpio_claim_input(self.h, self.tach, lgpio.SET_PULL_UP)

        lgpio.callback(
            self.h,
            self.tach,
            lgpio.FALLING_EDGE,
            self._pulse
        )

    def _pulse(self, chip, gpio, level, tick):
        self.pulses += 1

    def set_speed(self, duty):
        duty = max(MIN_DUTY, min(100, duty))
        lgpio.tx_pwm(self.h, self.pwm, PWM_FREQ, duty)
        self.last_duty = duty

    def get_rpm(self):
        now = time.time()
        duration = max(0.1, now - self.last_time)
        rpm = (self.pulses * 60) / (duration * PULSES_PER_REV)
        self.pulses = 0
        self.last_time = now
        self.rpm_history.append(rpm)

        if len(self.rpm_history) > RPM_SMOOTHING:
            self.rpm_history.pop(0)

        return int(sum(self.rpm_history) / len(self.rpm_history))

# --------------------------------------------------
# SENSOR DETECTION
# --------------------------------------------------

def find_cpu_sensor():
    for i in range(5):
        path = f"/sys/class/thermal/thermal_zone{i}"

        if os.path.exists(path):
            try:
                with open(f"{path}/type") as f:
                    if "cpu" in f.read().lower():
                        return f"{path}/temp"
            except:
                pass

    return "/sys/class/thermal/thermal_zone0/temp"


def find_nvme_sensors():
    sensors = []
    for d in glob.glob("/sys/class/hwmon/hwmon*"):
        try:
            with open(f"{d}/name") as f:
                if "nvme" in f.read().lower():
                    sensors.append(f"{d}/temp1_input")
        except:
            pass

    return sensors


def read_temp(path):
    try:
        with open(path) as f:
            return float(f.read()) / 1000.0
    except:
        return None

# --------------------------------------------------
# CURVE INTERPOLATION
# --------------------------------------------------

def interpolate(temp, curve):
    if temp <= curve[0][0]:
        return curve[0][1]

    if temp >= curve[-1][0]:
        return curve[-1][1]

    for i in range(len(curve)-1):
        t1, d1 = curve[i]
        t2, d2 = curve[i+1]
        if t1 <= temp <= t2:
            return d1 + (d2-d1)*(temp-t1)/(t2-t1)

# --------------------------------------------------
# CONTROLLER
# --------------------------------------------------

class Controller:
    def __init__(self):
        try:
            self.h = lgpio.gpiochip_open(0)
        except:
            logging.error("Cannot open GPIO chip")
            sys.exit(1)

        self.nvme = Fan(self.h, PWM_PINS["nvme"], TACH_PINS["nvme"], "NVME")
        self.top = Fan(self.h, PWM_PINS["top"], TACH_PINS["top"], "CASE_TOP")
        self.bottom = Fan(self.h, PWM_PINS["bottom"], TACH_PINS["bottom"], "CASE_BOTTOM")

        self.cpu_sensor = find_cpu_sensor()
        self.nvme_sensors = find_nvme_sensors()

        self.last_cpu = None
        self.last_nvme = None

        logging.info(f"CPU sensor: {self.cpu_sensor}")
        logging.info(f"NVMe sensors: {self.nvme_sensors}")

    # --------------------------------------------------

    def get_temps(self):
        cpu = read_temp(self.cpu_sensor)
        if cpu is None:
            cpu = 35

        nvme_list = []
        for s in self.nvme_sensors:
            t = read_temp(s)
            if t is not None:
                nvme_list.append(t)

        nvme = max(nvme_list) if nvme_list else cpu

        if self.last_cpu is not None:
            if abs(cpu - self.last_cpu) < TEMP_HYSTERESIS:
                cpu = self.last_cpu
        if self.last_nvme is not None:
            if abs(nvme - self.last_nvme) < TEMP_HYSTERESIS:
                nvme = self.last_nvme

        self.last_cpu = cpu
        self.last_nvme = nvme

        return cpu, nvme

    # --------------------------------------------------

    def startup_boost(self):
        logging.info("Startup boost")
        for f in [self.nvme, self.top, self.bottom]:
            f.set_speed(100)
        time.sleep(2)

    # --------------------------------------------------

    def check_stall(self, fan, rpm):
        if fan.last_duty > 60 and rpm < STALL_RPM_THRESHOLD:
            logging.warning(
                f"Fan stall suspected: {fan.name} duty={fan.last_duty} rpm={rpm}"
            )

    # --------------------------------------------------

    def write_stats(self, cpu, nvme, r1, r2, r3):
        try:
            with open(STATS_FILE, "w") as f:
                f.write(f"{cpu:.1f},{nvme:.1f},{r1},{r2},{r3}")
        except:
            pass

    # --------------------------------------------------

    def watchdog_refresh(self):
        # refresh PWM signals so fans never timeout
        for fan in [self.nvme, self.top, self.bottom]:
            fan.set_speed(fan.last_duty)

    # --------------------------------------------------

    def loop(self):
        self.startup_boost()

        while True:
            cpu, nvme = self.get_temps()

            cpu_duty = interpolate(cpu, CPU_CURVE)
            nvme_duty = interpolate(nvme, NVME_CURVE)

            self.top.set_speed(cpu_duty)
            self.bottom.set_speed(cpu_duty)
            self.nvme.set_speed(nvme_duty)

            rpm_nvme = self.nvme.get_rpm()
            rpm_top = self.top.get_rpm()
            rpm_bottom = self.bottom.get_rpm()

            self.check_stall(self.nvme, rpm_nvme)
            self.check_stall(self.top, rpm_top)
            self.check_stall(self.bottom, rpm_bottom)

            self.watchdog_refresh()

            logging.info(
                f"CPU:{cpu:.1f}C NVMe:{nvme:.1f}C | "
                f"RPM NVME:{rpm_nvme} TOP:{rpm_top} BOT:{rpm_bottom}"
            )

            self.write_stats(cpu, nvme, rpm_nvme, rpm_top, rpm_bottom)

            time.sleep(5)

    # --------------------------------------------------

    def stop(self):
        logging.warning("Controller stopping → entering safe fan mode")

        for p in PWM_PINS.values():
            lgpio.tx_pwm(self.h, p, PWM_FREQ, SAFE_DUTY)

        lgpio.gpiochip_close(self.h)

        sys.exit(0)

# --------------------------------------------------
# MAIN
# --------------------------------------------------

if __name__ == "__main__":
    ctrl = Controller()
    signal.signal(signal.SIGTERM, lambda s,f: ctrl.stop())
    try:
        ctrl.loop()
    except KeyboardInterrupt:
        ctrl.stop()

Note that with 25Khz PWM_FREQ I get a ‘bad PWM frequency’ when calling lgpio.tx_pwm(self.h, self.pwm, PWM_FREQ, duty).

Now the behavior is weird:
a) Sometimes I can control all three fans, other times just one (!!) but consistently, when only one works, it is the CASE TOP fan.
b) RPM values are always 0… not sure what am I doing wrong.
c) when stopping the script I get the call to the stop… but fans do not go to 60% cycle as the code intents… they stop completely!

Humm… I do not understand enough about the raspberry pi and fan control to make this work :frowning:

These are the readings from the pins that I am using for PWM (12, 13, 19) and Tach (25, 17, 23) for each one of the three fans, respectively:

root@grpi2:/opt/pironman#  pinctrl get 12,13,19,25,17,23
12: ip    pd | lo // GPIO12 = input
13: ip    pd | hi // GPIO13 = input
17: ip    pu | lo // GPIO17 = input
19: op dl pd | lo // GPIO19 = output
23: ip    pu | hi // GPIO23 = input
25: ip    pu | hi // GPIO25 = input

I think you are having issues because the pwm pins have different functions that can be set called alternate functions: GPIO 18 (PCM Clock) at Raspberry Pi GPIO Pinout

Your output from pinctrl confirms that the pins are not configured for pwm. They are configured for input and output. Here is what my pinctrl outputs for comparison:

12: a0    pd | lo // GPIO12 = PWM0_CHAN0
18: a3    pd | hi // GPIO18 = PWM0_CHAN2

It’s possible that lgpio doesn’t require changes to the kernel overlays, but I am not familiar with it enough to know for sure. I mainly write in c/c++. Any code written in Python is pretty unfamiliar to me!


One thing that we can try is manually setting the PWM values in the operating system itself to ensure the pins and hardware are configured correctly. Then move to the code to see if we can find a fault there.

Try running these steps and let me know what each of the steps say and do.

1. The Pin Mux Test

First, ensure the pins are routed to the PWM engine and not to Audio or I2S.

# Set Pin 12 to PWM mode (Alt 0)
sudo pinctrl set 12 a0
# Set Pin 18 to PWM mode (Alt 3)
sudo pinctrl set 18 a3

# Verify the settings
pinctrl get 12,18

  • Pin 12 should show a0 // GPIO12 = PWM0_CHAN0

  • Pin 18 should show a3 // GPIO18 = PWM0_CHAN2


2. The Sysfs PWM Test (Manual Spin-up)

We will now tell the kernel to send a 50% duty cycle signal to both channels.

For Pin 12 (Channel 0):

# Export the channel if it doesn't exist
sudo bash -c "echo 0 > /sys/class/pwm/pwmchip0/export" 2>/dev/null || true

# Set 25kHz frequency (40,000ns period) and 50% duty (20,000ns)
sudo bash -c "echo 40000 > /sys/class/pwm/pwmchip0/pwm0/period"
sudo bash -c "echo 20000 > /sys/class/pwm/pwmchip0/pwm0/duty_cycle"
sudo bash -c "echo 1 > /sys/class/pwm/pwmchip0/pwm0/enable"

For Pin 18 (Channel 2):

# Export the channel (Channel 2)
sudo bash -c "echo 2 > /sys/class/pwm/pwmchip0/export" 2>/dev/null || true

# Set 25kHz frequency and 50% duty
sudo bash -c "echo 40000 > /sys/class/pwm/pwmchip0/pwm2/period"
sudo bash -c "echo 20000 > /sys/class/pwm/pwmchip0/pwm2/duty_cycle"
sudo bash -c "echo 1 > /sys/class/pwm/pwmchip0/pwm2/enable"


3. Verify with Multimeter

With the probes connected to the physical pins:

  • Physical Pin 32 (GPIO 12): Should read ~1.65V.

  • Physical Pin 12 (GPIO 18): Should read ~1.65V.

Thanks for the help!!! I will do further testing.

I managed to improve quite a bit… I still cannot read RPM (always zero)… but I can control now all three fans. I was missing claiming the pins correctly:

        lgpio.gpio_claim_output(self.h, self.pwm)
        lgpio.gpio_claim_input(self.h, self.tach, lgpio.SET_PULL_UP)

The pins are now:

root@grpi2:/opt/pironman# pinctrl get 12,13,19,25,17,23
12: op dl pd | lo // GPIO12 = output
13: op dl pd | lo // GPIO13 = output
17: ip    pu | hi // GPIO17 = input
19: op dl pd | lo // GPIO19 = output
23: ip    pu | hi // GPIO23 = input
25: ip    pu | hi // GPIO25 = input

I will investigate if pins 12, 13 and 19 (the PWM ones) need to be assigned to the PWM channels, but it is so far working

Upon interruption of the script, the fans go into safe duty.

Will deep dive into the RPM issue, not sure what is going on.

Hummm… I am measuring between 0.9 and 1.1V on the fan tach (multimeter between tach and ground)… Isn’t this too low? I think the raspberry expects some 3.3V and is therefore not registering rpms.

Yeah, according to https://cdn.noctua.at/preview/media/Noctua_PWM_specifications_white_paper.pdf, I will need to pull up the voltage of the tach line… maybe adding a resistor between the tach line and the 3.3V pin… alas… My pironman 5 MAX case looks like a Borg… with a NVME HAT on top, a fan on top, kepton wire and 12 wires all over the place… resistors are going to take the cake!

Yeah… pulling up the tach line is explained here as well directly from Noctua: https://www.noctua.at/en/support/faqs/microcontroller-guide-pwm-setup-and-rpm-monitoring. Also this blog post specifically tells about the pull resistor and the 3.3V line: An Efficient Way to Read a Fan’s Tachometer Signal with a Raspberry Pi | Steve’s Blog

Alas, this is a deep rabbit hole :slight_smile:

Oh wow, this is cool! It sends electrical pulses as “digital pulse signal with a frequency in Hertz (Hz)”. So then, it sounds like we just need to do a pull up on an input line, read how many times the rising edge happens and then use that to calculate the fan speed. Sounds fairly straightforward! Famous last words, haha.

Do you have a repo or a branch off my repo we could work together on this? I had no intention to read the fan speed, but now I feel invested and want to play with it :laughing:

Yes, exactly. All we need is a resistor (around 4.7KOhm) going from the tach wire and then to the resistor and to the 3.3V line if I am understanding correctly.

I was planning on forking your repo, as what I have in mind is quite different (I am reading two NVME drives, I have three fans, not just the two internal fans, I want to read the RPMs, etc). Also, I am dumping the results to memory files as my intention is to have a systemd service running this script and read this memory file from Home Assistant so that I can monitor the raspberry (which runs proxmox and glances, so I have other things monitored as well).

In any case, here is the code. Note that it is not 100% tested yet… at least not comprehensively. I ordered a resistor book from Amazon and once it gets here, I will do more wiring to correctly read RPMs and will continue testing, but feel free to comment on the code below!

"""
Pironman Fan Controller (Production Grade)
------------------------------------------

Controls three PWM fans on Raspberry Pi 5.

Features
--------
• Independent PWM control
• Accurate RPM measurement (requires 3.3V pull-up on tach)
• Moving-average RPM smoothing
• Thermal hysteresis
• Startup boost
• Fan stall detection
• Watchdog failsafe
• NVMe multi-drive detection
• Shared-memory stats export
• Systemd friendly

Stats file:
    /dev/shm/pironman_stats

Format:
    CPU_TEMP,NVME_TEMP,RPM_NVME,RPM_TOP,RPM_BOTTOM
"""

import time
import signal
import logging
import os
import lgpio
import sys
import glob

# --------------------------------------------------
# GPIO CONFIGURATION
# --------------------------------------------------

PWM_PINS = {
    "nvme": 12,
    "top": 13,
    "bottom": 19
}

TACH_PINS = {
    "nvme": 25,
    "top": 17,
    "bottom": 23
}

# --------------------------------------------------
# FAN CHARACTERISTICS
# --------------------------------------------------

PWM_FREQ = 10000
PULSES_PER_REV = 2
MIN_DUTY = 35
SAFE_DUTY = 60
STALL_RPM_THRESHOLD = 200

RPM_SMOOTHING = 3
TEMP_HYSTERESIS = 2

STATS_FILE = "/dev/shm/pironman_stats"

# --------------------------------------------------
# FAN CURVES
# --------------------------------------------------

CPU_CURVE = [
    (40, 25),
    (50, 45),
    (60, 75),
    (70, 100)
]

NVME_CURVE = [
    (35, 30),
    (45, 55),
    (55, 80),
    (65, 100)
]

# --------------------------------------------------
# LOGGING
# --------------------------------------------------

logging.basicConfig(
    level=logging.INFO,
    format="[PIRONMAN] %(asctime)s %(levelname)s: %(message)s"
)

# --------------------------------------------------
# FAN CLASS
# --------------------------------------------------

class Fan:

    def __init__(self, chip, pwm_gpio, tach_gpio, name):
        self.name = name
        self.h = chip
        self.pwm = pwm_gpio
        self.tach = tach_gpio

        self.pulses = 0
        self.last_time = time.time()
        self.last_duty = MIN_DUTY
        self.rpm_history = []

        # Claim PWM pins
        lgpio.gpio_claim_output(self.h, self.pwm)
        # Configure tach GPIO input with pull-up
        lgpio.gpio_claim_input(self.h, self.tach, lgpio.SET_PULL_UP)

        # Callback to count pulses
        self.cb = lgpio.callback(
            self.h,
            self.tach,
            lgpio.FALLING_EDGE,
            self._pulse
        )

    def _pulse(self, chip, gpio, level, tick):
        self.pulses += 1

    def set_speed(self, duty):
        duty = max(MIN_DUTY, min(100, duty))
        lgpio.tx_pwm(self.h, self.pwm, PWM_FREQ, duty)
        self.last_duty = duty

    def get_rpm(self):
        now = time.time()
        duration = max(0.1, now - self.last_time)
        rpm = (self.pulses * 60) / (duration * PULSES_PER_REV)
        self.pulses = 0
        self.last_time = now
        self.rpm_history.append(rpm)

        if len(self.rpm_history) > RPM_SMOOTHING:
            self.rpm_history.pop(0)

        return int(sum(self.rpm_history) / len(self.rpm_history))

# --------------------------------------------------
# SENSOR DETECTION
# --------------------------------------------------

def find_cpu_sensor():
    for i in range(5):
        path = f"/sys/class/thermal/thermal_zone{i}"
        if os.path.exists(path):
            try:
                with open(f"{path}/type") as f:
                    if "cpu" in f.read().lower():
                        return f"{path}/temp"
            except:
                pass
    return "/sys/class/thermal/thermal_zone0/temp"

def find_nvme_sensors():
    sensors = []
    for d in glob.glob("/sys/class/hwmon/hwmon*"):
        try:
            with open(f"{d}/name") as f:
                if "nvme" in f.read().lower():
                    sensors.append(f"{d}/temp1_input")
        except:
            pass
    return sensors

def read_temp(path):
    try:
        with open(path) as f:
            return float(f.read()) / 1000.0
    except:
        return None

# --------------------------------------------------
# CURVE INTERPOLATION
# --------------------------------------------------

def interpolate(temp, curve):
    if temp <= curve[0][0]:
        return curve[0][1]
    if temp >= curve[-1][0]:
        return curve[-1][1]
    for i in range(len(curve)-1):
        t1, d1 = curve[i]
        t2, d2 = curve[i+1]
        if t1 <= temp <= t2:
            return d1 + (d2-d1)*(temp-t1)/(t2-t1)

# --------------------------------------------------
# CONTROLLER
# --------------------------------------------------

class Controller:

    def __init__(self):
        try:
            self.h = lgpio.gpiochip_open(0)
        except:
            logging.error("Cannot open GPIO chip")
            sys.exit(1)

        self.nvme = Fan(self.h, PWM_PINS["nvme"], TACH_PINS["nvme"], "NVME")
        self.top = Fan(self.h, PWM_PINS["top"], TACH_PINS["top"], "CASE_TOP")
        self.bottom = Fan(self.h, PWM_PINS["bottom"], TACH_PINS["bottom"], "CASE_BOTTOM")

        self.cpu_sensor = find_cpu_sensor()
        self.nvme_sensors = find_nvme_sensors()

        self.last_cpu = None
        self.last_nvme = None

        logging.info(f"CPU sensor: {self.cpu_sensor}")
        logging.info(f"NVMe sensors: {self.nvme_sensors}")

    # --------------------------------------------------

    def get_temps(self):
        cpu = read_temp(self.cpu_sensor) or 35
        nvme_list = [read_temp(s) for s in self.nvme_sensors if read_temp(s) is not None]
        nvme = max(nvme_list) if nvme_list else cpu

        if self.last_cpu is not None and abs(cpu - self.last_cpu) < TEMP_HYSTERESIS:
            cpu = self.last_cpu
        if self.last_nvme is not None and abs(nvme - self.last_nvme) < TEMP_HYSTERESIS:
            nvme = self.last_nvme

        self.last_cpu = cpu
        self.last_nvme = nvme
        return cpu, nvme

    # --------------------------------------------------

    def startup_boost(self):
        logging.info("Startup boost")
        for f in [self.nvme, self.top, self.bottom]:
            f.set_speed(100)
        time.sleep(2)

    # --------------------------------------------------

    def check_stall(self, fan, rpm):
        if fan.last_duty > 60 and rpm < STALL_RPM_THRESHOLD:
            logging.warning(f"Fan stall suspected: {fan.name} duty={fan.last_duty} rpm={rpm}")

    # --------------------------------------------------

    def write_stats(self, cpu, nvme, r1, r2, r3):
        try:
            with open(STATS_FILE, "w") as f:
                f.write(f"{cpu:.1f},{nvme:.1f},{r1},{r2},{r3}")
        except:
            pass

    # --------------------------------------------------

    def watchdog_refresh(self):
        for fan in [self.nvme, self.top, self.bottom]:
            fan.set_speed(fan.last_duty)

    # --------------------------------------------------

    def stop(self):
        """
        Robust stop: sets all fans to SAFE_DUTY, waits, then closes GPIO
        """
        logging.warning("Controller stopping → entering SAFE_DUTY mode")
        for f in [self.nvme, self.top, self.bottom]:
            try:
                f.set_speed(SAFE_DUTY)
                logging.info(f"{f.name} set to SAFE_DUTY={SAFE_DUTY}%")
            except Exception as e:
                logging.error(f"Failed to set {f.name} to SAFE_DUTY: {e}")

        time.sleep(0.5)  # Give PWM time to settle

        try:
            lgpio.gpiochip_close(self.h)
            logging.info("GPIO chip closed successfully")
        except Exception as e:
            logging.error(f"Failed to close GPIO chip: {e}")

        sys.exit(0)

    # --------------------------------------------------

    def loop(self):
        self.startup_boost()
        while True:
            cpu, nvme = self.get_temps()
            cpu_duty = interpolate(cpu, CPU_CURVE)
            nvme_duty = interpolate(nvme, NVME_CURVE)

            self.top.set_speed(cpu_duty)
            self.bottom.set_speed(cpu_duty)
            self.nvme.set_speed(nvme_duty)

            rpm_nvme = self.nvme.get_rpm()
            rpm_top = self.top.get_rpm()
            rpm_bottom = self.bottom.get_rpm()

            self.check_stall(self.nvme, rpm_nvme)
            self.check_stall(self.top, rpm_top)
            self.check_stall(self.bottom, rpm_bottom)

            self.watchdog_refresh()

            logging.info(
                f"CPU:{cpu:.1f}C NVMe:{nvme:.1f}C | "
                f"RPM NVME:{rpm_nvme} TOP:{rpm_top} BOT:{rpm_bottom}"
            )

            self.write_stats(cpu, nvme, rpm_nvme, rpm_top, rpm_bottom)

            time.sleep(5)

# --------------------------------------------------
# MAIN
# --------------------------------------------------

if __name__ == "__main__":
    ctrl = Controller()
    signal.signal(signal.SIGTERM, lambda s,f: ctrl.stop())
    signal.signal(signal.SIGINT,  lambda s,f: ctrl.stop())  # CTRL+C

    try:
        ctrl.loop()
    except Exception as e:
        logging.error(f"Unexpected exception: {e}")
        ctrl.stop()