2023寒假一起练平台(5)- 小王同学基于ESP32的恒温自动控制系统
本次选择的是项目4 - 实现一个恒温自动控制系统:
IO扩展板上有一处加温电阻,将加热区域用物体(纸巾等)包裹起来,通过电流给电阻加热,并通过温度传感器感知板上温度的变化,测温以及在LCD屏上的温度显示。
一、开发版介绍
1、ESP32-S2 WiFi模块简介
ESP32-S2 是一款高度集成、高性价比、低功耗、主打安全的单核 Wi-Fi SoC,具备强大的功能和丰富的 IO 接口。使用乐鑫ESP-IDF开发环境,我们可以通过USB对其编程,作为带wifi的MCU单独使用,也可以烧录AT固件,作为WiFi透传模块与RP2040游戏机套件结合使用。
ESP32-S2 WiFi模块是物联网、可穿戴电子设备和智能家居等应用场景的理想选择,另搭配输入控制、输出显示以及传感器感知和控制的套件,使其功能更加完善。
该模块板载了:
- ESP32-S2-MINI-1模组
- 这是一款2.4 GHz WiFi 模组
- 内置 ESP32S2 系列芯片,Xtensa® 单核 32 位 LX7 微处理器
- 内置芯片叠封 4 MB flash,可叠封 2 MB PSRAM
- 37 个 GPIO,具有丰富的外设
- 板载 PCB 天线
配套的ESP32 S2 开发板除了ESP32wifi模组之外还集成了USB TYPE -C接口,两个按键,一个电源指示灯,一个用户LED灯,2排10pin的排针,将重要IO引出。使用USB供电或通过排针3.3V供电。
2、输入、输出扩展板介绍:
本扩展板包含如下功能:
- 按键、旋转编码器输入 - 以模拟信号的方式
- 双电位计控制输入 - 以数字信号的方式
- RGB三色LED显示
- 1.44寸128*128 LCD,SPI总线访问
- MMA7660三轴姿态传感器
- 电阻加热
- 温度传感器
- 与ESP32-S2核心模块的接口
二、设计思路
正如扩展板的原理图所示的加热-->控温:首先控制MOS管导通,使得电阻流过电流产生热量,进而使电路板升温,再使用温度传感器将温度值读出,根据当前的温度值不断地进行反馈控制MOS管通断,使电路板温度保持在设定值范围。
在ESP32-S2的扩展板上,电阻最大加热功率P=U²/R = 3.3*3.3/(68//68//68//68) ≈ 0.64W
根据如上所示的设计思路,结合ESP32-S2芯片特性,绘制出大体上的软件流程:
三、开发环境准备
1.Platform IO with Visual Studio Code,用于编写代码及下载程序。
2.ComAssistant,用于串口调试
四、项目任务的代码实现
本次项目使用Arduino框架开发,Arduino上手容易,简单快捷。
综合项目的要求,首先确定项目的具体实现需求:
1.读取温度传感器
2.驱动屏幕进行显示
3.产生PWM波形驱动MOS管导通,通过电流给电阻加热
4.使用编码器/按键进行温度的设置
首先是温度传感器,型号为NST112-DSTR,这是一款超小封装的高精度低功耗温度传感器,通过I2C接口数字进行访问以获取温度。该传感器的用法也较为简单,只需配置两个寄存器即可。
#include "Arduino.h"
#include "Wire.h"//IIC库
#define NST112_ADDRESS 0x48 //温度传感器地址
#define GET_ADDR 0X00//读寄存器
#define SET_ADDR 0X01//写寄存器
#define VAL_BASE 0.0625//基准读数
#define NUM_BIT 12//数据位数
#define CFG_CMD_0 0b01100000//数据高
#define CFG_CMD_1 0b11100000//数据低//8HZ
void NST112_Init()
{
Wire.begin();//默认引脚加入
Wire.beginTransmission(NST112_ADDRESS);
Wire.write(SET_ADDR);//进入设置寄存器
Wire.write(CFG_CMD_0);//写寄存器1
Wire.write(CFG_CMD_1 | (NUM_BIT==13?(1<<4):0));//写寄存器2
Wire.endTransmission();//结束
}
float NST112_Read()
{
byte MSB;
byte LSB;
uint16_t value;
float temp;
Wire.beginTransmission(NST112_ADDRESS);
Wire.write(GET_ADDR);//进入温度寄存器
Wire.endTransmission();//结束
Wire.requestFrom(NST112_ADDRESS,2);
while(Wire.available())
{
MSB = Wire.read();
LSB = Wire.read();
value = ((MSB<<8)|LSB) >>(NUM_BIT==13?3:4);
temp = ((float)value)*VAL_BASE;
}
return temp ;
}
屏幕的驱动IC是ST7735,1.44寸128*128分辨率 ,使用SPI接口驱动屏幕进行显示,为了方便,选用了TFT-espi驱动库,还在屏幕上绘制了模拟指针的仪表盘,这样可以更为直观的看到温度的变化。
// #########################################################################
// 在屏幕上绘制模拟仪表 Draw the analogue meter on the screen
// #########################################################################
void analogMeter()
{
// Meter outline
tft.fillRect(0, 0, M_SIZE * 239, M_SIZE * 131, TFT_GREY);
// tft.fillRect(1, M_SIZE * 3, M_SIZE * 234, M_SIZE * 125, TFT_WHITE);
tft.fillRect(1, M_SIZE * 3, M_SIZE * 234, M_SIZE * 125, TFT_BLACK);
// tft.setTextColor(TFT_BLACK); // Text colour
tft.setTextColor(TFT_WHITE); // Text colour
// Draw ticks every 5 degrees from -50 to +50 degrees (100 deg. FSD swing)
for (int i = -50; i < 51; i += 5)
{
// Long scale tick length
int tl = 15;
// Coodinates of tick to draw
float sx = cos((i - 90) * 0.0174532925);
float sy = sin((i - 90) * 0.0174532925);
uint16_t x0 = sx * (M_SIZE * 100 + tl) + M_SIZE * 120;
uint16_t y0 = sy * (M_SIZE * 100 + tl) + M_SIZE * 150;
uint16_t x1 = sx * M_SIZE * 100 + M_SIZE * 120;
uint16_t y1 = sy * M_SIZE * 100 + M_SIZE * 150;
// Coordinates of next tick for zone fill
float sx2 = cos((i + 5 - 90) * 0.0174532925);
float sy2 = sin((i + 5 - 90) * 0.0174532925);
int x2 = sx2 * (M_SIZE * 100 + tl) + M_SIZE * 120;
int y2 = sy2 * (M_SIZE * 100 + tl) + M_SIZE * 150;
int x3 = sx2 * M_SIZE * 100 + M_SIZE * 120;
int y3 = sy2 * M_SIZE * 100 + M_SIZE * 150;
// Yellow zone limits
//if (i >= -50 && i < 0) {
// tft.fillTriangle(x0, y0, x1, y1, x2, y2, TFT_YELLOW);
// tft.fillTriangle(x1, y1, x2, y2, x3, y3, TFT_YELLOW);
//}
// Green zone limits
if (i >= 0 && i < 25)
{
tft.fillTriangle(x0, y0, x1, y1, x2, y2, TFT_ORANGE);
tft.fillTriangle(x1, y1, x2, y2, x3, y3, TFT_ORANGE);
}
// Orange zone limits
if (i >= 25 && i < 50)
{
tft.fillTriangle(x0, y0, x1, y1, x2, y2, TFT_RED);
tft.fillTriangle(x1, y1, x2, y2, x3, y3, TFT_RED);
}
// Short scale tick length
if (i % 25 != 0)
tl = 8;
// Recalculate coords incase tick lenght changed
x0 = sx * (M_SIZE * 100 + tl) + M_SIZE * 120;
y0 = sy * (M_SIZE * 100 + tl) + M_SIZE * 150;
x1 = sx * M_SIZE * 100 + M_SIZE * 120;
y1 = sy * M_SIZE * 100 + M_SIZE * 150;
// Draw tick
// tft.drawLine(x0, y0, x1, y1, TFT_BLACK);
tft.drawLine(x0, y0, x1, y1, TFT_WHITE);
// Check if labels should be drawn, with position tweaks
if (i % 25 == 0)
{
// Calculate label positions
x0 = sx * (M_SIZE * 100 + tl + 10) + M_SIZE * 120;
y0 = sy * (M_SIZE * 100 + tl + 10) + M_SIZE * 150;
switch (i / 25)
{
case -2:
tft.drawCentreString("0", x0 + 7, y0 - 6, 1);
break;
case -1:
tft.drawCentreString("25", x0 + 2, y0, 1);
break;
// case 0: tft.drawCentreString("50", x0, y0, 1); break;
case 0:
tft.drawCentreString("50", x0, y0 + 2, 1);
break;
case 1:
tft.drawCentreString("75", x0, y0, 1);
break;
case 2:
// tft.drawCentreString("100", x0 - 2, y0 - 4, 1);
tft.drawCentreString("100", x0 - 7, y0 - 10, 1);
break;
}
}
// Now draw the arc of the scale
sx = cos((i + 5 - 90) * 0.0174532925);
sy = sin((i + 5 - 90) * 0.0174532925);
x0 = sx * M_SIZE * 100 + M_SIZE * 120;
y0 = sy * M_SIZE * 100 + M_SIZE * 150;
// Draw scale arc, don't draw the last part
if (i < 50)
// tft.drawLine(x0, y0, x1, y1, TFT_BLACK);
tft.drawLine(x0, y0, x1, y1, TFT_WHITE);
}
// tft.drawString("%RH", M_SIZE * (3 + 230 - 40), M_SIZE * (119 - 20), 2); // Units at bottom right
tft.drawString("℃", M_SIZE * (230 - 40), M_SIZE * (119 - 20), 2); // Units at bottom right
tft.pushImage((M_SIZE * 120)-16,(M_SIZE * 75)-8,32,32,sheshidu);
// tft.drawCentreString("℃", M_SIZE * 120, M_SIZE * 75, 4); // Comment out to avoid font 4
// tft.drawRect(1, M_SIZE * 3, M_SIZE * 236, M_SIZE * 126, TFT_BLACK); // Draw bezel line
tft.drawRect(1, M_SIZE * 3, M_SIZE * 236, M_SIZE * 126, TFT_WHITE); // Draw bezel line
plotNeedle(28, 0); // Put meter needle at 0
}
// #########################################################################
//更新针位置
//这个功能是在针移动时阻塞,时间取决于ms_delay
//如果在针扫描区域内绘制文本,则10ms可最大程度地减少针闪烁
//如果文本不在扫描区域,则较小的值可以,对于即时移动为零,但看起来不现实...(注: 满刻度偏转的100增量)
// #########################################################################
void plotNeedle(int value, byte ms_delay)
{
// tft.setTextColor(TFT_BLACK, TFT_WHITE);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
char buf[8];
dtostrf(value, 4, 0, buf);
// tft.drawRightString(buf, 33, M_SIZE * (119 - 20), 2);
if (value < -10)
value = -10; // Limit value to emulate needle end stops
if (value > 110)
value = 110;
// Move the needle until new value reached
while (!(value == old_analog))
{
if (old_analog < value)
old_analog++;
else
old_analog--;
if (ms_delay == 0)
old_analog = value; // Update immediately if delay is 0
float sdeg = map(old_analog, -10, 110, -150, -30); // Map value to angle
// Calculate tip of needle coords
float sx = cos(sdeg * 0.0174532925);
float sy = sin(sdeg * 0.0174532925);
// Calculate x delta of needle start (does not start at pivot point)
float tx = tan((sdeg + 90) * 0.0174532925);
// Erase old needle image
tft.drawLine(M_SIZE * (120 + 24 * ltx) - 1, M_SIZE * (150 - 24), osx - 1, osy, TFT_BLACK);
tft.drawLine(M_SIZE * (120 + 24 * ltx), M_SIZE * (150 - 24), osx, osy, TFT_BLACK);
tft.drawLine(M_SIZE * (120 + 24 * ltx) + 1, M_SIZE * (150 - 24), osx + 1, osy, TFT_BLACK);
// Re-plot text under needle
// tft.setTextColor(TFT_BLACK, TFT_WHITE);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.pushImage((M_SIZE * 120)-16,(M_SIZE * 75)-8,32,32,sheshidu);
// tft.drawCentreString("℃", M_SIZE * 120, M_SIZE * 75, 4); // // Comment out to avoid font 4
// Store new needle end coords for next erase
ltx = tx;
osx = M_SIZE * (sx * 98 + 120);
osy = M_SIZE * (sy * 98 + 150);
// Draw the needle in the new postion, magenta makes needle a bit bolder
// draws 3 lines to thicken needle
tft.drawLine(M_SIZE * (120 + 24 * ltx) - 1, M_SIZE * (150 - 24), osx - 1, osy, TFT_RED);
tft.drawLine(M_SIZE * (120 + 24 * ltx), M_SIZE * (150 - 24), osx, osy, TFT_MAGENTA);
tft.drawLine(M_SIZE * (120 + 24 * ltx) + 1, M_SIZE * (150 - 24), osx + 1, osy, TFT_RED);
// Slow needle down slightly as it approaches new postion
if (abs(old_analog - value) < 10)
ms_delay += ms_delay / 5;
// Wait before next update
delay(ms_delay);
}
}
产生PWM波形则使用的是ESP32S2微控制器芯片的LEDC功能,LED 控制器 (LEDC) 主要用于控制 LED,也可产生 PWM 信号用于其他设备的控制。LEDC的高速通道模式在硬件中实现,可以自动且无干扰地改变 PWM 占空比,使用起来也是非常方便的。只需要设置LEDC的通道,分辨率,频率,最后将该通道与相应的GPIO端口绑定即可使用。
pinMode(PWM_Pin, OUTPUT);
ledcSetup(ledChannel, PWM_freq, resolution);
ledcAttachPin(PWM_Pin, ledChannel);
当PWM波形为高电平时,驱动MOS管开启,此时电阻流过电流开始发热,当PWM波形为低电平时,驱动MOS管关闭,此时电阻没有电流不发热。这样,通过控制PWM波形的占空比,就可以控制电阻的平均功率,进而控制发热温度。
在这个过程中,使用经久不衰的PID算法,计算出在设定温度下,相应的PWM波形占空比,并且随着实际温度自动进行调整,最终使其达到设定的温度。
在PID算法中,还采用了积分分离的方式,防止温度超调,但是在最终的测试中,由于温控系统惯性环节较大,仅使用PD两项的系数就能达到较好的目标。
//单闭环位置PID//pid结构—目标-实际
float SinglePID_Position(Position_PID *pid, float Target, float Measure)
{
float Err; //误差
if (pid == NULL)
{
// SerialBT.println("pid is NULL");
return 0.0;
}
Err = Target - Measure; //目标值减实际值
/* 积分分离 */
if (abs(Err) > (pid->Integraldead_zone)) //积分绝对值大于 积分盲区
{
pid->index = 0; //积分指数赋值0
}
else
{
pid->index = 1; //积分指数赋值1
}
pid->Output = pid->Kp * Err + pid->Kd * (Err - pid->Last_Err); //pd输出
pid->Integral += pid->Ki * Err * pid->index; //积分累加
constrain(pid->Output, pid->OutputMin, pid->OutputMax);
pid->Integral = constrain(pid->Integral, pid->I_outputMin, pid->I_outputMax); ////积分限幅
pid->Output += pid->Integral; //pid完整的输出
pid->Output = constrain(pid->Output, pid->OutputMin, pid->OutputMax); ////输出限幅
pid->Last_Err = Err; //保存上一次控制量
return pid->Output; //返回pid控制量
}
在该套控制系统中,由于温度传感器自动更新的频率为8Hz,为保证系统的一致性,所以PID程序的执行频率也应为8Hz,因此创建一个定时任务,每隔125ms运行一次即可,包含温度读取,PID计算,更新占空比。
void callback2() //回调函数2,读取温度并计算PWM值进行调整
{
Temp_now = NST112_Read();
Temp_OUT_PWM += SinglePID_Position(&Position_PID_Temp, Temp_target, Temp_now);
Temp_OUT_PWM = constrain(Temp_OUT_PWM, 0, 4095);
if (Heating == 0)
{
Temp_OUT_PWM = 0;
tft.setCursor(90, 86, 2);
tft.print("Off");
}
else
{
ledcWrite(ledChannel, (uint32_t)Temp_OUT_PWM);
if (abs(Temp_now - Temp_target) < 1.0F)
{
digitalWrite(LED_R_Pin, 0);
tft.setCursor(90, 86, 2);
tft.print(" Ok ");
}
else
{
digitalWrite(LED_R_Pin, 1);
tft.setCursor(90, 86, 2);
tft.print(" On");
}
}
sprintf(Serial_buff, "{Temp}%.2f,%.2f\r\n", Temp_target, Temp_now, Temp_OUT_PWM);
Serial.print(Serial_buff);
sprintf(Serial_buff, "{PWM}%.2f\r\n", Temp_OUT_PWM);
Serial.print(Serial_buff);
// Serial.print(",");
// Serial.println(Heating);
tft.setCursor(30, 70, 2);
tft.println(Temp_target);
// tft.setCursor(84, 20, 2);
tft.setCursor(30, 86, 2);
tft.println(Temp_now);
// tft.setCursor(20, 70, 2);
tft.setCursor(30, 102, 2);
tft.println(Temp_OUT_PWM);
plotNeedle((int)Temp_now, 0); // Put meter needle at 0
// tft.print(Heating);
}
最后是编码器、按键的部分,由于扩展板电路设计比较奇特,按键和编码器使用的是电阻网络分压的方案,因此使用ADC读取相应引脚的数值,即可判断出是某个按键按下,但是编码器比较特殊,将编码器的AB相及中央按键均接入了该电阻网络,单次的读取并不能体现是否旋转,需要高频率地进行ADC采样,根据结果进行判断正/反转
下图为采样频率200Hz的ADC采集数据。
图1为顺时针旋转,图2为逆时针旋转,图三为编码器中央按键,图四为普通按键
可以看出来编码器在进行不同方向的旋转时,ADC检测到的电压台阶顺序是不一样的,因此根据这个顺序,在符合电压最低处时判断上一台阶的电压值,即可分析出是哪个方向的旋转。
事先使用手动方式测量出各个操作ADC的测量值,再根据此值判断即可
//ADC的几个测量值,对应无操作、编码器旋转(3个)、编码器按键按下、普通按键按下。
uint16_t ADC_Val[6] = {7120, 6800, 6175, 6490, 5865, 2140};
//得到按键的值
void key_scan()
{
ADC0 = analogRead(ADC_Pin);
Serial.print("{A}");
Serial.println(ADC0);
if ((ADC0 > (ADC_Val[0] - ADC_error)) && (ADC0 < (ADC_Val[0] + ADC_error))) //未按下
{
for (uint8_t i = 0; i < (sizeof(key) / sizeof(KEY)); ++i)
{
key[i].val = 1;
}
}
else if ((ADC0 > (ADC_Val[1] - ADC_error)) && (ADC0 < (ADC_Val[1] + ADC_error)))
{
key[0].val = 0;
}
else if ((ADC0 > (ADC_Val[2] - ADC_error)) && (ADC0 < (ADC_Val[2] + ADC_error)))
{
key[1].val = 0;
}
else if ((ADC0 > (ADC_Val[3] - ADC_error)) && (ADC0 < (ADC_Val[3] + ADC_error)))
{
key[2].val = 0;
}
else if ((ADC0 > (ADC_Val[4] - ADC_error)) && (ADC0 < (ADC_Val[4] + ADC_error)))
{
key[3].val = 0;
}
else if ((ADC0 > (ADC_Val[5] - ADC_error)) && (ADC0 < (ADC_Val[5] + ADC_error)))
{
key[4].val = 0;
}
else
{
}
for (uint8_t i = 0; i < (sizeof(key) / sizeof(KEY)); ++i) //变更状态
{
if (key[i].last_val != key[i].val) //发生改变
{
key[i].last_val = key[i].val; //更新状态
if (key[i].val == LOW)
{
key_msg.id = i;
// key_msg.pressed = true;
}
}
}
if ((key_msg.id == 0) || (key_msg.id == 1) || (key_msg.id == 2))
{
if ((key_msg.id == 1)) //转到中间1的时候,判断上一次的值
{
switch (Encoder_Val_last)
{
case 0: //右转
Encoder_Direction = 1;
Temp_target += Add_Val;
break;
case 2: //左转
Encoder_Direction = 0;
Temp_target -= Add_Val;
break;
default:
break;
}
}
Encoder_Val_last = key_msg.id;
key_msg.id = (-1);
}
}
五、功能展示
按下编码器中央按键时,切换开启/关闭加热,按下普通按键时,切换需要设置的温度位数(10、1、0.1),顺时针旋转编码器增加设定值,逆时针旋转编码器减少设定值。
当温度达到设定温度的±1℃时(原先要求的是±3℃,这里稍微提高了一下控制精度),亮起红灯,并在屏幕上显示“OK”字样。
六、遇到的主要难题及解决方法
感觉该项目的主要难点就在编码器/按键方面,通过ADC读值的方式确定按键的状态,这样的好处也很明显,就是只用一个GPIO的端口即可完成多个按键的读取再有就是关于PID的部分,由于温控系统的大惯性环节,当PWM占空比升高后,温度上升并没有那么迅速,因此PID中的微分系数需要比较大,才能使得系统稳定。
七、后续完善计划
作为一个基本能够使用的恒温自动控制系统,已基本满足要求,但是还是有许多不足之处可以完善,比如在界面不够精美,按键交互不太完善,抗干扰性能不强等,由于时间关系暂未解决,后续的话有时间再抽空完善一下,这样总体的感觉也会提升。
八、参考资料
1.Arduino https://www.arduino.cc/
2.PlatformIO https://platformio.org/
3.乐鑫信息科技 https://www.espressif.com.cn/
4.高精度、低功耗数字温度传感器NST112 https://www.novosns.com/news-center-57
5.积分分离PID控制算法https://blog.csdn.net/songyulong8888/article/details/117389191