ESP8266開發之旅 網路篇⑯ 無線更新——OTA韌體更新

微控制器菜鳥發表於2019-06-19

1. 前言

    前面的博文中,我們編寫的韌體都是通過ArduinoIDE往串列埠線上的ESP8266模組去燒寫韌體。這樣就會有幾個弊端:

  • 需要經常插拔轉接線,很容易造成8266串列埠丟失;
  • 如果是將ESP8266做成產品並交付到客戶手上之後應該如何更新產品中的ESP8266韌體呢?難道要使用者拿到技術中心來更新?如果是這樣,這個產品必定屬於失敗產品。

在這裡,就引入我們本篇章需要了解的實用知識 —— OTA功能。
    OTA —— Over the air update of the firmware,也就是無線韌體更新,這個可以說是非常炫酷且實用的功能。
    那麼O他的本質是什麼?它又是如何工作的呢?
    一般情況下,當我們使用串列埠線更新8266的韌體是通過SerialBootLoader來更新,這個屬於開發板內建好的預設方式。
    而OTA因為用到的是WIFI網路,所以我們假設也有一個名為“WIFIOTABootLoader”的東西來處理韌體的無線寫入更新,但是這個WIFIOTABootLoader需要我們先通過串列埠線預先寫入到ESP8266。換句話說就是,我們得在專案程式碼中嵌入用於O他的 WIFIOTABootLoader。
    那麼問題來了,WIFIOTABootLoader到底是什麼原理呢?
    萬變不離其宗,博主第一個想到的就是 WebServer、UDP、mDNS的混合使用,通過mDNS可以解決域名訪問問題,WebServer提供web頁面供開發者上傳韌體檔案,然後WebServer處理具體的請求,再把檔案寫入flash中(萬幸的是,博主去看底層程式碼,確實有這樣設計的思路)。

所以,要想深入理解OTA,請先回顧基礎知識:

  • ESP8266開發之旅 網路篇⑩ UDP服務
  • ESP8266開發之旅 網路篇⑪ WebServer——ESP8266WebServer庫的使用
  • ESP8266開發之旅 網路篇⑫ 域名服務——ESP8266mDNS庫

2. OTA方式

    在Arduino Core For ESP8266中,使用OTA功能可以有三種方式:

  • ArduinoOTA —— OTA之Arduino IDE更新,也就是無線更新需要利用到Arduino IDE,只是不需要通過串列埠線燒寫而已,這種方式適合開發者;
  • WebUpdateOTA —— OTA之web更新,通過8266上配置的webserver來選擇韌體更新,這種方式適合開發者以及有簡單配置經驗的消費者;
  • ServerUpdateOTA —— OTA之伺服器更新,通過公網伺服器,把韌體放在雲端伺服器上,下載更新,這種方式適合零基礎的消費者,無感知更新;

其實不管哪一種方式,其最終目的:

為了把新韌體燒寫到Flash中,然後替代掉舊韌體,以達到更新韌體的效果。

那麼,我們來看看最終新舊韌體是怎麼用替換,請看下圖:

image

  1. 沒有更新韌體前,當前韌體(current sketch,綠色部分)和 檔案系統(spiffs,藍色部分)位於flash儲存空間的不同區域,中間空白的是空閒空間;
  2. 韌體更新時,新韌體(new sketch,黃色所示)將寫入空閒空間,此時flash同時存在這三個物件;
  3. 重啟模組後,新韌體會覆蓋掉舊韌體,然後從當前韌體的開始地址處開始執行,以達到韌體更新的目的。

接下來,我們看看這三種方式是怎麼用實現了以上三個步驟。

3. ArduinoOTA —— OTA之Arduino IDE更新

    為了更好地使用ArduinoOTA,先來了解一下ArduinoOTA需要用到的庫,然後再具體分析裡面的實現原理。請在程式碼裡面引入以下庫:

#include <ArduinoOTA.h>

    檢視 ArduinoOTA 底層原始碼,可以發現引入 UdpContext、ESP8266mDNS、WiFiClient(同時關聯WiFiServer),也就是說用到了UDP服務、TCP服務以及mDNS域名對映,這個是一個關鍵點。

在這裡,博主也總結了ArduinoOTA庫的百度腦圖:

image

總體上,方法可以細分為3大類:

  • 安全策略配置
  • 管理OTA
  • 韌體更新相關

3.1 安全策略配置

一般來說,使用預設的安全策略配置就好,但是如果有特殊要求,也可以自行配置。

3.1.1 setHostname —— 設定主機名

函式說明:

/**
 * 設定主機名,主要用於mDNS的域名對映
 * @param  hostName 主機名
 */
void setHostname(const char *hostname);

注意點:

  • 預設主機名是esp8266-xxxxx

3.1.2 getHostname —— 獲取主機名

函式說明:

/**
 * 獲取主機名
 * @return String 主機名
 */
String getHostname();

3.1.3 setPassword —— 設定訪問密碼

函式說明:

/**
 * 設定訪問密碼
 * @param password 上傳密碼,預設為NULL
 */
void setPassword(const char *password);

原始碼說明:

void ArduinoOTAClass::setPassword(const char * password) {
  if (!_initialized && !_password.length() && password) {
    //MD5編碼 建議用這個方法更好
    MD5Builder passmd5;
    passmd5.begin();
    passmd5.add(password);
    passmd5.calculate();
    _password = passmd5.toString();
  }
}

3.1.4 setPasswordHash —— 設定訪問密碼雜湊值

函式說明:

/**
 * 設定訪問密碼雜湊值
 * @param password 上傳密碼Hash值 MD5(password)
 */
void setPasswordHash(const char *password);

原始碼說明:

void ArduinoOTAClass::setPasswordHash(const char * password) {
  if (!_initialized && !_password.length() && password) {
    //md5編碼的password
    _password = password;
  }
}

3.1.5 setPort —— 設定Udp服務埠

函式說明:

/**
 * 設定Udp服務埠
 * @param port Udp服務埠
 */
void setPort(uint16_t port);

注意點:

  • 以上程式碼請在begin方法之前呼叫;

3.2 管理OTA

3.2.1 begin —— 啟動ArduinoOTA服務

函式說明:

/**
 * 啟動ArduinoOTA服務
 */
void begin();

原始碼說明:

void ArduinoOTAClass::begin() {
  if (_initialized)
    return;

  //配置主機名,預設 esp8266-xxxx
  if (!_hostname.length()) {
    char tmp[15];
    sprintf(tmp, "esp8266-%06x", ESP.getChipId());
    _hostname = tmp;
  }
  //udp服務埠號,預設8266
  if (!_port) {
    _port = 8266;
  }

  if(_udp_ota){
    _udp_ota->unref();
    _udp_ota = 0;
  }

  //啟動UDP服務
  _udp_ota = new UdpContext;
  _udp_ota->ref();

  if(!_udp_ota->listen(*IP_ADDR_ANY, _port))
    return;
    //繫結了回撥函式
  _udp_ota->onRx(std::bind(&ArduinoOTAClass::_onRx, this));
  //啟動mDNS服務
  MDNS.begin(_hostname.c_str());

  if (_password.length()) {
    MDNS.enableArduino(_port, true);
  } else {
    //mDNS註冊OTA服務
    MDNS.enableArduino(_port);
  }
  _initialized = true;
  _state = OTA_IDLE;
#ifdef OTA_DEBUG
  OTA_DEBUG.printf("OTA server at: %s.local:%u\n", _hostname.c_str(), _port);
#endif
}

/**
 * 解析收到的OTA請求
 */
void ArduinoOTAClass::_onRx(){
  if(!_udp_ota->next()) return;
  ip_addr_t ota_ip;

  if (_state == OTA_IDLE) {
    //檢視當前OTA命令 可以燒寫韌體或者燒寫SPIFFS
    int cmd = parseInt();
    if (cmd != U_FLASH && cmd != U_SPIFFS)
      return;
    _ota_ip = _udp_ota->getRemoteAddress();
    _cmd  = cmd;
    _ota_port = parseInt();
    _ota_udp_port = _udp_ota->getRemotePort();
    _size = parseInt();
    _udp_ota->read();
    _md5 = readStringUntil('\n');
    _md5.trim();
    if(_md5.length() != 32)
      return;

    ota_ip.addr = (uint32_t)_ota_ip;

    //驗證密碼,需要IDE輸入密碼
    if (_password.length()){
      MD5Builder nonce_md5;
      nonce_md5.begin();
      nonce_md5.add(String(micros()));
      nonce_md5.calculate();
      _nonce = nonce_md5.toString();

      char auth_req[38];
      sprintf(auth_req, "AUTH %s", _nonce.c_str());
      _udp_ota->append((const char *)auth_req, strlen(auth_req));
      _udp_ota->send(&ota_ip, _ota_udp_port);
      //切換到驗證狀態
      _state = OTA_WAITAUTH;
      return;
    } else {
       //切換到更新韌體狀態
      _state = OTA_RUNUPDATE;
    }
  } else if (_state == OTA_WAITAUTH) {
    int cmd = parseInt();
    if (cmd != U_AUTH) {
      _state = OTA_IDLE;
      return;
    }
    _udp_ota->read();
    String cnonce = readStringUntil(' ');
    String response = readStringUntil('\n');
    if (cnonce.length() != 32 || response.length() != 32) {
      _state = OTA_IDLE;
      return;
    }

    String challenge = _password + ":" + String(_nonce) + ":" + cnonce;
    MD5Builder _challengemd5;
    _challengemd5.begin();
    _challengemd5.add(challenge);
    _challengemd5.calculate();
    String result = _challengemd5.toString();

    ota_ip.addr = (uint32_t)_ota_ip;
    if(result.equalsConstantTime(response)) {
       //驗證通過 切換到更新韌體狀態 等待韌體接收
      _state = OTA_RUNUPDATE;
    } else {
      _udp_ota->append("Authentication Failed", 21);
      _udp_ota->send(&ota_ip, _ota_udp_port);
      if (_error_callback) _error_callback(OTA_AUTH_ERROR);
      _state = OTA_IDLE;
    }
  }

  while(_udp_ota->next()) _udp_ota->flush();
}

可以看出,begin方法主要是根據配置內容,啟動mDNS服務,預設域名是esp8266-xxxx,啟動UDP服務,預設埠是8266,這個是後面ArduinoIDE無線傳輸韌體的根本。

3.2.2 handle —— 處理韌體更新

函式說明:

/**
 * 處理韌體更新,這個方法需要在loop方法中不斷檢測呼叫
 */
void handle();

原始碼說明:

void ArduinoOTAClass::handle() {
  if (_state == OTA_RUNUPDATE) {
     //處理韌體傳輸更新
    _runUpdate();
    _state = OTA_IDLE;
  }
}

/**
 * 處理韌體傳輸更新
 */
void ArduinoOTAClass::_runUpdate() {
  ip_addr_t ota_ip;
  ota_ip.addr = (uint32_t)_ota_ip;

  //檢視Update是否啟動成功,Update類主要用於跟flash打交道,用於更新韌體或者SPIFFS,下面博主會說明一下
  if (!Update.begin(_size, _cmd)) {
#ifdef OTA_DEBUG
    OTA_DEBUG.println("Update Begin Error");
#endif
    if (_error_callback) {
      _error_callback(OTA_BEGIN_ERROR);
    }
    
    StreamString ss;
    Update.printError(ss);
    _udp_ota->append("ERR: ", 5);
    _udp_ota->append(ss.c_str(), ss.length());
    _udp_ota->send(&ota_ip, _ota_udp_port);
    delay(100);
    _udp_ota->listen(*IP_ADDR_ANY, _port);
    _state = OTA_IDLE;
    return;
  }
  _udp_ota->append("OK", 2);
  _udp_ota->send(&ota_ip, _ota_udp_port);
  delay(100);

  Update.setMD5(_md5.c_str());
  //停止UDP服務
  WiFiUDP::stopAll();
  WiFiClient::stopAll();

  //執行OTA開始回撥
  if (_start_callback) {
    _start_callback();
  }
  if (_progress_callback) {
    _progress_callback(0, _size);
  }
  //連線到IDE建立的服務地址
  WiFiClient client;
  if (!client.connect(_ota_ip, _ota_port)) {
#ifdef OTA_DEBUG
    OTA_DEBUG.printf("Connect Failed\n");
#endif
    _udp_ota->listen(*IP_ADDR_ANY, _port);
    if (_error_callback) {
      _error_callback(OTA_CONNECT_ERROR);
    }
    _state = OTA_IDLE;
  }

  uint32_t written, total = 0;
  while (!Update.isFinished() && client.connected()) {
    int waited = 1000;
    //接收韌體內容
    while (!client.available() && waited--)
      delay(1);
    if (!waited){
#ifdef OTA_DEBUG
      OTA_DEBUG.printf("Receive Failed\n");
#endif
      _udp_ota->listen(*IP_ADDR_ANY, _port);
      if (_error_callback) {
        _error_callback(OTA_RECEIVE_ERROR);
      }
      _state = OTA_IDLE;
    }
    //把韌體內容寫入flash
    written = Update.write(client);
    if (written > 0) {
      client.print(written, DEC);
      total += written;
      //回撥呼叫進度
      if(_progress_callback) {
        _progress_callback(total, _size);
      }
    }
  }
  //更新結束
  if (Update.end()) {
    //回撥接收成功
    client.print("OK");
    client.stop();
    delay(10);
#ifdef OTA_DEBUG
    OTA_DEBUG.printf("Update Success\n");
#endif
     //OTA結束回撥
    if (_end_callback) {
      _end_callback();
    }
    //自動重啟
    if(_rebootOnSuccess){
#ifdef OTA_DEBUG
    OTA_DEBUG.printf("Rebooting...\n");
#endif
      //let serial/network finish tasks that might be given in _end_callback
      delay(100);
      //重啟命令
      ESP.restart();
    }
  } else {
    _udp_ota->listen(*IP_ADDR_ANY, _port);
    if (_error_callback) {
      _error_callback(OTA_END_ERROR);
    }
    Update.printError(client);
#ifdef OTA_DEBUG
    Update.printError(OTA_DEBUG);
#endif
    _state = OTA_IDLE;
  }
}

接下來,看看Update類,這是一個寫Flash儲存空間的重要類,重點看幾個方法:

Update.begin原始碼說明

bool UpdaterClass::begin(size_t size, int command) {
  ....... //省略前面細節
  if (command == U_FLASH) {
    //以下程式碼就是確認燒寫位置,燒寫位置在我們文章開頭說到的空閒空間,處於當前程式區和SPIFFS之間
    //size of current sketch rounded to a sector
    uint32_t currentSketchSize = (ESP.getSketchSize() + FLASH_SECTOR_SIZE - 1) & (~(FLASH_SECTOR_SIZE - 1));
    //address of the end of the space available for sketch and update
    //_SPIFFS_start SPIFFS開始地址
    uint32_t updateEndAddress = (uint32_t)&_SPIFFS_start - 0x40200000;
    //size of the update rounded to a sector
    uint32_t roundedSize = (size + FLASH_SECTOR_SIZE - 1) & (~(FLASH_SECTOR_SIZE - 1));
    //address where we will start writing the update
    updateStartAddress = (updateEndAddress > roundedSize)? (updateEndAddress - roundedSize) : 0;
    .....//省略細節
  }
  else if (command == U_SPIFFS) {
     //如果是燒寫SPIFFS
     updateStartAddress = (uint32_t)&_SPIFFS_start - 0x40200000;
  }
  else {
    //不支援其他命令
    // unknown command
#ifdef DEBUG_UPDATER
    DEBUG_UPDATER.println(F("[begin] Unknown update command."));
#endif
    return false;
  }

  //initialize 記錄更新位置
  _startAddress = updateStartAddress;
  _currentAddress = _startAddress;
  .......省略細節
}

Update.end原始碼說明

bool UpdaterClass::end(bool evenIfRemaining){
  ..... //省略前面細節
  if (_command == U_FLASH) {
    //設定重啟後copy新韌體覆蓋舊韌體
    eboot_command ebcmd;
    ebcmd.action = ACTION_COPY_RAW;
    ebcmd.args[0] = _startAddress;
    ebcmd.args[1] = 0x00000;
    ebcmd.args[2] = _size;
    eboot_command_write(&ebcmd);

#ifdef DEBUG_UPDATER
    DEBUG_UPDATER.printf("Staged: address:0x%08X, size:0x%08X\n", _startAddress, _size);
  }
  else if (_command == U_SPIFFS) {
    DEBUG_UPDATER.printf("SPIFFS: address:0x%08X, size:0x%08X\n", _startAddress, _size);
#endif
  }

  _reset();
  return true;
}

3.2.3 setRebootOnSuccess —— 設定韌體更新完畢是否自動重啟

函式說明:

/**
 * 設定韌體更新完畢是否自動重啟
 * @param reboot 是否自動重啟,預設true
 */
void setRebootOnSuccess(bool reboot);

注意點:

  • 這個函式可以設定成true,讓8266可以自動重啟;

3.3 韌體更新相關

3.3.1 onStart —— OTA開始連線回撥

函式說明:

/**
 * 回撥函式定義
 */
typedef std::function<void(void)> THandlerFunction;

/**
 * OTA開始連線回撥 fn
 * @param fn 回撥函式
 */
void onStart(THandlerFunction fn);

3.3.2 onEnd —— OTA結束回撥

函式說明:

/**
 * 回撥函式定義
 */
typedef std::function<void(void)> THandlerFunction;

/**
 * OTA結束回撥 fn
 * @param fn 回撥函式
 */
void onEnd(THandlerFunction fn);

3.3.3 onError —— OTA出錯回撥

函式說明:

/**
 * 回撥函式定義
 * @param ota_error_t 錯誤原因
 */
typedef std::function<void(ota_error_t)> THandlerFunction_Error;

/**
 * OTA出錯回撥 fn
 * @param fn 回撥函式
 */
void onError(THandlerFunction_Error fn);

錯誤原因定義如下:

typedef enum {
  OTA_AUTH_ERROR,//驗證失敗
  OTA_BEGIN_ERROR,//update 開啟失敗
  OTA_CONNECT_ERROR,//網路連線失敗
  OTA_RECEIVE_ERROR,//接收韌體失敗
  OTA_END_ERROR//結束失敗
} ota_error_t;

3.3.4 onProgress —— OTA接收韌體進度

函式說明:

/**
 * 回撥函式定義
 * @param 韌體當前資料大小
 * @param 韌體總大小
 */
typedef std::function<void(unsigned int, unsigned int)> THandlerFunction_Progress;

/**
 * OTA接收韌體進度 回撥fn
 * @param fn 回撥函式
 */
void onProgress(THandlerFunction_Progress fn);

3.4 例項

實驗說明

    OTA之Arduino IDE更新,需要利用到ArduinoOTA庫。也就意味著我們需要首先往8266燒寫支援ArduinoO他的程式碼,然後ArduinoIDE會通過UDP通訊連線到8266建立的UDP服務,通過UDP服務校驗相應資訊,校驗通過後8266連線ArduinoIDE建立的Http服務,傳輸新韌體。

注意:

  • ArduinoOTA需要Python環境支援,需要讀者先安裝。

實驗準備

  • NodeMcu開發板
  • Python 2.7(不安裝不支援的Python 3.5,Windows使用者應選擇“將python.exe新增到路徑”(見下文 - 預設情況下未選擇此選項))python 2.7 提取碼:g9ds
    image

實驗步驟

    演示更新功能,需要區分新舊程式碼。先往NodeMcu燒寫V1.0版本程式碼:

/**
 * 功能描述:OTA之Arduino IDE更新 V1.0版本程式碼
 *
 */
#include <ESP8266WiFi.h>
#include <ESP8266mDNS.h>
#include <WiFiUdp.h>
#include <ArduinoOTA.h>

//除錯定義
#define DebugBegin(baud_rate)    Serial.begin(baud_rate)
#define DebugPrintln(message)    Serial.println(message)
#define DebugPrint(message)    Serial.print(message)
#define DebugPrintF(...) Serial.printf( __VA_ARGS__ )

#define CodeVersion "CodeVersion V1.0"

const char* ssid = "xxxx";//填上wifi賬號
const char* password = "xxxxx";//填上wifi密碼

void setup() {
  DebugBegin(115200);
  DebugPrintln("Booting Sketch....");
  DebugPrintln(CodeVersion);
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.waitForConnectResult() != WL_CONNECTED) {
    DebugPrintln("Connection Failed! Rebooting...");
    delay(5000);
  //重啟ESP8266模組
    ESP.restart();
  }

  // Port defaults to 8266
  // ArduinoOTA.setPort(8266);

  // Hostname defaults to esp8266-[ChipID]
  // ArduinoOTA.setHostname("myesp8266");

  // No authentication by default
  // ArduinoOTA.setPassword("admin");

  // Password can be set with it's md5 value as well
  // MD5(admin) = 21232f297a57a5a743894a0e4a801fc3
  // ArduinoOTA.setPasswordHash("21232f297a57a5a743894a0e4a801fc3");

  ArduinoOTA.onStart([]() {
    String type;
  //判斷一下OTA內容
    if (ArduinoOTA.getCommand() == U_FLASH) {
      type = "sketch";
    } else { // U_SPIFFS
      type = "filesystem";
    }

    // NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end()
    DebugPrintln("Start updating " + type);
  });
  ArduinoOTA.onEnd([]() {
    DebugPrintln("\nEnd");
  });
  ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
    DebugPrintF("Progress: %u%%\r", (progress / (total / 100)));
  });
  ArduinoOTA.onError([](ota_error_t error) {
    DebugPrintF("Error[%u]: ", error);
    if (error == OTA_AUTH_ERROR) {
      DebugPrintln("Auth Failed");
    } else if (error == OTA_BEGIN_ERROR) {
      DebugPrintln("Begin Failed");
    } else if (error == OTA_CONNECT_ERROR) {
      DebugPrintln("Connect Failed");
    } else if (error == OTA_RECEIVE_ERROR) {
      DebugPrintln("Receive Failed");
    } else if (error == OTA_END_ERROR) {
      DebugPrintln("End Failed");
    }
  });
  ArduinoOTA.begin();
  DebugPrintln("Ready");
  DebugPrint("IP address: ");
  DebugPrintln(WiFi.localIP());
}

void loop() {
  ArduinoOTA.handle();
}

燒寫成功後,開啟串列埠監視器會看到下圖內容:

image

注意:燒寫成功後,關閉ArduinoIDE然後重新開啟(目的是為了和ESP8266建立無線通訊)

然後在工具選單的埠項中你會發現多了一個 "esp8266-xxxxx" 的選單項,選中它。
image

接下來,請往NodeMcu燒寫V1.1版本程式碼(跟上面程式碼一樣,就是改變了版本號):

/**
 * 功能描述:OTA之Arduino IDE更新 V1.1版本程式碼
 *
 */
#include <ESP8266WiFi.h>
#include <ESP8266mDNS.h>
#include <WiFiUdp.h>
#include <ArduinoOTA.h>

//除錯定義
#define DebugBegin(baud_rate)    Serial.begin(baud_rate)
#define DebugPrintln(message)    Serial.println(message)
#define DebugPrint(message)    Serial.print(message)
#define DebugPrintF(...) Serial.printf( __VA_ARGS__ )

#define CodeVersion "CodeVersion V1.1"

const char* ssid = "xxxx";//填上wifi賬號
const char* password = "xxxx";//填上wifi密碼

void setup() {
  DebugBegin(115200);
  DebugPrintln("Booting Sketch....");
  DebugPrintln(CodeVersion);
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.waitForConnectResult() != WL_CONNECTED) {
    DebugPrintln("Connection Failed! Rebooting...");
    delay(5000);
  //重啟ESP8266模組
    ESP.restart();
  }

  // Port defaults to 8266
  // ArduinoOTA.setPort(8266);

  // Hostname defaults to esp8266-[ChipID]
  // ArduinoOTA.setHostname("myesp8266");

  // No authentication by default
  // ArduinoOTA.setPassword("admin");

  // Password can be set with it's md5 value as well
  // MD5(admin) = 21232f297a57a5a743894a0e4a801fc3
  // ArduinoOTA.setPasswordHash("21232f297a57a5a743894a0e4a801fc3");

  ArduinoOTA.onStart([]() {
    String type;
  //判斷一下OTA內容
    if (ArduinoOTA.getCommand() == U_FLASH) {
      type = "sketch";
    } else { // U_SPIFFS
      type = "filesystem";
    }

    // NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end()
    DebugPrintln("Start updating " + type);
  });
  ArduinoOTA.onEnd([]() {
    DebugPrintln("\nEnd");
  });
  ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
    DebugPrintF("Progress: %u%%\r", (progress / (total / 100)));
  });
  ArduinoOTA.onError([](ota_error_t error) {
    DebugPrintF("Error[%u]: ", error);
    if (error == OTA_AUTH_ERROR) {
      DebugPrintln("Auth Failed");
    } else if (error == OTA_BEGIN_ERROR) {
      DebugPrintln("Begin Failed");
    } else if (error == OTA_CONNECT_ERROR) {
      DebugPrintln("Connect Failed");
    } else if (error == OTA_RECEIVE_ERROR) {
      DebugPrintln("Receive Failed");
    } else if (error == OTA_END_ERROR) {
      DebugPrintln("End Failed");
    }
  });
  ArduinoOTA.begin();
  DebugPrintln("Ready");
  DebugPrint("IP address: ");
  DebugPrintln(WiFi.localIP());
}

void loop() {
  ArduinoOTA.handle();
}

編譯點選上傳,會出現以下頁面:

image

更新完畢,重啟8266
image

實驗總結
OTA之Arduino IDE更新實現邏輯非常簡單,主要包括幾方面:

  • 連線WIFI
  • 配置 ArduinoOTA 物件的事件函式
  • 啟動 ArduinoOTA 服務 ArduinoOTA.begin()
  • 在 loop() 函式將處理權交由 ArduinoOTA.handle()

為了區分正常工作模式以及更新模式,我們可以設定個標誌位來區分(標誌位通過其他手段修改,比如按鈕、軟體控制)。

void loop() {
  if (flag ==0 ) {
    // 正常工作狀態的程式碼
  } else {
    ArduinoOTA.handle();
 }
}

4. WebUpdateOTA —— OTA之web更新

    OTA之web更新,通過8266上配置的webserver來選擇韌體更新,這種方式適合開發者以及有簡單配置經驗的消費者,其操作過程如下:

  1. 用ESP8266先建立一個Web伺服器然後提供一個web更新介面,需要使用到庫 ESP8266HTTPUpdateServer
  2. 通過Arduino將原始檔編譯為*.bin的二進位制檔案;
  3. 通過mDNS功能在瀏覽器中訪問ESP8266的伺服器頁面,預設服務地址為:http://esp8266.local/update;
  4. 通過Web介面將本地編譯好的*.bin二進位制韌體檔案上傳到ESP8266中;
  5. 上傳完成編譯檔案後ESP8266將韌體寫入Flash中

OTA之web更新,請加上以下標頭檔案:

#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <ESP8266HTTPUpdateServer.h>

接下來,先上一個博主總結的百度腦圖:

image

方法只有兩個,非常簡單。

4.1 updateCredentials —— 驗證使用者資訊

函式說明:

/**
 * 校驗使用者資訊
 * @param username 使用者名稱稱
 * @param password 使用者密碼
 */
void updateCredentials(const char * username, const char * password)

4.2 setup —— 配置WebOTA

函式說明:

/**
 * 配置WebOTA
 * @param ESP8266WebServer 需要繫結的webserver
 */
void setup(ESP8266WebServer *server){
  setup(server, NULL, NULL);
}

/**
 * 配置WebOTA
 * @param ESP8266WebServer 需要繫結的webserver
 * @param path 註冊uri
 */
void setup(ESP8266WebServer *server, const char * path){
  setup(server, path, NULL, NULL);
}

/**
 * 配置WebOTA
 * @param ESP8266WebServer 需要繫結的webserver
 * @param username 使用者名稱稱
 * @param password 使用者密碼
 */
void setup(ESP8266WebServer *server, const char * username, const char * password){
  setup(server, "/update", username, password);
}

/**
 * 配置WebOTA
 * @param ESP8266WebServer 需要繫結的webserver
 * @param username 使用者名稱稱
 * @param password 使用者密碼
 * @param path 註冊uri (預設是"/update")
 */
void setup(ESP8266WebServer *server, const char * path, const char * username, const char * password);

來分析一下setup原始碼:

/**
 * 配置WebOTA
 * @param ESP8266WebServer 需要繫結的webserver
 * @param username 使用者名稱稱
 * @param password 使用者密碼
 * @param path 註冊uri (預設是"/update")
 */
void ESP8266HTTPUpdateServer::setup(ESP8266WebServer *server, const char * path, const char * username, const char * password)
{
    _server = server;
    _username = (char *)username;
    _password = (char *)password;

    // 註冊webserver的響應回撥函式
    _server->on(path, HTTP_GET, [&](){
      //校驗使用者資訊 通過就傳送更新頁面
      if(_username != NULL && _password != NULL && !_server->authenticate(_username, _password))
        return _server->requestAuthentication();
      _server->send_P(200, PSTR("text/html"), serverIndex);
    });

    // 註冊webserver的響應回撥函式 處理檔案上傳 檔案結束
    _server->on(path, HTTP_POST, [&](){
      //檔案上傳完畢回撥
      if(!_authenticated)
        return _server->requestAuthentication();
      if (Update.hasError()) {
        _server->send(200, F("text/html"), String(F("Update error: ")) + _updaterError);
      } else {
        _server->client().setNoDelay(true);
        _server->send_P(200, PSTR("text/html"), successResponse);
        delay(100);
        //斷開http連線
        _server->client().stop();
        //重啟ESP8266
        ESP.restart();
      }
    },[&](){
      // 通過 Update 物件處理檔案上傳,關於update物件請看上面的講解。
      HTTPUpload& upload = _server->upload();
      //韌體上傳開始
      if(upload.status == UPLOAD_FILE_START){
        _updaterError = String();
        if (_serial_output)
          Serial.setDebugOutput(true);

        _authenticated = (_username == NULL || _password == NULL || _server->authenticate(_username, _password));
        if(!_authenticated){
          if (_serial_output)
            Serial.printf("Unauthenticated Update\n");
          return;
        }

        WiFiUDP::stopAll();
        if (_serial_output)
          Serial.printf("Update: %s\n", upload.filename.c_str());
        uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
        if(!Update.begin(maxSketchSpace)){//start with max available size
          _setUpdaterError();
        }
      } else if(_authenticated && upload.status == UPLOAD_FILE_WRITE && !_updaterError.length()){
        //韌體正在寫入
        if (_serial_output) Serial.printf(".");
        if(Update.write(upload.buf, upload.currentSize) != upload.currentSize){
          _setUpdaterError();
        }
      } else if(_authenticated && upload.status == UPLOAD_FILE_END && !_updaterError.length()){
      //韌體正在寫入結束
        if(Update.end(true)){ //true to set the size to the current progress
          if (_serial_output) Serial.printf("Update Success: %u\nRebooting...\n", upload.totalSize);
        } else {
          _setUpdaterError();
        }
        if (_serial_output) Serial.setDebugOutput(false);
      } else if(_authenticated && upload.status == UPLOAD_FILE_ABORTED){
        Update.end();
        if (_serial_output) Serial.println("Update was aborted");
      }
      delay(0);
    });
}

整體上來說,博主比較建議這種方法,簡單快捷,巧妙利用了webserver。

4.3 例項

4.3.1 系統自帶OTA之web更新

實驗說明

演示ESP8266 OTA之web更新,通過建立的webserver來上傳新韌體以達到更新目的。

實驗準備

  • NodeMcu開發板

實驗原始碼

先往ESP8266燒寫V1.0版本程式碼,如下:

/*
 * 功能描述:OTA之web更新 V1.0版本程式碼
 */

#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <ESP8266HTTPUpdateServer.h>

//除錯定義
#define DebugBegin(baud_rate)    Serial.begin(baud_rate)
#define DebugPrintln(message)    Serial.println(message)
#define DebugPrint(message)    Serial.print(message)
#define DebugPrintF(...) Serial.printf( __VA_ARGS__ )

#define CodeVersion "CodeVersion V1.0"

const char* host = "esp8266-webupdate";
const char* ssid = "xxx";//填上wifi賬號
const char* password = "xxx";//填上wifi密碼

ESP8266WebServer httpServer(80);
ESP8266HTTPUpdateServer httpUpdater;

void setup(void) {
  DebugBegin(115200);
  DebugPrintln("Booting Sketch...");
  DebugPrintln(CodeVersion);
  WiFi.mode(WIFI_AP_STA);
  WiFi.begin(ssid, password);

  while (WiFi.waitForConnectResult() != WL_CONNECTED) {
    WiFi.begin(ssid, password);
    DebugPrintln("WiFi failed, retrying.");
  }
  //啟動mdns服務
  MDNS.begin(host);
  //配置webserver為更新server
  httpUpdater.setup(&httpServer);
  httpServer.begin();

  MDNS.addService("http", "tcp", 80);
  DebugPrintF("HTTPUpdateServer ready! Open http://%s.local/update in your browser\n", host);
}

void loop(void) {
  httpServer.handleClient();
  MDNS.update();
}

然後在串列埠偵錯程式就可以看到O他的更新頁面地址:
image

然後在瀏覽器裡面開啟該地址,會看到下面的介面:
image

接下來,開始更新程式碼。
在首選項設定裡面的“顯示詳細輸出”選項中選中"編譯"
image

然後修改程式碼為V1.1版本,如下:

/*
 * 功能描述:OTA之web更新 V1.1版本程式碼
 */

#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <ESP8266HTTPUpdateServer.h>

//除錯定義
#define DebugBegin(baud_rate)    Serial.begin(baud_rate)
#define DebugPrintln(message)    Serial.println(message)
#define DebugPrint(message)    Serial.print(message)
#define DebugPrintF(...) Serial.printf( __VA_ARGS__ )

#define CodeVersion "CodeVersion V1.1"

const char* host = "esp8266-webupdate";
const char* ssid = "xxx";//填上wifi賬號
const char* password = "xxx";//填上wifi密碼

ESP8266WebServer httpServer(80);
ESP8266HTTPUpdateServer httpUpdater;

void setup(void) {
  DebugBegin(115200);
  DebugPrintln("Booting Sketch...");
  DebugPrintln(CodeVersion);
  WiFi.mode(WIFI_AP_STA);
  WiFi.begin(ssid, password);

  while (WiFi.waitForConnectResult() != WL_CONNECTED) {
    WiFi.begin(ssid, password);
    DebugPrintln("WiFi failed, retrying.");
  }
  //啟動mdns服務
  MDNS.begin(host);
  //配置webserver為更新server
  httpUpdater.setup(&httpServer);
  httpServer.begin();

  MDNS.addService("http", "tcp", 80);
  DebugPrintF("HTTPUpdateServer ready! Open http://%s.local/update in your browser\n", host);
}

void loop(void) {
  httpServer.handleClient();
  MDNS.update();
}

編譯該程式碼,然後找到新韌體的本地地址,

image

回到瀏覽器點選“Choose file”按鈕然後選擇該新韌體就可以上傳到ESP8266中去:

image

更新結束。
image

實驗結果

可以看到下面的列印結果:

實驗總結

這個更新介面做得有點醜,可以提供給開發人員用,儘量還是不要給消費者用。

4.3.2 自定義OTA之web更新

實驗說明

在上面的系統自帶OTA之web更新例項中,由於是系統自帶的更新頁面,還是有點醜。對於開發人員來說,這個頁面我表示接受不了了。

自定義頁面有兩種方式:

  • 直接修改 ESP8266HTTPUpdateServer 裡面web頁面,讀者可以把 ESP8266HTTPUpdateServer.cpp檔案裡面的serverIndex改成下面博主提供的serverIndex,這裡暫且不講;
  • 基於 ESP8266HTTPUpdateServer 庫去自定義新庫,我們暫且命名為 ESP8266CustomHTTPUpdateServer,博主建議並講解這種方式;

ESP8266CustomHTTPUpdateServer庫的實現步驟:

  • 請找到ESP8266核心庫目錄,然後在libraries目錄下拷貝 ESP8266HTTPUpdateServer 目錄,重新命名為 ESP8266CustomHTTPUpdateServer

image

  • 修改 ESP8266CustomHTTPUpdateServer 裡面的類名,把 ESP8266HTTPUpdateServer 統一改成 ESP8266CustomHTTPUpdateServer;
  • 把 ESP8266CustomHTTPUpdateServer.cpp檔案裡面的serverIndex改成以下內容:
static const char serverIndex[] PROGMEM =
"<!DOCTYPE html>\r\n\
 <html lang=\"en\">\r\n\
 <head>\r\n\
 <meta charset=\"UTF-8\">\r\n\
 <title>ESP8266 WebOTA</title>\r\n\
 <style>\r\n\
 body{text-align: center;height: 100%;}\r\n\
 div,input{padding:5px;font-size:12px;}\r\n\
 input{width:95%;margin-top: 5px;}\r\n\
 button{padding:5px;border:0;border-radius:20px;background-color:#1fa3ec;color:#fff;line-height:30px;font-size:16px;width:100%;margin-top: 40px;}\r\n\
 .m-user-icon{width: 100px;height: 100px;border-radius: 50px}\r\n\
 .m-user-name{font-size: 18px;font-weight: bold;margin-top: 10px;}\r\n\
 .fileupload{position: relative;width:150px;height:25px;border:1px solid #66B3FF;border-radius: 4px;box-shadow: 1px 1px 5px #66B3FF;line-height: 25px;overflow: hidden;color: #66B3FF;;left: 50%;transform: translateX(-50%);text-overflow:ellipsis;white-space:nowrap}\r\n\
 .fileupload input{position: absolute;width:150px;height:25px;top: 0;left: 50%;transform: translateX(-50%);opacity: 0;filter: alpha(opacity=0);-ms-filter: 'alpha(opacity=0)';}\r\n\
 </style>\r\n\
 </head>\r\n\
 <body>\r\n\
 <div style=\"text-align:center;display:inline-block;min-width:260px;margin-top: 80px;\">\r\n\
 <div class=\"m-user\">\r\n\
 <img class= \"m-user-icon\" src=\"data:image/png;base64,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\">\r\n\
 <div class=\"m-user-name\">ESP8266 WebOTA更新</div>\r\n\
 </div>\r\n\
 <form method='POST' action='' enctype='multipart/form-data'>\r\n\
 <div class=\"fileupload\">\r\n\
 <script>\r\n\
 function getFilename(){\r\n\
 let filename=document.getElementById(\"file\").value;\r\n\
 if(filename===undefined||filename===\"\"){\r\n\
 document.getElementById(\"filename\").innerHTML=\"點選此處上傳檔案\";\r\n\
 } else{\r\n\
 let fn=filename.substring(filename.lastIndexOf(\"\\\")+1);\r\n\
 document.getElementById(\"filename\").innerHTML=fn; \r\n\
 }\r\n\
 }\r\n\
 </script>\r\n\
 <span id=\"filename\">點選選擇新韌體</span>\r\n\
 <input type=\"file\" name=\"file\" id=\"file\" onchange=\"getFilename()\"/>\r\n\
 </div>\r\n\
 <button type='submit'>確定更新</button>\r\n\
 </form>\r\n\
 <div style=\";margin-top: 10px;\">Copyright © 2019 By<a href='https://blog.csdn.net/wubo_fly'>微控制器菜鳥</a></div>\r\n\
 </div>\r\n\
 </body>\r\n\
 </html>";

當然好心的博主肯定不需要你們自己寫,下載下來放到你們的8266庫目錄吧 —— ESP8266CustomHTTPUpdateServer

注意:

  • ESP8266CustomHTTPUpdateServer庫用法跟ESP8266HTTPUpdateServer庫是一樣的,博主只是基於ESP8266HTTPUpdateServer修改web頁面而已,其他一概不改動。
  • 博主在ArduinoIDE 1.8.5版本和esp8266 2.4.2版本加入這個庫,編譯不過。後改用ArduinoIDE 1.8.9版本以及esp8266 2.5.0版本可以編譯通過,猜測是底層編譯器不一樣,請讀者注意一下。

實驗準備

  • 需要大家有一定的web基礎——html+css+js
  • NodeMcu開發板

實驗步驟

  • 先往ESP8266燒寫V1.0版本程式碼,如下:
/*
 * 功能描述:自定義OTA之web更新 V1.0版本程式碼
 */

#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <ESP8266CustomHTTPUpdateServer.h>

//除錯定義
#define DebugBegin(baud_rate)    Serial.begin(baud_rate)
#define DebugPrintln(message)    Serial.println(message)
#define DebugPrint(message)    Serial.print(message)
#define DebugPrintF(...) Serial.printf( __VA_ARGS__ )

#define CodeVersion "CodeVersion V1.0"

const char* host = "esp8266-webupdate";
const char* ssid = "xxxx";//填上wifi賬號
const char* password = "xxxx";//填上wifi密碼

ESP8266WebServer httpServer(80);
ESP8266CustomHTTPUpdateServer httpUpdater;

void setup(void) {
  DebugBegin(115200);
  DebugPrintln("Booting Sketch...");
  DebugPrintln(CodeVersion);
  WiFi.mode(WIFI_AP_STA);
  WiFi.begin(ssid, password);

  while (WiFi.waitForConnectResult() != WL_CONNECTED) {
    WiFi.begin(ssid, password);
    DebugPrintln("WiFi failed, retrying.");
  }
  //啟動mdns服務
  MDNS.begin(host);
  //配置webserver為更新server
  httpUpdater.setup(&httpServer);
  httpServer.begin();

  MDNS.addService("http", "tcp", 80);
  DebugPrintF("HTTPUpdateServer ready! Open http://%s.local/update in your browser\n", host);
}

void loop(void) {
  httpServer.handleClient();
  MDNS.update();
}

可以看到串列埠列印資訊
image

然後可以在電腦瀏覽器訪問 http://esp8266-webupdate.local/update

image

接著修改程式碼為V1.1版本,如下:

/*
 * 功能描述:自定義OTA之web更新 V1.1版本程式碼
 */

#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <ESP8266CustomHTTPUpdateServer.h>

//除錯定義
#define DebugBegin(baud_rate)    Serial.begin(baud_rate)
#define DebugPrintln(message)    Serial.println(message)
#define DebugPrint(message)    Serial.print(message)
#define DebugPrintF(...) Serial.printf( __VA_ARGS__ )

#define CodeVersion "CodeVersion V1.1"

const char* host = "esp8266-webupdate";
const char* ssid = "TP-LINK_5344";//填上wifi賬號
const char* password = "6206908you11011010";//填上wifi密碼

ESP8266WebServer httpServer(80);
ESP8266CustomHTTPUpdateServer httpUpdater;

void setup(void) {
  DebugBegin(115200);
  DebugPrintln("Booting Sketch...");
  DebugPrintln(CodeVersion);
  WiFi.mode(WIFI_AP_STA);
  WiFi.begin(ssid, password);

  while (WiFi.waitForConnectResult() != WL_CONNECTED) {
    WiFi.begin(ssid, password);
    DebugPrintln("WiFi failed, retrying.");
  }
  //啟動mdns服務
  MDNS.begin(host);
  //配置webserver為更新server
  httpUpdater.setup(&httpServer);
  httpServer.begin();

  MDNS.addService("http", "tcp", 80);
  DebugPrintF("HTTPUpdateServer ready! Open http://%s.local/update in your browser\n", host);
}

void loop(void) {
  httpServer.handleClient();
  MDNS.update();
}

編譯程式碼,注意最終生成bin檔案儲存位置

image

選擇該bin檔案,更新完畢,可以看到串列埠列印資訊:
image

5. ServerUpdateOTA —— OTA之伺服器更新

OTA之伺服器更新,通過公網伺服器,把韌體放在雲端伺服器上,下載更新,這種方式適合零基礎的消費者,無感知更新;
不過由於博主暫時沒有自主開發伺服器程式的能力,所以這裡暫時只討論需用用到的庫,原理本質上都是一樣的。
ServerUpdateOTA需要用到 ESP8266httpUpdate 庫,請在程式碼中引入以下標頭檔案:

#include <ESP8266HTTPClient.h>
#include <ESP8266httpUpdate.h>

接下來,先上一個博主總結的百度腦圖:

image

方法只有兩個,非常簡單。

5.1 update —— 更新韌體

函式說明:

/**
 * 更新韌體(http)
 * @param url 韌體下載地址
 * @param currentVersion 韌體當前版本
 * @return t_httpUpdate_return 更新狀態
 */
t_httpUpdate_return update(const String& url, const String& currentVersion = "");

/**
 * 更新韌體(https)
 * @param url 韌體下載地址
 * @param currentVersion 韌體當前版本
 * @param httpsFingerprint https相關資訊
 * @return t_httpUpdate_return 更新狀態 
 */
t_httpUpdate_return update(const String& url, const String& currentVersion,const String& httpsFingerprint);

/**
 * 更新韌體(https)
 * @param url 韌體下載地址
 * @param currentVersion 韌體當前版本
 * @param httpsFingerprint https相關資訊
 * @return t_httpUpdate_return 更新狀態 
 */
t_httpUpdate_return update(const String& url, const String& currentVersion,const uint8_t httpsFingerprint[20]); // BearSSL

/**
 * 更新韌體(http)
 * @param host 主機
 * @param port 埠
 * @param uri  uri 地址
 * @param currentVersion 韌體當前版本
 * @return t_httpUpdate_return 更新狀態 
 */
t_httpUpdate_return update(const String& host, uint16_t port, const String& uri = "/",const String& currentVersion = "");

/**
 * 更新韌體(https)
 * @param host 主機
 * @param port 埠
 * @param uri  uri 地址
 * @param currentVersion 韌體當前版本
 * @param httpsFingerprint https相關資訊
 * @return t_httpUpdate_return 更新狀態 
 */
t_httpUpdate_return update(const String& host, uint16_t port, const String& url,const String& currentVersion, const String& httpsFingerprint);

/**
 * 更新韌體(https)
 * @param host 主機
 * @param port 埠
 * @param uri  uri 地址
 * @param currentVersion 韌體當前版本
 * @param httpsFingerprint https相關資訊
 * @return t_httpUpdate_return 更新狀態 
 */
t_httpUpdate_return update(const String& host, uint16_t port, const String& url,const String& currentVersion, const uint8_t httpsFingerprint[20]); // BearSSL

t_httpUpdate_return定義如下:

enum HTTPUpdateResult {
    HTTP_UPDATE_FAILED,//更新失敗
    HTTP_UPDATE_NO_UPDATES,//未開始更新
    HTTP_UPDATE_OK//更新完畢
};

5.2 rebootOnUpdate —— 是否自動重啟

函式說明:

/**
 * 設定是否自動重啟
 * @param reboot true表示自動重啟,預設false
 */
void rebootOnUpdate(bool reboot);

5.3 updateSpiffs —— 更新SPIFFS

函式說明:

/**
 * 更新韌體(http)
 * @param url 韌體下載地址
 * @param currentVersion 韌體當前版本
 * @return t_httpUpdate_return 更新狀態
 */
t_httpUpdate_return updateSpiffs(const String& url, const String& currentVersion = "");

/**
 * 更新韌體(http)
 * @param url 韌體下載地址
 * @param currentVersion 韌體當前版本
 * @return t_httpUpdate_return 更新狀態
 */
t_httpUpdate_return updateSpiffs(const String& url, const String& currentVersion, const String& httpsFingerprint);

/**
 * 更新韌體(http)
 * @param url 韌體下載地址
 * @param currentVersion 韌體當前版本
 * @return t_httpUpdate_return 更新狀態
 */
t_httpUpdate_return updateSpiffs(const String& url, const String& currentVersion, const uint8_t httpsFingerprint[20]); // BearSSL

5.4 例項

博主沒有具體的伺服器(原理都是非常相似的,把伺服器上面的新韌體下載下來,然後更新),所以這裡只是給一個通用的程式碼:

/**
 * 功能描述:OTA之伺服器更新
 */

#include <Arduino.h>

#include <ESP8266WiFi.h>
#include <ESP8266WiFiMulti.h>

#include <ESP8266HTTPClient.h>
#include <ESP8266httpUpdate.h>

//除錯定義
#define DebugBegin(baud_rate)    Serial.begin(baud_rate)
#define DebugPrintln(message)    Serial.println(message)
#define DebugPrint(message)    Serial.print(message)
#define DebugPrintF(...) Serial.printf( __VA_ARGS__ )

ESP8266WiFiMulti WiFiMulti;

void setup() {

  DebugBegin(115200);
  WiFi.mode(WIFI_STA);
  //這裡填上wifi賬號 SSID 和 密碼 PASSWORD
  WiFiMulti.addAP("SSID", "PASSWORD");
}

void loop() {
  // wait for WiFi connection
  if ((WiFiMulti.run() == WL_CONNECTED)) {

    //填上伺服器地址
    t_httpUpdate_return ret = ESPhttpUpdate.update("http://server/file.bin");
    //t_httpUpdate_return  ret = ESPhttpUpdate.update("https://server/file.bin", "", "fingerprint");

    switch (ret) {
      case HTTP_UPDATE_FAILED:
        DebugPrintF("HTTP_UPDATE_FAILD Error (%d): %s", ESPhttpUpdate.getLastError(), ESPhttpUpdate.getLastErrorString().c_str());
        break;

      case HTTP_UPDATE_NO_UPDATES:
        DebugPrintln("HTTP_UPDATE_NO_UPDATES");
        break;

      case HTTP_UPDATE_OK:
        DebugPrintln("HTTP_UPDATE_OK");
        break;
    }
  }
}

等博主後面學習了伺服器開發,再補回來吧。

6. 總結

在Arduino Core For ESP8266中,使用OTA功能可以有三種方式:

  • ArduinoOTA —— OTA之Arduino IDE更新,也就是無線更新需要利用到Arduino IDE,只是不需要通過串列埠線燒寫而已,這種方式適合開發者;
  • WebUpdateOTA —— OTA之web更新,通過8266上配置的webserver來選擇韌體更新,這種方式適合開發者以及有簡單配置經驗的消費者;
  • ServerUpdateOTA —— OTA之伺服器更新,通過公網伺服器,把韌體放在雲端伺服器上,下載更新,這種方式適合零基礎的消費者,無感知更新;

至於使用哪一種,看具體需求。
其實不管哪一種方式,其最終目的:

為了把新韌體燒寫到Flash中,然後替代掉舊韌體,以達到更新韌體的效果。

注意,OTA更新也可以更新SPIFFS。

相關文章