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.
Take the cover off the over and drill a hole for the thermocouple.
Put high temp stove insulation around it (optional).
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.
The controller is just a little breakout board made with horizon eda. It's about as simple as it gets.
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.
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..
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.
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!