Fan controller with ESP32, Micropython, MQTT and Node-RED

I have been working on a project, it’s a fan controller based on ESP32 and Micropython. MQTT is used for communicating with the ESP32 and I’m using Node-RED to configure settings and display temp and fan speed graphs. Both Node-RED and the MQTT broker are installed on a Raspberry pi.

Features implemented to far:

  • Read temperature with a DS1820 sensor (placed in the chassis close to the GFX card)
  • Generate a PWM signal for controlling the duty cycle of 3 fans (two front intake, one rear exhaust).
  • Manual control of fan speed
  • Automatic control of fan speed based on temperature

It works nicely, fans only speed up when the chassis is getting warm, and I don’t need to mess with some other fan control software.

Here is the UI (Node-RED dashboard):
Node_Red_UI

Node-RED nodes:
node_red_nodes

ESP32 (Adafruit HUZZAH32):
esp32

DS1820 temp sensor (excuse the “cable management”):
sensor

Mighty 140mm Noctua intake fans, providing Moar airflow…
front_fans

Mighty 140mm Noctua rear exhaust fan:
rear_fan

Main Python code (a bit messy with global variables, I will rewrite as a class), note that I also have a “boot.py” setting up the WiFi connection:

import time
import json
from umqttsimple import MQTTClient
import ubinascii
import machine
import micropython
from machine import Pin, PWM, ADC
import onewire
import ds18x20

mqtt_server = '192.168.1.50'
client_id = ubinascii.hexlify(machine.unique_id())

topic_pub = b'fancontrol'
topic_sub = b'setfan'
topic_init = b'init'

last_message = 0
message_interval = 2 # seconds

auto = True
roms = None
duty_changed = False

frequency = 25000 # Hz
duty_percent = 30

# A0 on board, temperature sensor
ds_pin = Pin(26)
ds_sensor = ds18x20.DS18X20(onewire.OneWire(ds_pin))

# A2 on board
#pot = ADC(Pin(34))
#pot.width(ADC.WIDTH_10BIT)
#pot.atten(ADC.ATTN_11DB)

# 21 on board, PWM output signal
led = PWM(Pin(21), frequency)

def sub_cb(topic, msg):
    '''Callback handling MQTT messages'''
    
    global duty_percent
    global duty_changed
    global auto
    
    try:
        message = json.loads(msg)
        
        # Fan speed has been changed from UI
        if 'fanspeed' in message:        
            print('Set fan message')
            duty_percent_new = message.get('fanspeed', 100)
        
            if duty_percent_new != duty_percent:
                duty_changed = True
                duty_percent = duty_percent_new
            else:
                duty_changed = False
        
            print('Setting duty_percent to: ' + str(duty_percent))
        
        # Automatic mode enabled/disabled in UI
        if 'auto' in message:
            print('Change auto message')
            auto = message['auto']
                        
    except ValueError as e:
        print('Not a valid JSON')

def connect_mqtt():
    print('Trying to connect to MQTT broker...')
    global client_id, mqtt_server, topic_sub
    client = MQTTClient(client_id, mqtt_server)
    client.set_callback(sub_cb)
    client.connect()
    client.subscribe(topic_sub)
    print('Connected to %s MQTT broker' % (mqtt_server))
    return client

def restart_and_reconnect():
    print('Failed to connect to MQTT broker. Reconnecting...')
    time.sleep(1)
    machine.reset()

def get_roms():
    try:
        global roms
        roms = ds_sensor.scan()
    except OSError as e:
        return('Failed to read sensor.')

def read_sensor():
    '''Read the DS1820 sensor'''
    
    try:
        ds_sensor.convert_temp()
        time.sleep_ms(750)
        for rom in roms: 
          temp = ds_sensor.read_temp(rom)
          # uncomment for Fahrenheit
          # temp = temp * (9/5) + 32.0
        if (isinstance(temp, float) or (isinstance(temp, int))):
            #temp = (b'{0:3.1f},'.format(temp))
            temp = round(temp, 1)
            return temp
        else:
            return('Invalid sensor readings.')
    except OSError as e:
        return('Failed to read sensor.')

def set_duty_cycle(led, duty_percent):
    '''Set output pin duty cycle, based on 0%-100% duty_percent'''
    
    duty_full_range = (1023 / 100) * duty_percent
    
    # Invert
    duty_full_range = int(1023 - duty_full_range)
    
    led.duty(duty_full_range)
    print ("Set duty cycle: ", duty_percent, duty_full_range)

def printtime():
    print('{:02d}:{:02d}:{:02d} '.format(time.localtime()[3], time.localtime()[4], time.localtime()[5]), end='') 

def get_duty_from_temp(temp):
    '''Calculate fan duty cycle based on temp, linear equation (graph)'''
    
    # Linear equation calculation
    # y = k*x + m
    # k = (y2-y1) / (x2-x1)
    
    min_temp = 30 # x1
    min_duty = 25 # y1
    
    max_temp = 50 # x2 
    max_duty = 100 # y2
    
    k = (max_duty-min_duty) / (max_temp-min_temp)
    m = min_duty - k*min_temp
    
    # Lower temp interval
    if temp <= min_temp:
        return min_duty
    
    # Middle temp interval (use line in graph)
    elif temp <= max_temp:
        return int(round(k*temp + m, 0))
    
    # Upper temp interval
    else:
        return max_duty

try:
  client = connect_mqtt()
except OSError as e:
  restart_and_reconnect()

get_roms()

# Run fans at 100% for 2 seconds
set_duty_cycle(led, 100)
time.sleep(2)
set_duty_cycle(led, duty_percent)

# Initialize the speed setting gauge and turn on automatic mode
payload_init = {"initial_duty_cycle" : duty_percent,
                "auto" : True}

print("Publish init message...")
client.publish(topic_init, json.dumps(payload_init))

# Main loop
while True:
  try:
    client.check_msg()
    if (time.time() - last_message) > message_interval:
      temp = read_sensor()
      #fixa
      fan = round(duty_percent, 1)
      
      # Manual mode
      if not auto:
        fan = round(duty_percent, 1)
      
      payload = {"temp" : temp,
                 "speed" : fan}
            
      print("Publish message... ")
      print('Temp', str(temp))
      print('Calculated duty', str(get_duty_from_temp(temp)))
      print()
            
      client.publish(topic_pub, json.dumps(payload))
      last_message = time.time()              
  except OSError as e:
    print('Error checking for messages, restart')
    restart_and_reconnect()

  # Automatic mode enabled in UI, calculate fan speed bases on temp
  if auto:
    print('Auto mode')
    duty_percent_new = get_duty_from_temp(temp)
    
    # Only update if duty cycle has changed
    if duty_percent_new != duty_percent:
      duty_changed = True
      duty_percent = duty_percent_new
        
    else:
      duty_changed = False

  if duty_changed:
      set_duty_cycle(led, duty_percent)
      duty_changed = False
  
  time.sleep(1)

4 Likes

Huzzah!

1 Like

This looks really nice!

I need to get myself one of them Raspberries…