只用一块 ESP32,实现自动控温 + 显示 + 调节

在智能家居和工业控制中,“温度控制”几乎无处不在。从恒温鱼缸、咖啡机,到加热台、烘干箱,温度稳定都是关键。但要做到自动感知 + 智能调节 + 状态显示,并不简单。


本项目基于 ESP32 控制平台,通过 温度采集 + PID 自动调节 + 可视化显示 的组合,实现了一个真正意义上的 恒温自动控制系统。它不仅能自动检测环境温度,还能根据设定值智能控制加热元件,让系统温度始终保持在稳定范围内。简单来说,就是让设备“有脑子”,能自己“判断冷热、控制加热”!

1

开发板介绍


图片

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 Wi­Fi 模组;

  • 内置 ESP32­S2 系列芯片,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供电。

Fis76JnDCgZixkiVDU8POkM7Q3P0

2)输入、输出扩展板介绍

本扩展板包含如下功能:

  • 按键、旋转编码器输入 – 以模拟信号的方式

  • 双电位计控制输入 – 以数字信号的方式

  • RGB三色LED显示

  • 1.44寸128*128 LCD,SPI总线访问

  • MMA7660三轴姿态传感器

  • 电阻加热

  • 温度传感器

  • 与ESP32-S2核心模块的接口

FsxLaGm-2BHTVnsw4LMDm9Y7n12R

2

设计思路


图片

正如扩展板的原理图所示的加热–>控温:首先控制MOS管导通,使得电阻流过电流产生热量,进而使电路板升温,再使用温度传感器将温度值读出,根据当前的温度值不断地进行反馈控制MOS管通断,使电路板温度保持在设定值范围。

在ESP32-S2的扩展板上,电阻最大加热功率P=U²/R = 3.3*3.3/(68//68//68//68) ≈ 0.64W。

Fghmwfqqs5hGrIDpeHxs1TLd2dR6

根据如上所示的设计思路,结合ESP32-S2芯片特性,绘制出大体上的软件流程:    FoNQ0rqFdX5tb2slhYzrp_kReiP5

3

开发环境准备


图片

1.Platform IO with Visual Studio Code,用于编写代码及下载程序。

2.ComAssistant,用于串口调试

4

代码实现


图片

本次项目使用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//数据低//8HZvoid 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为逆时针旋转,图3为编码器中央按键,图4为普通按键。

FpPhPT8SbHhpNX4DWI3dCblX39qW


图1

Fq6sqCKFpBqGNwy8epYvY2Kek_tu

图2

FqaS6yIy77YPFdSQqZfHoSKcqLTf
图3

Fkgc_NElieD0zuSbxeB_M827e5sI
图4

可以看出来编码器在进行不同方向的旋转时,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);    }}

5

功能展示


图片

按下编码器中央按键时,切换开启/关闭加热,按下普通按键时,切换需要设置的温度位数(10、1、0.1),顺时针旋转编码器增加设定值,逆时针旋转编码器减少设定值。

当温度达到设定温度的±1℃时(原先要求的是±3℃,这里稍微提高了一下控制精度),亮起红灯,并在屏幕上显示“OK”字样。

FpI8rhvriiF__hu6ynnHD3rrGQdmFrtJ-DiHZuZyINHvbbK61-BfpMPF


FqfHn39Qjgld7FsLCRuNXRq1jhHs

6

项目总结


图片

感觉该项目的主要难点就在编码器/按键方面,通过ADC读值的方式确定按键的状态,这样的好处也很明显,就是只用一个GPIO的端口即可完成多个按键的读取再有就是关于PID的部分,由于温控系统的大惯性环节,当PWM占空比升高后,温度上升并没有那么迅速,因此PID中的微分系数需要比较大,才能使得系统稳定。

7

完善计划


图片

作为一个基本能够使用的恒温自动控制系统,已基本满足要求,但是还是有许多不足之处可以完善,比如在界面不够精美,按键交互不太完善,抗干扰性能不强等,由于时间关系暂未解决,后续的话有时间再抽空完善一下,这样总体的感觉也会提升。

8

参考资料


图片

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


图片

点击 “阅读原文” 查看项目

发表评论