基于STM32实现OTA&BootLoader 第五章——OTA功能开发【上】
一、OTA策略方案
1、OTA事件订阅和发布
(1)客户端可以向服务器订阅OTA事件,当服务器有新版本软件发布时,就可以把版本信息发给客户端确认,客户端根据服务器传来的版本信息判断是否需要更新软件。
(2)很显然,OTA事件的订阅和发布可以基于MQTT协议实现,为了便于区分,只有“MQTT客户端”和“MQTT服务端”是指代MQTT协议中的角色名词。(MQTT协议在本教程不做详细介绍,可参考《零基础入门学用物联网(ESP8266)》的第二部分)
(3)基于MQTT协议的OTA事件订阅和发布流程:
①服务器作为MQTT客户端,向MQTT服务端发布主题为“OTA_Event_example_test”的信息。
[1]有效载荷为新发布软件的版本号,格式为“VER-x.x.x-xxxx/xx/xx-xx:xx”。(前三个未知数为版本号关键信息,后面的未知数仅为发布日期,可忽略)
[2]QoS采用1级、dupFlag设置为True,保证每个MQTT客户端至少收到1次消息。
[3]retainFlag置为True,保证其它掉线的MQTT客户端上线后能收到订阅的OTA事件消息。
[4]服务器本身可以既作为MQTT客户端发布消息,又可作为MQTT服务端控制MQTT客户端间的通讯,考虑到搭建MQTT服务器不是本教程的重点,本教程使用公用的MQTT服务器,同样能实现OTA功能。

②客户端上电后,作为MQTT客户端,请求与MQTT服务端连接。
[1]cleanSession置为False,以指示自身是重要MQTT客户端,让MQTT服务端为其保存其未接收的MQTT报文。
[2]KeepAlive设置为60即可。

③客户端作为MQTT客户端,与MQTT服务端连接后马上订阅“OTA_Event_example_test”主题。

④如果MQTT服务端有收到该主题的MQTT报文且未发送过给该MQTT客户端,就会发送之,并且采用QoS=1,保证OTA事件信息能被MQTT客户端收到。

⑤在实际项目中,可能用户端上电一段时间后就不会允许进入OTA模式了,如果只有OTA事件的传递使用了这个MQTT服务器,那么在上电一段时间后,用户端可以主动断开这个MQTT连接,以释放网络资源,同时停止自身没有意义的任务(如发送心跳请求报文)。

2、新版本软件数据流传输
(1)如果用户端收到了OTA事件,则可向数据服务器发送请求,请求服务器响应回传新版本软件。
(2)很显然,新版本软件数据流传输可通过HTTP协议实现,不过,这并不意味着只能通过HTTP协议实现。(HTTP协议在本教程不做详细介绍,可参考《零基础入门学用物联网(ESP8266)》的第一部分及《计算机网络》第六章)
(3)当新版本软件较大时,需要采取断点续传或分片上传,分段传输一个大文件,避免将整个软件放在一个HTTP响应中回传;如果是基于TCP的可靠传输,那么上层HTTP协议可以不再单独对软件数据做校验,当然,这取决于项目对网络安全的需求。
(4)基于HTTP协议的新版本软件数据流传输:
①用户端向数据服务器发送GET方法的HTTP请求,其中URL为需求下载的软件版本大小(单位为字节)。
②数据服务器根据用户端的HTTP请求,将其需求的软件大小(单位为字节)通过HTTP响应报文回传给用户端。

③用户端向数据服务器发送GET方法的HTTP请求,其中URL为需求下载的软件版本。
④数据服务器根据用户端的HTTP请求,将其需求的软件通过HTTP响应报文回传给用户端。

3、软件更新
(1)如果项目是基于第三章设计的双分区BootLoader程序进行开发,那么OTA功能需要在A区程序中开发,A区程序负责将数据服务器回传的新版本软件数据流写入W25Q64,完成写入后,置位OTA Flag,然后重启(Reset)CPU,BootLoader就会将W25Q64中的新版本软件写入Flash的A区中。

(2)如果项目是基于第四章设计的三分区BootLoader程序进行开发,那么OTA功能需要在B区程序中开发,B区程序负责将数据服务器回传的新版本软件数据流直接写入Flash的A区中。

(3)在一个复杂的网络中,以车联网为例,并不是所有控制器都具备联网功能,往往是车上的多媒体与数据服务器连接,将所有控制器的新版本软件下载后,再通过UDS协议将各控制器的新版本软件传输给对应控制器,这要求各控制器支持UDS的相应服务,而非本教程的联网OTA功能,因此这种情况不在本教程的讨论范围内。

(4)需要说明的是,本章基于第三章设计的双分区BootLoader程序进行OTA功能开发,并假定控制器具备联网功能。只要掌握了这种OTA策略方案开发,那么其它变式的OTA策略方案也是万变不离其宗的。
4、Demo架构
(1)本教程设计需要更新软件的客户端为STM32F103C8T6单片机,单片机通过ESP8266进行联网,而存放新版本软件的服务器也由ESP8266搭建。
(2)单片机与客户端ESP8266之间使用串口通讯,单片机向ESP8266发送AT指令,ESP8266仅负责执行AT指令并回传数据,不在ESP8266中做任何个性化操作(如将服务器的IP地址写死)。
(3)客户端硬件电路连接方式:
①USB转串口模块:
|
STM32引脚 |
模块引脚 |
|
PA10 |
TXD |
|
PA9 |
RXD |
|
GND |
GND |
|
/ |
VCC与3.3V短接 |
②W25Q64模块:
|
STM32引脚 |
模块引脚 |
|
PA4 |
CS |
|
PA5 |
CLK |
|
PA6 |
DO |
|
PA7 |
DI |
|
3.3V |
VCC |
|
GND |
GND |
③24C02模块:
|
STM32引脚 |
模块引脚 |
|
PB11 |
SDA |
|
PB10 |
SCL |
|
3.3V |
VCC |
|
GND |
GND |
④ESP8266模块(需要为ESP8266提供独立的3.3V电源):
|
STM32引脚 |
模块引脚 |
|
PA2(USART2_TX) |
RX(GPIO3)(UART0) |
|
PA3(USART2_RX) |
TX(GPIO1)(UART0) |
|
GND |
GND |
二、开发服务端ESP8266
1、新版本软件上传
(1)当开发者完成新版本软件开发时,他们需要将软件及软件版本号上传服务器,那么开发者操作的就是客户端,ESP8266作为服务端。
①客户端向ESP8266发送HTTP请求,请求访问根目录“/”。
②ESP8266发回HTTP响应,客户端基于HTTP响应在浏览器构建上传文件的网站。
③客户端向ESP8266发送HTTP请求,请求向服务器上传数据,并声明写入状态(开始写入/写入中/写入结束)。
④ESP8266根据客户端上传的数据构建文件,并写入闪存文件系统,然后发回HTTP响应。
⑤重复③、④,直到写入结束,即可完成软件上传。
(2)程序流程示意图:

(3)将以下代码及网页文件下载至作为服务器的ESP8266(需要声明的是,本例程借用作者“太极创客”的示例程序,下载方法可参考《零基础入门学用物联网(ESP8266)》的第一部分)。
#include <ESP8266WiFi.h>
#include <ESP8266WiFiMulti.h>
#include <ESP8266WebServer.h>
#include <FS.h>
ESP8266WiFiMulti wifiMulti;
ESP8266WebServer esp8266_server(80);
File fsUploadFile; // 建立文件对象用于闪存文件上传
void setup(){
Serial.begin(9600);
Serial.println("");
wifiMulti.addAP("Zevalin_Computer", "00114514");
wifiMulti.addAP("Zevalin_esp8266", "00114514");
Serial.println("Connecting ...");
int i = 0;
while (wifiMulti.run() != WL_CONNECTED){
delay(1000);
Serial.print(i++); Serial.print('.');
}
Serial.println('\n');
Serial.print("Connected to "); Serial.println(WiFi.SSID());
Serial.print("IP address:\t"); Serial.println(WiFi.localIP());
if(SPIFFS.begin()){ // 启动闪存文件系统
Serial.println("SPIFFS Started.");
}
else{
Serial.println("SPIFFS Failed to Start.");
}
esp8266_server.on("/upload.html", HTTP_POST, respondOK, handleFileUpload); // 如果客户端通过upload页面向服务器发送文件(请求方法POST),则回复状态码200给客户端,并且运行处理文件上传函数
esp8266_server.onNotFound(handleUserRequest);
esp8266_server.begin(); // 启动网站服务
Serial.println("HTTP server started");
}
void loop(){
esp8266_server.handleClient();
}
// 处理上传文件函数
void handleFileUpload(){
HTTPUpload& upload = esp8266_server.upload();
if(upload.status == UPLOAD_FILE_START){ // 如果上传状态为UPLOAD_FILE_START
String filename = upload.filename; // 建立字符串变量用于存放上传文件名
if(!filename.startsWith("/")) filename = "/" + filename; // 为上传文件名前加上"/"
Serial.println("File Name: " + filename); // 通过串口监视器输出上传文件的名称
fsUploadFile = SPIFFS.open(filename, "w"); // 在SPIFFS中建立文件用于写入用户上传的文件数据
}
else if(upload.status == UPLOAD_FILE_WRITE){ // 如果上传状态为UPLOAD_FILE_WRITE
if(fsUploadFile)
fsUploadFile.write(upload.buf, upload.currentSize); // 向SPIFFS文件写入浏览器发来的文件数据
}
else if(upload.status == UPLOAD_FILE_END){ // 如果上传状态为UPLOAD_FILE_END
if(fsUploadFile){ // 如果文件成功建立(建立成功,文件指针为非空指针)
fsUploadFile.close(); // 将文件关闭
Serial.println(" Size: "+ upload.totalSize); // 通过串口监视器输出文件大小
esp8266_server.sendHeader("Location","/success.html"); // 将浏览器跳转到/success.html(成功上传页面)
esp8266_server.send(303); // 发送响应代码303(重定向到新页面)
}
else{ // 如果文件未能成功建立(建立失败,文件指针为空指针)
Serial.println("File upload failed"); // 通过串口监视器输出报错信息
esp8266_server.send(500, "text/plain", "500: couldn't create file"); // 向浏览器发送响应代码500(服务器错误)
}
}
}
//回复状态码 200 给客户端
void respondOK(){
esp8266_server.send(200);
}
// 处理用户浏览器的HTTP访问
void handleUserRequest(){
// 获取用户请求网址信息
String webAddress = esp8266_server.uri();
// 通过handleFileRead函数处理用户访问
bool fileReadOK = handleFileRead(webAddress);
// 如果在SPIFFS无法找到用户访问的资源,则回复404 (Not Found)
if (!fileReadOK){
esp8266_server.send(404, "text/plain", "404 Not Found");
}
}
bool handleFileRead(String path) { //处理浏览器HTTP访问
if (path.endsWith("/")) { // 如果访问地址以"/"为结尾
path = "/index.html"; // 则将访问地址修改为/index.html便于SPIFFS访问
}
String contentType = getContentType(path); // 获取文件类型
if (SPIFFS.exists(path)) { // 如果访问的文件可以在SPIFFS中找到
File file = SPIFFS.open(path, "r"); // 尝试打开该文件
esp8266_server.streamFile(file, contentType);// 将该文件返回给浏览器
file.close(); // 关闭文件
return true; // 返回true
}
return false; // 如果文件未找到,则返回false
}
// 获取文件类型
String getContentType(String filename){
if(filename.endsWith(".htm")) return "text/html";
else if(filename.endsWith(".html")) return "text/html";
else if(filename.endsWith(".css")) return "text/css";
else if(filename.endsWith(".js")) return "application/javascript";
else if(filename.endsWith(".png")) return "image/png";
else if(filename.endsWith(".gif")) return "image/gif";
else if(filename.endsWith(".jpg")) return "image/jpeg";
else if(filename.endsWith(".ico")) return "image/x-icon";
else if(filename.endsWith(".xml")) return "text/xml";
else if(filename.endsWith(".pdf")) return "application/x-pdf";
else if(filename.endsWith(".zip")) return "application/x-zip";
else if(filename.endsWith(".gz")) return "application/x-gzip";
return "text/plain";
}
(4)闪存系统中的文件(文件的编写非本教程重点,具体可在前端相关教程中了解):

①taichi-maker.jpg文件(在根目录下的img文件夹中):

②index.html文件(在根目录中):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="main.css">
<title>太极创客-零基础入门学用物联网教程</title>
</head>
<body>
<center>
<a href="http://www.taichi-maker.com" target="_blank"><img src="/img/taichi-maker.jpg" alt="太极创客"></a>
<h1>ESP8266 SPIFFS 文件上传</h1>
<p>此页面用于演示如何通过浏览器向ESP8266开发板的SPIFFS上传文件。</p>
<p></p>
<p>本教程可在太极创客网站免费获取。太极创客网址: <a href="http://www.taichi-maker.com">www.taichi-maker.com</a> .
<p> <a href="/upload.html">点击此处进入文件上传页面</a> </p>
</center>
</body>
</html>
③success.html文件(在根目录中):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="main.css">
<title>太极创客-零基础入门学用物联网教程</title>
</head>
<body>
<a href="http://www.taichi-maker.com" target="_blank"><img src="/img/taichi-maker.jpg" alt="太极创客"></a>
<h1>ESP8266 SPIFFS 文件上传成功</h1>
<p><a href="/">返回首页</a></p>
</body>
</html>
④upload.html文件(在根目录中):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="main.css">
<title>太极创客-零基础入门学用物联网教程</title>
</head>
<body>
<center>
<a href="http://www.taichi-maker.com" target="_blank"><img src="/img/taichi-maker.jpg" alt="太极创客"></a>
<h1>ESP8266 SPIFFS 文件上传</h1>
<p>点击按钮选择需要上传的文件</p>
<form method="POST" enctype="multipart/form-data">
<input type="file" name="data">
<input class="button" type="submit" value="上传">
</form>
</center>
</body>
</html>
⑤main.css文件(在根目录中):
body {
font-family: sans-serif;
color: #444;
}
(5)软件上传操作:
①打开电脑热点,这里以“Zevalin_Computer”为例进行Wi-Fi热点网络命名,密码“00114514”。
②将程序及网页文件上传开发板后,复位ESP8266开发板,在串口监视器可以看到开发板回传其IP地址。

②将IP地址输入浏览器的访问地址,即可打开服务器提供的文件上传网站。

③进入文件上传页面,将准备下载到STM32中的bin文件上传,文件需要按软件版本号(x.x.x)命名。

2、OTA事件发布
(1)当有新版本软件上传时,意味着需要各客户端进行OTA更新,那么数据服务器应该先将OTA事件发布出去,让订阅了OTA事件的客户端都收到信息,信息内容为最新的软件版本号,客户端收到版本号后可以和当前的软件版本号进行对比,以判断是否需要更新自身程序。
(2)需要说明的是,本例程使用的PubSubClient库无法直接发布QoS=1的MQTT消息,如果需要发布QoS=1的消息,则需要更换库(如AsyncMqttClient库),由于QoS不是本教程的重点,因此本例程仍使用PubSubClient库。
(3)将以下代码文件编译,下载到ESP8266中(闪存文件系统中的文件不会重置,因此无需重复下载网页文件)。相比于上一例程,本例程增加了发布OTA事件的代码。
#include <ESP8266WiFi.h>
#include <ESP8266WiFiMulti.h>
#include <ESP8266WebServer.h>
#include <PubSubClient.h>
#include <FS.h>
ESP8266WiFiMulti wifiMulti;
ESP8266WebServer esp8266_server(80);
const char* mqttServer = "test.mosquitto.org"; //如无法使用,可更换为其它公用MQTT服务端地址
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
const int subQoS = 1; // 客户端订阅主题时使用的QoS级别(截止2025,不支持QoS = 2)
const bool cleanSession = false; // 清除会话(如QoS>0,必须要设为false)
const char* willTopic = "willTopic"; // 遗嘱主题名称
const char* willMsg = "willMsg"; // 遗嘱主题信息
const int willQos = 0; // 遗嘱QoS
const int willRetain = false; // 遗嘱保留
File fsUploadFile; // 建立文件对象用于闪存文件上传
void setup(){
Serial.begin(9600);
Serial.println("");
wifiMulti.addAP("Zevalin_Computer", "00114514");
wifiMulti.addAP("Zevalin_esp8266", "00114514");
Serial.println("Connecting ...");
int i = 0;
while (wifiMulti.run() != WL_CONNECTED){
delay(1000);
Serial.print(i++); Serial.print('.');
}
Serial.println('\n');
Serial.print("Connected to "); Serial.println(WiFi.SSID());
Serial.print("IP address:\t"); Serial.println(WiFi.localIP());
if(SPIFFS.begin()){ // 启动闪存文件系统
Serial.println("SPIFFS Started.");
}
else{
Serial.println("SPIFFS Failed to Start.");
}
esp8266_server.on("/upload.html", HTTP_POST, respondOK, handleFileUpload); // 如果客户端通过upload页面向服务器发送文件(请求方法POST),则回复状态码200给客户端,并且运行处理文件上传函数
esp8266_server.onNotFound(handleUserRequest);
esp8266_server.begin(); // 启动网站服务
Serial.println("HTTP server started");
mqttClient.setServer(mqttServer, 1883); //设置MQTT服务端和端口号
connectMQTTServer(); //连接MQTT服务端
}
void loop(){
esp8266_server.handleClient(); // 处理用户端的请求
if (mqttClient.connected()) { // 如果开发板成功连接MQTT服务端
mqttClient.loop(); // 保持心跳
}
else { // 如果开发板未能成功连接MQTT服务端
connectMQTTServer(); // 不断尝试连接MQTT服务端
}
}
void connectMQTTServer(){
// 生成客户端ID
String clientId = "esp8266-OTA-DataSever-Test";
// 连接MQTT服务端
if (mqttClient.connect(clientId.c_str(), NULL, NULL, willTopic, willQos, willRetain, willMsg, cleanSession)) {
Serial.println("MQTT Server Connected.");
Serial.println("Server Address: ");
Serial.println(mqttServer);
Serial.println("ClientId:");
Serial.println(clientId);
}
else {
Serial.print("MQTT Server Connect Failed. Client State:");
Serial.println(mqttClient.state());
delay(3000);
}
}
void pubMQTTmsg(String Version){ // 发布信息
// 建立发布主题字符串"OTA_Event_example_test"
String topicString = "OTA_Event_example_test";
char publishTopic[topicString.length() + 1]; //创建字符数组(末位为'/0')
strcpy(publishTopic, topicString.c_str()); //字符串拷贝到字符数组中
// 建立发布信息,信息内容格式为“VER-x.x.x-xxxx/xx/xx-xx:xx”
String messageString = Version; //软件版本号
char publishMsg[messageString.length() + 1]; //创建字符数组(末位为'/0')
strcpy(publishMsg, messageString.c_str()); //字符串拷贝到字符数组中
// ESP8266向主题发布信息(设置为保留消息)
if(mqttClient.publish(publishTopic, publishMsg, true)) {
Serial.println("Publish Topic:");Serial.println(publishTopic);
Serial.println("Publish message:");Serial.println(publishMsg);
}
else {
Serial.println("Message Publish Failed.");
}
}
// 处理上传文件函数
void handleFileUpload(){
HTTPUpload& upload = esp8266_server.upload();
if(upload.status == UPLOAD_FILE_START){ // 如果上传状态为UPLOAD_FILE_START
String filename = upload.filename; // 建立字符串变量用于存放上传文件名
if(!filename.startsWith("/")) filename = "/" + filename; // 为上传文件名前加上"/"
Serial.println("File Name: " + filename); // 通过串口监视器输出上传文件的名称
fsUploadFile = SPIFFS.open(filename, "w"); // 在SPIFFS中建立文件用于写入用户上传的文件数据
}
else if(upload.status == UPLOAD_FILE_WRITE){ // 如果上传状态为UPLOAD_FILE_WRITE
if(fsUploadFile)
fsUploadFile.write(upload.buf, upload.currentSize); // 向SPIFFS文件写入浏览器发来的文件数据
}
else if(upload.status == UPLOAD_FILE_END){ // 如果上传状态为UPLOAD_FILE_END
if(fsUploadFile){ // 如果文件成功建立(建立成功,文件指针为非空指针)
fsUploadFile.close(); // 将文件关闭
Serial.println(" Size: "+ upload.totalSize); // 通过串口监视器输出文件大小
esp8266_server.sendHeader("Location","/success.html"); // 将浏览器跳转到/success.html(成功上传页面)
esp8266_server.send(303); // 发送响应代码303(重定向到新页面)
String Version = "VER-" + upload.filename.substring(0, 5) + "-xxxx/xx/xx-xx:xx"; // 构建版本号字符串,由于不计划用时间做任何逻辑判断,此处直接默认
pubMQTTmsg(Version); // 发布OTA事件
}
else{ // 如果文件未能成功建立(建立失败,文件指针为空指针)
Serial.println("File upload failed"); // 通过串口监视器输出报错信息
esp8266_server.send(500, "text/plain", "500: couldn't create file"); // 向浏览器发送响应代码500(服务器错误)
}
}
}
//回复状态码 200 给客户端
void respondOK(){
esp8266_server.send(200);
}
// 处理用户浏览器的HTTP访问
void handleUserRequest(){
// 获取用户请求网址信息
String webAddress = esp8266_server.uri();
// 通过handleFileRead函数处理用户访问
bool fileReadOK = handleFileRead(webAddress);
// 如果在SPIFFS无法找到用户访问的资源,则回复404 (Not Found)
if (!fileReadOK){
esp8266_server.send(404, "text/plain", "404 Not Found");
}
}
bool handleFileRead(String path) { //处理浏览器HTTP访问
if (path.endsWith("/")) { // 如果访问地址以"/"为结尾
path = "/index.html"; // 则将访问地址修改为/index.html便于SPIFFS访问
}
String contentType = getContentType(path); // 获取文件类型
if (SPIFFS.exists(path)) { // 如果访问的文件可以在SPIFFS中找到
File file = SPIFFS.open(path, "r"); // 尝试打开该文件
esp8266_server.streamFile(file, contentType);// 将该文件返回给浏览器
file.close(); // 关闭文件
return true; // 返回true
}
return false; // 如果文件未找到,则返回false
}
// 获取文件类型
String getContentType(String filename){
if(filename.endsWith(".htm")) return "text/html";
else if(filename.endsWith(".html")) return "text/html";
else if(filename.endsWith(".css")) return "text/css";
else if(filename.endsWith(".js")) return "application/javascript";
else if(filename.endsWith(".png")) return "image/png";
else if(filename.endsWith(".gif")) return "image/gif";
else if(filename.endsWith(".jpg")) return "image/jpeg";
else if(filename.endsWith(".ico")) return "image/x-icon";
else if(filename.endsWith(".xml")) return "text/xml";
else if(filename.endsWith(".pdf")) return "application/x-pdf";
else if(filename.endsWith(".zip")) return "application/x-zip";
else if(filename.endsWith(".gz")) return "application/x-gzip";
return "text/plain";
}
(4)将程序下载到ESP8266后,按照上一例程的测试方法,往开发板上传bin文件,文件需要按软件版本号(x.x.x)命名,然后在电脑打开MQTT.fx,连接公用MQTT服务器,订阅“OTA_Event_example_test”主题,理想情况下将收到软件版本号。

3、软件下载请求响应
(1)客户端会先后发送两种请求,第一个(种)请求是将要下载软件的大小(可选),第二个(种)请求是软件本身的内容,服务器需要具备响应这两种请求的逻辑。
(2)由于采用断点续传的方式传输软件,客户端需要在HTTP请求头中标识从哪个字节开始请求内容,服务器需要在HTTP响应头中标识此次回传的内容是从第几个字节到第几个字节。客户端默认从第0个字节开始请求,根据服务器回传的结束字节位置,决定下一次从第几个字节开始请求。

(3)程序流程示意图:

(4)将以下代码文件编译,下载到ESP8266中(闪存文件系统中的文件不会重置,因此无需重复下载网页文件)。相比于上一例程,本例程增加了软件下载请求响应的代码。
#include <ESP8266WiFi.h>
#include <ESP8266WiFiMulti.h>
#include <ESP8266WebServer.h>
#include <PubSubClient.h>
#include <FS.h>
#define ChunkSize 128 // 传输软件数据时,每次传输128字节
ESP8266WiFiMulti wifiMulti;
ESP8266WebServer esp8266_server(80);
const char* mqttServer = "test.mosquitto.org"; //如无法使用,可更换为其它公用MQTT服务端地址
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
const int subQoS = 1; // 客户端订阅主题时使用的QoS级别(截止2025,不支持QoS = 2)
const bool cleanSession = false; // 清除会话(如QoS>0,必须要设为false)
const char* willTopic = "willTopic"; // 遗嘱主题名称
const char* willMsg = "willMsg"; // 遗嘱主题信息
const int willQos = 0; // 遗嘱QoS
const int willRetain = false; // 遗嘱保留
File fsUploadFile; // 建立文件对象用于闪存文件上传
void setup(){
Serial.begin(9600);
Serial.println("");
wifiMulti.addAP("Zevalin_Computer", "00114514");
wifiMulti.addAP("Zevalin_esp8266", "00114514");
Serial.println("Connecting ...");
int i = 0;
while (wifiMulti.run() != WL_CONNECTED){
delay(1000);
Serial.print(i++); Serial.print('.');
}
Serial.println('\n');
Serial.print("Connected to "); Serial.println(WiFi.SSID());
Serial.print("IP address:\t"); Serial.println(WiFi.localIP());
if(SPIFFS.begin()){ // 启动闪存文件系统
Serial.println("SPIFFS Started.");
}
else{
Serial.println("SPIFFS Failed to Start.");
}
// 声明需要收集的请求头——Range
const char* headerkeys[] = {"Range"};
size_t headerkeysCount = sizeof(headerkeys) / sizeof(char*);
esp8266_server.collectHeaders(headerkeys, headerkeysCount); // 告诉服务器要收集这些请求头
esp8266_server.on("/upload.html", HTTP_POST, respondOK, handleFileUpload); // 如果客户端通过upload页面向服务器发送文件(请求方法POST),则回复状态码200给客户端,并且运行处理文件上传函数
esp8266_server.onNotFound(handleUserRequest);
esp8266_server.begin(); // 启动网站服务
Serial.println("HTTP server started");
mqttClient.setServer(mqttServer, 1883); // 设置MQTT服务端和端口号
connectMQTTServer(); // 连接MQTT服务端
}
void loop(){
esp8266_server.handleClient(); // 处理用户端的请求
if (mqttClient.connected()) { // 如果开发板成功连接MQTT服务端
mqttClient.loop(); // 保持心跳
}
else { // 如果开发板未能成功连接MQTT服务端
connectMQTTServer(); // 不断尝试连接MQTT服务端
}
}
void connectMQTTServer(){
// 生成客户端ID
String clientId = "esp8266-OTA-DataSever-Test";
// 连接MQTT服务端
if (mqttClient.connect(clientId.c_str(), NULL, NULL, willTopic, willQos, willRetain, willMsg, cleanSession)) {
Serial.println("MQTT Server Connected.");
Serial.println("Server Address: ");
Serial.println(mqttServer);
Serial.println("ClientId:");
Serial.println(clientId);
}
else {
Serial.print("MQTT Server Connect Failed. Client State:");
Serial.println(mqttClient.state());
delay(3000);
}
}
void pubMQTTmsg(String Version){ // 发布信息
// 建立发布主题字符串"OTA_Event_example_test"
String topicString = "OTA_Event_example_test";
char publishTopic[topicString.length() + 1]; //创建字符数组(末位为'/0')
strcpy(publishTopic, topicString.c_str()); //字符串拷贝到字符数组中
// 建立发布信息,信息内容格式为“VER-x.x.x-xxxx/xx/xx-xx:xx”
String messageString = Version; //软件版本号
char publishMsg[messageString.length() + 1]; //创建字符数组(末位为'/0')
strcpy(publishMsg, messageString.c_str()); //字符串拷贝到字符数组中
// ESP8266向主题发布信息(设置为保留消息)
if(mqttClient.publish(publishTopic, publishMsg, true)) {
Serial.println("Publish Topic:");Serial.println(publishTopic);
Serial.println("Publish message:");Serial.println(publishMsg);
}
else {
Serial.println("Message Publish Failed.");
}
}
// 处理上传文件函数
void handleFileUpload(){
HTTPUpload& upload = esp8266_server.upload();
if(upload.status == UPLOAD_FILE_START){ // 如果上传状态为UPLOAD_FILE_START
String filename = upload.filename; // 建立字符串变量用于存放上传文件名
if(!filename.startsWith("/")) filename = "/" + filename; // 为上传文件名加前缀“/”
Serial.println("File Name: " + filename); // 通过串口监视器输出上传文件的名称
fsUploadFile = SPIFFS.open(filename, "w"); // 在SPIFFS中建立文件用于写入用户上传的文件数据
}
else if(upload.status == UPLOAD_FILE_WRITE){ // 如果上传状态为UPLOAD_FILE_WRITE
if(fsUploadFile)
fsUploadFile.write(upload.buf, upload.currentSize); // 向SPIFFS文件写入浏览器发来的文件数据
}
else if(upload.status == UPLOAD_FILE_END){ // 如果上传状态为UPLOAD_FILE_END
if(fsUploadFile){ // 如果文件成功建立(建立成功,文件指针为非空指针)
fsUploadFile.close(); // 将文件关闭
Serial.println(" Size: "+ upload.totalSize); // 通过串口监视器输出文件大小
esp8266_server.sendHeader("Location","/success.html"); // 将浏览器跳转到/success.html(成功上传页面)
esp8266_server.send(303); // 发送响应代码303(重定向到新页面)
String Version = "VER-" + upload.filename.substring(0, 5) + "-xxxx/xx/xx-xx:xx"; // 构建版本号字符串,由于不计划用时间做任何逻辑判断,此处直接默认
pubMQTTmsg(Version); // 发布OTA事件
}
else{ // 如果文件未能成功建立(建立失败,文件指针为空指针)
Serial.println("File upload failed"); // 通过串口监视器输出报错信息
esp8266_server.send(500, "text/plain", "500: couldn't create file"); // 向浏览器发送响应代码500(服务器错误)
}
}
}
//回复状态码 200 给客户端
void respondOK(){
esp8266_server.send(200);
}
// 处理用户浏览器的HTTP访问
void handleUserRequest(){
// 获取用户请求网址信息
String webAddress = esp8266_server.uri();
// 通过handleFileRead函数处理用户访问
bool fileReadOK = handleFileRead(webAddress);
// 如果在SPIFFS无法找到用户访问的资源,则回复404 (Not Found)
if (!fileReadOK){
esp8266_server.send(404, "text/plain", "404 Not Found");
}
}
bool handleFileRead(String path) { // 处理浏览器HTTP访问
/* 处理STM32客户端的请求 */
if (path.endsWith("/size")) { // 如果访问地址以"/size"为结尾(/download/VER-x.x.x.bin/size)
int start = path.indexOf("/download/VER-") + 14;
int end = path.lastIndexOf("/size");
File file = SPIFFS.open("/" + path.substring(start, end), "r"); // 尝试打开请求的文件
String Size = String(file.size()); // 获取文件字节数
esp8266_server.send(200, "text/plain", "Size:" + Size); // 发送HTTP响应,告知用户端文件的字节数
return true; // 返回true
}
else if (path.endsWith("/HEX")) { // 如果访问地址以"/HEX"为结尾(/download/VER-x.x.x.bin/HEX)
int start = path.indexOf("/download/VER-") + 14;
int end = path.lastIndexOf("/HEX");
File file = SPIFFS.open("/" + path.substring(start, end), "r"); // 尝试打开请求的文件
// 设定一个缓冲区的大小,每次处理128字节
uint8_t buffer[ChunkSize];
// 获取本次处理的起始字节位置
String range = esp8266_server.header("Range");
size_t startByte = 0;
if (range.startsWith("bytes=")) {
int eq = range.indexOf('=');
int dash = range.indexOf('-', eq);
if (dash > eq) {
startByte = range.substring(eq+1, dash).toInt();
}
}
// 移动文件指针到起始位置,如果定位起始字节失败,关闭文件后直接返回
if (!file.seek(startByte, SeekSet)) {
file.close();
return false;
}
// 读取目标数据,并获取本次实际读取的字节数,得到结束字节位置
size_t bytesRead = file.read(buffer, ChunkSize);
size_t endByte = startByte + bytesRead - 1;
// 获取客户端对象
WiFiClient client = esp8266_server.client();
// 发送 HTTP 响应状态行
client.print("HTTP/1.1 206 Partial Content\r\n");
// 发送自定义头部
client.print("Content-Range: bytes=" + String(startByte) + "-" + String(endByte) + "/" + String(file.size()) + "\r\n");
client.print("Content-Length: " + String(bytesRead) + "\r\n");
client.print("Content-Type: application/octet-stream\r\n");
// 空行结束头部
client.print("\r\n");
// 发送二进制数据
client.write(buffer, bytesRead);
file.close(); // 关闭文件
return true; // 返回true
}
/* 处理上传文件的电脑客户端的请求 */
if (path.endsWith("/")) { // 如果访问地址以"/"为结尾
path = "/index.html"; // 则将访问地址修改为/index.html便于SPIFFS访问
}
String contentType = getContentType(path); // 获取文件类型
if (SPIFFS.exists(path)) { // 如果访问的文件可以在SPIFFS中找到
File file = SPIFFS.open(path, "r"); // 尝试打开该文件
esp8266_server.streamFile(file, contentType);// 将该文件返回给浏览器
file.close(); // 关闭文件
return true; // 返回true
}
return false; // 如果文件未找到,则返回false
}
// 获取文件类型
String getContentType(String filename){
if(filename.endsWith(".htm")) return "text/html";
else if(filename.endsWith(".html")) return "text/html";
else if(filename.endsWith(".css")) return "text/css";
else if(filename.endsWith(".js")) return "application/javascript";
else if(filename.endsWith(".png")) return "image/png";
else if(filename.endsWith(".gif")) return "image/gif";
else if(filename.endsWith(".jpg")) return "image/jpeg";
else if(filename.endsWith(".ico")) return "image/x-icon";
else if(filename.endsWith(".xml")) return "text/xml";
else if(filename.endsWith(".pdf")) return "application/x-pdf";
else if(filename.endsWith(".zip")) return "application/x-zip";
else if(filename.endsWith(".gz")) return "application/x-gzip";
return "text/plain";
}
更多推荐
所有评论(0)