写在前面
前面几天倒是一直在打智能车比赛,没有更新什么博客,现在好不容易空闲下来却不知写点什么好,于是先开一个新系列,即基于ESP物联网设备的嵌入式开发。
我们嵌入式开发在很多时候都有无线连接的需求,如蓝牙串口通信,物联网,甚至是之前的那篇博客的核心需求大模型结合ESP32设备?一次简单尝试 - TangSong404’s Blog。而无线通信中WIFI模块相较于蓝牙模块有更多的用武之地,其中最常见而便宜的便是esp-01s模块。
这篇文章我将记录如何通过esp-01s模块,使得stm32实现”在线调参与波形图”和”简单的http通信”两个功能的方法与源码。
前提知识
有关模块
ESP8266 (ESP-01S)是一款轻便,超低功耗的一款WIFI模块,可对其进行二次开发,该模块出厂时默认自带出厂固件的,但如果进行了开发即下载了自己写的程序,想再次使用原厂AT指令,这时需要重新烧录原厂AT固件。
我们本篇博客全部功能都基于原厂AT固件,通过uart与stm32通信来实现。这样开发者只需要掌握stm32等主控mcu的开发经验而不需要关注wifi模块其本身。
当然,该模块的二次开发具有强大而完整的生态,完全可以使用arduino IDE的esp8266库进行更多功能的开发。由简入繁,这就需要在模块与主控mcu双端进行代码编写了,本次博客就不赘述了。
AT指令
AT指令,是一种用于与调制解调器和其他通信设备进行交互的命令语言。
指令通常以AT开头,后跟特定的命令和参数,以控制设备的行为。被广泛应用于各种通信设备,例如:GSM/GPRS/3G/4G/5G 模块、蓝牙模块、WiFi 模块、GPS 模块
控制esp01s的常见AT指令如下
基本命令
AT: 测试, 模块正常应当返回OK
AT+RST: 重启模块
ATE: 配置 AT 命令的回显.
WIFI命令
AT+CWMODE: 查看/设置当前的WIFI模式(Station/SoftAP/Station+SoftAP)
AT+CWLAP: 列出周围的WIFI AP, 需要先设置为station模式, AT+CWMODE=1
AT+CWJAP: 连接到WIFI AP, 命令格式 AT+CWJAP=”abcdp”,”cptbtptp”
TCP/IP命令
AT+PING: ping指定的地址, 返回平均响应时间
AT+CIPSTART: 建立TCP/UDP/SSL连接, 命令格式 AT+CIPSTART=”UDP”,”192.168.10.246”,8082
AT+CIPMODE: 查询/设置传输模式
DMA通信
stm32与无线模块的通信是随时且频繁的,况且运用场景往往都是实时性要求高的控制系统调整场景,所以接收与发送都不应该是阻塞式的,我们选择DMA进行UART通信正适用于此场景。
整个通信流程应当如下图所示
很显然由于具体的收发操作是由DMA完成的,stm32几乎只要不断向tx_fifo中写入数据以及从rx_fifo中读取数据即可完成通信,由通信导致的CPU占用率相当低
功能实现
基础封装代码
dma式uart
dma方法的uart代码我已经实现封装如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void dma_uart_init(DMA_UART *dma_uart,DMA_HandleTypeDef* dma,UART_HandleTypeDef *uart,FIFO* txf,FIFO* rxf);
void dma_uart_transmit(DMA_UART *dma_uart,uint8_t *pData,uint16_t Size);
void dma_uart_fifo_clear(DMA_UART *dma_uart);
uint16_t dma_uart_fifo_read(DMA_UART *dma_uart,uint8_t* array,uint16_t len);
uint16_t dma_uart_fifo_tail_peek(DMA_UART *dma_uart,uint8_t* array,uint16_t len);
void dma_uart_callback_Rx(DMA_UART *dma_uart,UART_HandleTypeDef *uart,uint16_t Size);
void dma_uart_callback_Tx(DMA_UART *dma_uart,UART_HandleTypeDef *uart);
|
模块初始化
首先要做的是模块使能,紧接着就是对模块进行复位
发送AT+CWAUTOCONN=0
关闭WIFI自动连接并且发送AT+CWMODE=1
设置模块为STA模式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| uint8_t esp01s_init(DMA_HandleTypeDef* dma,UART_HandleTypeDef *uart,GPIO_TypeDef *Port,uint16_t RST,uint16_t EN){ ESP_Port = Port; ESP_RST = RST; ESP_EN = EN; ESP01_EN(1); ESP01_RST(0); HAL_Delay(10); ESP01_RST(1); HAL_Delay(500); dma_uart_init(&esp_dma_uart,dma,uart,&esp_txf,&esp_rxf); dma_uart_transmit(&esp_dma_uart,(uint8_t*)"AT+CWAUTOCONN=0\r\n",17); uint8_t state = esp01s_waitAck("OK"); dma_uart_fifo_clear(&esp_dma_uart); if(state)return state; dma_uart_transmit(&esp_dma_uart,(uint8_t*)"AT+CWMODE=1\r\n",13); state = esp01s_waitAck("OK"); dma_uart_fifo_clear(&esp_dma_uart); return state; }
|
大多数AT指令执行成功的响应都是”OK”,而执行失败的响应都是”ERROR”,我们需要通过判断其相应值来判定当前操作是否执行成功
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| static uint8_t esp01s_waitAck (char *wait_buffer){ uint8_t return_state = ACK_TIMEOUT; uint32_t timeout = WAIT_TIME; char receiver_buffer[8] = {0,0,0,0,0,0,0,0}; do { HAL_Delay(1); dma_uart_fifo_tail_peek(&esp_dma_uart, (uint8_t *)receiver_buffer, 8); if(strstr(receiver_buffer, wait_buffer)) { return_state = ACK_OK; break; } else if(strstr(receiver_buffer, "ERROR")) { return_state = ACK_ERROR; break; } }while(timeout --); return return_state; }
|
dma_uart_fifo_tail_peek
可以查看队尾的值,也就是最新接收到的值,当其包含”OK”时才说明执行成功,若是”ERROR”执行失败,否则就会返回超时
WIFI与UDP连接
有了上面部分的解释,这部分的逻辑应该是清晰明了了,无非只是AT指令的不同而已
值得注意的是发送AT+CIPSEND
开启透传后最后的相应是”>”而不是”OK”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| uint8_t esp01s_wifi(const char* ssid,const char* password){ memset(ATs,0,sizeof(ATs)); dma_uart_fifo_clear(&esp_dma_uart); snprintf((char*)ATs, sizeof(ATs), "AT+CWJAP=\"%s\",\"%s\"\r\n", ssid, password); dma_uart_transmit(&esp_dma_uart,ATs,sizeof(ATs)); uint8_t state = esp01s_waitAck("OK"); dma_uart_fifo_clear(&esp_dma_uart); return state; }
uint8_t esp01s_udp(const char* ip,uint16_t port){ dma_uart_fifo_clear(&esp_dma_uart); memset(ATs,0,sizeof(ATs)); snprintf((char*)ATs, sizeof(ATs), "AT+CIPSTART=\"UDP\",\"%s\",%hu,8080\r\n", ip, port); dma_uart_transmit(&esp_dma_uart,ATs,sizeof(ATs)); uint8_t state = esp01s_waitAck("OK"); dma_uart_fifo_clear(&esp_dma_uart); if(state)return state; dma_uart_transmit(&esp_dma_uart,(uint8_t*)"AT+CIPMODE=1\r\n",14); state = esp01s_waitAck("OK"); dma_uart_fifo_clear(&esp_dma_uart); if(state)return state;
dma_uart_transmit(&esp_dma_uart,(uint8_t*)"AT+CIPSEND\r\n",12); state = esp01s_waitAck(">"); dma_uart_fifo_clear(&esp_dma_uart); if(state)return state; return 0; }
|
至此基础的封装基本完成,后面你所看到的诸如esp01s_send
以及esp01s_receive
等函数也仅仅是给dma_uart_transmit
以及dma_uart_fifo_read
函数套了一层壳而已。由于可以望文生义,便不再拿来展示了
VOFA在线波形图与调参
我们平时使用VOFA调控制参数时,不论是蓝牙调参还是有线连接,往往需要用到计算机的COM口,一个usb转ttl模块在所难免。
而使用esp-01s模块进行调参,我们只需要保证模块与计算机处于一个局域网下,就可以打开udp端口进行连接,十分方便。
数据协议
波形图的解析由上位机实现,所以我们只需要在stm32端按照FireWater或者JustFloat协议发送多个通道的数据即可实现波形图
VOFA三大协议
而想实现在线调参则是上位机向下位机发送数据,我们需要自定义数据协议并在下位机实现解析
我自定义的协议为
[索引]-[浮点数据]
[索引]-[浮点数据]
….
params\r\n
在下位机接收到以params\r\n
为结尾大的数据,则会解析每一条[索引]-[浮点数据]\n
为该索引通道获取数据。
上位机的配置
以下便是VOFA上位机使用的效果图
udp连接与波形图
保证你的计算机与下位机处于同一局域网下
数据接口选择UDP,配置好你预定的远程与本地端口。至于下位机IP,你可以在你所连接WIFI的连接设备列表下找到
要显示波形图的话,数据引擎要选择JustFloat或者FireWater,多个下位机发来的数据会被自动解析,右键波形图可以在图上设置X轴与Y轴,操作简单就不多描述了
在线调参
我们可以直接在发送区直接发送如下数据来同时修改多个通道的参数并发送
0-3.14159\n1-0.618\n2-2.5params\r\n
但更多的时候人性化的操作往往是通过滑块或者输入框来调节单个参数的数值
于是我们这时候就需要在VOFA中创建命令,如下图所示
发送内容中的%f占位符将会被滑块当前的值取代,其余部分按照自定义的数据协议来即可,图中我们要绑定通道3的参数,所以就设定
3-%f\nparams\r\n
然后在滑块控件上右键绑定刚刚设置的命令,当滑块被拖动或者输入框的值改变时,命令中设定的发送内容也会被发送至下位机
至此上位机的配置基本完成
下位机代码
初始化,简单易懂
1 2 3 4 5 6 7 8
| uint8_t vofa_init(void){ uint8_t udp_times = 10; if(esp01s_init(&ESP_DMA,&ESP_UART,ESP_Port,ESP_RST_PIN,ESP_EN_PIN))return 0; if(esp01s_wifi(SSID,PASSWORD))return 0; while(udp_times--&&esp01s_udp(REMOTE_IP,REMOTE_PORT)){HAL_Delay(50);} esp01s_send((uint8_t*)"The udp connection has been established!\r\n",42); return 1; }
|
三大VOFA数据协议,由于已经提供了void esp01s_send(uint8_t *pData,uint16_t Size)
函数,所以写起来没有难度,这里就省略了,需要源码者可以查看我的仓库SugarSong404/TangSong_Stm32_Library
1 2 3
| void vofa_justfloat(uint8_t channels, ...); void vofa_rawdata(uint8_t* pData,uint16_t len); void vofa_firewater(const char *format, ...);
|
下面的在线调参的参数接收功能是唯一需要一些解释的部分
我们不断查看接收队列最新接收的值并存放在end数组中,当其与params\r\n
相等时将接收队列所有的值读取,不断地去匹配%d-%f\n
格式化字符串,从中获取到index以及data,于是我们便可以通过参数x=params[index]来实时修改x的值为data
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| void vofa_parameters(void){ char end[9] = {0}; end[8] = '\0'; char temp[128] = {0}; char* ptr = temp; int index = 0; float data = 0.0f; esp01s_peek((uint8_t*)end,sizeof(end)-1); if(!strcmp(end,"params\r\n")){ esp01s_receive((uint8_t*)temp,sizeof(temp)); while(sscanf(ptr,"%d-%f\n", &index,&data)==2){ if(index<MAX_CH_NUM){ params[index] = data; vofa_firewater("param %d set %f!\n",index,data); } else vofa_firewater("param %d out of range!\n",index); ptr = strchr(ptr, '\n'); if (ptr == NULL)break; ptr++; } } }
|
简单的http通信
测试方法与结果
为了方便测试,我们先在计算机localhost:8084开放一个本地服务器
可以通过向/get_test路径发送GET请求,或者携带key参数向/post_test路径发送POST请求进行模拟
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const express = require('express'); const app = express(); app.use(express.json()); app.get('/get_test', (req, res) => { res.json({time:Date.now() }); console.log("GET:") console.log("ID is "+req.query.id) }); app.post('/post_test', (req, res) => { res.json({time:Date.now() }); console.log("POST:") console.log("apikey is " + req.body.apikey) }); const PORT = 8084; app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); });
|
GET请求
构建一个建议的GET请求体大致如下
1 2 3 4
| GET /get_test?id=123 HTTP/1.1 Content-Type: application/json;charset=utf-8 Host: 192.168.96.246 Connection: Keep Alive
|
测试成功时服务器端会输出如下图的ID is 123
,注意此时的id是在query中传递的
POST请求
构建一个建议的POST请求体大致如下
1 2 3 4 5 6 7
| POST /post_test HTTP/1.1 Content-Type: application/json;charset=utf-8 Host: 192.168.96.246 Connection: Keep-Alive Content-Length: 16
{"apiKey":"123"}
|
测试成功时服务器端会输出apiKey is 123
,注意此时的apiKey为了模拟post的请求,不放query里传递了,而是作为post请求的body传递
而同样的,下位机端将接收到服务器响应,包含了一个json格式的时间戳,这在上面GET请求部分也是如此
下位机代码
于是我们便可以写代码测试了,这里只是简单的做个测试,所以代码结构与逻辑并不会那么严谨
先建立一个esp01s层建立TCP连接
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| uint8_t esp01s_tcp_start(const char* ip,uint16_t port){ dma_uart_fifo_clear(&esp_dma_uart); memset(ATs,0,sizeof(ATs)); snprintf((char*)ATs, sizeof(ATs), "AT+CIPSTART=\"TCP\",\"%s\",%hu\r\n", ip, port); dma_uart_transmit(&esp_dma_uart,ATs,sizeof(ATs)); uint8_t state = esp01s_waitAck("OK"); if(state)return state; dma_uart_transmit(&esp_dma_uart,(uint8_t*)"AT+CIPMODE=1\r\n",14); state = esp01s_waitAck("OK"); dma_uart_fifo_clear(&esp_dma_uart); if(state)return state;
dma_uart_transmit(&esp_dma_uart,(uint8_t*)"AT+CIPSEND\r\n",12); state = esp01s_waitAck(">"); dma_uart_fifo_clear(&esp_dma_uart); if(state)return state; return 0; }
|
然后便可以在http层快乐地写get与post的方法了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| #include "http.h" char http_content[200]; uint8_t http_init(void){ if(esp01s_init(&ESP_DMA,&ESP_UART,ESP_Port,ESP_RST_PIN,ESP_EN_PIN))return 0; if(esp01s_wifi(SSID,PASSWORD))return 0; return 1; } void http_get(const char* host, const char* router,uint16_t port) { memset(http_content,0,sizeof(http_content)); esp01s_tcp_start(host,port); uint8_t len = snprintf(http_content, sizeof(http_content), "GET %s HTTP/1.1\r\n" "Content-Type: application/json;charset=utf-8\r\n" "Host: %s\r\n" "Connection: Keep-Alive\r\n\r\n", router, host); esp01s_send((uint8_t*)http_content,len); } void http_post(const char* host, const char* router,uint16_t port,const char* json){ memset(http_content,0,sizeof(http_content)); esp01s_tcp_start(host,port); uint8_t len = snprintf(http_content, sizeof(http_content), "POST %s HTTP/1.1\r\n" "Content-Type: application/json;charset=utf-8\r\n" "Host: %s\r\nContent-Length: %d\r\n" "Connection: Keep-Alive\r\n\r\n%s\r\n\r\n", router, host,strlen(json),json); esp01s_send((uint8_t*)http_content,len); }
|
并且按着如下方法调用
1 2
| http_get("192.168.20.246","/get_test?id=123",8084); http_post("192.168.20.246","/post_test",8084,"{\"apiKey\":123}");
|
很轻而易举地便可以实现上面的测试成功结果
后记
在大模型结合ESP32设备?一次简单尝试 - TangSong404’s Blog这篇博客的后记中我对M5Stack小机器人代码有着如下改进的想法
后续要做的不仅仅是换api
更应该对代码进行封装,变成一个个可直接调用方法的类库
再进一步抽象为抽象类或者接口,可以由不同的实现类继承,适应不同的api,定义一套规范
不过当时那都是对于物联网ESP32单片机的,现在我想将这个想法玩的彻底一点,就是通过stm32与esp01s模块实现同样功能的小机器人,为任意单片机提供一个连接大模型的便捷方法,这将会在接下来的博客中娓娓道来。