一、项目需求
- 温度测量,将测量到的温度信息显示在LCD屏幕上,并绘制温升曲线。
- 温度颜色,当温度超过50°C时LED转为红色,低于20°C时转为蓝色,正常状态下为绿色。
- 恒温控制,使用旋转编码器设定目标温度,并且通过程序控制加热功率,使得温度尽快尽量稳定的维持在目标温度。温度偏离设定温度±3°C时LED灯呈现紫色与温度颜色交替闪烁。
二、完成的功能及达到的性能
2.1 温度显示
开发板的扩展板有一块1.44寸的 TFT LCD屏幕,分辨率为 128 * 128 。 用于温度展示及温升曲线的绘制展示。显示区域分为两部分:上半部分以数值的形式显示当前设定目标温度与实时温度,下半部分为温升曲线图。
如下图所示,曲线图中绿色曲线表示设定的目标温度,红色表示当前传感器测量到的实时温度。
2.2 温度颜色控制
加热器工作时的高温容易造成烫伤,为了便于直观地展示加热器当前的温度区间,采用不同的LED灯光来表示当前加热器的温度。超过 50°C 时显示红色,当LED呈现红色时,不应该当触碰加热区域。蓝色表示低温区间,可以随意触碰,属于安全区间。绿色表示的温度范围是环境温度达不到的区间,但未达到烫伤的温度,通常表示加热器开始工作,温度上升超过温度温度。
2.3 恒温控制
本制作默认设定恒温温度为 60°C ,可通过旋转编码器来调整恒温的设定测试。编码器左旋时,目标温度减小。编码器右旋时,目标温度增大。
当传感器采集到的实时温度偏离设定温度±3°C时LED闪烁紫色,和温度颜色控制显示的颜色交替闪烁,以便通过LED颜色方便直观地了解当前的温度区间。
按下编码器右侧的按钮时,可打开或关闭加热控制。加热控制打开时,加热器将以最大功率尽快地加热至设定温度,然后稳定在设定温度附近振荡,因加热电阻与温度传感器蹭隔了一层PCB板,采集时效有所延迟。目前该系统仅能维持在目标温度 ±1°C 以内。
三、实现思路
3.1 按钮
如上图所示,项目中硬件设备的按钮,使用按键网络形式,接到ESP32的 ADC 管脚,因此通过ESP32读取ADC管脚的电压并根据原理图的电阻值计算得出相应按钮或编码器转动时,ADC管脚的电压值
3.2 LED
如上面原理图所示,LED灯是一只共阳极RGB三色灯,RGB分别接到了ESP32的 12、43、44 管脚。控制相应管脚输出低电平,即可实现点亮相应颜色。输出高电平控制相应颜色熄灭。
3.2 LCD屏显示
扩展板上带有一块1.44寸的TFT LCD屏幕,采用SPI通信。使用 LVGL 图形库绘制UI显示温度及温升曲线。
3.4 温度采集
如上原理图所示,温度采集是基于I2C通信协议的 NST112-DSTR 芯片,通过I2C协议与芯片通信,获取当前传感器采集到的实时温度。
3.5 加热控制
监听按键按下,软件判断当前加热工作状态,对加热器进行打开或关闭操作。从上面原理图可得知控制ESP32的相应管脚输出PWM,即可控制加热电阻的功率。系统的目标是恒温,因此需要根据当前采集到的实时温度与目标温度进行比较,计算出一个合理的PWM值,以控制加热器稳定在目标温度附近。
需要注意扩展板上 V-HEAT 管脚并没有和ESP32的 V-HEAT 管脚直接相连,需要通过一根杜邦线将其连接起来,才可控制加热。
四、实现过程
4.1 软件功能架构
系统软件采用 Arduino FreeRTOS 实现相应功能,每个功能建立一个任务,并行运行。
4.2 流程图
4.3 软件依赖库
软件基于 Arduino 进行编写,使用的库有:
- FreeRTOS: 实时操作系统库,用于多任务管理。
- Wire: I2C通信协议库,用于温度传感器通信。
- LVGL: 图形库,用于UI绘制显示。
- TFT_eSPI: SPI屏通信库,用于图库库的底层驱动。
- FastPID: PID算法库,用于控制恒温加热输出的占空比计算。
4.4 主要功能代码展示
4.4.1 LED 控制任务
/**
* LED闪烁任务实现
* 温度超过50°C时转为红色,低于20°C时转为蓝色,正常状态下为绿色。
* 实时温度在设定温度的±3°C以内时,LED 设定为紫色闪烁。
*/
void task_led_1(void *pt) {
bool blue_state = true;
while(1) {
if(50 < current_temperature) { // 大于50度,红色点亮,其它蓝绿色熄灭。
digitalWrite(LED_RED, 0);
digitalWrite(LED_GREEN, 1);
digitalWrite(LED_BLUE, 1);
} else if(20 < current_temperature) { // 大于20度县小小于50度,绿色点亮,其它红蓝色熄灭。
digitalWrite(LED_RED, 1);
digitalWrite(LED_GREEN, 0);
digitalWrite(LED_BLUE, 1);
} else { // 小于20度,蓝色点亮,其它红绿色熄灭。
digitalWrite(LED_RED, 1);
digitalWrite(LED_GREEN, 1);
digitalWrite(LED_BLUE, 0);
}
if(3 > abs(target_temperature - current_temperature)) {
digitalWrite(LED_GREEN, 1);
digitalWrite(LED_RED, blue_state);
digitalWrite(LED_BLUE, blue_state);
if(blue_state && 50 < current_temperature) { // 紫灯灭时,相应温度的灯亮。
digitalWrite(LED_RED, 0);
} else if(blue_state && 20 < current_temperature) {
digitalWrite(LED_GREEN, 0);
} else if(blue_state) {
digitalWrite(LED_BLUE, 0);
}
blue_state = !blue_state;
}
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
4.4.2 串口读取任务
调试PID时,为了方便,增加了一个串口读取任务,用于读取串口输入的PID值,实时设计软件的PID参数。
/**
* 串口读取任务实现,用于加热PID调参
*/
void task_serial_read(void *pt) {
while(1) {
size_t available_bytes = Serial.available();
if(0 < available_bytes) {
char buffer[11] = {0};
for(unsigned char i = 0; i < available_bytes; ++i) {
buffer[i] = Serial.read();
}
if(('P' == buffer[0] || 'I' == buffer[0] || 'D' == buffer[0] || 'T' == buffer[0]
|| 'p' == buffer[0] || 'i' == buffer[0] || 'd' == buffer[0] || 't' == buffer[0]) && ':' == buffer[1]) {
double value = atof(buffer + 2);
switch(buffer[0]) {
case 'P':
case 'p':
kp = value;
Serial.print("Set Header pid new value result: ");
if(myPID.err()) { // 有错误的话,需要Clear一下。
myPID.clear();
}
Serial.println(myPID.configure(kp, ki, kd, 10, HEATER_PWM_TIMER_10_BIT));
break;
case 'I':
case 'i':
ki = value;
Serial.print("Set Header pid new value result: ");
if(myPID.err()) { // 有错误的话,需要Clear一下。
myPID.clear();
}
Serial.println(myPID.configure(kp, ki, kd, 10, HEATER_PWM_TIMER_10_BIT));
break;
case 'D':
case 'd':
kd = value;
Serial.print("Set Header pid new value result: ");
if(myPID.err()) { // 有错误的话,需要Clear一下。
myPID.clear();
}
Serial.println(myPID.configure(kp, ki, kd, 10, HEATER_PWM_TIMER_10_BIT));
break;
case 'T':
case 't': // 设置目标温度
if(100 > value || 0 > value) { // 无效目标温度。
Serial.print("Invalid target temperature: ");
Serial.println(value );
} else {
target_temperature = value;
Serial.print("Serial change target temperature to: ");
Serial.println(value);
}
break;
default:
break;
}
} else if('G' == buffer[0] || 'g' == buffer[0]) { // 串口接收到了G或g,打印当前PID参数值。
Serial.print("Current Kp: ");
Serial.print(kp);
Serial.print(" Ki: ");
Serial.print(ki);
Serial.print(" Kd: ");
Serial.println(kd);
}
} else {
vTaskDelay(100 / portTICK_PERIOD_MS);
}
}
}
4.4.3 温度采集任务
/**
* 温度传感器数据读取任务实现
*/
void task_i2c_nst112_dstr(void *pt) {
while(1) {
Wire.beginTransmission(NST112_DSTR_ADDR);
Wire.write(0x00);
Wire.endTransmission();
Wire.requestFrom(NST112_DSTR_ADDR, 2);
uint16_t value = Wire.read();
float temperature = (float)value;
value = Wire.read();
Wire.endTransmission();
temperature += (float)value / 256.00;
current_temperature = temperature;
vTaskDelay(200 / portTICK_PERIOD_MS);
}
}
4.4.4 PID计算
原本是根据PID的公式自己实现了一版计算代码,实际使用过程中,感觉效果不是很理想,故引入了FastPID库,进行计算。最终效果和自己实现的相差不大,但代码量精减了不少。最终还是使用了库的方案来计算加热控制占空比。
// 使用库计算PID
uint16_t output_duty = myPID.step(target_temperature, current_temperature);
4.4.5 按键监听任务
/**
* 编码器,按钮读取任务实现
*/
void task_adc_1(void *pt) {
analogReadResolution(12); // 设置ADC精度为12位
while(1) {
int analogValue = analogRead(A_OUT);
if (analogValue <= (SAMPLE_VOLT[0] + SIMPLE_VOLT_RANGE)) { // ADC电压低于最高电压按钮时,才判断触发。
vTaskDelay(KEY_DOWN_DEBOUNCE_TICKS); // 延时进行消抖判断。
analogValue = analogRead(A_OUT); // 再次获取
// 查表,判断是哪个按键事件
for(uint8_t i = 0; i < SIMPLE_VOLT_LENGTH; ++i) {
if((analogValue >= SAMPLE_VOLT[i] - SIMPLE_VOLT_RANGE)
&& (analogValue <= SAMPLE_VOLT[i] + SIMPLE_VOLT_RANGE)) {
// 按钮按下
key_enum ke = (key_enum)i;
switch(ke) {
case encoder_turn_left: // 编码器左旋
target_temperature -= 1; // 设定的目标温度减小1度
Serial.print("Target temperature:");
Serial.println(target_temperature);
break;
case encoder_turn_right: // 编码器右旋
target_temperature += 1; // 设定的目标温度增加1度
Serial.print("Target temperature:");
Serial.println(target_temperature);
break;
case encoder_key_down: // 编码器按下
vTaskDelay(2 * KEY_DOWN_DEBOUNCE_TICKS); // 该按键需要3倍消抖时间延时用于避免重复触发
break;
case key_down: // 按钮按下
heater_power = !heater_power; // 打开关闭加热电源
if(heater_power) { // 打开电源时,可能需要重新配置一下PID。
Serial.print("Header pid configure result: ");
if(myPID.err()) { // 有错误的话,需要Clear一下。
myPID.clear();
}
Serial.println(myPID.configure(kp, ki, kd, 10, HEATER_PWM_TIMER_10_BIT));
}
vTaskDelay(3 * KEY_DOWN_DEBOUNCE_TICKS); // 该按键需要3倍消抖时间延时用于避免重复触发
break;
default:
break;
}
}
}
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
4.4.6 UI布局
/**
* UI布局
*/
void lv_header(void)
{
// 标题
lv_obj_t * label_title = lv_label_create(lv_scr_act()); // 标题
lv_label_set_text(label_title, "Heater");
lv_obj_set_pos(label_title, 0, 0);
lv_obj_set_size(label_title, screenWidth, 20);
static lv_style_t style_label_title; // 创建标题样式
// lv_style_set_bg_color(&style_label_title, lv_color_make(0x12, 0x55, 0x8d));
// lv_style_set_text_color(&style_label_title, lv_color_make(0xff, 0xff, 0xff));
lv_style_set_text_color(&style_label_title, lv_color_make(0x21, 0x95, 0xf6));
lv_style_set_text_font(&style_label_title, &lv_font_montserrat_16);
lv_style_set_text_align(&style_label_title, LV_TEXT_ALIGN_CENTER);
lv_style_set_pad_left(&style_label_title, 0);
lv_style_set_pad_right(&style_label_title, 0);
lv_style_set_pad_top(&style_label_title, 2);
lv_style_set_pad_bottom(&style_label_title, 0);
lv_obj_add_style(label_title, &style_label_title, LV_PART_MAIN|LV_STATE_DEFAULT);
// 设定温度
label_target = lv_label_create(lv_scr_act());
lv_obj_set_pos(label_target, 0, 20);
lv_obj_set_size(label_target, screenWidth, 16);
lv_obj_set_scrollbar_mode(label_target, LV_SCROLLBAR_MODE_OFF);
lv_label_set_recolor(label_target, true);
lv_label_set_text(label_target, "Target:");
lv_label_set_long_mode(label_target, LV_LABEL_LONG_WRAP);
static lv_style_t style_label_target; // 设定温度样式
lv_style_set_bg_color(&style_label_target, lv_color_make(0x12, 0x55, 0x8d));
lv_style_set_text_color(&style_label_target, lv_color_make(0x21, 0x95, 0xf6));
lv_style_set_text_font(&style_label_target, &lv_font_montserrat_12);
lv_style_set_text_align(&style_label_target, LV_TEXT_ALIGN_LEFT);
lv_style_set_pad_left(&style_label_target, 0);
lv_style_set_pad_right(&style_label_target, 0);
lv_style_set_pad_top(&style_label_target, 2);
lv_style_set_pad_bottom(&style_label_target, 0);
lv_obj_add_style(label_target, &style_label_target, LV_PART_MAIN|LV_STATE_DEFAULT);
// 当前温度
label_current = lv_label_create(lv_scr_act());
lv_obj_set_pos(label_current, 0, 36);
lv_obj_set_size(label_current, 128, 16);
lv_obj_set_scrollbar_mode(label_current, LV_SCROLLBAR_MODE_OFF);
lv_label_set_recolor(label_current, true);
lv_label_set_text(label_current, "Current:");
lv_label_set_long_mode(label_current, LV_LABEL_LONG_WRAP);
static lv_style_t style_label_current; // 当前温度样式
lv_style_set_text_color(&style_label_current, lv_color_make(0x21, 0x95, 0xf6));
lv_style_set_text_font(&style_label_current, &lv_font_montserrat_12);
lv_style_set_text_align(&style_label_current, LV_TEXT_ALIGN_LEFT);
lv_style_set_pad_left(&style_label_current, 0);
lv_style_set_pad_right(&style_label_current, 0);
lv_style_set_pad_top(&style_label_current, 2);
lv_style_set_pad_bottom(&style_label_current, 0);
lv_obj_add_style(label_current, &style_label_current, LV_PART_MAIN|LV_STATE_DEFAULT);
// 折线图
chart = lv_chart_create(lv_scr_act());
lv_obj_set_size(chart, screenWidth, 76);
lv_obj_set_pos(chart, 0, 52);
lv_chart_set_type(chart, LV_CHART_TYPE_LINE); // 设置图表类型为折线图
lv_chart_set_point_count(chart, 2000); // 最大数据量
/* 添加两条曲线(目标温度,当前温度)*/
ser1 = lv_chart_add_series(chart, lv_palette_main(LV_PALETTE_RED), LV_CHART_AXIS_PRIMARY_Y);
ser2 = lv_chart_add_series(chart, lv_palette_main(LV_PALETTE_GREEN), LV_CHART_AXIS_SECONDARY_Y);
}
五、遇到的主要难题
对于新入门的人来说,处处是问题,从最早的开发环境安装,到每个环节遇到的知识点,都只能一点点查资料。
过年前刚拿到板子的时候,开发环境就装了一周才安装好,差点就要放弃了,好在群里的小伙伴都很积极帮助,最终才基本完成本作品。在此特别感谢群里的小伙伴,非常感谢!
目前离我的构思,还有两个大的难点未能解决。在未来的计划中,再慢慢完善吧
- LVGL 设计并实现一个精美的UI。
- LVGL 编码器输入控制UI。
六、未来的计划建议
截止目前虽然已实现了基本的加热及恒温控制功能,但距离自己想要实现的一款恒温加热台的产品,还相差甚远。软件方面存在以下几大方面的不足,在此写下以便后续进行持续改进。
- UI功能不完善,需要做到通过编码器对目标温度进行设置,以及设置的保存功能。
- 扩展板上的陀罗仪功能集成进来,使其具备防倾倒功能。
- ESP的WIFI功能可用用来做远程UI展示,以及远程调试、设置等。