一、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";
}

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐