软件设计
1 简介
OK,终于来到软件代码部分了.这里有95%以上的核心实现代码是我自己一点点编写起来的,也是第一次自己做了一个操作菜单,其中的逻辑关系还是比较复杂的.编写工程代码前后用时将近7天.
代码主要部分包括
- 菜单设计
- TFT屏幕显示图像和文字
- ESP8266 WIFI、MQTT设置
- 电压电流表实现
- SHT40 温湿度获取及显示
- 系统设置
- ADC、定时器及按键控制
限于开源工程文档的篇幅,恕无法面面俱到讲解,如果你对软件实现感兴趣,可以下载附件中的工程文件.几乎每个函数我都有写说明,一些重要的语句也有注释,如有不理解的或者代码改进建议,欢迎在评论区中留言讨论.
2 菜单设计
菜单逻辑上采用两级菜单,主菜单用来选择功能,如连接WIFI、电压电流表等,放在屏幕左侧;而次级菜单用来选择主功能下的分支,放在屏幕右侧.菜单层级图如图17所示.
菜单相关的函数均放在了menu.c
源文件下.Show_Status_Bar
函数用以显示当前电量、WIFI连接状态、ESP8266连接状态、MQTT连接状态.核心是调用了TFT屏幕的绘图函数showimage_16
,该函数可在GUI.c
源文件中查看.其中,被绘制的图片又是由图片取模工具得到的,转换后的十六进制数组放在了Picture.c
文件中
void Show_Status_Bar()
{
//最左侧放电池电量
uint16_t bat_volume = Battery_Volume();
if(bat_volume>=61 && bat_volume<=100)
{
Gui_DrawFont_Num16(0,0,GREEN,BLACK,bat_volume/10);
Gui_DrawFont_Num16(8,0,GREEN,BLACK,bat_volume%10);
}
else if(bat_volume>=21 && bat_volume<=60)
{
Gui_DrawFont_Num16(0,0,YELLOW,BLACK,bat_volume/10);
Gui_DrawFont_Num16(8,0,YELLOW,BLACK,bat_volume%10);
}
else if(bat_volume>0&& bat_volume<=20)
{
Gui_DrawFont_Num16(0,0,RED,BLACK,bat_volume/10);
Gui_DrawFont_Num16(8,0,RED,BLACK,bat_volume%10);
}
Gui_DrawFont_GBK16(16,0,WHITE,BLACK,"%");
//右数第一个放WIFI状态
switch(WIFI_Status)
{
case 0:
showimage_16(WIFI_Disconnected_Icon,144,0);
mqtt_status = 0;
break;
case 1:
showimage_16(WIFI_Connected_Icon,144,0);
break;
}
//右数第二个放ESP8266连接状态
switch(ESP8266_Status)
{
case 0 :
showimage_16(ESP8266_ERROR,124,0);
break;
case 1 :
showimage_16(ESP8266_OK,124,0);
break;
}
//右数第三个放MQTT连接状态
switch(mqtt_status)
{
case 0:
showimage_16(Mqtt_Error_Icon,104,0);
break;
case 1:
showimage_16(Mqtt_OK_Icon,104,0);
break;
}
}
显示主菜单和次级菜单的函数分别为Show_Main_Menu
和Show_Sub_Menu
.预先将按钮的名称存放于char* main_menu_button[]
和char* sub_menu_button[5][5]
数组中,这样只需根据菜单的索引值即可绘制某个位置的按钮名称.
void Show_Main_Menu()
{
//画分割线
Gui_DrawLine(0,20,160,20,GREEN);
Gui_DrawLine(80,20,80,128,GREEN);
//显示状态栏
Show_Status_Bar();
//渲染主菜单
for(uint8_t i = 0; i<5; i++)
{
uint8_t x = 0,y = 22+i*20;
Gui_DrawFont_GBK16(x,y,WHITE,BLACK,main_menu_button[i]);
}
Choose_Main_Function(); //高亮选中主菜单
Show_Sub_Menu(); //同时显示当前次级菜单
if(in_sub_menu_flag == 1) //若在次级菜单,则高亮选中次级菜单的功能
Choose_Sub_Function();
}
void Show_Sub_Menu()
{
Lcd_Part_Clear(81,22,160,128,BLACK);
for(uint8_t i = 0; i<=sub_menu_maxidx[main_menu_index]; i++)
{
uint8_t x = 81,y = 22+i*20;
Gui_DrawFont_GBK16(x,y,WHITE,BLACK,sub_menu_button[main_menu_index][i]);
}
}
有了整体框架后,就要追究细节问题了,该怎样显示点击上下左右按键后,当前所选择的按钮呢?这就用到了下面四个函数,它们是用来绘制主、次级菜单按钮被选中与恢复未被选中状态的.
/*次级菜单按钮被选中*/
void Choose_Sub_Function()
{
uint8_t x = 81,y=40;
Gui_DrawLine(x,y+sub_menu_index*20,x+75,y+sub_menu_index*20,ORANGE);
}
/*主菜单按钮被选中*/
void Choose_Main_Function()
{
uint8_t x = 0,y=40;
Gui_DrawLine(x,y+main_menu_index*20,x+75,y+main_menu_index*20,LIGHTBLUE);
}
//次级菜单 从被选中状态恢复成未被选中状态
void Restore_Sub_Menu_Button(uint8_t idx)
{
uint8_t x = 81,y = 40;
Gui_DrawLine(x,y+idx*20,x+75,y+idx*20,BLACK);
}
//主菜单 从被选中状态恢复成未被选中状态
void Restore_Main_Menu_Button(uint8_t idx)
{
uint8_t x = 0,y = 40;
Gui_DrawLine(x,y+idx*20,x+75,y+idx*20,BLACK);
}
到这步为止,好像还差一个东西,那就是执行相应按钮所对应的功能,即按下“确认”键后,进到功能所在界面.使用的函数为Run_Function
.所有的函数也都存在一个指向函数地址的二维数组 void (*FUN[5][5])()
里面,要调用的时候只需传入对应的索引值即可.
/*存放各个运行函数的数组*/
void (*FUN[5][5])() = {
{Launch_VA_Meter,Launch_VA_Meter},//电压电流
{SHT40_Show_Temp_Humid},//温湿度计
{Connect_WIFI,Network_info},//WIFI
{MQTT_Setup},//MQTT
{System_Info,ESP8266_Reset,MCU_Sleep,Brightness_Setup}};//系统设置
void Run_Function()
{
in_progress_flag = 1;
Lcd_Clear(BLACK);
if(main_menu_index==0) //针对电压电流表菜单单独设置,用于传参
{
FUN[main_menu_index][sub_menu_index](sub_menu_index);
}
else
{
FUN[main_menu_index][sub_menu_index]();
}
}
3 WIFI连接&MQTT连接
与WIFI连接有关的函数放在了esp8266_cmd.c
文件下,配置WIFI名和密码需要在main.h
文件的宏定义中配置.
向ESP8266发送命令,主要靠的是ESP8266的官方MQTT AT固件.AT指令是非常好用的,只需要在串口发送命令即可.例如在电脑给ESP8266串口发送AT
,ESP8266会返回OK
,所有的AT指令集参见官方文档.
那么用程序让CW32在串口给ESP8266发送指令,即可实现与电脑串口向ESP8266发指令一样的功能.实现发送指令的函数为ESP8266_SendCmd
,其底层的串口发送函数为USART_SendString
.
在用户层,只需调用连接WIFI函数Connect_WIFI
即可完成WIFI的连接.
void Connect_WIFI() {
GPIO_WritePin(ESP8266_EN_PORT,ESP8266_EN_PIN,GPIO_Pin_SET); //开启ESP8266
delay_ms(100);
if(GPIO_ReadPin(ESP8266_PORT,ESP8266_PIN)==GPIO_Pin_SET)
{
ESP8266_Status = 1;
}
if(ESP8266_Status == 0)
{
Gui_DrawFont_GBK16(8,72,RED,YELLOW,"ESP8266 Not Found");
delay_ms(250);
return ;
}
if(WIFI_Status==0) {
delay_ms(200);
char *t = malloc(100);
strcat(t,"AT+CWJAP=\"");
strcat(t,WIFI_SSID);
strcat(t,"\",\"");
strcat(t,WIFI_PASSWORD);
strcat(t,"\"\r\n");
Gui_DrawFont_GBK16(0,0,WHITE,BLACK,"WIFI Connecting...");
ESP8266_SendCmd((uint8_t *)"AT+CWMODE=1,0\r\n",(uint8_t *)"OK");
if(ESP8266_SendCmd((uint8_t *)t,(uint8_t *)"CONNECTED"))
{
WIFI_Status = 1;
FUN[2][0] = Disconnect_WIFI;
sub_menu_button[2][0]="断开WIFI ";
Gui_DrawFont_GBK16(0,16,GREEN,BLACK,"WIFI Connected!");
Gui_DrawFont_GBK16(0,32,WHITE,BLACK,strcat_new("SSID:",WIFI_SSID));
ESP8266_Last_Status = 1;
free(t);
connect_retry_cnt = 0;
delay_ms(200);
Lcd_Clear(BLACK);
Show_Main_Menu();
in_progress_flag = 0;
return ;
}
else
{
free(t);
WIFI_Status = 0;
connect_retry_cnt++;
switch(connect_retry_cnt)
{
case 1:
Gui_DrawFont_GBK16(0,16,RED,BLACK,"Retrying...[1]");
Connect_WIFI();
break;
case 2:
Gui_DrawFont_GBK16(0,16,RED,BLACK,"Retrying...[2]");
Connect_WIFI();
break;
default:
connect_retry_cnt=0;
ESP8266_SendCmd((uint8_t *)"AT+CWMODE=0,0\r\n",(uint8_t *)"OK");
Gui_DrawFont_GBK16(0,16,RED,BLACK,"WIFI Not Connected!");
delay_ms(200);
Lcd_Clear(BLACK);
Show_Main_Menu();
in_progress_flag = 0;
break;
}
return ;
}
}
}
限于篇幅,网络信息查询以及断开WIFI的函数请自行在源文件中查看,原理类似.
连接至WIFI后,就可以连接MQTT了.首先要在mqtt.h
头文件的宏定义中配置好MQTT连接的相关参数,具体的配置方法在4.2.3节中讲.
MQTT发布消息函数为MQTT_Publish
,函数传入的参数是字符型数据数组,使用AT+MQTTPUB
指令,经过一系列的字符串拼接操作,发送出去.
void MQTT_Publish(char *data)
{
if(mqtt_status==1)
{
char buffer[400] = {0};
strcpy(buffer,"AT+MQTTPUB=0,\"");
strcat(buffer,MQTT_TOPIC);
strcat(buffer,"\",\"");
strcat(buffer,data);
strcat(buffer,"\",0,0\r\n");
ESP8266_SendCmd((uint8_t*)buffer,(uint8_t*)"OK");
free(buffer);
}
}
4 电压电流表
与电压电流表功能相关的函数都在va_meter.c
文件下.Launch_VA_Meter
函数是启动电压电流表的入口函数,这里主要是在屏幕上绘制数值需要动点小心思处理一下,采用了取尾数的方式把浮点数转为字符串.
这个函数里包含了两种模式:图形模式和数字模式,由va_meter_style
变量控制模式选择.
void Launch_VA_Meter(uint8_t idx)
{
Lcd_Clear(BLACK);
Init_VAmeter_ADC();
key_flag[2]=0;
uint8_t send_wait = 100;
double mqtt_volt,mqtt_curr;
//初始化不同样式的界面
switch(idx)
{
case 0:
Gui_DrawFont_GBK16(40,0,LIGHTBLUE,BLACK,"电压电流表");
Gui_DrawLine(0,18,160,18,WHITE);
Gui_DrawLine(0,111,160,111,WHITE);
time = 2;
va_meter_style = 0;
break;
case 1:
Gui_DrawFont_GBK16(40,0,LIGHTBLUE,BLACK,"电压电流表");
Gui_DrawFont_Num32_2(64,24,YELLOW,BLACK,0); //显示电压的小数点
Gui_DrawFont_GBK16(144,38,YELLOW,BLACK,"V"); //显示电压的单位
Gui_DrawFont_Num32_2(96,80,YELLOW,BLACK,0); //显示电流的小数点
Gui_DrawFont_GBK16(144,80,YELLOW,BLACK,"m"); //显示电压的单位 m
Gui_DrawFont_GBK16(144,96,YELLOW,BLACK,"A"); //显示电压的单位 A
va_meter_style = 1;
break;
}
while(key_flag[2]==0) //向左按键未被按下,一直在循环内
{
ADC_GET();
//处理电压数据
if(adc_value[0]>=4090) //当电压值大于3V时,换挡到0~31V
{
voltage_value = (adc_value[1]/4095.0 * 1.5 * 21)-0.075; //参考电压是1.5V,分压比1:20 经校准比正常值高0.075V,故减去0.075
Gui_DrawFont_GBK16(128,0,RED,BLACK,"3V");
}
else //电压值小于3V时,换挡到0~3V
{
voltage_value = (adc_value[0]/4095.0 * 1.5 * 2); //参考电压是1.5V,分压比1:1
Gui_DrawFont_GBK16(128,0,GREEN,BLACK,"3V");
}
if(voltage_value <=0.3) //当数值为0.3以下时,视为ADC误差,将值置为0,本次采集数据无效
voltage_value = 0.0;
mqtt_volt = voltage_value;
voltage_value *= 100; //处理以显示小数点后2位
for(uint8_t i = 0; i<4; i++)
voltage_num[3-i]=(int)(voltage_value/(pow(10,i))) %10;
//处理电流数据
current_value = ((adc_value[2]/4095.0 * 1.5)/0.2 * 1000)-10.8; //参考电压是1.5V,采样电阻0.2ohm,电流单位mA,经校准比理论值高10.8mA
if(current_value < 0)
continue;
mqtt_curr = current_value;
current_value *= 10; //处理以显示小数点后1位
for(uint8_t i = 0; i<4; i++)
current_num[3-i]=(int)(current_value/(pow(10,i))) %10;
//MQTT发送间隔 4-->-约等于0.3s
send_wait++;
if(send_wait>4)
{
VAmeter_Mqtt_Send_Data(mqtt_volt,mqtt_curr);
send_wait=0;
}
switch(va_meter_style)
{
case 0:
for(uint8_t i = 0; i<2; i++)
{
Gui_DrawFont_Num16(i*8,112,GREEN,BLACK,voltage_num[i]);
}
Gui_DrawFont_GBK16(16,112,ORANGE,BLACK,".");
for(uint8_t i = 2; i<4; i++)
{
Gui_DrawFont_Num16(8+i*8,112,GREEN,BLACK,voltage_num[i]);
}
Gui_DrawFont_GBK16(40,112,ORANGE,BLACK,"V");
for(uint8_t i = 0; i<3; i++)
{
Gui_DrawFont_Num16(60+i*8,112,BLUE,BLACK,current_num[i]);
}
Gui_DrawFont_GBK16(84,112,ORANGE,BLACK,".");
Gui_DrawFont_Num16(92,112,BLUE,BLACK,current_num[3]);
Gui_DrawFont_GBK16(100,112,ORANGE,BLACK,"mA");
Draw_Value_Line();
break;
case 1:
//电压
for(uint8_t i = 0; i<2; i++)
Gui_DrawFont_Num32(i*32,24,ORANGE,BLACK,voltage_num[i]);
for(uint8_t i = 2; i<4; i++)
{
Gui_DrawFont_Num32(16+i*32,24,BLUE,BLACK,voltage_num[i]);
}
//电流
for(uint8_t i = 0; i<3; i++)
{
Gui_DrawFont_Num32(i*32,80,ORANGE,BLACK,current_num[i]);
}
Gui_DrawFont_Num32(112,80,BLUE,BLACK,current_num[3]);
break;
}
delay_ms(5);
}
Lcd_Clear(BLACK);
Show_Main_Menu();
in_progress_flag = 0;
key_flag[2]=0;
}
图形模式下,画图的函数为Draw_Value_Line
.电压是用曲线图表示,电流是柱状图.
void Draw_Value_Line()
{
if(time==158)
{
time=2;
Gui_DrawLine(2,20,2,110,BLACK);
}
voltage_value=(int)voltage_value/10;
current_value=(int)current_value/10;
//画图范围纵坐标20-110,横坐标2-158
if(voltage_value>MAX_VOLTAGE_Y*10 || current_value>MAX_CURRENT_Y)
return ;
//先绘制电流柱状图,如果和电压曲线图颠倒绘制顺序,则会遮住曲线图
Gui_DrawLine(time,110-(int)(90*current_value/MAX_CURRENT_Y),time,110,BLUE);
//再绘制电压曲线图
if(time==2)
{
Gui_DrawPoint(time,110-(int)(90*voltage_value/10/MAX_VOLTAGE_Y),GREEN);
}
else
{
Gui_DrawLine(last_time,110-(int)(90*last_voltage_value/10/MAX_VOLTAGE_Y),time,110-(int)(90*voltage_value/10/MAX_VOLTAGE_Y),GREEN); //voltage
}
Gui_DrawLine(time+1,20,time+1,110,BLACK); //擦除下一时刻的值
time++;
last_voltage_value = voltage_value;
last_time = time;
}
还有一个函数VAmeter_Mqtt_Send_Data
,是发送电压、电流和功率数据到MQTT消息队列的,在连接WIFI、MQTT之后该功能会自动开启.
void VAmeter_Mqtt_Send_Data(double volt,double curr)
{
if(mqtt_status == 0)
return ;
double pwr = volt*curr;
char *data = malloc(150);
char t[10] = {0};
strcpy(data,"{\\\"volt\\\":");
num2char(t,volt,log10(volt)+1,2);
strcat(data,t);
memset(t,0,10);
strcat(data,"\\,\\\"curr\\\":");
num2char(t,curr,log10(curr)+1,1);
strcat(data,t);
memset(t,0,10);
strcat(data,"\\,\\\"pwr\\\":");
num2char(t,pwr,log10(pwr)+1,1);
strcat(data,t);
free(t);
strcat(data,"}");
MQTT_Publish(data);
free(data);
}
5 中断、按键、计时器
整个工程里一共配置了2个基本计时器BTIM,以及1个GPIOA端口的中断,它们各自的功能如下表所示
中断 | 功能 |
---|---|
BTIM1 | 判断按键状态 每10ms进一次中断 |
BTIM2 | 更新状态栏 每1s进一次中断 |
GPIOA | 从休眠中唤醒 仅检测PA10(确认键)引脚 |
此外,还使用通用计时器GTIM3实现屏幕的PWM调光,频率为1250Hz,其初始化函数如下
void GTIM3_PWM_Init(void)
{
PC15_AFx_GTIM3CH2(); // 复用功能为通用定时器3通道2
/*********** GTIM3配置 ***********/
GTIM_InitTypeDef GTIM_InitStruct; // 通用定时器初始化结构体
__RCC_GTIM3_CLK_ENABLE(); // 使能通用定时器1时钟
GTIM_InitStruct.Mode = GTIM_MODE_TIME; // 定时器模式
GTIM_InitStruct.OneShotMode = GTIM_COUNT_CONTINUE; // 连续计数模式
GTIM_InitStruct.Prescaler = GTIM_PRESCALER_DIV512; // DCLK = PCLK / 128 = 64MHz/512 = 125KHz
GTIM_InitStruct.ReloadValue = 100; // 重装载值设置 PWM:1.25KHz
GTIM_InitStruct.ToggleOutState = DISABLE; // 输出翻转功能
GTIM_TimeBaseInit(CW_GTIM3, >IM_InitStruct); // 初始化
GTIM_OCInit(CW_GTIM3, GTIM_CHANNEL2, GTIM_OC_OUTPUT_PWM_HIGH); // 配置输出比较通道3为PWM模式
GTIM_SetCompare2(CW_GTIM3, lcd_brightness); //设置初始占空比为lcd_brightness/100 = 50/100 = 50%
GTIM_Cmd(CW_GTIM3, ENABLE); // 使能定时器
}
按键相关的函数在key.c
源文件中,初始化函数Key_Init
将各个按键设置为上拉输入模式.
void Key_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
/*
PA8 -> UP_KEY
PA9->DOWN_KEY
PA10->LEFT_KEY
PA11->RIGHT_KEY
PA12->CONFIRM_KEY
*/
GPIO_InitStruct.Pins = LEFT_KEY_PIN|RIGHT_KEY_PIN|UP_KEY_PIN | CONFIRM_KEY_PIN| DOWN_KEY_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT_PULLUP; //上拉输入
GPIO_InitStruct.Speed = GPIO_SPEED_HIGH; //输出速度高
GPIO_Init(CW_GPIOA, &GPIO_InitStruct); //初始化
}
为了防止按键抖动造成误判,每次定时器BITM1中断(10ms),都会判断一次按键的状态.Check_Key_Status
函数用到了状态机.
如果按键为按下状态,就将key_status
对应的按键下标置为1.然后过10ms再来判断一次,如果仍为按下状态,则是真的按下了而非抖动,将key_flag
对应的按键置为1. 否则将key_flag
置为0.
检测按键长按的方法也是类似,只是多判断几轮,如果按键被按下时间大于130ms,则为长按,将key_flag
对应按键置为2.
void Check_Key_Status()
{
for(uint8_t i = 0;i<5;i++)
{
if(key_status[i]==0)
{
if(GPIO_ReadPin(key_pin_port[i],key_pin[i])==GPIO_Pin_RESET)
{
key_status[i] = 1;
}
}
else if(key_status[i] == 1)
{
if(GPIO_ReadPin(key_pin_port[i],key_pin[i])==GPIO_Pin_RESET) //识别到短按
{
key_flag[i] = 1;
key_status[i] = 2;
}
else
{
key_status[i] = 0;
}
}
else if(key_status[i] >= 2 && key_status[i]<15)
{
if(GPIO_ReadPin(key_pin_port[i],key_pin[i])==GPIO_Pin_RESET) //从短按到长按的中间态,按键至少按下了130ms
{
key_status[i]++;
}
else
{
key_status[i]--;
}
}
else if(key_status[i] == 15)
{
if(GPIO_ReadPin(key_pin_port[i],key_pin[i])==GPIO_Pin_RESET) //识别到长按
{
key_flag[i] = 2;
}
else
{
key_status[i]--;
}
}
else
{
if(GPIO_ReadPin(key_pin_port[i],key_pin[i])==GPIO_Pin_SET)
{
key_flag[i]=0;
}
}
}
}