├── .gitignore ├── FIRMWARE ├── firmware_idf3_generic.bin ├── firmware_idf3_generic_spiram.bin └── readme.md ├── MAIN ├── buzzer.py ├── config.json ├── gui.py ├── load_profiles.py ├── main.py ├── max31855.py ├── max6675.py ├── oven_control.py ├── pid.py ├── profiles │ ├── sn42bi576ag04.json │ ├── sn63pb37.json │ └── sn965ag30cu05.json ├── rtttl.py ├── songs.py ├── touch_cali.py └── uftpd.py ├── pic ├── internal.jpg ├── overview.jpg ├── pid.jpg └── screen.jpg ├── readme.md └── readme_zh.md /.gitignore: -------------------------------------------------------------------------------- 1 | /ref/ 2 | /MAIN/boot.py 3 | -------------------------------------------------------------------------------- /FIRMWARE/firmware_idf3_generic.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dukeduck1984/uReflowOven-Esp32-Micropython/7d70962d888e31739df076ab7a12c2ff2bd16b0a/FIRMWARE/firmware_idf3_generic.bin -------------------------------------------------------------------------------- /FIRMWARE/firmware_idf3_generic_spiram.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dukeduck1984/uReflowOven-Esp32-Micropython/7d70962d888e31739df076ab7a12c2ff2bd16b0a/FIRMWARE/firmware_idf3_generic_spiram.bin -------------------------------------------------------------------------------- /FIRMWARE/readme.md: -------------------------------------------------------------------------------- 1 | ## Micropython 1.12 + LittlevGL 2 | 3 | * The firmwares are built from lv_micropython with ESP-IDF v3.x 4 | * The file ```firmware_idf3_generic_spiram.bin``` is for the ESP32 dev boards with external SPIRAM, 5 | while ```firmware_idf3_generic.bin``` is for the ones without SPIRAM. 6 | 7 | ### Flashing the firmware 8 | 9 | * Pls refer to http://micropython.org/download#esp32 10 | 11 | ### Build firmware by yourself 12 | 13 | * If you would like to build firmware on your own, pls refer to [here](https://github.com/littlevgl/lv_micropython) 14 | and [here](https://github.com/littlevgl/lv_binding_micropython). 15 | 16 | --- 17 | ## 中文说明 18 | 19 | * 本固件基于lv_micropython及ESP-IDF v3.x 20 | 21 | * ```firmware_idf3_generic_spiram.bin```用于板载SPIRAM的ESP32开发板; 22 | ```firmware_idf3_generic.bin```用于不带外部SPIRAM的ESP32开发板。 23 | 24 | ### 刷入固件 25 | * 具体步骤参考http://micropython.org/download#esp32 26 | 27 | ### 自制固件 28 | * 具体步骤参考[这里](https://github.com/littlevgl/lv_micropython) 29 | 以及 [这里](https://github.com/littlevgl/lv_binding_micropython)。 30 | -------------------------------------------------------------------------------- /MAIN/buzzer.py: -------------------------------------------------------------------------------- 1 | import machine 2 | import utime 3 | import songs # song list 4 | import _thread 5 | from rtttl import RTTTL # rtttl parser 6 | 7 | 8 | class Buzzer: 9 | def __init__(self, pin, volume=900): 10 | """ 11 | Initialize the pwm pin for controlling the buzzer. 12 | It should be a passive active low piezo buzzer. 13 | :param pin: int; the pwm pin number 14 | :param volume: int; the duty cycle of the pwm. higher the duty cycle, higher the volume of the buzzer 15 | """ 16 | self.buz = machine.PWM(machine.Pin(pin), duty=0, freq=440) 17 | self.volume = volume 18 | self.tones = { 19 | # define frequency for each tone 20 | 'C4': 262, 21 | 'CS4': 277, 22 | 'D4': 294, 23 | 'DS4': 311, 24 | 'E4': 330, 25 | 'F4': 349, 26 | 'FS4': 370, 27 | 'G4': 392, 28 | 'GS4': 415, 29 | 'A4': 440, 30 | 'AS4': 466, 31 | 'B4': 494, 32 | 'C5': 523, 33 | 'CS5': 554, 34 | 'D5': 587, 35 | 'DS5': 622, 36 | 'E5': 659, 37 | 'F5': 698, 38 | 'FS5': 740, 39 | 'G5': 784, 40 | 'GS5': 831, 41 | 'A5': 880, 42 | 'AS5': 932, 43 | 'B5': 988, 44 | } 45 | self.tone1 = ['A5', 'B5', 'C5', 'B5', 'C5', 'D5', 'C5', 'D5', 'E5', 'D5', 'E5', 'E5'] 46 | self.tone2 = ['G5', 'C5', 'G5', 'C5'] 47 | self.tone3 = ['E5', 0, 'E5', 0, 'E5'] 48 | self.mute = False 49 | self.is_playing = False 50 | self.song = None 51 | 52 | def play_tone(self, freq, msec): 53 | """ 54 | play the tune by its freq and tempo 55 | :param freq: int; frequency of the tune 56 | :param msec: float; tempo 57 | :return: None 58 | """ 59 | # print('freq = {:6.1f} msec = {:6.1f}'.format(freq, msec)) 60 | if freq > 0: 61 | self.buz.freq(int(freq)) 62 | self.buz.duty(int(self.volume)) 63 | utime.sleep_ms(int(msec * 0.9)) 64 | self.buz.duty(0) 65 | utime.sleep_ms(int(msec * 0.1)) 66 | 67 | def play(self, tune): 68 | """ 69 | parse the tune to be play 70 | :param tune: tuple; the tune of the song 71 | :return: None 72 | """ 73 | try: 74 | for freq, msec in tune.notes(): 75 | if not self.mute: 76 | self.is_playing = True 77 | self.play_tone(freq, msec) 78 | else: 79 | self.play_tone(0, 0) 80 | self.is_playing = False 81 | self.mute = False 82 | break 83 | self.is_playing = False 84 | self.mute = False 85 | except KeyboardInterrupt: 86 | self.play_tone(0, 0) 87 | 88 | def play_song(self, search): 89 | """ 90 | play a song stored in songs.py 91 | :param search: string; song name listed in songs.py 92 | :return: None 93 | """ 94 | while self.is_playing: 95 | self.mute = True 96 | else: 97 | self.mute = False 98 | # play song in a new thread (non-blocking) 99 | # _thread.stack_size(16 * 1024) # set stack size to avoid runtime error 100 | # play_tone = _thread.start_new_thread(self.play, (RTTTL(songs.find(search)),)) 101 | self.play(RTTTL(songs.find(search))) 102 | self.song = None 103 | 104 | def activate(self, song): 105 | self.song = song 106 | -------------------------------------------------------------------------------- /MAIN/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "touch_cali_file": "touch_cali.json", 3 | "default_alloy": "Sn63/Pb37", 4 | "title": "uReflow Oven", 5 | "heater_pins": { 6 | "heater": 21, 7 | "heater_active_low": false 8 | }, 9 | "tft_pins": { 10 | "miso": 5, 11 | "mosi": 18, 12 | "sck": 19, 13 | "cs": 13, 14 | "dc": 12, 15 | "rst": 4, 16 | "acc": 14, 17 | "led": 15, 18 | "acc_active_low": false, 19 | "led_active_low": false, 20 | "is_portrait": true 21 | }, 22 | "touch_pins": { 23 | "cs": 25, 24 | "interrupt": 35 25 | }, 26 | "buzzer_pin": 22, 27 | "sensor_type": "MAX31855", 28 | "sensor_offset": 0.0, 29 | "sensor_pins": { 30 | "hwspi": 2, 31 | "cs": 27, 32 | "miso": 33, 33 | "sck": 32 34 | }, 35 | "sampling_hz": 5, 36 | "display_refresh_hz": 2.5, 37 | "pid": { 38 | "kp": 0.1, 39 | "ki": 0.03, 40 | "kd": 300.0 41 | }, 42 | "advanced_temp_tuning": { 43 | "preheat_until": 75, 44 | "previsioning": 0, 45 | "overshoot_comp": 0 46 | }, 47 | "ftp": { 48 | "enable": true, 49 | "ssid": "Reflower ftp://192.168.4.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /MAIN/gui.py: -------------------------------------------------------------------------------- 1 | import machine 2 | import ujson 3 | import uos 4 | import utime 5 | 6 | import lvgl as lv 7 | import lvesp32 8 | 9 | 10 | class GUI: 11 | CHART_WIDTH = 240 12 | CHART_HEIGHT = 120 13 | CHART_TOP_PADDING = 10 14 | 15 | def __init__(self, profiles_obj, config_dict, pid_obj, sensor_obj): 16 | self.profiles = profiles_obj 17 | self.config = config_dict 18 | self.pid = pid_obj 19 | self.sensor = sensor_obj 20 | self.pid_params = self.config.get('pid') 21 | self.temp_offset = self.config.get('sensor_offset') 22 | self.alloy_list = self.profiles.get_profile_alloy_names() 23 | self.has_started = False 24 | self.main_scr = lv.obj() 25 | self.oven_title = self.oven_title_init() 26 | self.chart, self.chart_series = self.chart_init() 27 | self.profile_title_label, self.profile_title_cont, self.profile_title_text = self.profile_title_init() 28 | self.timer_label, self.timer_cont, self.timer_text = self.timer_init() 29 | self.profile_alloy_selector = self.profile_selector_init() 30 | self.start_btn, self.start_label = self.start_btn_init() 31 | self.stage_cont, self.stage_label = self.stage_init() 32 | self.settings_btn = self.settings_btn_init() 33 | self.temp_text = self.temp_init() 34 | self.led = self.led_init() 35 | self.line = None 36 | self.dashed_line = None 37 | self.point_count = None 38 | self.chart_point_list = None 39 | self.profile_detail_init() 40 | self.profile_alloy_selector.move_foreground() 41 | self.show_set_btn_hide_stage() 42 | self.reflow_process_start_cb = None 43 | self.reflow_process_stop_cb = None 44 | self.current_input_placeholder = 'Set Kp' 45 | lv.scr_load(self.main_scr) 46 | 47 | def profile_detail_init(self): 48 | """ 49 | This method is called every time a different alloy profile is selected. 50 | 1. Set reflow profile title per selection 51 | 2. Draw ideal reflow temp profile in solid line and melting temp in dashed line 52 | 3. Update chart settings to receive data points 53 | """ 54 | # Set title 55 | self.profile_title = self.profiles.get_profile_title() 56 | self.set_profile_title_text(self.profile_title) 57 | # Draw ideal reflow temp profile in solid line 58 | self.profile_chart_points = self.profiles.get_profile_chart_points( 59 | GUI.CHART_WIDTH, 60 | GUI.CHART_HEIGHT, 61 | GUI.CHART_TOP_PADDING, 62 | ) 63 | if self.line: 64 | lv.obj.delete(self.line) 65 | self.line = None 66 | self.line = self.draw_profile_line(self.profile_chart_points) 67 | # Draw melting temp in dashed line 68 | chart_melting_y_point = self.profiles.get_chart_melting_y_point( 69 | GUI.CHART_WIDTH, 70 | GUI.CHART_HEIGHT, 71 | GUI.CHART_TOP_PADDING, 72 | ) 73 | melting_temp = self.profiles.get_melting_temp() 74 | if self.dashed_line: 75 | lv.obj.delete(self.dashed_line) 76 | self.dashed_line = None 77 | self.dashed_line = self.draw_melting_dash_line(chart_melting_y_point, melting_temp) 78 | # Update chart settings 79 | temp_range = self.profiles.get_temp_range() 80 | self.chart.set_range(temp_range[0], temp_range[-1] + GUI.CHART_TOP_PADDING) # min, max temp in the chart 81 | self.point_count = self.profiles.get_chart_point_count() 82 | self.chart.set_point_count(self.point_count) 83 | self.chart_point_list = self.null_chart_list 84 | 85 | @property 86 | def null_chart_list(self): 87 | """ 88 | Generate a null list for the chart 89 | :return: List 90 | """ 91 | return [lv.CHART_POINT.DEF] * self.point_count 92 | 93 | def chart_init(self): 94 | """ 95 | Initialize the temp chart on the screen 96 | """ 97 | chart = lv.chart(self.main_scr) 98 | chart.set_size(GUI.CHART_WIDTH, GUI.CHART_HEIGHT) # width, height pixel of the chart 99 | chart.align(lv.scr_act(), lv.ALIGN.IN_BOTTOM_MID, 0, 0) 100 | chart.set_type(lv.chart.TYPE.LINE) 101 | chart.set_style(lv.chart.STYLE.MAIN, lv.style_plain) 102 | chart.set_series_opa(lv.OPA.COVER) 103 | chart.set_series_width(3) 104 | chart_series = chart.add_series(lv.color_make(0xFF, 0, 0)) 105 | return chart, chart_series 106 | 107 | def chart_clear(self): 108 | """ 109 | Clear the chart with null points 110 | """ 111 | self.chart_point_list = self.null_chart_list 112 | self.chart.set_points(self.chart_series, self.chart_point_list) 113 | 114 | def chart_update(self, temp_list): 115 | """ 116 | Update chart data, should be called every 1s 117 | :param temp_list: list of actual temp with increasing length - new point appended to the tail 118 | """ 119 | list_length = len(temp_list) 120 | self.chart_point_list[:list_length] = temp_list 121 | self.chart.set_points(self.chart_series, self.chart_point_list) 122 | 123 | def draw_profile_line(self, points): 124 | """ 125 | Draw reflow temp profile over the chart per selection 126 | """ 127 | style_line = lv.style_t() 128 | lv.style_copy(style_line, lv.style_transp) 129 | style_line.line.color = lv.color_make(0, 0x80, 0) 130 | style_line.line.width = 3 131 | style_line.line.rounded = 1 132 | style_line.line.opa = lv.OPA._40 133 | line = lv.line(self.chart) 134 | line.set_points(points, len(points)) # Set the points 135 | line.set_style(lv.line.STYLE.MAIN, style_line) 136 | line.align(self.chart, lv.ALIGN.IN_BOTTOM_MID, 0, 0) 137 | line.set_y_invert(True) 138 | return line 139 | 140 | def draw_melting_dash_line(self, y_point, melting_temp): 141 | """ 142 | Draw melting temp with dashed line over the chart 143 | """ 144 | # Container for dashed line 145 | style_cont = lv.style_t() 146 | lv.style_copy(style_cont, lv.style_transp) 147 | dashed_segments = 10 148 | dashed_cont = lv.cont(self.chart) 149 | dashed_cont.set_style(lv.line.STYLE.MAIN, style_cont) 150 | dashed_cont.set_width(GUI.CHART_WIDTH) 151 | # Draw dashed line 152 | style_dash_line = lv.style_t() 153 | lv.style_copy(style_dash_line, lv.style_transp) 154 | style_dash_line.line.color = lv.color_make(0xFF, 0x68, 0x33) 155 | style_dash_line.line.width = 3 156 | dash_width = int(GUI.CHART_WIDTH / (dashed_segments * 2 - 1)) 157 | dashed_points = [ 158 | {'x': 0, 'y': 0}, 159 | {'x': dash_width, 'y': 0} 160 | ] 161 | dashed_line0 = lv.line(dashed_cont) 162 | dashed_line0.set_points(dashed_points, len(dashed_points)) 163 | dashed_line0.set_style(lv.line.STYLE.MAIN, style_dash_line) 164 | dashed_line0.align(None, lv.ALIGN.IN_LEFT_MID, 0, 0) 165 | for i in range(dashed_segments - 1): 166 | dl_name = 'dashed_line' + str(i+1) 167 | parent_name = 'dashed_line' + str(i) 168 | locals()[dl_name] = lv.line(dashed_cont, dashed_line0) 169 | locals()[dl_name].align(None, lv.ALIGN.IN_LEFT_MID, dash_width * (i+1) * 2, 0) 170 | # Melting temp 171 | melt_label = lv.label(dashed_cont) 172 | melt_label.set_recolor(True) 173 | melt_label.set_text('#FF6833 ' + str(melting_temp) + '#') 174 | # Put above elements in place 175 | dashed_cont.align_origo(self.chart, lv.ALIGN.IN_BOTTOM_MID, 0, -y_point) 176 | melt_label.align(dashed_cont, lv.ALIGN.IN_TOP_LEFT, 8, 12) 177 | return dashed_cont 178 | 179 | def led_init(self): 180 | """ 181 | Initialize the LED on the screen 182 | """ 183 | style_led = lv.style_t() 184 | lv.style_copy(style_led, lv.style_pretty_color) 185 | style_led.body.radius = 800 # large enough to draw a circle 186 | style_led.body.main_color = lv.color_make(0xb5, 0x0f, 0x04) 187 | style_led.body.grad_color = lv.color_make(0x50, 0x07, 0x02) 188 | style_led.body.border.color = lv.color_make(0xfa, 0x0f, 0x00) 189 | style_led.body.border.width = 3 190 | style_led.body.border.opa = lv.OPA._30 191 | style_led.body.shadow.color = lv.color_make(0xb5, 0x0f, 0x04) 192 | style_led.body.shadow.width = 5 193 | led = lv.led(self.main_scr) 194 | led.set_style(lv.led.STYLE.MAIN, style_led) 195 | led.align(lv.scr_act(), lv.ALIGN.IN_TOP_RIGHT, -10, 5) 196 | led.off() 197 | return led 198 | 199 | def led_turn_on(self): 200 | """ 201 | Turn on the LED to indicate heating 202 | Should be called externally 203 | """ 204 | self.led.on() 205 | 206 | def led_turn_off(self): 207 | """ 208 | Turn off the LED to indicate not heating 209 | Should be called externally 210 | """ 211 | self.led.off() 212 | 213 | def oven_title_init(self): 214 | """ 215 | Initialize the oven title on the screen. 216 | """ 217 | style_title = lv.style_t() 218 | lv.style_copy(style_title, lv.style_transp_fit) 219 | style_title.text.font = lv.font_roboto_28 220 | title_label = lv.label(self.main_scr) 221 | title_label.set_style(lv.label.STYLE.MAIN, style_title) 222 | title_label.set_text(self.config.get('title')) 223 | title_label.align(lv.scr_act(), lv.ALIGN.IN_TOP_LEFT, 8, 3) 224 | return title_label 225 | 226 | def profile_title_init(self): 227 | """ 228 | Initialize reflow profile title on the screen 229 | """ 230 | # Profile Label 231 | profile_label = lv.label(self.main_scr) 232 | profile_label.set_text('Profile:') 233 | profile_label.align(self.oven_title, lv.ALIGN.OUT_BOTTOM_LEFT, -4, 3) 234 | # Profile Container 235 | profile_cont = lv.cont(self.main_scr) 236 | profile_cont.set_size(80, 28) 237 | # profile_cont.set_auto_realign(True) 238 | profile_cont.align(profile_label, lv.ALIGN.OUT_BOTTOM_LEFT, 2, 2) 239 | profile_cont.set_fit(lv.FIT.NONE) 240 | profile_cont.set_layout(lv.LAYOUT.COL_M) 241 | # Profile Text 242 | profile_text = lv.label(profile_cont) 243 | profile_text.set_text('N/A') 244 | return profile_label, profile_cont, profile_text 245 | 246 | def set_profile_title_text(self, text): 247 | """ 248 | Set the reflow profile title text 249 | It's called by another method. 250 | """ 251 | self.profile_title_text.set_text(text) 252 | 253 | def profile_selector_init(self): 254 | """ 255 | Initialize alloy reflow profile drop-down selector on the screen 256 | """ 257 | # Alloy Label 258 | alloy_label = lv.label(self.main_scr) 259 | alloy_label.set_text('Solder Paste:') 260 | alloy_label.align(self.profile_title_label, lv.ALIGN.OUT_RIGHT_MID, 38, 0) 261 | 262 | this = self 263 | 264 | # paste alloy selection drop-down 265 | def alloy_select_handler(obj, event): 266 | if event == lv.EVENT.VALUE_CHANGED: 267 | profile_alloy_name = this.alloy_list[alloy_select.get_selected()] 268 | this.profiles.load_profile_details(profile_alloy_name) 269 | this.profile_detail_init() 270 | 271 | # style_selector = lv.style_t() 272 | # lv.style_copy(style_selector, lv.style_pretty_color) 273 | alloy_select = lv.ddlist(self.main_scr) 274 | alloy_select.set_options('\n'.join(self.alloy_list)) 275 | alloy_select.set_selected(self.profiles.get_default_alloy_index()) 276 | alloy_select.set_event_cb(alloy_select_handler) 277 | alloy_select.set_fix_width(140) 278 | alloy_select.set_draw_arrow(True) 279 | alloy_select.align(alloy_label, lv.ALIGN.OUT_BOTTOM_LEFT, 2, 2) 280 | alloy_select.set_style(lv.ddlist.STYLE.BG, lv.style_pretty_color) 281 | alloy_select.set_style(lv.ddlist.STYLE.SEL, lv.style_pretty_color) 282 | return alloy_select 283 | 284 | def disable_alloy_selector(self, is_disabled): 285 | if is_disabled: 286 | # style_disabled = lv.style_t() 287 | # lv.style_copy(style_disabled, lv.style_btn_ina) 288 | self.profile_alloy_selector.set_style(lv.ddlist.STYLE.BG, lv.style_btn_ina) 289 | self.profile_alloy_selector.set_style(lv.ddlist.STYLE.SEL, lv.style_btn_ina) 290 | self.profile_alloy_selector.set_click(False) 291 | else: 292 | # style_enabled = lv.style_t() 293 | # lv.style_copy(style_enabled, lv.style_pretty_color) 294 | # self.profile_alloy_selector.set_style(lv.ddlist.STYLE.MAIN, style_enabled) 295 | self.profile_alloy_selector.set_style(lv.ddlist.STYLE.BG, lv.style_pretty_color) 296 | self.profile_alloy_selector.set_style(lv.ddlist.STYLE.SEL, lv.style_pretty_color) 297 | self.profile_alloy_selector.set_click(True) 298 | 299 | def save_default_alloy(self): 300 | alloy_name = self.alloy_list[self.profile_alloy_selector.get_selected()] 301 | # with open('config.json', 'r') as f: 302 | # data = ujson.load(f) 303 | # data['default_alloy'] = alloy_name 304 | self.config['default_alloy'] = alloy_name 305 | with open('config.json', 'w') as f: 306 | ujson.dump(self.config, f) 307 | 308 | def timer_init(self): 309 | """ 310 | Initialize the timer on the screen 311 | """ 312 | # Time Label 313 | time_label = lv.label(self.main_scr) 314 | time_label.set_text('Time:') 315 | time_label.align(self.profile_title_cont, lv.ALIGN.OUT_BOTTOM_LEFT, -2, 5) 316 | # Time Container 317 | time_cont = lv.cont(self.main_scr, self.profile_title_cont) 318 | time_cont.align(time_label, lv.ALIGN.OUT_BOTTOM_LEFT, 2, 2) 319 | # Time Text 320 | time_text = lv.label(time_cont) 321 | time_text.set_text('00:00') 322 | return time_label, time_cont, time_text 323 | 324 | def set_timer_text(self, time): 325 | """ 326 | Update the timer with the elapsed time 327 | Should be called externally 328 | """ 329 | self.timer_text.set_text(str(time)) 330 | 331 | def temp_init(self): 332 | """ 333 | Initialize the temp display on the screen 334 | """ 335 | # Temp Label 336 | temp_label = lv.label(self.main_scr) 337 | temp_label.set_text('Temp(`C):') 338 | temp_label.align(self.timer_cont, lv.ALIGN.OUT_BOTTOM_LEFT, -2, 5) 339 | # Temp Container 340 | temp_cont = lv.cont(self.main_scr, self.profile_title_cont) 341 | temp_cont.align(temp_label, lv.ALIGN.OUT_BOTTOM_LEFT, 2, 2) 342 | # Temp Text 343 | temp_text = lv.label(temp_cont) 344 | temp_text.set_text('- - -') 345 | return temp_text 346 | 347 | def temp_update(self, temp): 348 | """ 349 | Update the actual real-time temp 350 | Should be called externally 351 | """ 352 | try: 353 | float(temp) 354 | temp = '{:.1f}'.format(temp) 355 | except ValueError: 356 | pass 357 | finally: 358 | self.temp_text.set_text(temp) 359 | 360 | def popup_confirm_stop(self): 361 | modal_style = lv.style_t() 362 | lv.style_copy(modal_style, lv.style_plain_color) 363 | modal_style.body.main_color = modal_style.body.grad_color = lv.color_make(0, 0, 0) 364 | modal_style.body.opa = lv.OPA._50 365 | bg = lv.obj(self.main_scr) 366 | bg.set_style(modal_style) 367 | bg.set_pos(0, 0) 368 | bg.set_size(self.main_scr.get_width(), self.main_scr.get_height()) 369 | bg.set_opa_scale_enable(True) 370 | 371 | popup_stop = lv.mbox(bg) 372 | popup_stop.set_text('Do you really want to stop the soldering process?') 373 | btns = ['OK', 'Cancel', ''] 374 | popup_stop.add_btns(btns) 375 | this = self 376 | 377 | def event_handler(obj, event): 378 | if event == lv.EVENT.VALUE_CHANGED: 379 | if popup_stop.get_active_btn() == 0: 380 | this.set_reflow_process_on(False) 381 | else: 382 | pass 383 | 384 | bg.del_async() 385 | popup_stop.start_auto_close(5) 386 | 387 | popup_stop.set_event_cb(event_handler) 388 | popup_stop.align(None, lv.ALIGN.CENTER, 0, 0) 389 | 390 | def popup_settings(self): 391 | modal_style = lv.style_t() 392 | lv.style_copy(modal_style, lv.style_plain_color) 393 | modal_style.body.main_color = modal_style.body.grad_color = lv.color_make(0, 0, 0) 394 | modal_style.body.opa = lv.OPA._50 395 | bg = lv.obj(self.main_scr) 396 | bg.set_style(modal_style) 397 | bg.set_pos(0, 0) 398 | bg.set_size(self.main_scr.get_width(), self.main_scr.get_height()) 399 | bg.set_opa_scale_enable(True) 400 | 401 | popup_settings = lv.mbox(bg) 402 | popup_settings.set_text('Settings') 403 | btns = ['Set PID Params', '\n', 'Calibrate Touch', '\n', 'Close', ''] 404 | popup_settings.add_btns(btns) 405 | 406 | lv.cont.set_fit(popup_settings, lv.FIT.NONE) 407 | mbox_style = popup_settings.get_style(popup_settings.STYLE.BTN_REL) 408 | popup_cali_style = lv.style_t() 409 | lv.style_copy(popup_cali_style, mbox_style) 410 | popup_cali_style.body.padding.bottom = 115 411 | popup_settings.set_style(popup_settings.STYLE.BTN_REL, popup_cali_style) 412 | popup_settings.set_height(186) 413 | 414 | def event_handler(obj, event): 415 | if event == lv.EVENT.VALUE_CHANGED: 416 | active_btn_text = popup_settings.get_active_btn_text() 417 | tim = machine.Timer(-1) 418 | # Note: With PID, temp calibration no longer needed 419 | # if active_btn_text == 'Temp Sensor': 420 | # this.config['has_calibrated'] = False 421 | # with open('config.json', 'w') as f: 422 | # ujson.dump(this.config, f) 423 | # tim.init(period=500, mode=machine.Timer.ONE_SHOT, callback=lambda t:machine.reset()) 424 | # elif active_btn_text == 'Touch Screen': 425 | if active_btn_text == 'Calibrate Touch': 426 | uos.remove(self.config.get('touch_cali_file')) 427 | tim.init(period=500, mode=machine.Timer.ONE_SHOT, callback=lambda t: machine.reset()) 428 | elif active_btn_text == 'Set PID Params': 429 | tim.init(period=50, mode=machine.Timer.ONE_SHOT, callback=lambda t: self.popup_pid_params()) 430 | else: 431 | tim.deinit() 432 | bg.del_async() 433 | popup_settings.start_auto_close(5) 434 | 435 | popup_settings.set_event_cb(event_handler) 436 | popup_settings.align(None, lv.ALIGN.CENTER, 0, 0) 437 | 438 | def popup_pid_params(self): 439 | """ 440 | The popup window of PID params settings 441 | """ 442 | modal_style = lv.style_t() 443 | lv.style_copy(modal_style, lv.style_plain_color) 444 | modal_style.body.main_color = modal_style.body.grad_color = lv.color_make(0, 0, 0) 445 | modal_style.body.opa = lv.OPA._50 446 | bg = lv.obj(self.main_scr) 447 | bg.set_style(modal_style) 448 | bg.set_pos(0, 0) 449 | bg.set_size(self.main_scr.get_width(), self.main_scr.get_height()) 450 | bg.set_opa_scale_enable(True) 451 | 452 | # init mbox and title 453 | popup_pid = lv.mbox(bg) 454 | popup_pid.set_text('Set PID Params') 455 | popup_pid.set_size(220, 300) 456 | popup_pid.align(bg, lv.ALIGN.CENTER, 0, 0) 457 | 458 | input_cont = lv.cont(popup_pid) 459 | input_cont.set_size(210, 180) 460 | 461 | def input_event_cb(ta, event): 462 | if event == lv.EVENT.CLICKED: 463 | self.current_input_placeholder = ta.get_placeholder_text() 464 | if self.current_input_placeholder == 'Set Offset': 465 | popup_pid.align(bg, lv.ALIGN.CENTER, 0, -55) 466 | else: 467 | popup_pid.align(bg, lv.ALIGN.CENTER, 0, 0) 468 | if kb.get_hidden(): 469 | kb.set_hidden(False) 470 | # Focus on the clicked text area 471 | kb.set_ta(ta) 472 | 473 | def keyboard_event_cb(event_kb, event): 474 | event_kb.def_event_cb(event) 475 | if event == lv.EVENT.CANCEL or event == lv.EVENT.APPLY: 476 | kb.set_hidden(True) 477 | if self.current_input_placeholder == 'Set Offset': 478 | popup_pid.align(bg, lv.ALIGN.CENTER, 0, 0) 479 | 480 | # init keyboard 481 | kb = lv.kb(bg) 482 | kb.set_cursor_manage(True) 483 | kb.set_event_cb(keyboard_event_cb) 484 | lv.kb.set_mode(kb, lv.kb.MODE.NUM) 485 | rel_style = lv.style_t() 486 | pr_style = lv.style_t() 487 | lv.style_copy(rel_style, lv.style_btn_rel) 488 | rel_style.body.radius = 0 489 | rel_style.body.border.width = 1 490 | lv.style_copy(pr_style, lv.style_btn_pr) 491 | pr_style.body.radius = 0 492 | pr_style.body.border.width = 1 493 | kb.set_style(lv.kb.STYLE.BG, lv.style_transp_tight) 494 | kb.set_style(lv.kb.STYLE.BTN_REL, rel_style) 495 | kb.set_style(lv.kb.STYLE.BTN_PR, pr_style) 496 | 497 | # init text areas 498 | kp_input = lv.ta(input_cont) 499 | kp_input.set_text(str(self.pid_params.get('kp'))) 500 | kp_input.set_placeholder_text('Set Kp') 501 | kp_input.set_accepted_chars('0123456789.+-') 502 | kp_input.set_one_line(True) 503 | kp_input.set_width(120) 504 | kp_input.align(input_cont, lv.ALIGN.IN_TOP_MID, 30, 20) 505 | kp_input.set_event_cb(input_event_cb) 506 | kp_label = lv.label(input_cont) 507 | kp_label.set_text("Kp: ") 508 | kp_label.align(kp_input, lv.ALIGN.OUT_LEFT_MID, 0, 0) 509 | pid_title_label = lv.label(input_cont) 510 | pid_title_label.set_text("PID Params:") 511 | pid_title_label.align(kp_input, lv.ALIGN.OUT_TOP_LEFT, -65, 0) 512 | 513 | ki_input = lv.ta(input_cont) 514 | ki_input.set_text(str(self.pid_params.get('ki'))) 515 | ki_input.set_placeholder_text('Set Ki') 516 | ki_input.set_accepted_chars('0123456789.+-') 517 | ki_input.set_one_line(True) 518 | ki_input.set_width(120) 519 | ki_input.align(input_cont, lv.ALIGN.IN_TOP_MID, 30, 55) 520 | ki_input.set_event_cb(input_event_cb) 521 | ki_input.set_cursor_type(lv.CURSOR.LINE | lv.CURSOR.HIDDEN) 522 | ki_label = lv.label(input_cont) 523 | ki_label.set_text("Ki: ") 524 | ki_label.align(ki_input, lv.ALIGN.OUT_LEFT_MID, 0, 0) 525 | 526 | kd_input = lv.ta(input_cont) 527 | kd_input.set_text(str(self.pid_params.get('kd'))) 528 | kd_input.set_placeholder_text('Set Kd') 529 | kd_input.set_accepted_chars('0123456789.+-') 530 | kd_input.set_one_line(True) 531 | kd_input.set_width(120) 532 | kd_input.align(input_cont, lv.ALIGN.IN_TOP_MID, 30, 90) 533 | kd_input.set_event_cb(input_event_cb) 534 | kd_input.set_cursor_type(lv.CURSOR.LINE | lv.CURSOR.HIDDEN) 535 | kd_label = lv.label(input_cont) 536 | kd_label.set_text("Kd: ") 537 | kd_label.align(kd_input, lv.ALIGN.OUT_LEFT_MID, 0, 0) 538 | 539 | temp_offset_input = lv.ta(input_cont) 540 | temp_offset_input.set_text(str(self.temp_offset)) 541 | temp_offset_input.set_placeholder_text('Set Offset') 542 | temp_offset_input.set_accepted_chars('0123456789.+-') 543 | temp_offset_input.set_one_line(True) 544 | temp_offset_input.set_width(120) 545 | temp_offset_input.align(input_cont, lv.ALIGN.IN_TOP_MID, 30, 145) 546 | temp_offset_input.set_event_cb(input_event_cb) 547 | temp_offset_input.set_cursor_type(lv.CURSOR.LINE | lv.CURSOR.HIDDEN) 548 | temp_offset_label = lv.label(input_cont) 549 | temp_offset_label.set_text("Offset: ") 550 | temp_offset_label.align(temp_offset_input, lv.ALIGN.OUT_LEFT_MID, 0, 0) 551 | offset_title_label = lv.label(input_cont) 552 | offset_title_label.set_text("Temp Correction:") 553 | offset_title_label.align(temp_offset_input, lv.ALIGN.OUT_TOP_LEFT, -65, 0) 554 | 555 | # set btns to mbox 556 | btns = ['Save', 'Cancel', ''] 557 | popup_pid.add_btns(btns) 558 | 559 | lv.cont.set_fit(popup_pid, lv.FIT.NONE) 560 | mbox_style = popup_pid.get_style(popup_pid.STYLE.BTN_REL) 561 | popup_pid_style = lv.style_t() 562 | lv.style_copy(popup_pid_style, mbox_style) 563 | popup_pid_style.body.padding.bottom = 46 564 | popup_pid.set_style(popup_pid.STYLE.BTN_REL, popup_pid_style) 565 | popup_pid.set_size(220, 300) 566 | 567 | def event_handler(obj, event): 568 | if event == lv.EVENT.VALUE_CHANGED: 569 | active_btn_text = popup_pid.get_active_btn_text() 570 | if active_btn_text == 'Save': 571 | kp_value = float(kp_input.get_text()) 572 | ki_value = float(ki_input.get_text()) 573 | kd_value = float(kd_input.get_text()) 574 | temp_offset_value = float(temp_offset_input.get_text()) 575 | self.config['pid'] = { 576 | 'kp': kp_value, 577 | 'ki': ki_value, 578 | 'kd': kd_value 579 | } 580 | self.config['sensor_offset'] = temp_offset_value 581 | self.pid_params = self.config.get('pid') 582 | self.temp_offset = self.config.get('sensor_offset') 583 | # Save settings to config.json 584 | with open('config.json', 'w') as f: 585 | ujson.dump(self.config, f) 586 | # Apply settings immediately 587 | self.pid.reset(kp_value, ki_value, kd_value) 588 | self.sensor.set_offset(temp_offset_value) 589 | bg.del_async() 590 | popup_pid.start_auto_close(5) 591 | 592 | popup_pid.set_event_cb(event_handler) 593 | popup_pid.align(bg, lv.ALIGN.CENTER, 0, 0) 594 | kb.set_ta(kp_input) 595 | kb.set_hidden(True) 596 | 597 | def start_btn_init(self): 598 | """ 599 | Initialize the Start/Stop button on the screen 600 | """ 601 | this = self 602 | 603 | def start_btn_hander(obj, event): 604 | if event == lv.EVENT.CLICKED: 605 | if this.has_started: # Clicked to stop the process 606 | # popup to let user confirm the stop action 607 | this.popup_confirm_stop() 608 | else: # Clicked to start the process 609 | this.set_reflow_process_on(True) 610 | 611 | start_btn = lv.btn(self.main_scr) 612 | start_btn.set_size(140, 60) 613 | start_btn.set_event_cb(start_btn_hander) 614 | start_btn.align(self.timer_label, lv.ALIGN.IN_TOP_RIGHT, 190, 0) 615 | style_start = lv.style_t() 616 | lv.style_copy(style_start, lv.style_btn_rel) 617 | style_start.text.font = lv.font_roboto_28 618 | start_label = lv.label(start_btn) 619 | start_label.set_text(lv.SYMBOL.PLAY + ' Start') 620 | start_label.set_style(lv.label.STYLE.MAIN, style_start) 621 | return start_btn, start_label 622 | 623 | def set_start_btn_to_stop(self): 624 | """ 625 | Set the Start/Stop button status to 'Stop' 626 | It indicates that the reflow process is on. 627 | """ 628 | self.start_label.set_text(lv.SYMBOL.STOP + ' Stop') 629 | 630 | def reset_start_btn(self): 631 | """ 632 | Set the Start/Stop button status back to 'Start' 633 | It indicates that the reflow process is off (has finished, or not started yet). 634 | """ 635 | self.start_label.set_text(lv.SYMBOL.PLAY + ' Start') 636 | 637 | def settings_btn_init(self): 638 | # Cali Button 639 | def settings_btn_handler(obj, event): 640 | if event == lv.EVENT.CLICKED: 641 | # let user choose what to calibrate: touch screen or temp 642 | self.popup_settings() 643 | 644 | settings_btn = lv.btn(self.main_scr) 645 | settings_btn.set_size(140, 38) 646 | settings_btn.align(self.start_btn, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5) 647 | settings_btn.set_event_cb(settings_btn_handler) 648 | cali_label = lv.label(settings_btn) 649 | cali_label.set_text(lv.SYMBOL.SETTINGS + ' Settings') 650 | return settings_btn 651 | 652 | def stage_init(self): 653 | # Stage Container 654 | stage_cont = lv.cont(self.main_scr) 655 | stage_cont.set_size(140, 38) 656 | # stage_cont.set_auto_realign(True) 657 | stage_cont.align(self.start_btn, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5) 658 | stage_cont.set_fit(lv.FIT.NONE) 659 | stage_cont.set_layout(lv.LAYOUT.CENTER) 660 | style_stage = lv.style_t() 661 | lv.style_copy(style_stage, lv.style_plain) 662 | style_stage.text.font = lv.font_roboto_22 663 | stage_cont.set_style(lv.label.STYLE.MAIN, style_stage) 664 | stage_label = lv.label(stage_cont) 665 | stage_label.set_style(lv.label.STYLE.MAIN, style_stage) 666 | stage_label.set_recolor(True) 667 | stage_label.set_long_mode(lv.label.LONG.SROLL) 668 | stage_label.set_width(128) 669 | stage_label.set_text('') 670 | return stage_cont, stage_label 671 | 672 | def set_stage_text(self, text): 673 | """ 674 | Update the stage info to let user know which stage of the reflow is going now. 675 | Should be called externally 676 | """ 677 | self.stage_label.set_text(text) 678 | 679 | def show_stage_hide_set_btn(self): 680 | """ 681 | Hide the calibration button to show the stage info. 682 | """ 683 | self.stage_cont.set_hidden(False) 684 | self.settings_btn.set_hidden(True) 685 | 686 | def show_set_btn_hide_stage(self): 687 | """ 688 | Hide the stage info to show the calibration button 689 | """ 690 | self.stage_cont.set_hidden(True) 691 | self.settings_btn.set_hidden(False) 692 | 693 | def add_reflow_process_start_cb(self, start_cb): 694 | self.reflow_process_start_cb = start_cb 695 | 696 | def add_reflow_process_stop_cb(self, stop_cb): 697 | self.reflow_process_stop_cb = stop_cb 698 | 699 | def set_reflow_process_on(self, is_on): 700 | if is_on: 701 | self.has_started = is_on 702 | self.set_start_btn_to_stop() 703 | # disable the alloy selector 704 | self.disable_alloy_selector(is_on) 705 | self.show_stage_hide_set_btn() 706 | # clear temp chart data 707 | self.chart_clear() 708 | # save selected alloy to config.json as default_alloy 709 | self.save_default_alloy() 710 | if self.reflow_process_start_cb: 711 | self.reflow_process_start_cb() 712 | else: 713 | is_off = is_on 714 | self.has_started = is_off 715 | self.reset_start_btn() 716 | self.disable_alloy_selector(is_off) 717 | self.show_set_btn_hide_stage() 718 | if self.reflow_process_stop_cb: 719 | self.reflow_process_stop_cb() 720 | -------------------------------------------------------------------------------- /MAIN/load_profiles.py: -------------------------------------------------------------------------------- 1 | import uos 2 | import ujson 3 | 4 | 5 | class LoadProfiles: 6 | def __init__(self, default_alloy_name): 7 | self.profile_json_list = uos.listdir('profiles') 8 | self.profile_alloy_names = [] 9 | self.profile_dict = {} 10 | for profile_path in self.profile_json_list: 11 | with open('profiles/' + profile_path, 'r') as f: 12 | detail = ujson.load(f) 13 | alloy_name = detail.get('alloy') 14 | self.profile_alloy_names.append(alloy_name) 15 | self.profile_dict[alloy_name] = detail 16 | self.profile_details = None 17 | self.default_alloy_index = self.profile_alloy_names.index(default_alloy_name) 18 | self.load_profile_details(default_alloy_name) 19 | 20 | def get_profile_alloy_names(self): 21 | return self.profile_alloy_names 22 | 23 | def load_profile_details(self, selected_alloy_name): 24 | self.profile_details = self.profile_dict.get(selected_alloy_name) 25 | return self.profile_details 26 | 27 | def get_default_alloy_index(self): 28 | return self.default_alloy_index 29 | 30 | def get_profile_title(self): 31 | if self.profile_details: 32 | return self.profile_details.get('title') 33 | else: 34 | raise Exception('Profile details must be loaded with load_profile_details(profile_name)') 35 | 36 | def get_chart_point_count(self): 37 | if self.profile_details: 38 | return self.profile_details.get('time_range')[-1] 39 | else: 40 | raise Exception('Profile details must be loaded with load_profile_details(profile_name)') 41 | 42 | def get_temp_range(self): 43 | if self.profile_details: 44 | return self.profile_details.get('temp_range') 45 | else: 46 | raise Exception('Profile details must be loaded with load_profile_details(profile_name)') 47 | 48 | def get_time_range(self): 49 | if self.profile_details: 50 | return self.profile_details.get('time_range') 51 | else: 52 | raise Exception('Profile details must be loaded with load_profile_details(profile_name)') 53 | 54 | def get_temp_profile(self): 55 | if self.profile_details: 56 | return self.profile_details.get('profile') 57 | else: 58 | raise Exception('Profile details must be loaded with load_profile_details(profile_name)') 59 | 60 | def get_profile_stages(self): 61 | if self.profile_details: 62 | return self.profile_details.get('stages') 63 | else: 64 | raise Exception('Profile details must be loaded with load_profile_details(profile_name)') 65 | 66 | def get_melting_temp(self): 67 | if self.profile_details: 68 | return self.profile_details.get('melting_point') 69 | else: 70 | raise Exception('Profile details must be loaded with load_profile_details(profile_name)') 71 | 72 | def _calc_chart_factor(self, chart_width, chart_height, chart_top_padding): 73 | temp_range = self.get_temp_range() 74 | temp_min = temp_range[0] 75 | temp_max = temp_range[-1] 76 | time_range = self.get_time_range() 77 | time_min = time_range[0] 78 | time_max = time_range[-1] 79 | x_factor = chart_width / time_max 80 | y_factor = chart_height / (temp_max - temp_min + chart_top_padding) 81 | temp_min_offset = temp_min * y_factor 82 | return x_factor, y_factor, temp_min_offset 83 | 84 | def get_profile_chart_points(self, chart_width, chart_height, chart_top_padding): 85 | """ 86 | These points are for lv.line() to draw the ideal reflow temp profile to give the user a visual confirmation. 87 | The points are for lv.line(), make sure to set_y_invert(True) 88 | :param chart_width: width in pixel of the lv.chart 89 | :param chart_height: height in pixel of the lv.chart 90 | :param top_padding: empty space above the highest point 91 | :return: list of point x & y 92 | """ 93 | x_factor, y_factor, temp_min_offset = self._calc_chart_factor(chart_width, chart_height, chart_top_padding) 94 | temp_profile_list = self.get_temp_profile() 95 | profile_chart_points = [] 96 | for p in temp_profile_list: 97 | point = { 98 | 'x': int(p[0] * x_factor), 99 | 'y': int(p[-1] * y_factor - temp_min_offset), 100 | } 101 | profile_chart_points.append(point) 102 | return profile_chart_points 103 | 104 | def get_chart_melting_y_point(self, chart_width, chart_height, chart_top_padding): 105 | """ 106 | For drawing a horizontal line marking the melting temp of the ideal reflow profile. 107 | """ 108 | _, y_factor, temp_min_offset = self._calc_chart_factor(chart_width, chart_height, chart_top_padding) 109 | melting_temp = self.get_melting_temp() 110 | return int(melting_temp * y_factor - temp_min_offset) 111 | -------------------------------------------------------------------------------- /MAIN/main.py: -------------------------------------------------------------------------------- 1 | import machine 2 | import gc 3 | import ujson 4 | import uos 5 | import lvgl as lv 6 | import lvesp32 7 | 8 | from ili9341 import ili9341 9 | from xpt2046 import xpt2046 10 | 11 | machine.freq(240000000) 12 | 13 | with open('config.json', 'r') as f: 14 | config = ujson.load(f) 15 | 16 | disp = ili9341( 17 | miso = config['tft_pins']['miso'], 18 | mosi = config['tft_pins']['mosi'], 19 | clk = config['tft_pins']['sck'], 20 | cs = config['tft_pins']['cs'], 21 | dc = config['tft_pins']['dc'], 22 | rst = config['tft_pins']['rst'], 23 | power = config['tft_pins']['acc'], 24 | backlight = config['tft_pins']['led'], 25 | power_on = 0 if config['tft_pins']['acc_active_low'] else 1, 26 | backlight_on = 0 if config['tft_pins']['led_active_low'] else 1, 27 | width = 240 if config['tft_pins']['is_portrait'] else 320, 28 | height = 320 if config['tft_pins']['is_portrait'] else 240, 29 | rot = ili9341.PORTRAIT if config['tft_pins']['is_portrait'] else ili9341.LANDSCAPE 30 | ) 31 | 32 | touch_args = {} 33 | if config.get('touch_cali_file') in uos.listdir(): 34 | with open(config.get('touch_cali_file'), 'r') as f: 35 | touch_args = ujson.load(f) 36 | touch_args['cs'] = config['touch_pins']['cs'] 37 | touch_args['transpose'] = config['tft_pins']['is_portrait'] 38 | touch = xpt2046(**touch_args) 39 | 40 | if config.get('touch_cali_file') not in uos.listdir(): 41 | from touch_cali import TouchCali 42 | touch_cali = TouchCali(touch, config) 43 | touch_cali.start() 44 | else: 45 | import gc 46 | import utime 47 | import _thread 48 | from buzzer import Buzzer 49 | from gui import GUI 50 | from load_profiles import LoadProfiles 51 | from oven_control import OvenControl 52 | from pid import PID 53 | 54 | if config.get('sensor_type') == 'MAX6675': 55 | from max6675 import MAX6675 as Sensor 56 | else: 57 | from max31855 import MAX31855 as Sensor 58 | 59 | reflow_profiles = LoadProfiles(config['default_alloy']) 60 | 61 | temp_sensor = Sensor( 62 | hwspi = config['sensor_pins']['hwspi'], 63 | cs = config['sensor_pins']['cs'], 64 | miso = config['sensor_pins']['miso'], 65 | sck = config['sensor_pins']['sck'], 66 | offset = config['sensor_offset'], 67 | cache_time = int(1000/config['sampling_hz']) 68 | ) 69 | 70 | heater = machine.Signal( 71 | machine.Pin(config['heater_pins']['heater'], machine.Pin.OUT), 72 | invert=config['heater_pins']['heater_active_low'] 73 | ) 74 | heater.off() 75 | 76 | buzzer = Buzzer(config['buzzer_pin']) 77 | 78 | def measure_temp(): 79 | global TEMP_GUI_LAST_UPDATE 80 | while True: 81 | try: 82 | t = temp_sensor.get_temp() 83 | except Exception as e: 84 | t = str(e) 85 | gui.temp_update(t) 86 | gc.collect() 87 | utime.sleep_ms(int(1000/config['display_refresh_hz'])) 88 | 89 | def buzzer_activate(): 90 | while True: 91 | if buzzer.song: 92 | buzzer.play_song(buzzer.song) 93 | gc.collect() 94 | 95 | _thread.stack_size(7 * 1024) 96 | temp_th = _thread.start_new_thread(measure_temp, ()) 97 | buzzer_th = _thread.start_new_thread(buzzer_activate, ()) 98 | 99 | pid = PID(config['pid']['kp'], config['pid']['ki'], config['pid']['kd']) 100 | 101 | gui = GUI(reflow_profiles, config, pid, temp_sensor) 102 | 103 | oven_control = OvenControl(heater, temp_sensor, pid, reflow_profiles, gui, buzzer, machine.Timer(0), config) 104 | 105 | # Starting FTP service for future updates 106 | if config['ftp']['enable']: 107 | import network 108 | ap = network.WLAN(network.AP_IF) 109 | ap.config(essid=config['ftp']['ssid']) 110 | ap.active(True) 111 | while not ap.active(): 112 | utime.sleep_ms(500) 113 | else: 114 | import uftpd 115 | -------------------------------------------------------------------------------- /MAIN/max31855.py: -------------------------------------------------------------------------------- 1 | import ustruct 2 | import utime 3 | from machine import Pin, SPI 4 | 5 | 6 | class MAX31855: 7 | def __init__(self, hwspi=2, cs=None, sck=None, miso=None, offset=0.0, cache_time=0): 8 | """ 9 | :param hwspi: Hardware SPI bus id 10 | HSPI(id=1): sck=14, mosi=13, miso=12 11 | VSPI(id=2): sck=18, mosi=23, miso=19 12 | :param cs: chip select pin 13 | :param sck: serial clock pin 14 | :param mosi: mosi pin 15 | :param miso: miso pin 16 | 17 | FOR ADAFRUIT MAX31855 BREAKOUT BOARD WIRING 18 | ESP32 3V3 => Sensor VDD 19 | ESP32 GND => Sensor GND 20 | ESP32 SCK => Sensor CLK 21 | ESP32 MISO => Sensor DO 22 | ESP32 any digital IO pin => Sensor CS 23 | """ 24 | baudrate = 10**5 25 | self._offset = offset 26 | self._cs = Pin(cs, Pin.OUT) 27 | self._data = bytearray(4) 28 | self.cache_time = cache_time 29 | self.last_read = 0 30 | self.last_read_time = 0 31 | 32 | if hwspi == 1 or hwspi == 2: 33 | # Hardware SPI Bus 34 | self._spi = SPI(hwspi, baudrate=baudrate, sck=Pin(sck), miso=Pin(miso)) 35 | else: 36 | # Software SPI Bus 37 | self._spi = SPI(baudrate=baudrate, sck=Pin(sck), miso=Pin(miso)) 38 | 39 | def get_offset(self): 40 | return self._offset 41 | 42 | def set_offset(self, offset): 43 | self._offset = offset 44 | 45 | def read_temp(self, internal=False): 46 | self._cs.value(0) 47 | try: 48 | self._spi.readinto(self._data) 49 | finally: 50 | self._cs.value(1) 51 | 52 | if self._data[3] & 0x01: 53 | raise RuntimeError("NC") # not connected 54 | if self._data[3] & 0x02: 55 | raise RuntimeError("X_GND") # shortcut to GND 56 | if self._data[3] & 0x04: 57 | raise RuntimeError("X_PWR") # shortcut to power 58 | if self._data[1] & 0x01: 59 | raise RuntimeError("ERR") # faulty reading 60 | 61 | temp, refer = ustruct.unpack('>hh', self._data) 62 | refer >>= 4 63 | temp >>= 2 64 | self.last_read_time = utime.ticks_ms() 65 | self.last_read = refer * 0.0625 + self._offset if internal else temp * 0.25 + self._offset 66 | return self.last_read 67 | 68 | def get_temp(self): 69 | if utime.ticks_diff(utime.ticks_ms(), self.last_read_time) < self.cache_time: 70 | return self.last_read 71 | return self.read_temp() 72 | -------------------------------------------------------------------------------- /MAIN/max6675.py: -------------------------------------------------------------------------------- 1 | import ustruct 2 | import utime 3 | from machine import Pin, SPI 4 | 5 | class MAX6675: 6 | def __init__(self, hwspi=2, cs=None, sck=None, miso=None, offset=0.0, cache_time=0): 7 | baudrate = 10**5 8 | self._offset = offset 9 | self._cs = Pin(cs, Pin.OUT) 10 | self.cache_time = cache_time 11 | self.last_read = 0 12 | self.last_read_time = 0 13 | 14 | if hwspi == 1 or hwspi == 2: 15 | # Hardware SPI Bus 16 | self._spi = SPI(hwspi, baudrate=baudrate, sck=Pin(sck), miso=Pin(miso)) 17 | else: 18 | # Software SPI Bus 19 | self._spi = SPI(baudrate=baudrate, sck=Pin(sck), miso=Pin(miso)) 20 | 21 | def get_offset(self): 22 | return self._offset 23 | 24 | def set_offset(self, offset): 25 | self._offset = offset 26 | 27 | def read_temp(self, internal=False): 28 | data = bytearray(2) 29 | self._cs.value(0) 30 | try: 31 | self._spi.readinto(data) 32 | finally: 33 | self._cs.value(1) 34 | 35 | if data[1] & 0x04: 36 | raise RuntimeError("NC") # not connected 37 | 38 | self.last_read_time = utime.ticks_ms() 39 | self.last_read = ((data[0]<<8 | data[1]) >> 3) * 0.25 + self._offset 40 | return self.last_read 41 | 42 | def get_temp(self): 43 | if utime.ticks_diff(utime.ticks_ms(), self.last_read_time) < self.cache_time: 44 | return self.last_read 45 | return self.read_temp() 46 | -------------------------------------------------------------------------------- /MAIN/oven_control.py: -------------------------------------------------------------------------------- 1 | import machine 2 | import utime 3 | 4 | class OvenControl: 5 | states = ("wait", "ready", "start", "preheat", "soak", "reflow", "cool") 6 | 7 | def __init__(self, oven_obj, temp_sensor_obj, pid_obj, reflow_profiles_obj, gui_obj, buzzer_obj, timer_obj, config): 8 | self.config = config 9 | self.oven = oven_obj 10 | self.gui = gui_obj 11 | self.beep = buzzer_obj 12 | self.tim = timer_obj 13 | self.pid = pid_obj 14 | self.profiles = reflow_profiles_obj 15 | self.sensor = temp_sensor_obj 16 | self.ontemp = self.get_temp() 17 | self.offtemp = self.ontemp 18 | self.ontime = 0 19 | self.offtime = 0 20 | self.SAMPLING_HZ = self.config.get('sampling_hz') 21 | self.PREHEAT_UNTIL = self.config.get('advanced_temp_tuning').get('preheat_until') 22 | self.PREVISIONING = self.config.get('advanced_temp_tuning').get('previsioning') 23 | self.OVERSHOOT_COMP = self.config.get('advanced_temp_tuning').get('overshoot_comp') 24 | self.reflow_start = 0 25 | self.oven_state = 'ready' 26 | self.last_state = 'ready' 27 | self.timer_timediff = 0 28 | self.stage_timediff = 0 29 | self.stage_text = '' 30 | self.temp_points = [] 31 | self.has_started = False 32 | self.timer_start_time = None 33 | self.stage_start_time = None 34 | self.timer_last_called = None 35 | self.oven_reset() 36 | self.format_time(0) 37 | self.gui.add_reflow_process_start_cb(self.reflow_process_start) 38 | self.gui.add_reflow_process_stop_cb(self.reflow_process_stop) 39 | 40 | def set_oven_state(self, state): 41 | self.oven_state = state 42 | self._oven_state_change_timing_alert() 43 | 44 | def get_profile_temp(self, seconds): 45 | x1 = self.profiles.get_temp_profile()[0][0] 46 | y1 = self.profiles.get_temp_profile()[0][1] 47 | for point in self.profiles.get_temp_profile(): 48 | x2 = point[0] 49 | y2 = point[1] 50 | if x1 <= seconds < x2: 51 | temp = y1 + (y2 - y1) * (seconds - x1) // (x2 - x1) 52 | return temp 53 | x1 = x2 54 | y1 = y2 55 | return 0 56 | 57 | def oven_reset(self): 58 | self.ontime = 0 59 | self.offtime = 0 60 | self.reflow_start = 0 61 | self.oven_enable(False) 62 | 63 | def get_temp(self): 64 | try: 65 | return self.sensor.get_temp() 66 | except Exception as e: 67 | print('Emergency off') 68 | self.oven.off() 69 | self.ontime = 0 70 | self.offtime = 0 71 | self.reflow_start = 0 72 | self.offtemp = 0 73 | self.has_started = False 74 | self.gui.led_turn_off() 75 | return 0 76 | 77 | def oven_enable(self, enable): 78 | # self.control = enable 79 | if enable: 80 | self.oven.on() 81 | self.gui.led_turn_on() 82 | self.offtime = 0 83 | self.ontime = utime.time() 84 | self.ontemp = self.get_temp() 85 | else: 86 | self.oven.off() 87 | self.gui.led_turn_off() 88 | self.offtime = utime.time() 89 | self.ontime = 0 90 | self.offtemp = self.get_temp() 91 | 92 | def format_time(self, sec): 93 | minutes = sec // 60 94 | seconds = int(sec) % 60 95 | time = "{:02d}:{:02d}".format(minutes, seconds, width=2) 96 | self.gui.set_timer_text(time) 97 | 98 | def _reflow_temp_control(self): 99 | """This function is called every 100ms""" 100 | stages = self.profiles.get_profile_stages() 101 | temp = self.get_temp() 102 | if self.oven_state == "ready": 103 | self.oven_enable(False) 104 | if self.oven_state == "wait": 105 | self.oven_enable(False) 106 | if temp < 50: 107 | self.set_oven_state("start") 108 | if self.oven_state == "start": 109 | self.oven_enable(True) 110 | if self.oven_state == "start" and temp >= stages.get('preheat')[1]: 111 | self.set_oven_state("preheat") 112 | if self.oven_state == "preheat" and temp >= stages.get("soak")[1]: 113 | self.set_oven_state("soak") 114 | if self.oven_state == "soak" and temp >= stages.get("reflow")[1]: 115 | self.set_oven_state("reflow") 116 | if (self.oven_state == "reflow" 117 | and temp >= stages.get("cool")[1] 118 | and self.reflow_start > 0 119 | and (utime.time() - self.reflow_start >= 120 | stages.get("cool")[0] - stages.get("reflow")[0] - 15)): 121 | self.set_oven_state("cool") 122 | if self.oven_state == "cool": 123 | self.oven_enable(False) 124 | if self.oven_state == 'cool' and len(self.temp_points) >= len(self.gui.chart_point_list): 125 | self.beep.activate('Stop') 126 | self.has_started = False 127 | 128 | if self.oven_state in ("start", "preheat", "soak", "reflow"): 129 | # Update stage time diff 130 | if self.stage_start_time: 131 | self.stage_timediff = int(utime.time() - self.stage_start_time) 132 | # oven temp control here 133 | current_temp = self.get_temp() 134 | # if self.oven_state == 'start': 135 | # new_start_time = self.stage_timediff 136 | # else: 137 | # new_start_time = self.stage_timediff + stages.get(self.oven_state)[0] 138 | # set_temp = self.get_profile_temp(int(new_start_time + self.PROVISIONING)) - self.OVERSHOOT_COMP 139 | # set_temp = self.get_profile_temp(int(self.stage_timediff + self.PROVISIONING)) - self.OVERSHOOT_COMP 140 | set_temp = self.get_profile_temp(int(self.stage_timediff + self.PREVISIONING)) 141 | # Ignore PID & keep heating on during the early stage 142 | # if current_temp < self.PREHEAT_UNTIL or self.oven_state == 'start': 143 | if current_temp < self.PREHEAT_UNTIL: 144 | self.oven_enable(True) 145 | else: 146 | if self.oven_state == 'reflow': 147 | self.pid.ki_enable(True) 148 | else: 149 | self.pid.ki_enable(False) 150 | pid_output = self.pid.update(current_temp, set_temp) 151 | target_temp = set_temp + pid_output 152 | 153 | if current_temp > set_temp - self.OVERSHOOT_COMP: 154 | self.oven_enable(False) 155 | elif current_temp < target_temp: 156 | self.oven_enable(True) 157 | else: 158 | self.oven_enable(False) 159 | 160 | def _chart_update(self): 161 | low_end = self.profiles.get_temp_range()[0] 162 | oven_temp = self.get_temp() 163 | if oven_temp >= low_end: 164 | self.temp_points.append(int(oven_temp)) 165 | self.gui.chart_update(self.temp_points) 166 | # Reset the stage timer when the temp reaches the low end 167 | if len(self.temp_points) == 1: 168 | self.stage_start_time = utime.time() 169 | 170 | def _elapsed_timer_update(self): 171 | now = utime.time() 172 | self.timer_timediff = int(now - self.timer_start_time) 173 | self.format_time(self.timer_timediff) 174 | 175 | def _stage_timimg(self): 176 | # the elapsed timer starts here 177 | if self.oven_state == 'start' and (self.last_state == 'ready' or self.last_state == 'wait'): 178 | self.timer_start_time = utime.time() 179 | # the reflow timer starts here 180 | if self.oven_state == 'reflow' and self.last_state != "reflow": 181 | self.reflow_start = utime.time() 182 | 183 | def _oven_state_change_timing_alert(self): 184 | self._stage_timimg() 185 | if self.oven_state != self.last_state: 186 | # Reset the stage timer when a new stage starts 187 | # self.stage_start_time = utime.time() 188 | if self.oven_state == 'start': 189 | self.beep.activate('Start') 190 | elif self.oven_state == 'cool': 191 | self.beep.activate('SMBwater') 192 | elif self.oven_state == 'ready': 193 | pass 194 | elif self.oven_state == 'wait': 195 | self.beep.activate('TAG') 196 | else: 197 | self.beep.activate('Next') 198 | # Update stage message to user 199 | self._stage_message_update() 200 | self.last_state = self.oven_state 201 | 202 | def _stage_message_update(self): 203 | if self.oven_state == "ready": 204 | self.stage_text = "#003399 Ready#" 205 | if self.oven_state == "start": 206 | self.stage_text = "#009900 Starting#" 207 | if self.oven_state == "preheat": 208 | self.stage_text = "#FF6600 Preheat#" 209 | if self.oven_state == "soak": 210 | self.stage_text = "#FF0066 Soak#" 211 | if self.oven_state == "reflow": 212 | self.stage_text = "#FF0000 Reflow#" 213 | if self.oven_state == "cool" or self.oven_state == "wait": 214 | self.stage_text = "#0000FF Cool Down, Open Door#" 215 | self.gui.set_stage_text(self.stage_text) 216 | 217 | def _control_cb_handler(self): 218 | if self.has_started: 219 | # Oven temperature control logic 220 | # With PID, temp control logic should be called once per 100ms 221 | self._reflow_temp_control() 222 | # Below methods are called once per second 223 | if not self.timer_last_called: 224 | self.timer_last_called = utime.ticks_ms() 225 | if utime.ticks_diff(utime.ticks_ms(), self.timer_last_called) >= 1000: 226 | if self.oven_state in ("start", "preheat", "soak", "reflow", 'cool'): 227 | # Update gui temp chart 228 | self._chart_update() 229 | # Update elapsed timer 230 | self._elapsed_timer_update() 231 | self.timer_last_called = utime.ticks_ms() 232 | else: 233 | self.tim.deinit() 234 | # Same effect as click Stop button on GUI 235 | self.gui.set_reflow_process_on(False) 236 | 237 | def reflow_process_start(self): 238 | """ 239 | This method is called by clicking Start button on the GUI 240 | """ 241 | # clear the chart temp list 242 | self.temp_points = [] 243 | # reset the timer for the whole process 244 | # self.start_time = utime.time() 245 | # mark the progress to start 246 | self.has_started = True 247 | # set the oven state to start 248 | if self.get_temp() >= 50: 249 | self.set_oven_state('wait') 250 | else: 251 | self.set_oven_state('start') 252 | # initialize the hardware timer to call the control callback once every 200ms 253 | # With PID, the period of the timer should be 200ms now 254 | self.tim.init( 255 | period=int(1000 / self.SAMPLING_HZ), 256 | mode=machine.Timer.PERIODIC, 257 | callback=lambda t: self._control_cb_handler() 258 | ) 259 | 260 | def reflow_process_stop(self): 261 | """ 262 | This method is called by clicking Stop button on the GUI 263 | """ 264 | self.tim.deinit() 265 | self.has_started = False 266 | self.oven_reset() 267 | self.timer_start_time = None 268 | self.timer_timediff = 0 269 | self.format_time(self.timer_timediff) 270 | self.stage_text = '' 271 | self.gui.set_stage_text(self.stage_text) 272 | self.set_oven_state('ready') 273 | -------------------------------------------------------------------------------- /MAIN/pid.py: -------------------------------------------------------------------------------- 1 | class PID: 2 | def __init__(self, kp=2, ki=0.0001, kd=2): 3 | self.k_p = float(kp) 4 | self.k_i = float(ki) 5 | self.k_d = float(kd) 6 | self.k_p_backup = self.k_p 7 | self.k_i_backup = self.k_i 8 | self.k_d_backup = self.k_d 9 | self.last_error = 0 10 | self.integration = 0 11 | self.last_output = 0 12 | self.ki_is_enabled = False 13 | 14 | def update(self, temp, setpoint): 15 | """ 16 | temp: float; real-time temperature measured by ds18 sensor 17 | setpoint: float; target temperature to achieve 18 | return: float; temperature correction 19 | """ 20 | error = float(setpoint) - float(temp) 21 | if self.last_error == 0: 22 | self.last_error = error #catch first run error 23 | 24 | P_value = self.k_p * error 25 | # D_value = -(self.k_d * (error - self.last_error)) 26 | D_value = self.k_d * (error - self.last_error) 27 | self.last_error = error 28 | # if -10 < self.last_output < 10: 29 | if self.ki_is_enabled: 30 | self.integration = self.integration + error 31 | 32 | I_value = self.integration * self.k_i 33 | 34 | # self.last_output = max(min(P_value + I_value + D_value, 15), -200) 35 | self.last_output = P_value + I_value + D_value 36 | return self.last_output 37 | 38 | def ki_enable(self, new_boolean): 39 | self.ki_is_enabled = new_boolean 40 | 41 | def reset(self, kp=0, ki=0, kd=0): 42 | if kp or ki or kd: 43 | self.k_p = float(kp) 44 | self.k_i = float(ki) 45 | self.k_d = float(kd) 46 | else: 47 | self.k_p = self.k_p_backup 48 | self.k_i = self.k_i_backup 49 | self.k_d = self.k_d_backup 50 | -------------------------------------------------------------------------------- /MAIN/profiles/sn42bi576ag04.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Lead 138", 3 | "alloy": "Sn42/Bi57.6/Ag0.4", 4 | "melting_point": 138, 5 | "temp_range": [30,165], 6 | "time_range": [0,390], 7 | "reference": "http://www.chipquik.com/datasheets/TS391LT50.pdf", 8 | "stages": { 9 | "preheat": [90,90], 10 | "soak": [180,130], 11 | "reflow": [210,138], 12 | "cool": [270,138] 13 | }, 14 | "profile": [ 15 | [0,30], 16 | [90,90], 17 | [180,130], 18 | [210,138], 19 | [240,165], 20 | [270,138], 21 | [390,50] 22 | ] 23 | } -------------------------------------------------------------------------------- /MAIN/profiles/sn63pb37.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Lead 183", 3 | "alloy": "Sn63/Pb37", 4 | "melting_point": 183, 5 | "temp_range": [30,235], 6 | "time_range": [0,340], 7 | "reference": "https://www.chipquik.com/datasheets/TS391AX50.pdf", 8 | "stages": { 9 | "preheat": [30,100], 10 | "soak": [120,150], 11 | "reflow": [150,183], 12 | "cool": [240,183] 13 | }, 14 | "profile": [ 15 | [0,30], 16 | [20,90], 17 | [30,100], 18 | [40,110], 19 | [110,140], 20 | [120,150], 21 | [130,160], 22 | [150,183], 23 | [200,230], 24 | [210,235], 25 | [220,230], 26 | [240,183], 27 | [340,50] 28 | ] 29 | } -------------------------------------------------------------------------------- /MAIN/profiles/sn965ag30cu05.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Lead 217", 3 | "alloy": "Sn96.5/Ag3.0/Cu0.5", 4 | "melting_point": 217, 5 | "temp_range": [30,249], 6 | "time_range": [0,330], 7 | "reference": "https://www.chipquik.com/datasheets/TS391SNL50.pdf", 8 | "stages": { 9 | "preheat": [90,150], 10 | "soak": [180,175], 11 | "reflow": [210,217], 12 | "cool": [270,217] 13 | }, 14 | "profile": [ 15 | [0,30], 16 | [90,150], 17 | [180,175], 18 | [210,217], 19 | [240,249], 20 | [270,217], 21 | [330,50] 22 | ] 23 | } -------------------------------------------------------------------------------- /MAIN/rtttl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # You can find a description of RTTTL here: https://en.wikipedia.org/wiki/Ring_Tone_Transfer_Language 4 | 5 | NOTE = [ 6 | 440.0, # A 7 | 493.9, # B or H 8 | 261.6, # C 9 | 293.7, # D 10 | 329.6, # E 11 | 349.2, # F 12 | 392.0, # G 13 | 0.0, # pad 14 | 15 | 466.2, # A# 16 | 0.0, 17 | 277.2, # C# 18 | 311.1, # D# 19 | 0.0, 20 | 370.0, # F# 21 | 415.3, # G# 22 | 0.0, 23 | ] 24 | 25 | class RTTTL: 26 | 27 | def __init__(self, tune): 28 | tune_pieces = tune.split(':') 29 | if len(tune_pieces) != 3: 30 | raise ValueError('tune should contain exactly 2 colons') 31 | self.tune = tune_pieces[2] 32 | self.tune_idx = 0 33 | self.parse_defaults(tune_pieces[1]) 34 | 35 | def parse_defaults(self, defaults): 36 | # Example: d=4,o=5,b=140 37 | val = 0 38 | id = ' ' 39 | for char in defaults: 40 | char = char.lower() 41 | if char.isdigit(): 42 | val *= 10 43 | val += ord(char) - ord('0') 44 | if id == 'o': 45 | self.default_octave = val 46 | elif id == 'd': 47 | self.default_duration = val 48 | elif id == 'b': 49 | self.bpm = val 50 | elif char.isalpha(): 51 | id = char 52 | val = 0 53 | # 240000 = 60 sec/min * 4 beats/whole-note * 1000 msec/sec 54 | self.msec_per_whole_note = 240000.0 / self.bpm 55 | 56 | def next_char(self): 57 | if self.tune_idx < len(self.tune): 58 | char = self.tune[self.tune_idx] 59 | self.tune_idx += 1 60 | if char == ',': 61 | char = ' ' 62 | return char 63 | return '|' 64 | 65 | def notes(self): 66 | """Generator which generates notes. Each note is a tuple where the 67 | first element is the frequency (in Hz) and the second element is 68 | the duration (in milliseconds). 69 | """ 70 | while True: 71 | # Skip blank characters and commas 72 | char = self.next_char() 73 | while char == ' ': 74 | char = self.next_char() 75 | 76 | # Parse duration, if present. A duration of 1 means a whole note. 77 | # A duration of 8 means 1/8 note. 78 | duration = 0 79 | while char.isdigit(): 80 | duration *= 10 81 | duration += ord(char) - ord('0') 82 | char = self.next_char() 83 | if duration == 0: 84 | duration = self.default_duration 85 | 86 | if char == '|': # marker for end of tune 87 | return 88 | 89 | note = char.lower() 90 | if note >= 'a' and note <= 'g': 91 | note_idx = ord(note) - ord('a') 92 | elif note == 'h': 93 | note_idx = 1 # H is equivalent to B 94 | else: 95 | note_idx = 7 # pause 96 | char = self.next_char() 97 | 98 | # Check for sharp note 99 | if char == '#': 100 | note_idx += 8 101 | char = self.next_char() 102 | 103 | # Check for duration modifier before octave 104 | # The spec has the dot after the octave, but some places do it 105 | # the other way around. 106 | duration_multiplier = 1.0 107 | if char == '.': 108 | duration_multiplier = 1.5 109 | char = self.next_char() 110 | 111 | # Check for octave 112 | if char >= '4' and char <= '7': 113 | octave = ord(char) - ord('0') 114 | char = self.next_char() 115 | else: 116 | octave = self.default_octave 117 | 118 | # Check for duration modifier after octave 119 | if char == '.': 120 | duration_multiplier = 1.5 121 | char = self.next_char() 122 | 123 | freq = NOTE[note_idx] * (1 << (octave - 4)) 124 | msec = (self.msec_per_whole_note / duration) * duration_multiplier 125 | 126 | #print('note ', note, 'duration', duration, 'octave', octave, 'freq', freq, 'msec', msec) 127 | 128 | yield freq, msec 129 | 130 | -------------------------------------------------------------------------------- /MAIN/songs.py: -------------------------------------------------------------------------------- 1 | # The following RTTTL tunes were extracted from the following: 2 | # https://github.com/onebeartoe/media-players/blob/master/pi-ezo/src/main/java/org/onebeartoe/media/piezo/ports/rtttl/BuiltInSongs.java 3 | # most of which originated from here: 4 | # http://www.picaxe.com/RTTTL-Ringtones-for-Tune-Command/ 5 | # 6 | 7 | SONGS = [ 8 | 'Super Mario - Main Theme:d=4,o=5,b=125:a,8f.,16c,16d,16f,16p,f,16d,16c,16p,16f,16p,16f,16p,8c6,8a.,g,16c,a,8f.,16c,16d,16f,16p,f,16d,16c,16p,16f,16p,16a#,16a,16g,2f,16p,8a.,8f.,8c,8a.,f,16g#,16f,16c,16p,8g#.,2g,8a.,8f.,8c,8a.,f,16g#,16f,8c,2c6', 9 | 'Super Mario - Title Music:d=4,o=5,b=125:8d7,8d7,8d7,8d6,8d7,8d7,8d7,8d6,2d#7,8d7,p,32p,8d6,8b6,8b6,8b6,8d6,8b6,8b6,8b6,8d6,8b6,8b6,8b6,16b6,16c7,b6,8a6,8d6,8a6,8a6,8a6,8d6,8a6,8a6,8a6,8d6,8a6,8a6,8a6,16a6,16b6,a6,8g6,8d6,8b6,8b6,8b6,8d6,8b6,8b6,8b6,8d6,8b6,8b6,8b6,16a6,16b6,c7,e7,8d7,8d7,8d7,8d6,8c7,8c7,8c7,8f#6,2g6', 10 | 'SMBtheme:d=4,o=5,b=100:16e6,16e6,32p,8e6,16c6,8e6,8g6,8p,8g,8p,8c6,16p,8g,16p,8e,16p,8a,8b,16a#,8a,16g.,16e6,16g6,8a6,16f6,8g6,8e6,16c6,16d6,8b,16p,8c6,16p,8g,16p,8e,16p,8a,8b,16a#,8a,16g.,16e6,16g6,8a6,16f6,8g6,8e6,16c6,16d6,8b,8p,16g6,16f#6,16f6,16d#6,16p,16e6,16p,16g#,16a,16c6,16p,16a,16c6,16d6,8p,16g6,16f#6,16f6,16d#6,16p,16e6,16p,16c7,16p,16c7,16c7,p,16g6,16f#6,16f6,16d#6,16p,16e6,16p,16g#,16a,16c6,16p,16a,16c6,16d6,8p,16d#6,8p,16d6,8p,16c6', 11 | 'SMBwater:d=8,o=6,b=225:4d5,4e5,4f#5,4g5,4a5,4a#5,b5,b5,b5,p,b5,p,2b5,p,g5,2e.,2d#.,2e.,p,g5,a5,b5,c,d,2e.,2d#,4f,2e.,2p,p,g5,2d.,2c#.,2d.,p,g5,a5,b5,c,c#,2d.,2g5,4f,2e.,2p,p,g5,2g.,2g.,2g.,4g,4a,p,g,2f.,2f.,2f.,4f,4g,p,f,2e.,4a5,4b5,4f,e,e,4e.,b5,2c.', 12 | 'Pause:d=16,o=6,b=100:f5,f,d5,d,d#5,d#,2p', 13 | 'Resume:d=16,o=6,b=100:c,c5,a5,a,a#5,a#,2p', 14 | 'Next:d=16,o=6,b=100:c,c5,a5,a,a#5,a#,2p,f5,f,d5,d,d#5,d#,2p', 15 | 'Stop:d=4,o=5,b=90:32c6,32c6,32c6,8p,16b,16f6,16p,16f6,16f.6,16e.6,16d6,16c6,16p,16e,16p,16c', 16 | 'Start:d=4,o=5,b=100:16e6,16e6,32p,8e6,16c6,8e6,8g6,8p,8g,8p', 17 | 'TAG:d=8,o=5,b=160:d6,16p,2d6', 18 | 'Down:d=4,o=5,b=125:8d6', 19 | 'Up:d=4,o=5,b=125:8d7', 20 | 'Transformers:d=4,o=6,b=300:2p,16d,16d7,16d,16d7,16d,16d7,16d,16d7,16e,16e7,16e,16e7,16f,16f7,16f,16f7,16f,16f7,16f,16f7,16a5,16a5,16a5,16a5,16a#5,16a#,16a#5,16a#,16a#5,16a#,16a#5,16a#,16a#5,16a#,16a#5,16a#', 21 | 'Finish:d=4,o=6,b=101:g5,c,8c,c,e,d', 22 | 'The Simpsons:d=4,o=5,b=160:c.6,e6,f#6,8a6,g.6,e6,c6,8a,8f#,8f#,8f#,2g,8p,8p,8f#,8f#,8f#,8g,a#.,8c6,8c6,8c6,c6', 23 | 'Indiana:d=4,o=5,b=250:e,8p,8f,8g,8p,1c6,8p.,d,8p,8e,1f,p.,g,8p,8a,8b,8p,1f6,p,a,8p,8b,2c6,2d6,2e6,e,8p,8f,8g,8p,1c6,p,d6,8p,8e6,1f.6,g,8p,8g,e.6,8p,d6,8p,8g,e.6,8p,d6,8p,8g,f.6,8p,e6,8p,8d6,2c6', 24 | 'TakeOnMe:d=4,o=4,b=160:8f#5,8f#5,8f#5,8d5,8p,8b,8p,8e5,8p,8e5,8p,8e5,8g#5,8g#5,8a5,8b5,8a5,8a5,8a5,8e5,8p,8d5,8p,8f#5,8p,8f#5,8p,8f#5,8e5,8e5,8f#5,8e5,8f#5,8f#5,8f#5,8d5,8p,8b,8p,8e5,8p,8e5,8p,8e5,8g#5,8g#5,8a5,8b5,8a5,8a5,8a5,8e5,8p,8d5,8p,8f#5,8p,8f#5,8p,8f#5,8e5,8e5', 25 | 'Entertainer:d=4,o=5,b=140:8d,8d#,8e,c6,8e,c6,8e,2c.6,8c6,8d6,8d#6,8e6,8c6,8d6,e6,8b,d6,2c6,p,8d,8d#,8e,c6,8e,c6,8e,2c.6,8p,8a,8g,8f#,8a,8c6,e6,8d6,8c6,8a,2d6', 26 | 'Muppets:d=4,o=5,b=250:c6,c6,a,b,8a,b,g,p,c6,c6,a,8b,8a,8p,g.,p,e,e,g,f,8e,f,8c6,8c,8d,e,8e,8e,8p,8e,g,2p,c6,c6,a,b,8a,b,g,p,c6,c6,a,8b,a,g.,p,e,e,g,f,8e,f,8c6,8c,8d,e,8e,d,8d,c', 27 | 'Xfiles:d=4,o=5,b=125:e,b,a,b,d6,2b.,1p,e,b,a,b,e6,2b.,1p,g6,f#6,e6,d6,e6,2b.,1p,g6,f#6,e6,d6,f#6,2b.,1p,e,b,a,b,d6,2b.,1p,e,b,a,b,e6,2b.,1p,e6,2b.', 28 | 'Looney:d=4,o=5,b=140:32p,c6,8f6,8e6,8d6,8c6,a.,8c6,8f6,8e6,8d6,8d#6,e.6,8e6,8e6,8c6,8d6,8c6,8e6,8c6,8d6,8a,8c6,8g,8a#,8a,8f', 29 | '20thCenFox:d=16,o=5,b=140:b,8p,b,b,2b,p,c6,32p,b,32p,c6,32p,b,32p,c6,32p,b,8p,b,b,b,32p,b,32p,b,32p,b,32p,b,32p,b,32p,b,32p,g#,32p,a,32p,b,8p,b,b,2b,4p,8e,8g#,8b,1c#6,8f#,8a,8c#6,1e6,8a,8c#6,8e6,1e6,8b,8g#,8a,2b', 30 | 'Bond:d=4,o=5,b=80:32p,16c#6,32d#6,32d#6,16d#6,8d#6,16c#6,16c#6,16c#6,16c#6,32e6,32e6,16e6,8e6,16d#6,16d#6,16d#6,16c#6,32d#6,32d#6,16d#6,8d#6,16c#6,16c#6,16c#6,16c#6,32e6,32e6,16e6,8e6,16d#6,16d6,16c#6,16c#7,c.7,16g#6,16f#6,g#.6', 31 | 'MASH:d=8,o=5,b=140:4a,4g,f#,g,p,f#,p,g,p,f#,p,2e.,p,f#,e,4f#,e,f#,p,e,p,4d.,p,f#,4e,d,e,p,d,p,e,p,d,p,2c#.,p,d,c#,4d,c#,d,p,e,p,4f#,p,a,p,4b,a,b,p,a,p,b,p,2a.,4p,a,b,a,4b,a,b,p,2a.,a,4f#,a,b,p,d6,p,4e.6,d6,b,p,a,p,2b', 32 | 'StarWars:d=4,o=5,b=45:32p,32f#,32f#,32f#,8b.,8f#.6,32e6,32d#6,32c#6,8b.6,16f#.6,32e6,32d#6,32c#6,8b.6,16f#.6,32e6,32d#6,32e6,8c#.6,32f#,32f#,32f#,8b.,8f#.6,32e6,32d#6,32c#6,8b.6,16f#.6,32e6,32d#6,32c#6,8b.6,16f#.6,32e6,32d#6,32e6,8c#6', 33 | 'GoodBad:d=4,o=5,b=56:32p,32a#,32d#6,32a#,32d#6,8a#.,16f#.,16g#.,d#,32a#,32d#6,32a#,32d#6,8a#.,16f#.,16g#.,c#6,32a#,32d#6,32a#,32d#6,8a#.,16f#.,32f.,32d#.,c#,32a#,32d#6,32a#,32d#6,8a#.,16g#.,d#', 34 | 'TopGun:d=4,o=4,b=31:32p,16c#,16g#,16g#,32f#,32f,32f#,32f,16d#,16d#,32c#,32d#,16f,32d#,32f,16f#,32f,32c#,16f,d#,16c#,16g#,16g#,32f#,32f,32f#,32f,16d#,16d#,32c#,32d#,16f,32d#,32f,16f#,32f,32c#,g#', 35 | 'A-Team:d=8,o=5,b=125:4d#6,a#,2d#6,16p,g#,4a#,4d#.,p,16g,16a#,d#6,a#,f6,2d#6,16p,c#.6,16c6,16a#,g#.,2a#', 36 | 'Flinstones:d=4,o=5,b=40:32p,16f6,16a#,16a#6,32g6,16f6,16a#.,16f6,32d#6,32d6,32d6,32d#6,32f6,16a#,16c6,d6,16f6,16a#.,16a#6,32g6,16f6,16a#.,32f6,32f6,32d#6,32d6,32d6,32d#6,32f6,16a#,16c6,a#,16a6,16d.6,16a#6,32a6,32a6,32g6,32f#6,32a6,8g6,16g6,16c.6,32a6,32a6,32g6,32g6,32f6,32e6,32g6,8f6,16f6,16a#.,16a#6,32g6,16f6,16a#.,16f6,32d#6,32d6,32d6,32d#6,32f6,16a#,16c.6,32d6,32d#6,32f6,16a#,16c.6,32d6,32d#6,32f6,16a#6,16c7,8a#.6', 37 | 'Jeopardy:d=4,o=6,b=125:c,f,c,f5,c,f,2c,c,f,c,f,a.,8g,8f,8e,8d,8c#,c,f,c,f5,c,f,2c,f.,8d,c,a#5,a5,g5,f5,p,d#,g#,d#,g#5,d#,g#,2d#,d#,g#,d#,g#,c.7,8a#,8g#,8g,8f,8e,d#,g#,d#,g#5,d#,g#,2d#,g#.,8f,d#,c#,c,p,a#5,p,g#.5,d#,g#', 38 | 'Gadget:d=16,o=5,b=50:32d#,32f,32f#,32g#,a#,f#,a,f,g#,f#,32d#,32f,32f#,32g#,a#,d#6,4d6,32d#,32f,32f#,32g#,a#,f#,a,f,g#,f#,8d#', 39 | 'Smurfs:d=32,o=5,b=200:4c#6,16p,4f#6,p,16c#6,p,8d#6,p,8b,p,4g#,16p,4c#6,p,16a#,p,8f#,p,8a#,p,4g#,4p,g#,p,a#,p,b,p,c6,p,4c#6,16p,4f#6,p,16c#6,p,8d#6,p,8b,p,4g#,16p,4c#6,p,16a#,p,8b,p,8f,p,4f#', 40 | 'MahnaMahna:d=16,o=6,b=125:c#,c.,b5,8a#.5,8f.,4g#,a#,g.,4d#,8p,c#,c.,b5,8a#.5,8f.,g#.,8a#.,4g,8p,c#,c.,b5,8a#.5,8f.,4g#,f,g.,8d#.,f,g.,8d#.,f,8g,8d#.,f,8g,d#,8c,a#5,8d#.,8d#.,4d#,8d#.', 41 | 'LeisureSuit:d=16,o=6,b=56:f.5,f#.5,g.5,g#5,32a#5,f5,g#.5,a#.5,32f5,g#5,32a#5,g#5,8c#.,a#5,32c#,a5,a#.5,c#.,32a5,a#5,32c#,d#,8e,c#.,f.,f.,f.,f.,f,32e,d#,8d,a#.5,e,32f,e,32f,c#,d#.,c#', 42 | 'MissionImp:d=16,o=6,b=95:32d,32d#,32d,32d#,32d,32d#,32d,32d#,32d,32d,32d#,32e,32f,32f#,32g,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,a#,g,2d,32p,a#,g,2c#,32p,a#,g,2c,a#5,8c,2p,32p,a#5,g5,2f#,32p,a#5,g5,2f,32p,a#5,g5,2e,d#,8d', 43 | ] 44 | 45 | 46 | def find(name): 47 | for song in SONGS: 48 | song_name = song.split(':')[0] 49 | if song_name == name: 50 | return song 51 | -------------------------------------------------------------------------------- /MAIN/touch_cali.py: -------------------------------------------------------------------------------- 1 | import machine 2 | import ujson 3 | import utime 4 | 5 | import lvgl as lv 6 | import lvesp32 7 | 8 | 9 | class TouchCali: 10 | def __init__(self, touch_obj, config): 11 | self.touch_obj = touch_obj 12 | self.config = config 13 | self.cali_counter = 0 14 | self.touch_cali_scr = lv.obj() 15 | self.touch_cali_scr.set_click(True) 16 | self.scr_width = self.touch_obj.screen_width 17 | self.scr_height = self.touch_obj.screen_height 18 | self.marker_pos = [ 19 | (int(self.scr_width * 0.6), int(self.scr_height * 0.4)), 20 | (int(self.scr_width * 0.1), int(self.scr_height * 0.1)), 21 | (int(self.scr_width * 0.9), int(self.scr_height * 0.1)), 22 | (int(self.scr_width * 0.1), int(self.scr_height * 0.9)), 23 | (int(self.scr_width * 0.9), int(self.scr_height * 0.9)), 24 | (int(self.scr_width * 0.4), int(self.scr_height * 0.6)), 25 | ] 26 | self.marker_x, self.marker_y = self.marker_pos[self.cali_counter] 27 | self.marker_x_coords = [] 28 | self.marker_y_coords = [] 29 | self.raw_x_coords = [] 30 | self.raw_y_coords = [] 31 | 32 | self.marker_label = lv.label(self.touch_cali_scr) 33 | self.marker_label.set_recolor(True) 34 | self.marker_label.set_text('#FF0000 ' + lv.SYMBOL.PLUS + '#') 35 | self.marker_label.align_origo(self.touch_cali_scr, lv.ALIGN.IN_TOP_LEFT, self.marker_x, self.marker_y) 36 | 37 | text_style = lv.style_t() 38 | lv.style_copy(text_style, lv.style_transp_tight) 39 | text_style.text.font = lv.font_roboto_12 40 | self.text_label = lv.label(self.touch_cali_scr) 41 | self.text_label.set_style(lv.label.STYLE.MAIN, text_style) 42 | # text_label.align(cali_scr, lv.ALIGN.IN_TOP_LEFT, marker_x, marker_y) 43 | self.text_label.set_align(lv.label.ALIGN.CENTER) 44 | self.text_label.set_recolor(True) 45 | self.text_label.set_text('Click the marker\nto calibrate.') 46 | self.text_label.align_origo(self.touch_cali_scr, lv.ALIGN.CENTER, 0, 0) 47 | 48 | def start(self): 49 | self.touch_cali_scr.set_event_cb(self.touch_cali_handler) 50 | lv.scr_load(self.touch_cali_scr) 51 | 52 | def touch_cali_handler(self, obj, event): 53 | if event == lv.EVENT.PRESSED: 54 | if self.touch_obj.transpose: 55 | raw_y, raw_x = self.touch_obj.get_med_coords(3) 56 | else: 57 | raw_x, raw_y = self.touch_obj.get_med_coords(3) 58 | self.raw_x_coords.append(raw_x) 59 | self.raw_y_coords.append(raw_y) 60 | self.marker_x_coords.append(self.marker_x) 61 | self.marker_y_coords.append(self.marker_y) 62 | # globals()['coord_' + str(cali_counter)] = lv.label(cali_scr) 63 | # globals()['coord_' + str(cali_counter)].align(cali_scr, lv.ALIGN.IN_TOP_LEFT, marker_x, marker_y) 64 | # globals()['coord_' + str(cali_counter)].set_text('Raw_X: {}\nRaw_Y: {}'.format(raw_x, raw_y)) 65 | if self.cali_counter < len(self.marker_pos) - 1: 66 | self.cali_counter += 1 67 | self.marker_x, self.marker_y = self.marker_pos[self.cali_counter] 68 | self.marker_label.align_origo(self.touch_cali_scr, lv.ALIGN.IN_TOP_LEFT, self.marker_x, self.marker_y) 69 | # text_label.align(cali_scr, lv.ALIGN.IN_TOP_LEFT, marker_x, marker_y) 70 | else: 71 | self.marker_label.set_hidden(True) 72 | self.text_label.set_text('#16A000 Calibration Done!#\n#16A000 Click the screen to reboot.#') 73 | self.text_label.align_origo(self.touch_cali_scr, lv.ALIGN.CENTER, 0, 0) 74 | print('calibration done.') 75 | self.touch_cali_result() 76 | utime.sleep_ms(300) 77 | self.touch_cali_scr.set_event_cb( 78 | lambda obj, event: machine.reset() if event == lv.EVENT.PRESSED else None) 79 | 80 | def touch_cali_result(self): 81 | cal_x0_list = [] 82 | cal_x1_list = [] 83 | cal_y0_list = [] 84 | cal_y1_list = [] 85 | counter = len(self.raw_x_coords) // 2 86 | for i in range(counter * 2): 87 | if i % 2 == 0: 88 | x1 = (-self.scr_width * self.raw_x_coords[i] + self.raw_x_coords[i] * self.marker_x_coords[ 89 | i + 1] + self.scr_width * 90 | self.raw_x_coords[i + 1] - self.raw_x_coords[i + 1] * self.marker_x_coords[i]) \ 91 | / \ 92 | (-self.marker_x_coords[i] + self.marker_x_coords[i + 1]) 93 | x0 = (self.scr_width * self.raw_x_coords[i] - self.marker_x_coords[i] * x1) \ 94 | / \ 95 | (self.scr_width - self.marker_x_coords[i]) 96 | y1 = (-self.scr_height * self.raw_y_coords[i] + self.raw_y_coords[i] * self.marker_y_coords[ 97 | i + 1] + self.scr_height * 98 | self.raw_y_coords[ 99 | i + 1] - self.raw_y_coords[i + 1] * self.marker_y_coords[i]) \ 100 | / \ 101 | (-self.marker_y_coords[i] + self.marker_y_coords[i + 1]) 102 | y0 = (self.scr_height * self.raw_y_coords[i] - self.marker_y_coords[i] * y1) \ 103 | / \ 104 | (self.scr_height - self.marker_y_coords[i]) 105 | 106 | cal_x0_list.append(x0) 107 | cal_x1_list.append(x1) 108 | cal_y0_list.append(y0) 109 | cal_y1_list.append(y1) 110 | 111 | cal_x0 = int(sum(cal_x0_list) / len(cal_x0_list)) 112 | cal_x1 = int(sum(cal_x1_list) / len(cal_x1_list)) 113 | cal_y0 = int(sum(cal_y0_list) / len(cal_y0_list)) 114 | cal_y1 = int(sum(cal_y1_list) / len(cal_y1_list)) 115 | print('cal_x0 = {}; cal_x1 = {};'.format(cal_x0, cal_x1)) 116 | print('cal_y0 = {}; cal_y1 = {};'.format(cal_y0, cal_y1)) 117 | with open(self.config.get('touch_cali_file'), 'w') as f: 118 | data = { 119 | 'cal_x0': cal_x0, 120 | 'cal_x1': cal_x1, 121 | 'cal_y0': cal_y0, 122 | 'cal_y1': cal_y1, 123 | } 124 | try: 125 | ujson.dump(data, f) 126 | except: 127 | print('Error occurs when saving calibration results.') 128 | else: 129 | print('Calibration params saved.') 130 | -------------------------------------------------------------------------------- /MAIN/uftpd.py: -------------------------------------------------------------------------------- 1 | # 2 | # Small ftp server for ESP8266 Micropython 3 | # Based on the work of chrisgp - Christopher Popp and pfalcon - Paul Sokolovsky 4 | # 5 | # The server accepts passive mode only. It runs in background. 6 | # Start the server with: 7 | # 8 | # import uftpd 9 | # uftpd.start([port = 21][, verbose = level]) 10 | # 11 | # port is the port number (default 21) 12 | # verbose controls the level of printed activity messages, values 0, 1, 2 13 | # 14 | # Copyright (c) 2016 Christopher Popp (initial ftp server framework) 15 | # Copyright (c) 2016 Paul Sokolovsky (background execution control structure) 16 | # Copyright (c) 2016 Robert Hammelrath (putting the pieces together and a 17 | # few extensions) 18 | # Distributed under MIT License 19 | # 20 | import socket 21 | import network 22 | import uos 23 | import gc 24 | from time import sleep_ms, localtime 25 | from micropython import alloc_emergency_exception_buf 26 | 27 | # constant definitions 28 | _CHUNK_SIZE = const(1024) 29 | _SO_REGISTER_HANDLER = const(20) 30 | _COMMAND_TIMEOUT = const(300) 31 | _DATA_TIMEOUT = const(100) 32 | _DATA_PORT = const(13333) 33 | 34 | # Global variables 35 | ftpsocket = None 36 | datasocket = None 37 | client_list = [] 38 | verbose_l = 0 39 | client_busy = False 40 | # Interfaces: (IP-Address (string), IP-Address (integer), Netmask (integer)) 41 | AP_addr = ("0.0.0.0", 0, 0xffffff00) 42 | STA_addr = ("0.0.0.0", 0, 0xffffff00) 43 | 44 | _month_name = ("", "Jan", "Feb", "Mar", "Apr", "May", "Jun", 45 | "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") 46 | 47 | 48 | class FTP_client: 49 | 50 | def __init__(self, ftpsocket): 51 | global AP_addr, STA_addr 52 | self.command_client, self.remote_addr = ftpsocket.accept() 53 | self.remote_addr = self.remote_addr[0] 54 | self.command_client.settimeout(_COMMAND_TIMEOUT) 55 | log_msg(1, "FTP Command connection from:", self.remote_addr) 56 | self.command_client.setsockopt(socket.SOL_SOCKET, 57 | _SO_REGISTER_HANDLER, 58 | self.exec_ftp_command) 59 | self.command_client.sendall("220 Hello, this is the ESP8266.\r\n") 60 | self.cwd = '/' 61 | self.fromname = None 62 | # self.logged_in = False 63 | self.act_data_addr = self.remote_addr 64 | self.DATA_PORT = 20 65 | self.active = True 66 | # check which interface was used by comparing the caller's ip 67 | # adress with the ip adresses of STA and AP; consider netmask; 68 | # select IP address for passive mode 69 | if ((AP_addr[1] & AP_addr[2]) == 70 | (num_ip(self.remote_addr) & AP_addr[2])): 71 | self.pasv_data_addr = AP_addr[0] 72 | elif ((STA_addr[1] & STA_addr[2]) == 73 | (num_ip(self.remote_addr) & STA_addr[2])): 74 | self.pasv_data_addr = STA_addr[0] 75 | else: 76 | self.pasv_data_addr = "0.0.0.0" # Ivalid value 77 | 78 | def send_list_data(self, path, data_client, full): 79 | try: 80 | for fname in uos.listdir(path): 81 | data_client.sendall(self.make_description(path, fname, full)) 82 | except: # path may be a file name or pattern 83 | path, pattern = self.split_path(path) 84 | try: 85 | for fname in uos.listdir(path): 86 | if self.fncmp(fname, pattern): 87 | data_client.sendall( 88 | self.make_description(path, fname, full)) 89 | except: 90 | pass 91 | 92 | def make_description(self, path, fname, full): 93 | global _month_name 94 | if full: 95 | stat = uos.stat(self.get_absolute_path(path, fname)) 96 | file_permissions = ("drwxr-xr-x" 97 | if (stat[0] & 0o170000 == 0o040000) 98 | else "-rw-r--r--") 99 | file_size = stat[6] 100 | tm = localtime(stat[7]) 101 | if tm[0] != localtime()[0]: 102 | description = "{} 1 owner group {:>10} {} {:2} {:>5} {}\r\n".\ 103 | format(file_permissions, file_size, 104 | _month_name[tm[1]], tm[2], tm[0], fname) 105 | else: 106 | description = "{} 1 owner group {:>10} {} {:2} {:02}:{:02} {}\r\n".\ 107 | format(file_permissions, file_size, 108 | _month_name[tm[1]], tm[2], tm[3], tm[4], fname) 109 | else: 110 | description = fname + "\r\n" 111 | return description 112 | 113 | def send_file_data(self, path, data_client): 114 | with open(path, "r") as file: 115 | chunk = file.read(_CHUNK_SIZE) 116 | while len(chunk) > 0: 117 | data_client.sendall(chunk) 118 | chunk = file.read(_CHUNK_SIZE) 119 | data_client.close() 120 | 121 | def save_file_data(self, path, data_client, mode): 122 | with open(path, mode) as file: 123 | chunk = data_client.recv(_CHUNK_SIZE) 124 | while len(chunk) > 0: 125 | file.write(chunk) 126 | chunk = data_client.recv(_CHUNK_SIZE) 127 | data_client.close() 128 | 129 | def get_absolute_path(self, cwd, payload): 130 | # Just a few special cases "..", "." and "" 131 | # If payload start's with /, set cwd to / 132 | # and consider the remainder a relative path 133 | if payload.startswith('/'): 134 | cwd = "/" 135 | for token in payload.split("/"): 136 | if token == '..': 137 | cwd = self.split_path(cwd)[0] 138 | elif token != '.' and token != '': 139 | if cwd == '/': 140 | cwd += token 141 | else: 142 | cwd = cwd + '/' + token 143 | return cwd 144 | 145 | def split_path(self, path): # instead of path.rpartition('/') 146 | tail = path.split('/')[-1] 147 | head = path[:-(len(tail) + 1)] 148 | return ('/' if head == '' else head, tail) 149 | 150 | # compare fname against pattern. Pattern may contain 151 | # the wildcards ? and *. 152 | def fncmp(self, fname, pattern): 153 | pi = 0 154 | si = 0 155 | while pi < len(pattern) and si < len(fname): 156 | if (fname[si] == pattern[pi]) or (pattern[pi] == '?'): 157 | si += 1 158 | pi += 1 159 | else: 160 | if pattern[pi] == '*': # recurse 161 | if pi == len(pattern.rstrip("*?")): # only wildcards left 162 | return True 163 | while si < len(fname): 164 | if self.fncmp(fname[si:], pattern[pi + 1:]): 165 | return True 166 | else: 167 | si += 1 168 | return False 169 | else: 170 | return False 171 | if pi == len(pattern.rstrip("*")) and si == len(fname): 172 | return True 173 | else: 174 | return False 175 | 176 | def open_dataclient(self): 177 | if self.active: # active mode 178 | data_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 179 | data_client.settimeout(_DATA_TIMEOUT) 180 | data_client.connect((self.act_data_addr, self.DATA_PORT)) 181 | log_msg(1, "FTP Data connection with:", self.act_data_addr) 182 | else: # passive mode 183 | data_client, data_addr = datasocket.accept() 184 | log_msg(1, "FTP Data connection with:", data_addr[0]) 185 | return data_client 186 | 187 | def exec_ftp_command(self, cl): 188 | global datasocket 189 | global client_busy 190 | global my_ip_addr 191 | 192 | try: 193 | gc.collect() 194 | 195 | data = cl.readline().decode("utf-8").rstrip("\r\n") 196 | 197 | if len(data) <= 0: 198 | # No data, close 199 | # This part is NOT CLEAN; there is still a chance that a 200 | # closing data connection will be signalled as closing 201 | # command connection 202 | log_msg(1, "*** No data, assume QUIT") 203 | close_client(cl) 204 | return 205 | 206 | if client_busy: # check if another client is busy 207 | cl.sendall("400 Device busy.\r\n") # tell so the remote client 208 | return # and quit 209 | client_busy = True # now it's my turn 210 | 211 | # check for log-in state may done here, like 212 | # if self.logged_in == False and not command in\ 213 | # ("USER", "PASS", "QUIT"): 214 | # cl.sendall("530 Not logged in.\r\n") 215 | # return 216 | 217 | command = data.split()[0].upper() 218 | payload = data[len(command):].lstrip() # partition is missing 219 | path = self.get_absolute_path(self.cwd, payload) 220 | log_msg(1, "Command={}, Payload={}".format(command, payload)) 221 | 222 | if command == "USER": 223 | # self.logged_in = True 224 | cl.sendall("230 Logged in.\r\n") 225 | # If you want to see a password,return 226 | # "331 Need password.\r\n" instead 227 | # If you want to reject an user, return 228 | # "530 Not logged in.\r\n" 229 | elif command == "PASS": 230 | # you may check here for a valid password and return 231 | # "530 Not logged in.\r\n" in case it's wrong 232 | # self.logged_in = True 233 | cl.sendall("230 Logged in.\r\n") 234 | elif command == "SYST": 235 | cl.sendall("215 UNIX Type: L8\r\n") 236 | elif command in ("TYPE", "NOOP", "ABOR"): # just accept & ignore 237 | cl.sendall('200 OK\r\n') 238 | elif command == "QUIT": 239 | cl.sendall('221 Bye.\r\n') 240 | close_client(cl) 241 | elif command == "PWD" or command == "XPWD": 242 | cl.sendall('257 "{}"\r\n'.format(self.cwd)) 243 | elif command == "CWD" or command == "XCWD": 244 | try: 245 | if (uos.stat(path)[0] & 0o170000) == 0o040000: 246 | self.cwd = path 247 | cl.sendall('250 OK\r\n') 248 | else: 249 | cl.sendall('550 Fail\r\n') 250 | except: 251 | cl.sendall('550 Fail\r\n') 252 | elif command == "PASV": 253 | cl.sendall('227 Entering Passive Mode ({},{},{}).\r\n'.format( 254 | self.pasv_data_addr.replace('.', ','), 255 | _DATA_PORT >> 8, _DATA_PORT % 256)) 256 | self.active = False 257 | elif command == "PORT": 258 | items = payload.split(",") 259 | if len(items) >= 6: 260 | self.act_data_addr = '.'.join(items[:4]) 261 | if self.act_data_addr == "127.0.1.1": 262 | # replace by command session addr 263 | self.act_data_addr = self.remote_addr 264 | self.DATA_PORT = int(items[4]) * 256 + int(items[5]) 265 | cl.sendall('200 OK\r\n') 266 | self.active = True 267 | else: 268 | cl.sendall('504 Fail\r\n') 269 | elif command == "LIST" or command == "NLST": 270 | if payload.startswith("-"): 271 | option = payload.split()[0].lower() 272 | path = self.get_absolute_path( 273 | self.cwd, payload[len(option):].lstrip()) 274 | else: 275 | option = "" 276 | try: 277 | data_client = self.open_dataclient() 278 | cl.sendall("150 Directory listing:\r\n") 279 | self.send_list_data(path, data_client, 280 | command == "LIST" or 'l' in option) 281 | cl.sendall("226 Done.\r\n") 282 | data_client.close() 283 | except: 284 | cl.sendall('550 Fail\r\n') 285 | if data_client is not None: 286 | data_client.close() 287 | elif command == "RETR": 288 | try: 289 | data_client = self.open_dataclient() 290 | cl.sendall("150 Opened data connection.\r\n") 291 | self.send_file_data(path, data_client) 292 | # if the next statement is reached, 293 | # the data_client was closed. 294 | data_client = None 295 | cl.sendall("226 Done.\r\n") 296 | except: 297 | cl.sendall('550 Fail\r\n') 298 | if data_client is not None: 299 | data_client.close() 300 | elif command == "STOR" or command == "APPE": 301 | try: 302 | data_client = self.open_dataclient() 303 | cl.sendall("150 Opened data connection.\r\n") 304 | self.save_file_data(path, data_client, 305 | "w" if command == "STOR" else "a") 306 | # if the next statement is reached, 307 | # the data_client was closed. 308 | data_client = None 309 | cl.sendall("226 Done.\r\n") 310 | except: 311 | cl.sendall('550 Fail\r\n') 312 | if data_client is not None: 313 | data_client.close() 314 | elif command == "SIZE": 315 | try: 316 | cl.sendall('213 {}\r\n'.format(uos.stat(path)[6])) 317 | except: 318 | cl.sendall('550 Fail\r\n') 319 | elif command == "STAT": 320 | if payload == "": 321 | cl.sendall("211-Connected to ({})\r\n" 322 | " Data address ({})\r\n" 323 | " TYPE: Binary STRU: File MODE: Stream\r\n" 324 | " Session timeout {}\r\n" 325 | "211 Client count is {}\r\n".format( 326 | self.remote_addr, self.pasv_data_addr, 327 | _COMMAND_TIMEOUT, len(client_list))) 328 | else: 329 | cl.sendall("213-Directory listing:\r\n") 330 | self.send_list_data(path, cl, True) 331 | cl.sendall("213 Done.\r\n") 332 | elif command == "DELE": 333 | try: 334 | uos.remove(path) 335 | cl.sendall('250 OK\r\n') 336 | except: 337 | cl.sendall('550 Fail\r\n') 338 | elif command == "RNFR": 339 | try: 340 | # just test if the name exists, exception if not 341 | uos.stat(path) 342 | self.fromname = path 343 | cl.sendall("350 Rename from\r\n") 344 | except: 345 | cl.sendall('550 Fail\r\n') 346 | elif command == "RNTO": 347 | try: 348 | uos.rename(self.fromname, path) 349 | cl.sendall('250 OK\r\n') 350 | except: 351 | cl.sendall('550 Fail\r\n') 352 | self.fromname = None 353 | elif command == "CDUP" or command == "XCUP": 354 | self.cwd = self.get_absolute_path(self.cwd, "..") 355 | cl.sendall('250 OK\r\n') 356 | elif command == "RMD" or command == "XRMD": 357 | try: 358 | uos.rmdir(path) 359 | cl.sendall('250 OK\r\n') 360 | except: 361 | cl.sendall('550 Fail\r\n') 362 | elif command == "MKD" or command == "XMKD": 363 | try: 364 | uos.mkdir(path) 365 | cl.sendall('250 OK\r\n') 366 | except: 367 | cl.sendall('550 Fail\r\n') 368 | else: 369 | cl.sendall("502 Unsupported command.\r\n") 370 | # log_msg(2, 371 | # "Unsupported command {} with payload {}".format(command, 372 | # payload)) 373 | # handle unexpected errors 374 | except Exception as err: 375 | log_msg(1, "Exception in exec_ftp_command: {}".format(err)) 376 | # tidy up before leaving 377 | client_busy = False 378 | 379 | 380 | def log_msg(level, *args): 381 | global verbose_l 382 | if verbose_l >= level: 383 | print(*args) 384 | 385 | 386 | # close client and remove it from the list 387 | def close_client(cl): 388 | cl.setsockopt(socket.SOL_SOCKET, _SO_REGISTER_HANDLER, None) 389 | cl.close() 390 | for i, client in enumerate(client_list): 391 | if client.command_client == cl: 392 | del client_list[i] 393 | break 394 | 395 | 396 | def accept_ftp_connect(ftpsocket): 397 | # Accept new calls for the server 398 | try: 399 | client_list.append(FTP_client(ftpsocket)) 400 | except: 401 | log_msg(1, "Attempt to connect failed") 402 | # try at least to reject 403 | try: 404 | temp_client, temp_addr = ftpsocket.accept() 405 | temp_client.close() 406 | except: 407 | pass 408 | 409 | 410 | def num_ip(ip): 411 | items = ip.split(".") 412 | return (int(items[0]) << 24 | int(items[1]) << 16 | 413 | int(items[2]) << 8 | int(items[3])) 414 | 415 | 416 | def stop(): 417 | global ftpsocket, datasocket 418 | global client_list 419 | global client_busy 420 | 421 | for client in client_list: 422 | client.command_client.setsockopt(socket.SOL_SOCKET, 423 | _SO_REGISTER_HANDLER, None) 424 | client.command_client.close() 425 | del client_list 426 | client_list = [] 427 | client_busy = False 428 | if ftpsocket is not None: 429 | ftpsocket.setsockopt(socket.SOL_SOCKET, _SO_REGISTER_HANDLER, None) 430 | ftpsocket.close() 431 | if datasocket is not None: 432 | datasocket.close() 433 | 434 | 435 | # start listening for ftp connections on port 21 436 | def start(port=21, verbose=0, splash=True): 437 | global ftpsocket, datasocket 438 | global verbose_l 439 | global client_list 440 | global client_busy 441 | global AP_addr, STA_addr 442 | 443 | alloc_emergency_exception_buf(100) 444 | verbose_l = verbose 445 | client_list = [] 446 | client_busy = False 447 | 448 | ftpsocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 449 | datasocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 450 | 451 | ftpsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 452 | datasocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 453 | 454 | ftpsocket.bind(('0.0.0.0', port)) 455 | datasocket.bind(('0.0.0.0', _DATA_PORT)) 456 | 457 | ftpsocket.listen(0) 458 | datasocket.listen(0) 459 | 460 | datasocket.settimeout(10) 461 | ftpsocket.setsockopt(socket.SOL_SOCKET, 462 | _SO_REGISTER_HANDLER, accept_ftp_connect) 463 | 464 | wlan = network.WLAN(network.AP_IF) 465 | if wlan.active(): 466 | ifconfig = wlan.ifconfig() 467 | # save IP address string and numerical values of IP adress and netmask 468 | AP_addr = (ifconfig[0], num_ip(ifconfig[0]), num_ip(ifconfig[1])) 469 | if splash: 470 | print("FTP server started on {}:{}".format(ifconfig[0], port)) 471 | wlan = network.WLAN(network.STA_IF) 472 | if wlan.active(): 473 | ifconfig = wlan.ifconfig() 474 | # save IP address string and numerical values of IP adress and netmask 475 | STA_addr = (ifconfig[0], num_ip(ifconfig[0]), num_ip(ifconfig[1])) 476 | if splash: 477 | print("FTP server started on {}:{}".format(ifconfig[0], port)) 478 | 479 | 480 | def restart(port=21, verbose=0, splash=True): 481 | stop() 482 | sleep_ms(200) 483 | start(port, verbose, splash) 484 | 485 | 486 | start(splash=True) 487 | -------------------------------------------------------------------------------- /pic/internal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dukeduck1984/uReflowOven-Esp32-Micropython/7d70962d888e31739df076ab7a12c2ff2bd16b0a/pic/internal.jpg -------------------------------------------------------------------------------- /pic/overview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dukeduck1984/uReflowOven-Esp32-Micropython/7d70962d888e31739df076ab7a12c2ff2bd16b0a/pic/overview.jpg -------------------------------------------------------------------------------- /pic/pid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dukeduck1984/uReflowOven-Esp32-Micropython/7d70962d888e31739df076ab7a12c2ff2bd16b0a/pic/pid.jpg -------------------------------------------------------------------------------- /pic/screen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dukeduck1984/uReflowOven-Esp32-Micropython/7d70962d888e31739df076ab7a12c2ff2bd16b0a/pic/screen.jpg -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## μReflow Oven with MicroPython & LittlevGL 2 | 3 | [中文版请见于此](./readme_zh.md)。 4 | 5 | *WARNING: when updating from a previous version, make sure to get the latest config.json and carefully verify that 6 | it reflects your system configuration. First and foremost make sure that the heater is configured to the right polarity.* 7 | 8 | Updated! Now the μReflow Oven is PID control enabled! 9 | 10 | ![](./pic/pid.jpg) 11 | 12 | For previous version which is non-PID controlled, pls see the branch ```Adafruit-EZ-Make-Oven-alike```. 13 | 14 | This project is an improved and heavily modified version of [Adafruit EZ Make Oven](https://learn.adafruit.com/ez-make-oven?view=all). 15 | The original code of EZ Make Oven can be found [here](https://github.com/adafruit/Adafruit_Learning_System_Guides/tree/master/PyPortal_EZ_Make_Oven). 16 | 17 | ![](./pic/overview.jpg) 18 | 19 | 20 | The purpose is to make a reflow soldering oven by modifying a kitchen oven with more affordable and widely available hardwares. 21 | Instead of an oven, a heating plate can also be used. 22 | 23 | ![](./pic/internal.jpg) 24 | 25 | The GUI of this project is built with LittlevGL ([lv_binding_micropython][lv]) which is a very powerful and easy to use GUI library. 26 | LittlevGL already comes with drivers for ILI9341 TFT display and XPT2046 touch controller, this project takes the advantage 27 | of both to ease the user operation. 28 | 29 | ![](./pic/screen.jpg) 30 | 31 | ### Bill of Materials 32 | * 1 x regular kitchen oven with 10-12L capacity, like [this][oven], OR a heater plate, like [this][plate]. 33 | * 1 x solid state relay rated at least 10A. Like [this][ssr] (the heater plate linked above already includes it). 34 | * 1 x passive piezo buzzer. Like [this][buzzer]. 35 | * 1 x ILI9341 TFT display with on-board XPT2046 touch controller. Like [this][tft]. 36 | * 1 x Thermocouple amplifier with K-thermocouple. So far, MAX31855 and MAX6675 are supported. [MAX31855][max31855] [MAX6675][max6675] 37 | * 1 x AC-DC5v power supply to power the ESP32 dev board. Like [this][acdc]. 38 | * 1 x ESP32 dev board. Like [this][esp32]. 39 | 40 | ### Oven Modification and Wiring 41 | * WARNING: The mains (220/110V) can be deadly. Make sure the oven is unplugged from the outlet before doing any modification 42 | or wiring. 43 | * Ovens are different one from another, but basically all you need to do is to bypass the original switch and timer, and 44 | let the solid state relay control the heating element, hence the ESP32 board can turn the heating element 45 | on and off via the solid state relay. 46 | 47 | ### The Firmware for ESP32 48 | * Pls refer to [here](./FIRMWARE/readme.md). 49 | 50 | ======= 51 | ### Configuration 52 | * Configuration is done by editing the ```config.json``` file. 53 | * Hardware wiring: edit the value of the key names ending with '_pins' to match your actual wiring. 54 | * The TFT screen and the touch controller share the same ```Clock```, ```Data In``` & ```Data Out``` pins. 55 | * The ACC pin is for switching power of the TFT screen. This pin is optional. If your display has an 56 | input to switch power (often labeled ACC), you may connect the designated GPIO directly to this pin. 57 | Alternatively, you could use a transistor to switch the power supply of the screen. 58 | It has been reported that the screen can even be powered directly from a GPIO pin, however note that 59 | an ESP32 GPIO pin can drive at most 50mA (according to specification) and a typical 2.8" screen will 60 | draw between 80mA and 250mA, so this method risks damaging your ESP32. 61 | Since this pin is optional, you may safely ignore it (simply wire the 3V3 pin of the screen to the 62 | 3V3 output of the ESP32). 63 | * The ```active_low``` properties can be used to make a pin active low. 64 | * ```sampling_hz``` determines the update rate of the temp sensor and the PID controller. The default setting ```5``` 65 | means 5HZ which is 5 times per second. 66 | * ```temp_offset``` & ```pid``` parameters can be set in the settings of the GUI. 67 | * ```advanced_temp_tuning``` can only be changed by editing the ```config.json```. 68 | * ```preheat_until``` (temperature in Celsius) is used to set a temperature below which the oven will always be on - it helps to 69 | heat up the oven as quickly as possible at the early stage. 70 | * ```previsioning``` (time in Second) is for the PID to look for the set temp X seconds ahead, as the reflow 71 | temperature profile is not constant but a changing curve, this parameter will make the PID more reactive. 72 | * ```overshoot_comp``` (temperature in Celsius) it helps reduce the overshoot. 73 | 74 | ### FTP access 75 | * The above mentioned ```advanced_temp_tuning``` may need some trial and error. To make the fine tuning 76 | process a bit easier, the ESP32 will create a WiFi access point named ```Reflower ftp://192.168.4.1``` 77 | * Simply connect to that SSID and you can edit the ```config.json``` by logging in 192.168.4.1:21 78 | via an FTP client, e.g. ```FileZiila```. 79 | 80 | ### Installation 81 | * All files are under ```MAIN``` folder. 82 | * After flashing the firmware, you need to edit ```config.json``` to change the GPIO pin numbers according 83 | to how you wiring your TFT display and other components. 84 | * Set sensor_type to either MAX31855 or MAX6675. 85 | * Some solid state relays will not switch on with the little current supplied by an ESP32 GPIO pin. 86 | In this case you have to use a transistor between the GPIO pin and the SSR. You may need to configure 87 | the pin as active low then. 88 | * Make sure you have configured the right polarity for all pins. 89 | * Transfer all the files and folder under ```MAIN``` to the ESP32 dev board and you are good to go. 90 | 91 | ### Usage Guide 92 | * Upon powering on the first time, you will be guided through touch screen calibration, once finished, the ESP32 93 | will reboot. 94 | * After calibration and reboot, the GUI will load, where you can select Solder Paste type from the 95 | drop-down menu, just choose the type you'll use, and the reflow temperature profile will show down below. 96 | * If your solder paste isn't there in the menu, you can build your own solder profile files. Pls refer to: 97 | https://learn.adafruit.com/ez-make-oven?view=all#the-toaster-oven, under chapter "Solder Paste Profiles". 98 | The new solder profile json file should be put under folder ```profiles```. 99 | * All set and click "Start" button to start the reflow soldering procress. 100 | * If you wish to re-calibrate the touch screen, click the 'Settings' button 101 | on the screen, and choose from the popup window. And follow the on-screen instruction. 102 | 103 | ### PID tuning tips 104 | * Firstly, set ```previsioning``` & ```overshoot_comp``` to ```0``` in ```config.json``` to avoid confusing behavior. 105 | * Set ```kp``` to a small value, e.g. ```0.1```, and ```kd``` to a large value, e.g. ```300```. This helps to minimize 106 | overshooting during the early stage which is typically seen in 'preheat' and 'soak' stage. Keep decreasing/increasing 107 | ```kp```/```kd``` value until minimum overshooting is observed. 108 | * With a small ```kp``` & a large ```kd```, it's very hard for the actual temp to reach the peak temp of the ideal reflow 109 | profile, this is when you need to tune the value of ```ki```. Slowly increase ```ki``` until the actual peak temp gets 110 | really close to the ideal profile. 111 | * Pls note that the integration part (where ki takes effects) of the PID algorithm is only enabled when it reaches 112 | 'reflow' stage - this is hard coded and cannot be changed by settings. The intention is to prevent overshooting in the 113 | early stage while it still can reach the peak temp of the ideal profile. 114 | 115 | 116 | [lv]:https://github.com/littlevgl/lv_binding_micropython 117 | [oven]:https://www.aliexpress.com/item/4000151934943.html 118 | [plate]:https://www.aliexpress.com/item/32946772052.html 119 | [ssr]:https://www.aliexpress.com/item/4000083560440.html 120 | [buzzer]:https://www.aliexpress.com/item/32808743801.html 121 | [tft]:https://www.aliexpress.com/item/32960934541.html 122 | [max31855]:https://www.aliexpress.com/item/32878757344.html 123 | [max6675]:https://www.aliexpress.com/item/4000465204314.html 124 | [acdc]:https://www.aliexpress.com/item/32821770958.html 125 | [esp32]:https://www.aliexpress.com/item/32855652152.html 126 | -------------------------------------------------------------------------------- /readme_zh.md: -------------------------------------------------------------------------------- 1 | ## 迷你回流焊炉 (μReflow Oven with MicroPython & LittlevGL) 2 | 3 | See [here](./readme.md) for English version. 4 | 5 | 更新了!现在迷你回流焊炉使用PID来进行温度控制 6 | 7 | *重要提示:如果你想从非PID温控的老版本升级至最新版,请确保下载了最新版的`config.json`,并仔细确认其中的设置与你的实际硬件设置相符。 8 | 最为重要的是,确保包括加热器在内的所有接线均正确。* 9 | 10 | ![](./pic/pid.jpg) 11 | 12 | 老版本(非PID温控)存放于branch ```Adafruit-EZ-Make-Oven-alike```。 13 | 14 | 本项目是在[Adafruit EZ Make Oven](https://learn.adafruit.com/ez-make-oven?view=all)的基础上改良和重度重写而来, 15 | EZ Make Oven的源代码[在此](https://github.com/adafruit/Adafruit_Learning_System_Guides/tree/master/PyPortal_EZ_Make_Oven)。 16 | 17 | ![](./pic/overview.jpg) 18 | 19 | 本项目的目的是利用一些常见且廉价的硬件对一个普通的家用小烤箱进行改装,最终得到一个实用的回流焊炉,从而方便DIY电子制作。 20 | 除了烤箱之外,也可以使用电炉。 21 | 22 | ![](./pic/internal.jpg) 23 | 24 | 本项目的用户界面通过LittlevGL ([lv_binding_micropython][lv]) 制作,这是一个功能强大又简单易用的图形界面库。 25 | LittlevGL自带了ILI9341 TFT屏幕和XPT2046触控驱动,本项目正是利用了带触控的ILI9341屏来方便用户的使用。 26 | 27 | ![](./pic/screen.jpg) 28 | 29 | ### 物料清单 30 | * 1 x 容积10-12升的家用小烤箱,例如[这个][oven]。或者电热炉,例如[这个][plate] 31 | * 1 x 10安倍的固态继电器,例如[这个][ssr];电热炉可能已经自带了继电器,请自行确认。 32 | * 1 x 无源压电蜂鸣器,例如[这个][buzzer]; 33 | * 1 x 带XPT2046触控芯片的ILI9341 TFT显示屏,例如[这个][tft]; 34 | * 1 x K型热电偶控制板,以及K型热电偶,例如[MAX31855][max31855],或者[MAX6675][max6675]; 35 | * 1 x 交流转直流5v的电源,用来给ESP32供电, 例如[这个][acdc]; 36 | * 1 x ESP32开发板,例如[这个][esp32]。 37 | 38 | ### 烤箱改装及接线 39 | * 警告: 220V市电若操作不当可能会造成人身伤害,甚至可能致命,确保在改装前把烤箱插头拔掉,切勿带电操作。 40 | * 不同型号的烤箱,其内部结构可能不同,但总体原理大同小异:你需要绕开原烤箱的温控及定时器,让固态继电器来 41 | 控制加热管,这样ESP32就可以通过通断固态继电器来控制烤箱的加热。 42 | 43 | ### 给ESP32刷入固件 44 | * 请参考[这篇](./FIRMWARE/readme.md)。 45 | 46 | ### 配置文件 47 | * 通过编辑 `config.json` 文件来进行配置。 48 | * 硬件接线: 修改以`_pins`结尾的键值,使其与你实际的接线相符。 49 | * TFT屏幕与触摸控制器共享 `Clock`, `Data In` 及 `Data Out` 接口,并联即可。 50 | * 配置文件中的ACC pin是用来给TFT屏幕供电的。此为可选项。如果你的显示屏有电源出发控制接口(通常标识为ACC),你可以用相应的GPIO 51 | 进行连接。你也可以用三极管来控制屏幕的供电。个别型号的TFT屏幕也可以直接由ESP32的GPIO口供电,但需要注意的是ESP32 GPIO最大电流 52 | 为50mA,通常2.8寸屏所需电流为80-250mA,因此直接供电(从ESP32的3.3V引脚直接连到屏幕的电源引脚)可能会损毁ESP32,请知晓该风险。 53 | * `active_low`选项用于低电平触发的用电器。 54 | * `sampling_hz` 决定了温度传感器和PID算法的刷新率,及每秒刷新几次。默认值为`5`,即5Hz每秒5次。 55 | * `temp_offset` 和 `pid` 的参数可以在图形界面中进行设置修改。 56 | * `advanced_temp_tuning` 只能通过编辑 `config.json` 进行修改。 57 | * `preheat_until` (摄氏度) 用于设置一个温度,在炉子达到该温度前,炉子的加热会一直开启,并忽略PID温控。这有助于在 58 | 刚开始时炉子快速升温。 59 | * `provisioning` (秒) 用于PID算法预知将要到达的温度:由于回流焊的温度不是恒温,而是一个动态变化的温度曲线,设置这么 60 | 一个参数有助于提高PID反应。 61 | * `overshoot_comp` (摄氏度) 用于降低温度过冲。 62 | 63 | ### FTP连接 64 | * 上述`advanced_temp_tuning`选项找到合理的设置参数需要进行多次尝试,为了方便这个调试过程,ESP32会生成一个名为 65 | `Reflower ftp://192.168.4.1`的WiFi热点。 66 | * 接入上述WiFi热点,并使用任意FTP客户端,如`FileZiila`,登录至`ftp://192.168.4.1:21`便可对`config.json`进行修改。 67 | 68 | ### 安装程序 69 | * 所需的文件均在 `MAIN` 目录下。 70 | * 在刷完固件后,你需要先对`config.json`进行编辑,确保各个GPIO端口号与你实际接线相符。 71 | * 根据实际使用的K型热电偶模块类型,设置`sensor_type`为`MAX31855`或`MAX6675`。 72 | * 有些型号的固态继电器不能被ESP32 GPIO引脚的电压所触发,这种情况你可能需要通过一个三极管和另一个电源来进行触发。请根据实际情况 73 | 设置`active_low`选项。 74 | * 再次检查确认接线和设置均正确无误。 75 | * 将`MAIN`目录下所有文件及文件夹上传至ESP32开发板中。 76 | 77 | ### 使用说明 78 | * 首次通电,程序会引导你进行屏幕校准,按照屏幕提示操作即可,结束后ESP32开发板 79 | 会重启。 80 | * 在校准并重启后,用户界面会加载,通过下拉菜单,你可以选择不同的焊锡膏,确保你选择的焊锡膏类型与你实际 81 | 使用的型号相符。在选择焊锡膏后,屏幕下方会显示该焊锡膏的工作温度及整个回流焊的温度变化曲线。 82 | * 如果你要使用的焊锡膏类型不在下拉菜单里,你也可以创建自己的焊锡膏类型文件,具体请参考: 83 | https://learn.adafruit.com/ez-make-oven?view=all#the-toaster-oven,步骤在"Solder Paste Profiles"章节下。 84 | 新创建的焊锡膏文件需上传至ESP32中的`profiles`目录内。 85 | * 全部准备就绪后,点击"Start"按钮就可以开始回流焊流程。 86 | * 如果你想要再次校准屏幕,可以点击屏幕上的"Settings"按钮,然后在弹窗中选择屏幕校准选项。 87 | 88 | ### 关于PID参数设置的提示 89 | * 首先在`config.json`中将`previsioning`和`overshoot_comp`均设置为`0`,以避免奇怪的温控行为。 90 | * 将参数`kp`设置为一个很小的数值,比如`0.1`,将参数`kd`设置为一个很大的数值,比如`300`,这样有助于在加热初期最小化 91 | 温度过冲现象(多见于‘preheat’和‘soak’阶段)。通过实际加热测试,不停调低`kp`调高`kd`的数值,直到温度过冲现象基本消失。 92 | * 由于`kp`很小而`kd`很大,在‘reflow’阶段温度可能很难达到理想的最高温度,这时就需要开始调试`ki`参数。缓慢增大`ki`的数值, 93 | 直到实际最高温度可以达到或非常接近理想的最高温度。 94 | * 请知晓:PID算法的积分部分(`ki`参数作用的部分)只在‘reflow’阶段才会起效,这是硬编码在程序里的,无法通过设置更改。 95 | 这样做的目的是为了尽可能在加热早期阶段避免温度过冲,但仍旧可以在‘reflow’阶段达到理想的最高温度。 96 | 97 | [lv]:https://github.com/littlevgl/lv_binding_micropython 98 | [oven]:https://www.aliexpress.com/item/4000151934943.html 99 | [plate]:https://www.aliexpress.com/item/32946772052.html 100 | [ssr]:https://www.aliexpress.com/item/4000083560440.html 101 | [buzzer]:https://www.aliexpress.com/item/32808743801.html 102 | [tft]:https://www.aliexpress.com/item/32960934541.html 103 | [max31855]:https://www.aliexpress.com/item/32878757344.html 104 | [max6675]:https://www.aliexpress.com/item/4000465204314.html 105 | [acdc]:https://www.aliexpress.com/item/32821770958.html 106 | [esp32]:https://www.aliexpress.com/item/32855652152.html --------------------------------------------------------------------------------