1 背景
烘焙咖啡豆的过程中,为了更好地测量和记录温度及其变化,使用ESP32C3制作了一个温度监控模块。
2 需求
如果从烘焙咖啡豆的角度去考虑,参照商用机器的设计,这个温度监测会有很多功能要实现。作为起始的设计,还是先简化需求,逐步实现更多功能。所以第一版先确定以下需求:
- 计时。这个实现起来简单,记录的温度也需要跟时间关联。
- 读取测量出的温度值。用于展示、分析、记录等。
- 展示温度变化。显示当前温度值,和温度与时间的曲线。
3 设计
3.1 元件
主控:ESP32C3-Core,刷上MicroPython固件
- 此开发板很廉价,最低9.9 RMB包邮。
- 支持WiFi和蓝牙,数据可以很方便地同步到其它设备。
- 基于MicroPython开发程序,调试很方便。
温度检测模块:MAX6675,K型热电偶温度传感模块,SPI接口。
温度检测探头:K型铠装热电偶,探头为可弯曲、接壳式、304不锈钢材质。
- 这个探头比温度检测模块附送的灵敏很多。
- 一般型号为WRNK191。
- 参考规格:直径1mm,插深50mm,线长500mm。
显示模块:SSD1306,单色OLED屏,0.96英寸,分辨率128x64,两线I2C接口。
- 廉价。
- I2C接口的数据线只有两根,减少GPIO的占用。
3.2 接线
这里忽略电源(VCC 3.3V)和接地(GND)的连接。详细如下:
ESP32C3的接口 | 模块 | 接口 |
---|
GPIO10 | MAX6675 | SO |
GPIO02 | MAX6675 | SCK |
GPIO12 | MAX6675 | CS |
GPIO05 | SSD1306 | SCL |
GPIO04 | SSD1306 | SDA |
接线不是固定的,可以根据实际调整,但是要改main.py
的对应配置。
4 程序
相关代码文件:
- max6675.py:MAX6675的驱动程序。
- ssd1306.py:SSD1306的驱动程序。
- series_list.py:温度数据类,方便后面扩展对温度数据的保存。
- main.py:主程序。
4.1 MAX6675的驱动程序
文件名max6675.py
。
# from https://github.com/BetaRavener/micropython-hw-lib/blob/master/MAX6675/max6675.py
import time
class MAX6675:
MEASUREMENT_PERIOD_MS = 220
def __init__(self, sck, cs, so):
"""
Creates new object for controlling MAX6675
:param sck: SCK (clock) pin, must be configured as Pin.OUT
:param cs: CS (select) pin, must be configured as Pin.OUT
:param so: SO (data) pin, must be configured as Pin.IN
"""
# Thermocouple
self._sck = sck
self._sck.off()
self._cs = cs
self._cs.on()
self._so = so
self._so.off()
self._last_measurement_start = 0
self._last_read_temp = 0
self._error = 0
def _cycle_sck(self):
self._sck.on()
time.sleep_us(1)
self._sck.off()
time.sleep_us(1)
def refresh(self):
"""
Start a new measurement.
"""
self._cs.off()
time.sleep_us(10)
self._cs.on()
self._last_measurement_start = time.ticks_ms()
def ready(self):
"""
Signals if measurement is finished.
:return: True if measurement is ready for reading.
"""
return time.ticks_ms() - self._last_measurement_start > MAX6675.MEASUREMENT_PERIOD_MS
def error(self):
"""
Returns error bit of last reading. If this bit is set (=1), there's problem with the
thermocouple - it can be damaged or loosely connected
:return: Error bit value
"""
return self._error
def read(self):
"""
Reads last measurement and starts a new one. If new measurement is not ready yet, returns last value.
Note: The last measurement can be quite old (e.g. since last call to `read`).
To refresh measurement, call `refresh` and wait for `ready` to become True before reading.
:return: Measured temperature
"""
# Check if new reading is available
if self.ready():
# Bring CS pin low to start protocol for reading result of
# the conversion process. Forcing the pin down outputs
# first (dummy) sign bit 15.
self._cs.off()
time.sleep_us(10)
# Read temperature bits 14-3 from MAX6675.
value = 0
for i in range(12):
# SCK should resemble clock signal and new SO value
# is presented at falling edge
self._cycle_sck()
value += self._so.value() << (11 - i)
# Read the TC Input pin to check if the input is open
self._cycle_sck()
self._error = self._so.value()
# Read the last two bits to complete protocol
for i in range(2):
self._cycle_sck()
# Finish protocol and start new measurement
self._cs.on()
self._last_measurement_start = time.ticks_ms()
self._last_read_temp = value * 0.25
return self._last_read_temp
4.2 SSD1306的驱动程序
文件名ssd1306.py
。
这个驱动继承了FrameBuffer
类,显示文字或绘图的方法,参考FrameBuffer
类的文档即可(代码有说明)。
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces
# from https://github.com/micropython/micropython-lib/blob/master/micropython/drivers/display/ssd1306/ssd1306.py
from micropython import const
import framebuf
# register definitions
SET_CONTRAST = const(0x81)
SET_ENTIRE_ON = const(0xA4)
SET_NORM_INV = const(0xA6)
SET_DISP = const(0xAE)
SET_MEM_ADDR = const(0x20)
SET_COL_ADDR = const(0x21)
SET_PAGE_ADDR = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP = const(0xA0)
SET_MUX_RATIO = const(0xA8)
SET_IREF_SELECT = const(0xAD)
SET_COM_OUT_DIR = const(0xC0)
SET_DISP_OFFSET = const(0xD3)
SET_COM_PIN_CFG = const(0xDA)
SET_DISP_CLK_DIV = const(0xD5)
SET_PRECHARGE = const(0xD9)
SET_VCOM_DESEL = const(0xDB)
SET_CHARGE_PUMP = const(0x8D)
# Subclassing FrameBuffer provides support for graphics primitives
# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html
class SSD1306(framebuf.FrameBuffer):
def __init__(self, width, height, external_vcc):
self.width = width
self.height = height
self.external_vcc = external_vcc
self.pages = self.height // 8
self.buffer = bytearray(self.pages * self.width)
super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
self.init_display()
def init_display(self):
for cmd in (
SET_DISP, # display off
# address setting
SET_MEM_ADDR,
0x00, # horizontal
# resolution and layout
SET_DISP_START_LINE, # start at line 0
SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0
SET_MUX_RATIO,
self.height - 1,
SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
SET_DISP_OFFSET,
0x00,
SET_COM_PIN_CFG,
0x02 if self.width > 2 * self.height else 0x12,
# timing and driving scheme
SET_DISP_CLK_DIV,
0x80,
SET_PRECHARGE,
0x22 if self.external_vcc else 0xF1,
SET_VCOM_DESEL,
0x30, # 0.83*Vcc
# display
SET_CONTRAST,
0xFF, # maximum
SET_ENTIRE_ON, # output follows RAM contents
SET_NORM_INV, # not inverted
SET_IREF_SELECT,
0x30, # enable internal IREF during display on
# charge pump
SET_CHARGE_PUMP,
0x10 if self.external_vcc else 0x14,
SET_DISP | 0x01, # display on
): # on
self.write_cmd(cmd)
self.fill(0)
self.show()
def poweroff(self):
self.write_cmd(SET_DISP)
def poweron(self):
self.write_cmd(SET_DISP | 0x01)
def contrast(self, contrast):
self.write_cmd(SET_CONTRAST)
self.write_cmd(contrast)
def invert(self, invert):
self.write_cmd(SET_NORM_INV | (invert & 1))
def rotate(self, rotate):
self.write_cmd(SET_COM_OUT_DIR | ((rotate & 1) << 3))
self.write_cmd(SET_SEG_REMAP | (rotate & 1))
def show(self):
x0 = 0
x1 = self.width - 1
if self.width != 128:
# narrow displays use centred columns
col_offset = (128 - self.width) // 2
x0 += col_offset
x1 += col_offset
self.write_cmd(SET_COL_ADDR)
self.write_cmd(x0)
self.write_cmd(x1)
self.write_cmd(SET_PAGE_ADDR)
self.write_cmd(0)
self.write_cmd(self.pages - 1)
self.write_data(self.buffer)
class SSD1306_I2C(SSD1306):
def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
self.i2c = i2c
self.addr = addr
self.temp = bytearray(2)
self.write_list = [b"\x40", None] # Co=0, D/C#=1
super().__init__(width, height, external_vcc)
def write_cmd(self, cmd):
self.temp[0] = 0x80 # Co=1, D/C#=0
self.temp[1] = cmd
self.i2c.writeto(self.addr, self.temp)
def write_data(self, buf):
self.write_list[1] = buf
self.i2c.writevto(self.addr, self.write_list)
class SSD1306_SPI(SSD1306):
def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
self.rate = 10 * 1024 * 1024
dc.init(dc.OUT, value=0)
res.init(res.OUT, value=0)
cs.init(cs.OUT, value=1)
self.spi = spi
self.dc = dc
self.res = res
self.cs = cs
import time
self.res(1)
time.sleep_ms(1)
self.res(0)
time.sleep_ms(10)
self.res(1)
super().__init__(width, height, external_vcc)
def write_cmd(self, cmd):
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
self.cs(1)
self.dc(0)
self.cs(0)
self.spi.write(bytearray([cmd]))
self.cs(1)
def write_data(self, buf):
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
self.cs(1)
self.dc(1)
self.cs(0)
self.spi.write(buf)
self.cs(1)
4.3 温度数据类
文件名series_list.py
。
想模拟成时序数据库,目前只是保存了屏幕可以显示的数据数量。
class SeriesList:
def __init__(self, maxLen: int, firstVal: float):
self._maxLen = 1 if maxLen <= 0 else maxLen
self._list = [firstVal]
self._len = len(self._list)
def append(self, val: float):
self._list.append(val)
if (self._len + 1) > self._maxLen:
delVal = self._list.pop(0)
else:
self._len += 1
def last(self, index: int = 0) -> int:
return self._list[self._len - index -1]
def histogram(self, maxRange: int) -> list:
hList = self._list.copy()
hMax = int(max(self._list))
hMin = int(min(self._list))
hRange = hMax - hMin + 1
rate = 1 if hRange <= maxRange else (maxRange / hRange)
if rate == 1:
for i, v in enumerate(hList):
hList[i] = int(v) - hMin
else:
for i, v in enumerate(hList):
hList[i] = int((int(v) - hMin) * rate)
return hList
4.4 主程序
文件名main.py
。
总结一下:
- 使用了定时任务(Timer)去探测温度和更新显示,每秒执行一次。
- 每次执行最大耗时约150毫秒,绘画曲线的代码可以再优化。
- 绘画曲线时,如果把整个区域置黑,再画曲线,性能比较高,但是会闪屏。目前是逐列置黑,再画,虽然慢了点,但观感良好。
- 展示的曲线是为了直观看到温度变化,如果显示范围内的温度差超过屏幕区域的分辨率,曲线会按比例压缩。
from micropython import const
import time
from machine import Pin, SoftI2C, Timer
from max6675 import MAX6675
from ssd1306 import SSD1306_I2C
from series_list import SeriesList
UNIT_60 = const(60)
SCREEN_W = const(128)
SCREEN_H = const(64)
HISTOGRAM_X = const(0)
HISTOGRAM_Y = const(10)
HISTOGRAM_W = const(SCREEN_W)
HISTOGRAM_H = const(SCREEN_H - HISTOGRAM_Y)
""" init MAX6675 ################################## """
print('init MAX6675')
so = Pin(10, Pin.IN) # GPIO10
sck = Pin(2, Pin.OUT) # GPIO02
cs = Pin(12, Pin.OUT) # GPIO12
max = MAX6675(sck, cs, so)
time.sleep(1)
curTemp = max.read()
""" init OLED ################################## """
print('init OLED')
i2c = SoftI2C(scl=Pin(5), sda=Pin(4))
oled = SSD1306_I2C(SCREEN_W, SCREEN_H, i2c)
""" init Ticks ################################## """
ticksStart = time.ticks_ms()
""" init Temperature list ################################## """
sList = SeriesList(SCREEN_W, curTemp)
""" init Timer ################################## """
def timerRefresh(t):
global max, curTemp, oled, timeSec, sList
duration = 0 if ticksStart==None else int(time.ticks_diff(time.ticks_ms(), ticksStart) / 1000)
dSec = duration % UNIT_60
dMin = int(duration / UNIT_60)
curTemp = max.read() # Current temperature
sList.append(curTemp)
oled.fill(0)
oled.text('{:02d}:{:02d} | {:>6.2f} C'.format(dMin, dSec, curTemp) , 0, 0)
hList = sList.histogram(HISTOGRAM_H)
hLen = len(hList)
hPre = 0
hCur = 0
startX = SCREEN_W - hLen
startY = HISTOGRAM_Y + HISTOGRAM_H - 1
for i, v in enumerate(hList):
hPre = v if i == 0 else hCur
hCur = v
oled.vline(startX + i, startY - hCur, (hCur - hPre if hPre < hCur else 1), 1)
if hPre > hCur:
oled.vline(startX + i - 1, startY - hPre, hPre - hCur, 1)
oled.show()
timerTemp = Timer(0)
timerTemp.init(period=500, mode=Timer.PERIODIC, callback=timerRefresh) # Every 1 second
print('start run')