Skip to content

软件设计

1 简介

OK,终于来到软件代码部分了.这里有95%以上的核心实现代码是我自己一点点编写起来的,也是第一次自己做了一个操作菜单,其中的逻辑关系还是比较复杂的.编写工程代码前后用时将近7天.

代码主要部分包括

  • 菜单设计
  • TFT屏幕显示图像和文字
  • ESP8266 WIFI、MQTT设置
  • 电压电流表实现
  • SHT40 温湿度获取及显示
  • 系统设置
  • ADC、定时器及按键控制

限于开源工程文档的篇幅,恕无法面面俱到讲解,如果你对软件实现感兴趣,可以下载附件中的工程文件.几乎每个函数我都有写说明,一些重要的语句也有注释,如有不理解的或者代码改进建议,欢迎在评论区中留言讨论.

2 菜单设计

菜单逻辑上采用两级菜单,主菜单用来选择功能,如连接WIFI、电压电流表等,放在屏幕左侧;而次级菜单用来选择主功能下的分支,放在屏幕右侧.菜单层级图如图17所示.

alt text

菜单相关的函数均放在了menu.c源文件下.Show_Status_Bar函数用以显示当前电量、WIFI连接状态、ESP8266连接状态、MQTT连接状态.核心是调用了TFT屏幕的绘图函数showimage_16,该函数可在GUI.c源文件中查看.其中,被绘制的图片又是由图片取模工具得到的,转换后的十六进制数组放在了Picture.c文件中

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_MenuShow_Sub_Menu.预先将按钮的名称存放于char* main_menu_button[]char* sub_menu_button[5][5]数组中,这样只需根据菜单的索引值即可绘制某个位置的按钮名称.

c
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();
}
c
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]);
    }
}

有了整体框架后,就要追究细节问题了,该怎样显示点击上下左右按键后,当前所选择的按钮呢?这就用到了下面四个函数,它们是用来绘制主、次级菜单按钮被选中与恢复未被选中状态的.

c
/*次级菜单按钮被选中*/
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])()里面,要调用的时候只需传入对应的索引值即可.

c
/*存放各个运行函数的数组*/
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}};//系统设置
c
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的连接.

c
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指令,经过一系列的字符串拼接操作,发送出去.

c
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变量控制模式选择.

c
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.电压是用曲线图表示,电流是柱状图.

c
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之后该功能会自动开启.

c
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,其初始化函数如下

c
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, &GTIM_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将各个按键设置为上拉输入模式.

c
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.

c
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;
			}
		}
	}
}