ESP32-CAM開發板(三):拍照並上傳影像到網站伺服器

本文將編寫一個讓ESP32-CAM定時拍照並上傳影像到網站伺服器的前端應用程式,並且說明背後的HTTP POST標頭訊息格式。你可以自行加上感測電路,讓ESP32-CAM開發板在某個情況下自動觸發、上傳影像,例如,感應到人體紅外線。

開發Arduino HTTP前端應用,例如,對某個網站服務發起GET或POST請求,通常會採用HTTPClient.h程式庫來簡化程式碼,像《超圖解ESP32深度實作》第七章的讀取氣象網站的JSON格式資料應用,我們不用自己編寫HTTP標頭訊息,也無需自行解析HTTP回應。

然而,HTTPClient.h程式庫的POST方法不具備上傳二進位檔案的功能,所以要用底層的WiFiClient.h程式庫傳送自行編寫HTTP標頭訊息。也因此,開發程式之前,必須要先認識夾帶二進位檔案的HTTP POST標頭訊息。

傳送二進位檔的HTTP POST標頭訊息格式

假設從用戶端(ESP32-CAM控制板)上傳影像檔給位於192.168.0.13的網站伺服器,接收與處理上傳影像的路由是“/esp32cam”,其HTTP標頭可大致分成兩個部分,首先,Content-Type欄位後面要附帶boundary(直譯為「分界」)參數,其值為自訂的識別名稱。

HTTP POST標頭

透過網頁表單上傳檔案時,「分界」識別名稱由瀏覽器自動產生,名稱最長不超過70個US-ASCII字元(也就是只能用ASCII編碼的前127個英數字和符號)。

緊接著的HTTP標頭第二部分,是夾帶上傳檔案的分界內容,這個部分的位元組大小要附加在上面的Content-Length欄位(此處假設為567890位元組)。

HTTP POST分界訊息

ESP32-CAM的上傳影像程式,將要送出如上述的HTTP POST標頭訊息給網站伺服器。

建立WiFiClient前端物件

Arduino官方的Ethernet.h標頭檔定義了乙太有線網路連線的物件方法(參閱《超圖解Arduino互動設計入門》第17章),而WiFi.h標頭檔則定義了Wi-Fi無線網路連線的物件方法,兩種不同物件的基本操作方法名稱都一樣,例如,初始連線的方法叫做begin()、輸出訊息的方法叫print()。

若用Arduino UNO搭配乙太有線網路擴展板,要透過Ethernet.h程式庫的EthernetClient類別建立前端物件,執行connect()方法即連線遠端伺服器:

#include <Ethernet.h> // 在Arduino UNO引用乙太網路程式庫 
: 略 
EthernetClient client; // 宣告乙太網路前端物件 
: 略 
client.connect(server, 80); // 連線到server變數指定的伺服器、80埠 
: 略 
client.print("hello"); // 透過網路送出訊息給伺服器 

在ESP32上使用Wi-Fi連線遠端HTTP伺服器的操作邏輯相同,只是換了程式庫:

#include <WiFi.h> // 內含Wi-Fi網路物件方法以及WiFiClient.h程式庫 
: 略 
WiFiClient client; // 宣告WiFi網路前端物件 
: 略 
client.connect(server, 80); // 連線到server變數指定的伺服器、80埠 
: 略 
client.print("hello"); // 透過網路送出訊息給伺服器 

從ESP32開發板傳送自訂的HTTP POST標頭

ESP32 Arduino程式開頭先引用程式庫並且定義幾個常數:

#include <WiFi.h> // 引用Wi-Fi程式庫 
#include "esp_camera.h" // 操作ESP32攝像頭的程式庫 
#define SERVER     "192.168.0.119" // 連線伺服器的IP位址或域名 
#define UPLOAD_URL "/esp32cam"     // 接收上傳檔的表單處理程式路徑 
#define PORT       80              // 網站伺服器的埠號 

HTTP標頭是純文字訊息,底下的boundBegin和boundEnd字串變數分別定義了「分界」內容的起始欄位和結尾,上傳影像檔名固定為pict.jpg。

String boundBegin = "--ESP32CAM\r\n";     // 分界內容的開頭 
boundBegin += "Content-Disposition: form-data; name=\"filename\"; filename=\"pict.jpg\"\r\n";
boundBegin += "Content-Type: image/jpeg\r\n";
boundBegin += "\r\n";

String boundEnd = "\r\n--ESP32CAM--\r\n"; // 分界內容的結尾 

假設ESP32攝像頭的影像儲存在叫做“fb”的結構體,底下的playloadSize變數將紀錄整個「分界」內容的位元組大小。

uint32_t imgSize = fb->len;    // 取得影像檔的大小 
uint32_t payloadSize = boundBegin.length() + imgSize + boundEnd.length();

取得「分界」內容的大小,就能組成HTTP POST訊息的第一部分:

String httpMsg = String("POST ") + UPLOAD_URL + " HTTP/1.1\r\n";
httpMsg += String("Host: ") + SERVER + "\r\n";
httpMsg += "User-Agent: Arduino/ESP32CAM\r\n";
httpMsg += "Content-Length: " + String(payloadSize) + "\r\n";
httpMsg += "Content-Type: multipart/form-data; boundary=ESP32CAM\r\n";
httpMsg += "\r\n";
httpMsg += boundBegin; // 加上「分界」內容的起始欄位 

然後透過前端物件的print()方法傳給伺服器:

client.print(httpMsg.c_str());  // 送出第一部分的HTTP標頭 

從ESP32上傳影像檔

實際要上傳的檔案夾在「分界」訊息中間,由於上傳檔的大小可能超過ESP32的記憶體容量,因此檔案以1024位元組(1KB)為單位分批上傳:

// 宣告指向影像檔的指標變數buf
uint8_t *buf = fb->buf;

// 從影像檔的開頭,每次最多讀取、傳送1024位元組
for (uint32_t i=0; i<imgSize; i+=1024) {
  if (i+1024 < imgSize) {
    client.write(buf, 1024);  // 傳送1024位元組大小的資料
    buf += 1024;
  // 若剩下不到1024位元組,則一次全部送出。
  } else if (imgSize%1024>0) {
    uint32_t remainder = imgSize%1024;
    client.write(buf, remainder);
  }
}

檔案上傳完畢,最後再送出「分界」訊息結尾:

client.print(boundEnd.c_str());

接收HTTP網站伺服器的回應

送出HTTP標頭給網站伺服器之後,便能刪除記憶體裡的影像檔:

esp_camera_fb_return(fb);

接著等候來自伺服器的回應,此處設定等待時間最長不超過10秒;若10秒內沒收到伺服器的回應,代表連線有問題或者網站伺服器掛了。

若在等待期間收到伺服器的回應,則讀取第一行回應之後關閉連線,完成上傳檔案。

// 等待伺服器的回應(最長等待10秒)
long timout = 10000L + millis();

while (timout > millis()) {   // 若尚未超過等待時間…
  Serial.print(".");        // 持續顯示"."
  delay(100);

  if (client.available()){  // 若收到伺服器的回應…
    Serial.println("\n伺服器回應:");
    // 讀取一行回應
    String line = client.readStringUntil('\r');
    Serial.println(line);
    break; // 跳出while回圈
  }
}

Serial.println("關閉連線");
client.stop();

底下是ESP32CAM控制板連線Wi-Fi網路、拍照、上傳網站伺服器的過程中,在Arduino IDE的「序列埠監控視窗」顯示的訊息:

序列埠監控視窗

ESP32CAM定時拍照與上傳網站伺服器的完整前端程式碼

筆者把連線遠端伺服器、上傳影像檔的程式寫在postImage()自訂函式裡面,設定每10秒鐘拍攝、上傳影像。接收上傳影像的HTTP網站伺服器程式碼,請參閱《使用Python Flask建置影像圖檔上傳網站服務(五)》這篇貼文。

#include <WiFi.h>
#include "esp_camera.h"
#define SERVER      "192.168.0.13"  // 請改成你的網站伺服器位址或域名
#define UPLOAD_URL  "/esp32cam"
#define PORT        80

const char* ssid = "你的Wi-Fi網路名稱";
const char* password = "網路密碼";

WiFiClient client;

const int timerInterval = 10000;    // 上傳影像的間隔毫秒數
unsigned long previousMillis = 0;

bool initCamera() {
  // 設定攝像頭的接腳和影像格式與尺寸
  static camera_config_t camera_config = {
    .pin_pwdn       = 32,  // 斷電腳
    .pin_reset      = -1,  // 重置腳
    .pin_xclk       = 0,   // 外部時脈腳
    .pin_sscb_sda   = 26,  // I2C資料腳
    .pin_sscb_scl   = 27,  // I2C時脈腳
    .pin_d7         = 35,  // 資料腳
    .pin_d6         = 34,
    .pin_d5         = 39,
    .pin_d4         = 36,
    .pin_d3         = 21,
    .pin_d2         = 19,
    .pin_d1         = 18,
    .pin_d0         = 5,
    .pin_vsync      = 25,   // 垂直同步腳
    .pin_href       = 23,   // 水平同步腳
    .pin_pclk       = 22,   // 像素時脈腳
    .xclk_freq_hz   = 20000000,       // 設定外部時脈:20MHz
    .ledc_timer     = LEDC_TIMER_0,   // 指定產生XCLK時脈的計時器
    .ledc_channel   = LEDC_CHANNEL_0, // 指定產生XCLM時脈的通道
    .pixel_format   = PIXFORMAT_JPEG, // 設定影像格式:JPEG
    .frame_size     = FRAMESIZE_SVGA, // 設定影像大小:SVGA
    .jpeg_quality   = 10,  // 設定JPEG影像畫質,有效值介於0-63,數字越低畫質越高。
    .fb_count       = 1    // 影像緩衝記憶區數量
  };

  // 初始化攝像頭
  esp_err_t err = esp_camera_init(&camera_config);
  if (err != ESP_OK) {
    Serial.printf("攝像頭出錯了,錯誤碼:0x%x", err);
    return false;
  }

  return true;
}

void postImage() {
  camera_fb_t *fb = NULL;    // 宣告儲存影像結構資料的變數
  fb = esp_camera_fb_get();  // 拍照

  if(!fb) {
    Serial.println("無法取得影像資料…");
    delay(1000);
    ESP.restart();  // 重新啟動
  }

  Serial.printf("連接伺服器:%s\n", SERVER);

  if (client.connect(SERVER, PORT)) {
    Serial.println("開始上傳影像…");     

    String boundBegin = "--ESP32CAM\r\n";
    boundBegin += "Content-Disposition: form-data; name=\"filename\"; filename=\"pict.jpg\"\r\n";
    boundBegin += "Content-Type: image/jpeg\r\n";
    boundBegin += "\r\n";

    String boundEnd = "\r\n--ESP32CAM--\r\n";

    uint32_t imgSize = fb->len;  // 取得影像檔的大小
    uint32_t payloadSize = boundBegin.length() + imgSize + boundEnd.length();

    String httpMsg = String("POST ") + UPLOAD_URL + " HTTP/1.1\r\n";
    httpMsg += String("Host: ") + SERVER + "\r\n";
    httpMsg += "User-Agent: Arduino/ESP32CAM\r\n";
    httpMsg += "Content-Length: " + String(payloadSize) + "\r\n";
    httpMsg += "Content-Type: multipart/form-data; boundary=ESP32CAM\r\n";
    httpMsg += "\r\n";
    httpMsg += boundBegin;

    // 送出HTTP標頭訊息
    client.print(httpMsg.c_str());

    // 上傳檔案
    uint8_t *buf = fb->buf;

    for (uint32_t i=0; i<imgSize; i+=1024) {
      if (i+1024 < imgSize) {
        client.write(buf, 1024);
        buf += 1024;
      } else if (imgSize%1024>0) {
        uint32_t remainder = imgSize%1024;
        client.write(buf, remainder);
      }
    }

    client.print(boundEnd.c_str());

    esp_camera_fb_return(fb);

    // 等待伺服器的回應(10秒)
    long timout = 10000L + millis();

    while (timout > millis()) {
      Serial.print(".");
      delay(100);

      if (client.available()){
        // 讀取伺服器的回應
        Serial.println("\n伺服器回應:");
        String line = client.readStringUntil('\r');
        Serial.println(line);
        break;
      }
    }

    Serial.println("關閉連線");
  } else {
    Serial.printf("無法連接伺服器:%s\n", SERVER);
  }

  client.stop();  // 關閉用戶端
}

void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.println("\n\n連接Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.print("\nIP位址:");
  Serial.println(WiFi.localIP());

  bool cameraReady = initCamera();
  if (!cameraReady) {
    Serial.println("攝像頭出錯了…");
    delay(1000);
    ESP.restart();  // 重新啟動ESP32
  }

  postImage();   // 上傳影像
}

void loop() {
  unsigned long currentMillis = millis();
  if (currentMillis - previousMillis >= timerInterval) {
    postImage();  // 上傳影像
    previousMillis = currentMillis;
  }
}
Posts created 470

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

Related Posts

Begin typing your search term above and press enter to search. Press ESC to cancel.

Back To Top