Freenove ESP32-S3-WROOM CAMボードを使って、ストリーミング(Mjpeg)を行います。

非同期 Web Serverについて

非同期 Web Serverとは、ページを再表示することなく、ウェブページの特定の部分だけを書き換えることができ(Ajaxの基幹技術)、これにより、所定の間隔で計測した値を自動的に更新表示できます。

これを実現するには、クライアントからのリクエストを待って応答するのではなく、サーバーの準備が整ったタイミングで、JavaScriptで組み込むHTTP通信のための組み込みオブジェクト「XMLHttpRequest」により呼び出し元に値を通知する「非同期処理」が必要になります。

非同期通信ができるWebサーバーを作成するには、Arduino IDE環境で提供されているESP32用の2つのライブラリー、非同期Webサーバーライブラリー「ESPAsyncWebServer」と非同期TCPライブラリー「Async TCP」を使用します。

JavaScriptのXMLHttpRequest(XHR)とデータをやり取りする場合、主に「サーバーからデータを取得する(GET)」と「サーバーにデータを送信する(POST)」の2つのパターンを次に示します。

  1. サーバーからデータを取得する (GET)
  2. ESP側のセンサー値などをJavaScriptで受け取ります。

    ESP32 側のコード

    特定のパス(例: /getvalue)にアクセスがあった際、文字列やJSONを返すように設定します。

    // サーバーのルート設定
    server.on("/getvalue", HTTP_GET, [](AsyncWebServerRequest *request){
      float sensorValue = 25.5; // 例としてセンサー値
      request->send(200, "text/plain", String(sensorValue));
    });
    

    JavaScript (HTML) 側のコード

    XMLHttpRequest を使って、ボタンクリック時などにデータを取得します。

    
    function getValue() {
      var xhttp = new XMLHttpRequest();
      xhttp.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
          // 取得したデータを要素に書き込む
          document.getElementById("display").innerHTML = this.responseText;
        }
      };
      xhttp.open("GET", "/getvalue", true);
      xhttp.send();
    }
    
  3. サーバーへデータを送信する (POST)
  4. JavaScript側で入力した値などをESP側に送ります。

    ESP32 側のコード

    request->hasParam() を使って送信された値を取り出します。

    server.on("/update", HTTP_POST, [](AsyncWebServerRequest *request){
      if (request->hasParam("input_val", true)) {
        String message = request->getParam("input_val", true)->value();
        Serial.println("受信データ: " + message);
        request->send(200, "text/plain", "OK");
      } else {
        request->send(400, "text/plain", "No data");
      }
    });
    

    JavaScript (HTML) 側のコード

    フォームデータ形式で送信します。

    function sendData() {
      var xhttp = new XMLHttpRequest();
      var val = document.getElementById("inputField").value;
      
      xhttp.open("POST", "/update", true);
      // POSTの場合はContent-typeの指定が必要
      xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
      
      xhttp.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
          console.log("送信成功: " + this.responseText);
        }
      };
      xhttp.send("input_val=" + val);
    }
    

JSON形式でやり取りする場合(推奨)

複数のデータを一度に送受信したい場合は、JSON形式が最も効率的です。

  • ESP側: ArduinoJson ライブラリを使用してJSONをパース/生成します。
  • JS側: JSON.parse() や JSON.stringify() を使用します。

ポイントと注意点

  • 非同期性: XMLHttpRequest の第三引数を true にすることで、通信中もブラウザがフリーズしません。
  • Fetch API: 最近のJavaScriptでは、XMLHttpRequest よりも簡潔に書ける fetch() API が主流になっています。特別な理由がなければ fetch を使うのも手です。

ストリーミングスケッチの作成

非同期 Web Serverを使ったストリーミングスケッチを作成します。

esp32s3_streaming.ino

/**********************************************************************
  Filename    : Camera and SDcard
  Description : Use the onboard buttons to take photo and save them to an SD card.
  Auther      : www.freenove.com
  Modification: 2022/11/02
**********************************************************************/
#include "esp_camera.h"
#define CAMERA_MODEL_ESP32S3_EYE
#include "camera_pins.h"
#include "ws2812.h"
#include "sd_read_write.h"
//******************************
#include "WiFi.h"
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
#include <FS.h>

#define BUTTON_PIN 0
//******************************
// Replace with your network credentials
const char* ssid = "aterm-5459b0-g";
const char* password = "618fcf2fb4b02";

// Create AsyncWebServer object on port 80
AsyncWebServer server(80);

// Photo File Name to save in SPIFFS
#define FILE_PHOTO "/photo.jpg"

const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML><html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body { text-align:center; }
    .vert { margin-bottom: 10%; }
    .hori{ margin-bottom: 0%; }
  </style>
</head>
<body>
  <div id="container">
    <h2>Freenove ESP32-S3-WROOM CAM</h2>
    <p>
      <table border="0" align="center">
        <tr>
        <td>時刻(秒):</td><td><span id="time" class="value">0</span></td>
        <td>&nbsp;&nbsp;&nbsp;処理(ms):</td><td><span id="period" class="value">0</span></td>
        </tr>
      </table>
    </p>
    <p>
      <button onclick="rotatePhoto();">回転</button>
    </p>
  </div>
  <div><img src="" id="photo" style="border: 1px solid #000;"></div>
</body>
<script>
  var deg = 0;
  const imgElement = document.getElementById('photo');

  function capturePhoto() {
    console.log("capturePhoto"); 
    const xhr = new XMLHttpRequest();
      // キャッシュを避けるためタイムスタンプを付与
    xhr.open('GET', '/capture?t=' + new Date().getTime(), true);
    xhr.responseType = 'blob'; // バイナリデータとして受け取る

    xhr.onload = function() {
      if (this.status === 200) {
        // 1. 取得したバイナリデータからURLを生成
        const blob = this.response;
        const url = URL.createObjectURL(blob);
          
        // 2. 前のURLをメモリ解放(メモリリーク防止)
        const oldUrl = imgElement.src;
        if (oldUrl.startsWith('blob:')) {
          URL.revokeObjectURL(oldUrl);
        }

        // 3. 画像を差し替え
        imgElement.src = url;

        // 4. 次のフレームを取得(再帰的に呼び出し)
        setTimeout(capturePhoto, 50); // 50ms待機(約20fps)
      }
    };

    xhr.onerror = function() {
      // エラー時は少し待ってからリトライ
      setTimeout(capturePhoto, 1000);
    };

    xhr.send();
  }
  capturePhoto();

  function rotatePhoto() {
    var img = document.getElementById("photo");
    deg += 90;
    if(isOdd(deg/90)){ document.getElementById("container").className = "vert"; }
    else{ document.getElementById("container").className = "hori"; }
    img.style.transform = "rotate(" + deg + "deg)";
  }
  function isOdd(n) { return Math.abs(n % 2) == 1; }
  var getTime = function () {
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
      if (this.readyState == 4 && this.status == 200) {
        document.getElementById("time").innerHTML = this.responseText;
      }
    };
    xhr.open("GET", "/time", true);
    xhr.send(null);
  }
  var getPeriod = function () {
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
      if (this.readyState == 4 && this.status == 200) {
        document.getElementById("period").innerHTML = this.responseText;
      }
    };
    xhr.open("GET", "/period", true);
    xhr.send(null);
  }
 
  setInterval(getTime, 500);
  setInterval(getPeriod, 250);

</script>
</html>)rawliteral";

hw_timer_t* timer = NULL;
uint64_t time_ms;
uint64_t current_time;

void setup() {
  Serial.begin(115200);
  Serial.setDebugOutput(false);
  Serial.println();
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  ws2812Init();
  sdmmcInit();
  //removeDir(SD_MMC, "/camera");
  createDir(SD_MMC, "/camera");
  listDir(SD_MMC, "/camera", 0);
  if (cameraSetup() == 1) {
    ws2812SetColor(2);
  } else {
    ws2812SetColor(1);
    return;
  }
  //******************************

  // Connect to Wi-Fi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  if (!SPIFFS.begin(true)) {
    Serial.println("An Error has occurred while mounting SPIFFS");
    ESP.restart();
  } else {
    delay(500);
    Serial.println("SPIFFS mounted successfully");
  }

  // Print ESP32 Local IP Address
  Serial.print("IP Address: http://");
  Serial.println(WiFi.localIP());

  // Route for root / web page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
    request->send(200, "text/html", index_html);
  });

  server.on("/capture", HTTP_GET, [](AsyncWebServerRequest* request) {
    current_time = timerReadMillis(timer);
    capturePhotoSaveSpiffs();
    time_ms = timerReadMillis(timer) - current_time;
    Serial.print("write (ms): ");
    Serial.println(time_ms);

    request->send(SPIFFS, FILE_PHOTO, "image/jpg", false);
  });
  server.on("/time", HTTP_GET, [](AsyncWebServerRequest* request) {
    request->send_P(200, "text/plain", String(timerReadMillis(timer) / 1000).c_str());
  });
  server.on("/period", HTTP_GET, [](AsyncWebServerRequest* request) {
    request->send_P(200, "text/plain", String(time_ms).c_str());
  });

  // Start server
  server.begin();

  timer = timerBegin(1000000);  // タイマーハンドルを取得
}

void loop() {
  if (digitalRead(BUTTON_PIN) == LOW) {
    delay(20);
    if (digitalRead(BUTTON_PIN) == LOW) {
      ws2812SetColor(3);
      while (digitalRead(BUTTON_PIN) == LOW)
        ;
      camera_fb_t* fb = NULL;
      fb = esp_camera_fb_get();
      if (fb != NULL) {
        int photo_index = readFileNum(SD_MMC, "/camera");
        if (photo_index != -1) {
          String path = "/camera/" + String(photo_index) + ".jpg";
          writejpg(SD_MMC, path.c_str(), fb->buf, fb->len);
        }
        esp_camera_fb_return(fb);
      } else {
        Serial.println("Camera capture failed.");
      }
      ws2812SetColor(2);
    }
  }
  delay(1);
}

int cameraSetup(void) {
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 10000000;
  //config.frame_size = FRAMESIZE_UXGA;
  config.frame_size = FRAMESIZE_VGA;
  config.pixel_format = PIXFORMAT_JPEG;  // for streaming
  config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
  config.fb_location = CAMERA_FB_IN_PSRAM;
  config.jpeg_quality = 12;
  config.fb_count = 1;

  // if PSRAM IC present, init with UXGA resolution and higher JPEG quality
  // for larger pre-allocated frame buffer.
  if (psramFound()) {
    config.jpeg_quality = 10;
    config.fb_count = 2;
    config.grab_mode = CAMERA_GRAB_LATEST;
  } else {
    // Limit the frame size when PSRAM is not available
    config.frame_size = FRAMESIZE_SVGA;
    config.fb_location = CAMERA_FB_IN_DRAM;
  }

  // camera init
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed with error 0x%x", err);
    return 0;
  }

  sensor_t* s = esp_camera_sensor_get();
  // initial sensors are flipped vertically and colors are a bit saturated
  s->set_vflip(s, 1);       // flip it back
  s->set_brightness(s, 1);  // up the brightness just a bit
  s->set_saturation(s, 0);  // lower the saturation

  Serial.println("Camera configuration complete!");
  return 1;
}

// Check if photo capture was successful
bool checkPhoto(fs::FS& fs) {
  File f_pic = fs.open(FILE_PHOTO);
  unsigned int pic_sz = f_pic.size();
  return (pic_sz > 100);
}

// Capture Photo and Save it to SPIFFS
void capturePhotoSaveSpiffs(void) {
  camera_fb_t* fb = NULL;  // pointer
  bool ok = 0;             // Boolean indicating if the picture has been taken correctly

  do {
    // Take a photo with the camera
    Serial.println("Taking a photo...");

    fb = esp_camera_fb_get();
    if (!fb) {
      Serial.println("Camera capture failed");
      return;
    }

    // Photo file name
    Serial.printf("Picture file name: %s\n", FILE_PHOTO);
    File file = SPIFFS.open(FILE_PHOTO, FILE_WRITE);

    // Insert the data in the photo file
    if (!file) {
      Serial.println("Failed to open file in writing mode");
    } else {
      file.write(fb->buf, fb->len);  // payload (image), payload length
      Serial.print("The picture has been saved in ");
      Serial.print(FILE_PHOTO);
      Serial.print(" - Size: ");
      Serial.print(file.size());
      Serial.println(" bytes");
    }
    // Close the file
    file.close();
    esp_camera_fb_return(fb);

    // check if file has been correctly saved in SPIFFS
    ok = checkPhoto(SPIFFS);
  } while (!ok);
}

ストリーミングスケッチの実行

作成した非同期 Web Serverを使ったストリーミングスケッチ「esp32s3_streaming.ino」を実行します。パソコンのブラウザから「http://192.168.10.108/」にアクセスします。

「回転」ボタンを押すごとに画像が90°回転します。