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つのパターンを次に示します。
- サーバーからデータを取得する (GET)
- サーバーへデータを送信する (POST)
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();
}
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> 処理(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°回転します。