diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7a52181 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +insert_final_newline = true + +[*.{ini,py,py.tpl,rst}] +indent_style = space +indent_size = 4 + +[*.{sh,bat.tpl,Makefile.tpl}] +indent_style = tab +indent_size = 4 + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore index b6e4761..6c49222 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# IDE's +.idea diff --git a/README.md b/README.md index 5acef0c..bae2a37 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,36 @@ Corporation September 2005, revision 1.3), to read the FAN speed using the tacho ## Dependencies * [Python 3](https://www.python.org/download/releases/3.0/) - The script interpreter -* [WiringPi-Python](https://github.com/WiringPi/WiringPi-Python) - Control Hardware features of Rasbberry Pi ## Documentations * [Noctua white paper](https://noctua.at/pub/media/wysiwyg/Noctua_PWM_specifications_white_paper.pdf) - Noctua PWM specifications white paper ## How to use ### Get repository -```sh -$ git clone git@github.com:alexfukahori/rpi-pwm-fan-control.git -$ cd rpi-pwm-fan-control -$ pip3 install -r requirements.txt -$ python3 ./rpi-pwmfan.py +```shell +git clone git@github.com:alexfukahori/rpi-pwm-fan-control.git +cd rpi-pwm-fan-control +pip3 install -r requirements.txt +``` + +### Set GPIO pins +Edit the `PWM_PIN` and `TACH_PIN` values in [rpi-pwmfan.py](./rpi-pwmfan.py) to match the pins used by your Pi. + +### Run the script +```shell +python3 ./rpi-pwmfan.py +``` + +### Use the script as a background service +Please edit the `rpi-pwm-fan-control.service` file and replace the `absolute_path_to_this_repo` with your path. +E.g.: `ExecStart=python3 /home/pi/scripts/rpi-pwm-fan-control/rpi-pwmfan.py` + +Then run following commands: + +```shell +sudo cp rpi-pwm-fan-control.service /etc/systemd/system +sudo systemctl daemon-reload +sudo systemctl enable rpi-pwm-fan-control ``` ## TODO List diff --git a/requirement.txt b/requirement.txt deleted file mode 100644 index de8ed7a..0000000 --- a/requirement.txt +++ /dev/null @@ -1 +0,0 @@ -wiringpi==2.60.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4f03ea2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pigpio==1.78 diff --git a/rpi-pwm-fan-control.service b/rpi-pwm-fan-control.service new file mode 100644 index 0000000..07de751 --- /dev/null +++ b/rpi-pwm-fan-control.service @@ -0,0 +1,9 @@ +[Unit] +Description=Service for RPi PWM Fan Control + +[Service] +ExecStart=python3 //rpi-pwm-fan-control/rpi-pwmfan.py +Restart=on-failure + +[Install] +WantedBy=default.target diff --git a/rpi-pwmfan.py b/rpi-pwmfan.py index 732dcc1..1ddda8c 100755 --- a/rpi-pwmfan.py +++ b/rpi-pwmfan.py @@ -1,183 +1,143 @@ #!/usr/bin/python3 -import wiringpi as wiringpi import time -from time import sleep - -pwmPin = 12 #HW PWM works on GPIO 12, 13, 18 and 19 on RPi4B -pwmRange = 5000 -tachoPin = 6 -lowTemp = 55 # Lowest temperature, if lowest of this, the FAN is Off -maxTemp = 60 # Higher than it, the FAN is on full speed -check_sec = 2 #Time to check temperature and set FAN speed - -percentTemp = (maxTemp-lowTemp)/100.0 - -rpmChkStartTime=None -rpmPulse = 0 - -###PID Parameters### -KP = 2 -KI = 1 -KD = 1 -TAU = 1 -PID_MIN = 0 -PID_MAX = 100 - - -class PID_Controller: - def __init__(self, kp, ki, kd, tau, limMin, limMax): - self.kp=kp - self.ki=ki - self.kd=kd - self.tau=tau - self.limMin=limMin - self.limMax=limMax - self.time=check_sec - self.integrator=0 - self.prevError=0 - self.differentiator=0 - self.prevMeasure=0 - self.out=0 - def update(self, setpoint, measure): - error=setpoint-measure - #error=measure-setpoint - #Proportional gain - proportional=self.kp*error - #Integral gain - self.integrator=self.integrator+0.5*self.ki*self.time*(error+self.prevError) - #Anti-wind-up - if self.limMax>proportional: - intLimMax=self.limMax-proportional - else: - intLimMax=0 - if self.limMinintLimMax: - self.integrator=intLimMax - else: - self.integrator=intLimMin - #Differentiator gain - self.differentiator=(2*self.kd*measure-self.prevMeasure)+(2*self.tau-self.time)*self.differentiator/(2*self.tau+self.time) - #Calculate output - self.out=proportional+self.integrator+self.differentiator - #Apply limits - if self.out > self.limMax: - self.out=self.limMax - elif self.out < self.limMin: - self.out=self.limMin - #Store data - print(self.prevError) - self.prevError=error - print(self.prevError) - self.prevMeasure=measure - -myPID=PID_Controller(KP,KI,KD,TAU,PID_MIN,PID_MAX) - -def getCPUTemp(): - f=open('/sys/class/thermal/thermal_zone0/temp', 'r') - temp=f.readline() - f.close() - ret=float(temp)/1000 - return ret - -def tachoISR(): - global rpmPulse - #print("interruption!!!") - rpmPulse+=1 - return - -def setupTacho(): - global rpmChkStartTime - - print("Setting up Tacho input pin") - wiringpi.wiringPiSetupGpio() - wiringpi.pinMode(tachoPin,wiringpi.INPUT) - wiringpi.pullUpDnControl(tachoPin,wiringpi.PUD_UP) - rpmChkStartTime=time.time() - #print("{:4d}".format(wiringpi.INT_EDGE_FALLING)) - wiringpi.wiringPiISR(tachoPin,wiringpi.INT_EDGE_FALLING,tachoISR) - return - -def readRPM(): - global rpmPulse, rpmChkStartTime - fanPulses=2 - - duration=time.time()-rpmChkStartTime - frequency=rpmPulse/duration - ret=int(frequency*60/fanPulses) - rpmChkStartTime=time.time() - rpmPulse=0 - print("Frequency {:3.2f} | RPM:{:4d}".format(frequency,ret)) -# with open('/tmp/adf-fanspeed', 'w') as f: -# f.write(str(ret)+'\n') -# f.close(); - return ret - -def fanOn(): - wiringpi.pwmWrite(pwmPin,pwmRange) - return - -def updateFanSpeed(): - temp=getCPUTemp() - myPID.update(lowTemp,temp) - #percentDiff = 45 - - - if myPID.out < 0: - percentDiff = 0 - else: - percentDiff = myPID.out - - with open('/tmp/adf-fanspeed', 'w') as f: - f.write(str(percentDiff)+'\n') - f.close(); - - #percentDiff = 100-myPID.out - #diff=temp-lowTemp - #percentDiff = 0 - #if diff > 0: - # percentDiff=diff/percentTemp - pwmDuty=int(percentDiff*pwmRange/100.0) - - print(myPID.out) - wiringpi.pwmWrite(pwmPin, pwmDuty) - #print("currTemp {:4.2f} tempDiff {:4.2f} percentDiff {:4.2f} pwmDuty {:5.0f}".format(temp, diff, percentDiff, pwmDuty)) - return +import pigpio -def setup(): - wiringpi.wiringPiSetupGpio() - #wiringpi.pinMode(pwmPin, 2) #HW PWM works on GPIO 12, 13, 18 and 19 on RPi4B - wiringpi.pinMode(pwmPin,wiringpi.PWM_OUTPUT) +# Pin configuration +PWM_PIN = 12 # Pin to drive PWM fan - HW PWM works on GPIO 12, 13, 18 and 19 on RPi4B +TACH_PIN = 6 # Fan's tachometer output pin + +# Temperature thresholds +MAX_TEMP = 70 # [°C] Above this temperature, the fan is at max speed +MIN_TEMP = 45 # [°C] Above this temperature, the fan starts +OFF_TEMP = 40 # [°C] Below this temperature, the fan is off + +# Fan settings +PWM_FREQ = 25000 # [kHZ] Noctua Specs: Target_Frequency=25kHz +PULSE = 2 # Noctua Specs: Noctua fans put out two pulses per revolution + +# Fan speed settings +RPM_MAX = 5000 # Noctua Specs: Max=5000 +RPM_MIN = 1500 # Noctua Specs: Min=1000 +RPM_OFF = 0 + +# Timing +WAIT = 2 # [s] Interval before adjusting RPM + +# Initialize pigpio +pi = pigpio.pi() + +# Remember pin modes +orig_pwm_pin_mode = -1 +orig_tach_pin_mode = -1 + +# Global variables +t = time.time() +rpm_pulse = 0 +tach_pin_callback = None + + +def getCpuTemperature(): + with open('/sys/class/thermal/thermal_zone0/temp') as f: + return float(f.read()) / 1000 - wiringpi.pwmSetClock(768) #Set PWM divider of base clock 19.2Mhz to 25Khz (Intel's recommendation for PWM FANs) - wiringpi.pwmSetRange(pwmRange) #Range setted - wiringpi.pwmWrite(pwmPin, pwmRange) # Setting to the max PWM - return +def handleTachometerPulse(gpio, level, tick): + """Handle the interrupt generated by the falling edge of the tachometer pulse.""" + global rpm_pulse + rpm_pulse += 1 # Increment pulse count when a pulse is detected + + +def getFanRPM(): + global t, rpm_pulse + + dt = time.time() - t + if dt < 0.002: + return # Reject spuriously short pulses + + frequency = rpm_pulse / dt + rpm = (frequency / PULSE) * 60 + + # Reset pulse counter and timestamp + rpm_pulse = 0 + t = time.time() + + return rpm + + +def setFanRPM(rpm): + duty_cycle = int((rpm / RPM_MAX) * 1000000) + pi.hardware_PWM(PWM_PIN, PWM_FREQ, duty_cycle) + + +def setup(): + global orig_pwm_pin_mode, orig_tach_pin_mode, tach_pin_callback + + print("Setting up...") + + orig_pwm_pin_mode = pi.get_mode(PWM_PIN) + orig_tach_pin_mode = pi.get_mode(TACH_PIN) + + # Set pin modes + pi.set_mode(PWM_PIN, pigpio.ALT5) # ALT5 mode for hardware PWM + pi.set_mode(TACH_PIN, pigpio.INPUT) + + # Add event to detect tachometer pulse + tach_pin_callback = pi.callback(TACH_PIN, pigpio.FALLING_EDGE, handleTachometerPulse) + + setFanRPM(RPM_OFF) # Set fan speed to off initially + + return + + +def cleanup(): + # Turn off the fan + setFanRPM(RPM_OFF) + + # Pin mode cleanup + if orig_pwm_pin_mode != -1: + pi.set_mode(PWM_PIN, orig_pwm_pin_mode) + + if orig_tach_pin_mode != -1: + pi.set_mode(TACH_PIN, orig_tach_pin_mode) + + if tach_pin_callback is not None: + tach_pin_callback.cancel() # Remove the callback associated with the tachometer pin + + pi.stop() + return + def main(): - print("PWM FAN control starting") - setup() - setupTacho() - #fanOn() - - while True: - try: - updateFanSpeed() - readRPM() - sleep(check_sec) - except KeyboardInterrupt: - fanOn() - break - except e: - print("Something went wrong") - print(e) - fanOn() + print("PWM FAN control starting...") + setup() + + while True: + try: + temp = getCpuTemperature() + print(f"CPU Temperature: {temp:.1f}") + + if temp >= MAX_TEMP: + setFanRPM(RPM_MAX) + elif temp >= MIN_TEMP: + delta = temp - MIN_TEMP + rpm = min(RPM_MAX, max(RPM_MIN, int(RPM_MIN + delta))) + setFanRPM(rpm) + elif temp < OFF_TEMP: + setFanRPM(RPM_OFF) + + rpm = getFanRPM() + print(f"Fan RPM: {rpm:.2f}") + time.sleep(WAIT) + + except KeyboardInterrupt: + cleanup() + break + except Exception as e: + print("Something went wrong") + print(e) + cleanup() -if __name__ == "__main__": - main() +if __name__ == "__main__": + main()