Micropython ESP8266 Reflow Oven

March 05, 2022

A short post on my micropython PCB reflow oven...

The total cost is about $100. The oven has two separate heaters and a convection fan. The software is micropython on an esp8266. You push a button and it runs the profile, nothing fancy.

Parts list

  • Oster toaster oven can be found in store at Walmart for about $45.
  • NodeMCU esp8266 - $8 can be found anywhere
  • 4 channel 5V relay board - $6 can be found anywhere
  • 20 x 4 LCD character display - $9 from anywhere
  • PCB $5 - Send me an email if you want the fab files
  • MAX31855 - $8 on digikey
  • Thermocouple - $10 on digikey
  • A 4 pin and 6 pin XT connectors and some wire - $1
  • High temp insulation (if you want)

Build

Take the cover off the over and drill a hole for the thermocouple.

Put high temp stove insulation around it (optional).

Reflow oven

Rewire the oven so elements so the top and bottom go on separate relays, and the fan on another. The controller switches the heaters on at slightly different times to reduce surge. The 4th relay is not used but could be for something.

I happened to have a piece of plastic from a coffee maker that worked as a mount for the relay board but anything will work to keep it off the case.

Reflow oven 4 channel relay board

Controller

The controller is just a little breakout board made with horizon eda. It's about as simple as it gets.

Reflow board

Unfortunately the esp8266 is limited in it's gpio pins and the S2 button does not work (it causes a reset).

If you want the fab files to make your own send me a message.

Software

Flash micropython for the esp8266. Then upload the following files. I use an little editor I made called micropyde. But you can use the esptool to flash the firmware and whatever to upload the files..

Micropython Reflow controller

max31855.py the thermocuple driver. I think this is from adafruit.

import ustruct

class MAX31855:
    """
    Driver for the  thermocouple amplifier.
    MicroPython example::
        import max31855
        from machine import SPI, Pin
        spi = SPI(1, baudrate=1000000)
        cs = Pin(15, Pin.OUT)
        s = max31855.MAX31855(spi, cs)
        print(s.read())
    """
    def __init__(self, spi, cs):
        self.spi = spi
        self.cs = cs
        self.data = bytearray(4)

    def read(self, internal=False, raw=False):
        """
        Read the measured temperature.

        If ``internal`` is ``True``, return a tuple with the measured
        temperature first and the internal reference temperature second.

        If ``raw`` is ``True``, return the values as 14- and 12- bit integers,
        otherwise convert them to Celsuius degrees and return as floating point
        numbers.
        """

        self.cs.off()
        try:
            self.spi.readinto(self.data)
        finally:
            self.cs.on()
        # The data has this format:
        # 00 --> OC fault
        # 01 --> SCG fault
        # 02 --> SCV fault
        # 03 --> reserved
        # 04 -. --> LSB
        # 05  |
        # 06  |
        # 07  |
        #      > reference
        # 08  |
        # 09  |
        # 10  |
        # 11  |
        # 12  |
        # 13  |
        # 14  | --> MSB
        # 15 -' --> sign
        #
        # 16 --> fault
        # 17 --> reserved
        # 18 -.  --> LSB
        # 19   |
        # 20   |
        # 21   |
        # 22   |
        # 23   |
        #       > temp
        # 24   |
        # 25   |
        # 26   |
        # 27   |
        # 28   |
        # 29   |
        # 30   | --> MSB
        # 31  -' --> sign
        if self.data[3] & 0x01:
            raise RuntimeError("thermocouple not connected")
        if self.data[3] & 0x02:
            raise RuntimeError("short circuit to ground")
        if self.data[3] & 0x04:
            raise RuntimeError("short circuit to power")
        if self.data[1] & 0x01:
            raise RuntimeError("faulty reading")
        temp, refer = ustruct.unpack('>hh', self.data)
        refer >>= 4
        temp >>= 2
        if raw:
            if internal:
                return temp, refer
            return temp
        if internal:
            return temp / 4, refer * 0.0625
        return temp / 4 

The lcd driver lcd20x4.py based on a github project in the comment. Slightly touched up.

# From https://github.com/dork3nergy/lcd_2004
import machine
from time import sleep_ms

class LCD:
    # initializes objects and lcd

    ENABLE = 0x04 #Set Entry Mode
    RS = 0x01 # Register select bit
    WIDTH = 20

    def __init__(self, addr, scl, sda):
        self.i2c = machine.I2C(scl=machine.Pin(scl), sda=machine.Pin(sda))
        self.address = addr
        self._backlight = 0x08 # Set Backlight ON
        self.write(0x03)
        self.write(0x03)
        self.write(0x03)
        self.write(0x02)

        self.write(0x20 | 0x08 | 0x04 | 0x00) # Set Functions 2Line,5x8,4Bit Mode
        self.write(0x08 | 0x04) # Turn Display On
        self.write(0x01) # Clear Screen
        self.write(0x04 | 0x02) # Set Entry Mode Left -> Right
        sleep_ms(300)

    def strobe(self, data):
        self.i2c.writeto(self.address, bytes([data | LCD.ENABLE | self._backlight]))
        sleep_ms(1)
        self.i2c.writeto(self.address, bytes([(data & ~LCD.ENABLE | self._backlight)]))
        sleep_ms(1)

    def write_four_bits(self, data):
        self.i2c.writeto(self.address, bytes([data | self._backlight]))
        self.strobe(data)

    def write(self, cmd, rs=0):
        # write a command to lcd
        self.write_four_bits(rs | (cmd & 0xF0))
        self.write_four_bits(rs | ((cmd << 4) & 0xF0))

    def set_line(self, line, col=0):
        if line == 1:
            self.write(0x80+col)
        if line == 2:
            self.write(0xC0+col)
        if line == 3:
            self.write(0x94+col)
        if line == 4:
            self.write(0xD4+col)

    def display_line(self, line, data):
        # Write a full line
        left = 20 - len(data)
        self.set_line(line, 0)
        for c in data[0:20]:
            self.write(ord(c), LCD.RS)
        for i in range(left):
            self.write(ord(' '), LCD.RS)

    def display(self, data, line=0, col=0):
        self.set_line(line, col)
        i = 1
        for c in data:
            if ((i > LCD.WIDTH) & (line < 4)):
                line = line + 1
                self.set_line(line,0)
                i = 1
            if ((i > LCD.WIDTH) & (line == 4)):
                break
            self.write(ord(c), LCD.RS)
            i = i + 1

    def off(self):
        self.write(0x08 | 0x00)

    def on(self):
        self.write(0x08 | 0x04)

    def clear(self):
        self.write(0x01) # Clear Screen
        self.write(0x02) # Set Home

    def backlight(self, on):
        if on:
            self._backlight = 0x08
        else:
            self._backlight = 0x00
        self.write(0)

pid.py is a simple pid controller. For my oven it didn't make much of a difference and a simple on off would also work.

import time

class PID:
    def __init__(self, p, i, d):
        self.kp = p
        self.ki = i
        self.kd =  d
        # (last_value, integral, derivative)
        self.state = [0.0, 0.0, 0.0]
        self.last_update = None
        self.last_result = None

    def start(self, t, actual):
        self.last_update = t
        self.state = [actual, 0.0, 0.0]
        self.last_result = None

    def update(self, t, target,  actual):
        # Check for initial update
        if self.last_update is None:
            raise RuntimeError("Must use start first")

        dt = time.ticks_diff(t, self.last_update)
        if dt == 0:
            return self.last_result
        self.last_update = t

        error = target - actual
        last_error = self.state[0]
        p = self.state[0] = error
        i = self.state[1] = self.state[1]*0.9 + error * dt
        d = self.state[2] = (error - last_error)/dt
        self.last_result = (self.kp * p) + (self.ki * i) + (self.kd * d)
        return self.last_result

solder profile, from github. I can't find the project anymore.

{
    "title": "Lead 183",
    "alloy": "Sn63/Pb37",
    "melting_point": 183,
    "temp_range": [30,235],
    "time_range": [0,400],
    "reference": "https://www.chipquik.com/datasheets/TS391AX50.pdf",
    "stages": {
        "preheat": [30,100],
        "soak": [120,150],
        "reflow": [150,183],
        "cool": [240,183]
        },
    "profile": [
        [0,23],
        [90,100],
        [120,140],
        [180,150],
        [190,160],
        [220,183],
        [280,230],
        [300,235],
        [330,230],
        [350,183],
        [400,23]
    ]
} 

Note: This is an extended duration profile since my oven has a damaged heater and heats up too slowly.

The main.py file puts it all together.

import os
import json
import time
from machine import Pin, Signal, SPI
from max31855 import MAX31855
from pid import PID
from lcd20x4 import LCD


class ReflowOven:
    Kp = 150
    Ki = 0.1
    Kd = 40
    def __init__(self):
        # Relay board is active low so on is off
        self.heat = Signal(Pin(16, Pin.OUT), invert=True)
        self.heat.off() 
        self.aux = Signal(Pin(2, Pin.OUT), invert=True)
        self.aux.off() 
        self.fan = Signal(Pin(0, Pin.OUT), invert=True)
        self.fan.off()
        self.btn1 = Pin(10, Pin.IN, Pin.PULL_UP)
        self.btn2 = Pin(9, Pin.IN, Pin.PULL_UP)

        self.cs = Pin(15, Pin.OUT)
        self.spi = SPI(1, baudrate=5000000, polarity=0, phase=0)
        self.sensor = MAX31855(self.spi, self.cs)
        self.lcd = LCD(0x27, 5, 4)
        self.display_line(1, "CodeLV Reflow 0.2") 

    def display_line(self, number, message):
        self.lcd.display_line(number, message) 

    def find_profiles(self):
        return [f for f in os.listdir('.') if f.endswith('.json')]

    def load_profile(self, profile):
        self.display_line(2, "Profile %s" % profile[0:-5]) 
        f = open(profile)
        try:
            return json.load(f)
        finally:
            f.close()

    def get_target(self, profile, t):
        last = None
        for p in profile['profile']:
            # Find the points along the time axis
            if t < p[0]:
                if last is None:
                    return p[1] # Start point (should never occur)
                # Calculate target using linear interpolation
                slope =  (p[1] -  last[1]) / (p[0] - last[0])
                dt = t - last[0]
                return slope * dt + last[1]
            last = p
        return 0 # Done

    def heatup(self):
        print("Heatup")
        self.heat.on()
        time.sleep(0.1) # Reduce surge
        self.aux.on()

    def cooldown(self):
        print("Cooldown")
        self.heat.off()
        self.aux.off()

    def read(self, retries=3):
        for i in range(retries):
            try:
                return self.sensor.read()
            except RuntimeError as e:
                time.sleep(0.1)
                print(e)
        raise ValueError("Could not read sensor")

    def run(self, profile):
        self.display_line(3, "Status: Running")
        print("Running %s" % profile["title"])
        # PID values
        controller = PID(ReflowOven.Kp, ReflowOven.Ki, ReflowOven.Kd)

        # Control loop
        start_time = time.time()
        end_time = profile['time_range'][1]
        dt = 0
        controller.start(dt, self.read())
        btn1 = self.btn1
        self.fan.on()
        try:
            state = False
            time.sleep(0.1)
            while dt < end_time:
                time.sleep(1)
                dt = time.ticks_diff(time.time(), start_time)
                target = self.get_target(profile, dt)
                temp = self.read()
                r = controller.update(dt, target, temp)
                print("Time: %s Target: %s C, Temp: %s C" % (
                    dt, target, temp))

                print("Result: %s, State: %s" % (r, controller.state))
                left = int(end_time - dt)
                self.display_line(3, "Time %ss Left %ss" % (dt, left))
                self.display_line(4, "Temp %sC Set: %sC" % (int(temp), int(target))) 

                if not btn1.value():
                    self.display_line(3, "Cancelled!")
                    time.sleep(1) # Don't restart
                    return

                # Update heat mode
                new_state = r > 0
                if state != new_state:
                    state = new_state
                    if state:
                        self.heatup()
                    else:
                        self.cooldown()
            self.display_line(3, "Done!")
            print("Done!")
        except Exception as e:
            self.display_line(3, "Stopped!")
            raise e
        finally:
            self.cooldown()
            self.fan.off()

    def monitor(self):
        btn1 = self.btn1
        temp = 0
        while True:
            try:
                temp = self.read()
            except KeyboardInterrupt:
                break
            except Exception:
                pass

            self.display_line(4, "Temp: %sC" % (int(temp),))
            if not btn1.value():
                return


oven = ReflowOven()
profiles = oven.find_profiles()
print("Profile files are: %s" % profiles) 
print("Load one and run to start")
profile = oven.load_profile('sn63pb37mod.json')
oven.display_line(3, "Ready.")
while True:
    oven.monitor()
    if not oven.btn1.value():
        time.sleep(1) # Allow time for release
        oven.run(profile)

Just upload and reset. Then push the first button to start. And again to cancel if needed.

Summary

It seems to work fine. So far I've made 50 or so boards for random stuff but never had any problems with not reflowing. The only problems I've had was bridging on fine pitch parts with my home-made stencils.

The lower elements on mine happend to be damanged but it still works fine. I usually do a warm up run if it's cold in the garage. I also open the door when it gets to the cooldown stage because the insulation retains the heat too long.

Anyways, hope you found something helpful here. Take care!