智能家居模块化扩展系统

项目来源韦东山老师项目:百问网嵌入式专家-韦东山嵌入式专注于嵌入式课程及硬件研发 (100ask.net)

MQTT源码来自kawaii-mqt :https://github.com/jiejieTop/mqttclient

1. MQTT协议

        MQTT协议全称是MessageQueuingTelemetryTransport,翻译过来就是消息队列遥测传输协议,它是物联网常用的应用层协议,运行在TCP/IP中的应用层中,依赖TCP协议,因此它具有非常高的可靠性,同时它是基于TCP协议的<客户端-服务器>模型发布/订阅主题消息的轻量级协议,也是我们常说的发送与接收数据。

1.1 MQTT通讯模型

        MQTT协议提供一对多的消息发布,可以降低应用程序的耦合性,用户只需要编写极少量的应用代码就能完成一对多的消息发布与订阅,该协议是基于<客户端-服务器>模型,在协议中主要有三种身份:发布者(Publisher)、服务器(Broker)以及订阅者(Subscriber)。其中,MQTT消息的发布者和订阅者都是客户端,服务器只是作为一个中转的存在,将发布者发布的消息进行转发给所有订阅该主题的订阅者;发布者可以发布在其权限之内的所有主题,并且消息发布者可以同时是订阅者,实现了生产者与消费者的脱耦,发布的消息可以同时被多个订阅者订阅。

2. 项目简介

        该项目主要使用STM32开发板基于MQTT实现一个智能家居系统,由于STM32开发板资源的限制,没有网卡等资源,故使用ESP8266无线模块实现无线网连接。

在该项目中主要有三个角色:发布者、服务器、订阅者。

开发板既可以是发布者,也可以是订阅者:

  1.         开发板充当发布者时,可发布主题消息给其他设备
  2.         开发板充当订阅者时,可订阅其他设备发来的主题消息

为什么使用MQTT、引入服务器? 

        网络通信中通过IP、端口表示自己、对方。但是公网IP有限;    

        为了解决公网IP不够,引用局域网,在公司或者家里只有路由器才有公网IP,电脑之内都是用局域网IP,局域网中的电脑想访问物联网公网,由路由器代理;

        如果只是实现局域网内使用手机控制开发上的设备,只需要开发板和手机就可以,因为同一局域网内两个设备可以通过路由器相互访问;

        但是如果想实现在任何地方都可以控制开发板,则需引入服务器;手机控制开发板或者开发板想发送信息给手机,由服务器代理;

项目实现过程

  1. 使用串口实现开发板和ESP8266无线模块通信;
  2. 实现ESP8266模块的AT命令和解析
  3. 实现网络传输层,网络连接,读、写、关闭;
  4. 通过MQTT实现客服端与服务器的通信;
  5. 搭建mosquito服务器,实现管理客户端连接并路由消息;
  6. 通过MQTTX工具验证系统

3. 串口驱动

        通过串口实现开发板与ESP8266通信,使开发板具有无线网功能;

  1.  使用Cube MX配置串口初始化
  2. 使用环形缓冲区保存、管理数据
  3. 串口写: 直接将数据写入串口DR寄存器,然后,USART硬件会自动将数据从DR寄存器移出,并通过串行线路发送给ESP8266;
  4. 串口读:读取环形缓冲区中数据,若没有数据就上锁,等串口中断开锁;

        环形缓冲区中数据来自于串口中断处理函数保存的,当USART3接收到数据时,串口接收中断被使能(通过USART_CR1_RXNEIE位),则会产生一个接收中断(RXNE标志位被置位)。接着中断处理函数将DR寄存器中数据保存进环形缓冲区

实现环形缓冲区

#define BUFFER_SIZE 1024        /* 环形缓冲区的大小 */
typedef struct
{
    unsigned char buffer[BUFFER_SIZE];  /* 缓冲区空间 */
    volatile unsigned int pW;           /* 写地址 */
    volatile unsigned int pR;           /* 读地址 */
} ring_buffer;

环形缓冲区中最重要两个状态:缓冲区满、缓冲区空

缓冲区满:写指针的下一个位置等于读指针的位置。

缓冲区空:读指针等于写指针的位置。

当缓冲区非满时可以写缓冲区,将char c写入ring_buffer *dst_buf中

当缓冲区非空时可以读缓冲区,将ring_buffer *dst_buf中数据读出保存到char c中

/*
 *  函数名:void ring_buffer_write(unsigned char c, ring_buffer *dst_buf)
 *  输入参数:c --> 要写入的数据
 *            dst_buf --> 指向目标缓冲区
 *  输出参数:无
 *  返回值:无
 *  函数作用:向目标缓冲区写入一个字节的数据,如果缓冲区满了就丢掉此数据
*/
void ring_buffer_write(unsigned char c, ring_buffer *dst_buf)
{
    int i = (dst_buf->pW + 1) % BUFFER_SIZE;
    if(i != dst_buf->pR)    // 环形缓冲区没有写满
    {
        dst_buf->buffer[dst_buf->pW] = c;
        dst_buf->pW = i;
    }
}

/*
 *  函数名:int ring_buffer_read(unsigned char *c, ring_buffer *dst_buf)
 *  输入参数:c --> 指向将读到的数据保存到内存中的地址
 *            dst_buf --> 指向目标缓冲区
 *  输出参数:无
 *  返回值:读到数据返回0,否则返回-1
 *  函数作用:从目标缓冲区读取一个字节的数据,如果缓冲区空了返回-1表明读取失败
*/
int ring_buffer_read(unsigned char *c, ring_buffer *dst_buf)
{
    if(dst_buf->pR == dst_buf->pW)
    {
        return -1;
    }
    else
    {
        *c = dst_buf->buffer[dst_buf->pR];
        dst_buf->pR = (dst_buf->pR + 1) % BUFFER_SIZE;
        return 0;
    }
}

串口写

        将数据直接写入串口数据寄存器:将char *buf中数据逐一写入串口DR寄存器中,USART硬件会自动将数据从DR寄存器移出,并通过串行线路发送给ESP8266;

void USART3_Write(char *buf, int len)
{
	int i = 0;
	while (i < len)
	{
		while ((huart3.Instance->SR & USART_SR_TXE) == 0);
		huart3.Instance->DR = buf[i];
		i++;
	}
}

        当USART3接收到数据时(数据来自于ESP8266返回给开发板的数据),串口接收中断被使能(通过USART_CR1_RXNEIE位),则会产生一个接收中断(RXNE标志位被置位)。接着中断处理函数将DR寄存器中数据保存进环形缓冲区并释放互斥锁

        引用互斥锁,因为多个任务会访问缓存区,避免竞争

void USART3_IRQHandler(void)
{
	/* 如果发生的是RX中断
	 * 把数据读出来, 存入环形buffer
	 */

	uint32_t isrflags	= READ_REG(huart3.Instance->SR);
	uint32_t cr1its 	= READ_REG(huart3.Instance->CR1);
	char c;
	
    /* UART in mode Receiver -------------------------------------------------*/
    if (((isrflags & USART_SR_RXNE) != RESET) && ((cr1its & USART_CR1_RXNEIE) != RESET))
    {
      c = huart3.Instance->DR;
	  ring_buffer_write(c, &uart3_buffer);
	  platform_mutex_unlock_from_isr(&uart_recv_mutex);
      return;
    }

}

串口读

串口读,将中断处理函数保存在环形缓冲区中数据读取出保存在char *c中

void USART3_Read(char *c, int timeout)
{
	while (1)
	{
		if (0 == ring_buffer_read((unsigned char *)c, &uart3_buffer))
			return;
		else
		{
			platform_mutex_lock_timeout(&uart_recv_mutex, timeout);
		}
	}		
}

4. ESP8266 AT命令解析

实现AT命令发送、接收

        ESP8266常用AT命令:

        创建Freertos多任务实现AT命令发送和解析,当开发板给ESP8266发送AT命令后,ESP8266会返回数据,根据返回数据判断发送AT命令是否成功,以及存储返回的有用数据

发送AT命令

  1. 调用底层串口写函数,写数据到串口DR寄存器,
  2. 等待互斥锁,获得AT解析任务释放的互斥锁证明AT命令发送成功
  3. 可以传入参数resp,保存AT解析函数解析后的返回数据

int ATSendCmd(char *buf,char *resp, int resp_len, int timeout)
{
	int ret;
	int err;
	
	/* 发生AT命令 */
	HAL_AT_Send(buf,strlen(buf));
	HAL_AT_Send("\r\n", 2);
	
	//g_cur_cmd = buf;

	/* 等待结果 
	 * 1 : 成功得到mutex
	 * 0 : 超时返回
	 */
	ret = platform_mutex_lock_timeout(&at_ret_mutex, timeout);
	if (ret)
	{
		/* 判断返回值 */
		/* 存储resp */
		err = GetATStatus();
		if (!err && resp)		
		{
			memcpy(resp, g_at_resp, resp_len > AT_RESP_LEN ? AT_RESP_LEN : resp_len);
		}
		return err;
	}
	else
	{
		return AT_TIMEOUT;
	}
}

AT解析函数

实现步骤:

        1. 发送AT命令到ESP8266后,ESP8266会返回状态如“ok”证明发送AT命令成功,

        2. 对于一些特殊命令,ESP8266会返回数据,解析命令后应该保存数据;

        3. 如果发送数据到ESP8266, ESP8266 设备接收到服务器发来的数据,将提示如下信息:

+IPD,n:xxxxxxxxxx                //    received    n    bytes,        data=xxxxxxxxxxx

应该保存数据并处理,如手机想控制开发板发送数据到ESP8266,ESP8266收到这些数据后开发板应该解析数据执行指定操作;

        4. ESP8266 返回数据尾部会自动添加\r\n.故遇到\r\n就解析数据;

具体实现过程:

  1. 调用底层串口函数读取环形缓冲区数据
  2.  判断是否遇到回车换号(\r\n),如果有回车换行,判断返回数据中是否有"OK\r\n"或者"ERROR\r\n",
  3. 返回数据中有"OK\r\n"则保存返回数据到全局变量g_at_resp,AT发送命令函数中可以接收这个全局变量值;并致标志位AT_OK,释放互斥锁给AT发送函数;
  4. 返回数据中有"ERROR\r\n",致标志位AT_ERR,释放互斥锁
  5. 遇到特殊字符串,需特殊处理;
void ATRecvParser( void * params)
{
	char buf[AT_RESP_LEN];
	int i = 0;
	while (1)
	{
		/* 读取WIFI模块发来的数据:  使用阻塞方式 */
		HAL_AT_Recv(&buf[i], (int)portMAX_DELAY);
		/* 得到了回车换行 */
		buf[i+1] = '\0';//对于所有数据先补结束符

		/* 解析结果 */
		/* 1. 何时解析?    
		 * 1.1 收到"\r\n"
		 * 1.2 收到特殊字符: "+IPD,"
		 */
		if (i && (buf[i-1] == '\r') && (buf[i] == '\n'))
		{
			/* 2. 怎么解析 */
			if (strstr(buf, "OK\r\n"))
			{
				/* 记录数据 */
				memcpy(g_at_resp, buf, i);
				SetATStatus(AT_OK);
				platform_mutex_unlock(&at_ret_mutex);
				i = 0;
			}
			else if (strstr(buf, "ERROR\r\n"))
			{
				memcpy(g_at_resp, buf, i);
				SetATStatus(AT_ERR);
				platform_mutex_unlock(&at_ret_mutex);
				i = 0;
			}

			i=0;	
		}
		else if (GetSpecialATString(buf))
		{
			ProcessSpecialATString(buf);
			i = 0;
		}
		else
		{
			i++;
		}

		if (i >= AT_RESP_LEN)
			i = 0;
	}
}

返回数据中有特殊字符串如:

static int GetSpecialATString(char *buf)
{
	if (strstr(buf, "+IPD,"))
		return 1;
	else
		return 0;
}

遇到"+IPD"需要特殊处理,保存数据长度以及具体数据,便于其他函数调用

        这里将返回的真实数据保存在一个新的全局环形缓存区g_packet_buffer中,以便其他函数使用;

        当成功保存进缓存区中后释放互斥锁给读取这个缓冲区的函数

static void ProcessSpecialATString(char *buf)
{
	int i = 0;
	int len = 0;
	/* +IPD,78:xxxxxxxxxx */
	//while (1)
	//{
		/* 解析出长度 */
		i = 0;
		while (1)
		{
			HAL_AT_Recv(&buf[i], (int)portMAX_DELAY);
			if (buf[i] == ':')//':'当 ESP8266 设备接收到服务器发来的数据,将提示如下信息:+IPD,n:xxxxxxxxxx				//	received	n	bytes,data=xxxxxxxxxxx	
			{
				break;
			}
			else
			{
				len = len * 10 + (buf[i] - '0');//记录下长度
			}
			i++;
		}
		/* 读取真正的数据 */
		i = 0;
		while (i < len)
		{
			HAL_AT_Recv(&buf[i], (int)portMAX_DELAY);
			if (i < AT_RESP_LEN)
			{
				/* 把数据放入环形buffer */
				ring_buffer_write(buf[i], &g_packet_buffer);
				
				/* wake up */
				/* 解锁 */
				platform_mutex_unlock(&at_packet_mutex);		
			}
			i++;
		}
	
	//}
}

        将ESP8266接收的数据保存到一个新的环形缓冲区中后,需要有一个函数读取这个缓存区数据

        直接读取缓存区g_packet_buffer数据,当读取没有数据时,应等待互斥锁,当获得互斥锁,证明解析函数解析出数据并并保存进g_packet_buffer缓冲区中,此时可以读取;

int ATReadData( unsigned char *c, int timeout)
{
	int ret;
	do {
		if (0 == ring_buffer_read((unsigned char *)c, &g_packet_buffer))//从环形缓冲区读到数据,数据来自ESp8266回复发送
		{	
		return AT_OK;
		}
		else
		{
			ret = platform_mutex_lock_timeout(&at_packet_mutex, timeout);//没有读到数据则等待互斥量,得到互斥量即解析函数中解析到数据,并保存在环形缓冲区中可再一次读取
			if (0 == ret)
				return AT_TIMEOUT;
		}
	}while (ret == 1);	
	return 0;
}

5. 网络层函数

        MQTT属于应用层协议,必然要依赖于传输层,如图所示,使用MQTT层mqtt_connect(client)网络连接,必然要调用底层网络层函数platform_net_socket_connect;

  1.         实现我们自己网络平台的连接、读、写、关闭等网络传输层功能,基于AT命令函数实现;因为因为我们使用的STM32开发板本身没有内置的网卡或网络堆栈来直接处理TCP/IP协议,不可以使用socket实现网络层。需要通过发送AT命令实现网络层函数;
  2. 网络连接

    1.         查询ESP8266AT命令手册,使用底层AT命令函数发送指定命令连接网络
      1.         这个函数同样依赖于 TCP 或 UDP 协议,但这是在 ESP8266 模块内部处理的,而不是在 STM32 上直接处理的;
  3. int platform_net_socket_connect(const char *host, const char *port, int proto)
    {
    	int err;
    	char cmd[100];
    	while (1)
    	{
    		/*配置WIFI模式*/
    		err=ATSendCmd("AT+CWMODE=1",NULL,0,200000);
    		if(err)
    		{
    			printf("AT+CWMODE=1 err = %d\r\n", err);
    			//return err;
    		}
    		/* 2. 连接路由器 */
    
    		/* 2.1 连接AP */
    		err = ATSendCmd("AT+CWJAP=\"" TEST_SSID "\",\"" TEST_PASSWD "\"", NULL, 0, 10000);
    		if (err)
    		{
    			printf("connect AP err = %d\r\n", err);
    			//return err;
    			continue;
    		}
    		
    		/* 2.2 关闭TCP连接 */
    		err = ATSendCmd("AT+CIPCLOSE", NULL, 0, 2000);
    		
    		/* 3. 连接到服务器 */
    		if (proto == PLATFORM_NET_PROTO_TCP)//TCPģʽ
    		{
    			sprintf(cmd, "AT+CIPSTART=\"TCP\",\"%s\",%s", host, port);//sprintf将数据保存到数组中
    		}
    		else//先通过sprintf将数据保存在数组cmd中
    		{
    			sprintf(cmd, "AT+CIPSTART=\"UDP\",\"%s\",%s", host, port);//UDP
    		}
    
    		err = ATSendCmd(cmd, NULL, 0, 20000);
    		if (err)
    		{
    			printf("%s err = %d\r\n", cmd, err);
    			//return err;
    			continue;
    		}
    		if (!err)
    			break;
    	}
    	return 0;
    }

    网络读

    1.         调用底层ATReadData函数,从环形缓冲区中读取服务器中发送给ESP8266的有用数据,填入参数增加超时限制;
  4. /* 返回得到的字节数 */
    int platform_net_socket_recv_timeout(int fd, unsigned char *buf, int len, int timeout)
    {
    	int i=0;
    	int err;
    	/* 读数据, 失败则阻塞timeout时间 */
    	while (i < len)
    	{
    		err = ATReadData(&buf[i], timeout);
    		if (err)
    		{
    			return 0;
    		}
    		i++;
    	}
    	return len;
    }

    网络写

    1.         调用底层AT命令发送函数,发送"AT+CIPSEND=len",表明ESP8266想要发送数据给服务器,指定要发送数据长度len;然后调用ATSendData发送len长度的数据,ATSendData给ATSendCmd的区别是不用再结尾发送"\r\n",指定ESP8266想要发送数据给服务器后,发送具体数据结尾不用添加"\r\n";
    2.          ATSendData同样是调用底层串口写函数;将数据通过串口写入串口DR寄存器,USART硬件会自动将数据从DR寄存器移出,并通过串行线路发送给ESP8266;
    3. nt platform_net_socket_write_timeout(int fd, char *buf, int len, int timeout)
      {
      	int err;
      	char cmd[20];
      	char resp[100];
      
      	sprintf(cmd, "AT+CIPSEND=%d", len);
      	err=ATSendCmd(cmd, resp ,100,timeout);
      	if (err)
      	{
      		resp[99] = '\0';
      		printf("%s err = %d, timeout = %d\r\n", cmd, err, timeout);
      		printf("resp : %s\r\n", resp);
      		return err;
      	}
      	err=ATSendData(buf, len ,timeout);
      	if (err)
      	{
      		printf("ATSendData err = %d\r\n", err);
      		return err;
      	}
      	return len;
      }

      网络关

      1.         发送指定命令关闭网络连接
        1. int platform_net_socket_close(int fd)
          {
          	return ATSendCmd("AT+CIPCLOSE",NULL,0,2000);
          }

            综合测试

          1.         测试之前需要搭建服务器这里使用mosquitto,
  5. 下载mosquitto
  6. 修改配置文件mosquitto.conf,如下修改:
# listener port-number [ip address/host name/unix socket path]
listener 1883

allow_anonymous true
  1. 使用DOS命令行,进入mosquitto-2.0.14-install-windows-x64的安装目录,执行命令启动MQTT Broker:
  2. cd  "c:\Program Files\mosquitto"
    .\mosquitto.exe -c mosquitto.conf -v

  3.         开启MQTT Broke后可以开始测试
  4. 本项目基于Freerots实现,主要有两个任务,一个为MQTT综合任务,一个为AT命令解析任务
  5.         在前面我们已经实现网络传输层函数,综合测试可直接调用MQTT中接口函数;
  6. 实现步骤:
  7. 创建一个客户端mqtt_client_t *client结构体;
  8. 创建一个消息mqtt_message_t msg,用于发布消息;
  9.  client = mqtt_lease():分配client客户端结构体空间,并初始化client结构体;
  10. 设置MQTT客户端连接到MQTT服务器(也称为MQTT代理或MQTT broker)时所需的参数。
  11. mqtt_connect:连接服务器
  12. mqtt_subscribe:订阅一个topic1主题,如果开发板收到这个主题的消息,则执行topic1_handler函数,可添加多个订阅;
  13. mqtt_publish():发布主题消息,消息内容为mqtt_message_t msg

        这里实现了开发板订阅了一个topic1主题,和一个topic1_handler处理函数,当开发板收到topic1主题消息,执行topic1_handler处理函数点亮一个小灯熄灭一个小灯,然后又不断发送topic1主题消息;

所以测试现象为开发板上两个小灯不断亮灭;使用MQTTX工具可以接受到数据,显示数值不断增加;

  1. //不提供函数-收到主题信息执行使用默认函数
    static void topic1_handler(void* client, message_data_t* msg)
    {
    	(void)client;
    	HAL_GPIO_WritePin(GPIOB,GPIO_PIN_5,GPIO_PIN_RESET); 
        HAL_GPIO_WritePin(GPIOE,GPIO_PIN_5,GPIO_PIN_SET);   	
    	
    	MQTT_LOG_I("--------------------------------------------------------------------------");
    	MQTT_LOG_I("%s:%d %s()...\ntopic: %s, qos: %d, \nmessage:%s", __FILE__, __LINE__, __FUNCTION__,  msg->topic_name, msg->message->qos, (char*)msg->message->payload);
    	MQTT_LOG_I("--------------------------------------------------------------------------");
    	
    }
    
    void MQTT_Client_Task(void *Param)
    {
    	int err;
    	char buf[20];
    	int cnt = 0;
        mqtt_client_t *client = NULL;//定义一个MQTT 客户端对象,用结构体封装 MQTT 协议相关的各种状态和信息。
    	mqtt_message_t msg;
    	memset(&msg, 0, sizeof(msg));
    
    
        mqtt_log_init();//初始化 MQTT 库或应用程序中的日志记录功能
        client = mqtt_lease();//分配client客户端结构体空间,并初始化client结构体
    	
        mqtt_set_port(client, "1883");//设置MQTT客户端将要连接的MQTT服务器的端口号
    
        mqtt_set_host(client, "192.168.2.102");//设置MQTT服务器的IP地址
        //mqtt_set_host(client, "47.114.187.247");
        mqtt_set_client_id(client, random_string(10));//设置MQTT客户端的ID
        mqtt_set_user_name(client, random_string(10));
        mqtt_set_password(client, random_string(10));
        mqtt_set_clean_session(client, 1);
    
        if (0 != mqtt_connect(client))//连接服务器
        {
    		printf("mqtt_connect err\r\n");
    		vTaskDelete(NULL);//freertos中任务不能直接返回,可以自杀结束
        }
    
        err = mqtt_subscribe(client, "topic1", QOS0, topic1_handler);//提供topic1_handler函数,收到topic1主题信息执行topic1_handler函数
    	if (err)
    	{
    		printf("mqtt_subscribe topic1 err\r\n");
    	}
    
        msg.payload = buf;
        msg.qos = 0;
    
        while (1) {
    		sprintf(buf, "dcq, %d", cnt++);
            msg.payloadlen = strlen(msg.payload);
    		mqtt_publish(client, "topic1", &msg);
    		vTaskDelay(3000);
        }
    }

  • 22
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值