写真1枚に環境情報も入れた 家庭菜園タイムラプスシステムの製作(2/3)
カメラ部の概要
カメラ部では、小型で強力なカメラ付きマイコン「XIAO ESP32S3 Sense」を使用します。
これに温湿度センサー(DHT11)と明るさ測定用のCDSセルを組み合わせ、撮影と同時に周囲の環境データを取得します。
回路図・接続方法
【ピン接続の参考】
- DHT11: D8番ピンに接続
- CDSセル: D9番ピン(アナログ入力)に接続
開発環境(Arduino IDE)の設定
最新版のArduino IDEを使用しています。事前に以下のボードマネージャーとライブラリをインストールし、設定を行ってください。
- ボードマネージャー
esp32(最新版を使用し、ボードは "XIAO_ESP32S3" を選択)
- 追加ライブラリ
DHT sensor library(by Adafruit) Ver1.4.7FTPClient_Generic(by Leonardo Bispo...)Ver1.6.0
- メニュー設定(重要)
- PSRAM:
"OPI PSRAM"に必ず設定してください(大容量データを扱うため必須です)。
- PSRAM:
プログラムの処理フロー
カメラ部は、起動後に以下のステップ(1〜8)を一定のインターバルで繰り返します。
- 初期化とネットワーク接続
各センサーやカメラ、PSRAMを初期化。M5Stack(親機)のアクセスポイントに接続し、IPアドレス(192.168.4.2)を取得します。 - 環境データの測定
DHT11から「温度」「湿度」を、CDSセルから「明るさ(ADC変換値)」を取得します。 - 表示用画像の撮影
M5Stackの画面表示に適したサイズで撮影し、一時的にPSRAMへ保存します。 - 保存用(最大画質)画像の撮影とデータ埋め込み
最大解像度で撮影後、JPEGの先頭(COMマーカー)にステップ2で測定した環境データをアスキーテキストとして埋め込み、PSRAMに保存します。 - 音声データの録音
内蔵マイクなどから2秒間の音声データを取得し、PSRAMに保存します。 - M5StackへのFTP送信
保存したデータを、M5Stack上のFTPサーバーへ以下のファイル名で送信します。- 表示用画像:
img.jpg - 保存用画像:
img2.jpg - 音声データ:
sound.wav
- 表示用画像:
- メモリの解放
送信が完了したら、次の撮影に向けてPSRAM上のデータ(バッファ)をすべて消去します。 - インターバル待機
指定した時間(テスト時は3分間)待機し、ステップ2へ戻ります。
プログラムコード
ESP32S3Sense-001.ino
/*
Seeed Studio XIAO ESP32S3 Sense
OV2640 Camera + FTP Upload + Sensors
Arduino IDE Compatible
Features:
- WiFi自動再接続
- FTP (PASVモード)
- LittleFS (PSRAM優先バッファ + フラッシュファイルシステム)
- SDカード不使用
- 撮影データを一旦PSRAM/LittleFSに保存 → 時間差付きFTP送信
- DHT11 + CDS + I2Sマイク対応
- JPEGにCOMマーカーでセンサ情報埋め込み
*/
#include <WiFi.h>
#include "esp_camera.h"
#include <FTPClient_Generic.h>
#include "ESP_I2S.h"
#include <DHT.h>
#include <FS.h>
#include <LittleFS.h> // LittleFSを使用
// ====================== ピン・定数定義 ======================
#define DHTPIN D8
#define DHTTYPE DHT11
#define ANALOG_CDS D9
// WiFi設定
const char* ssid = "M5Stack_FTP";
const char* password = "12345678";
// FTP設定
char* ftp_server = "192.168.4.1";
int ftp_port = 21;
char* ftp_user = "M5";
char* ftp_pass = "M5pass";
// =========================================================
// FTP Client
FTPClient_Generic ftp(ftp_server, ftp_user, ftp_pass, 30000);
// ボードID
#define BOARD_ID 88
// マーカー用
#define MAX_MARKER_LEN 64
char MARKER_TEXT[MAX_MARKER_LEN];
// ====================== カメラピン定義 ======================
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 10
#define SIOD_GPIO_NUM 40
#define SIOC_GPIO_NUM 39
#define Y9_GPIO_NUM 48
#define Y8_GPIO_NUM 11
#define Y7_GPIO_NUM 12
#define Y6_GPIO_NUM 14
#define Y5_GPIO_NUM 16
#define Y4_GPIO_NUM 18
#define Y3_GPIO_NUM 17
#define Y2_GPIO_NUM 15
#define VSYNC_GPIO_NUM 38
#define HREF_GPIO_NUM 47
#define PCLK_GPIO_NUM 13
// ====================== グローバル変数 ======================
camera_config_t config;
I2SClass i2s;
DHT dht(DHTPIN, DHTTYPE);
// ファイルパス(LittleFS内)
const char* FILE_IMG = "/img.jpg";
const char* FILE_IMG2 = "/img2.jpg";
const char* FILE_SOUND = "/sound.wav";
// ====================== 関数プロトタイプ ======================
bool connectWiFi();
bool initCamera(framesize_t size);
bool setResolution(framesize_t size);
bool uploadFTP(const char* remotePath, const char* localPath);
bool saveToLittleFS(const char* path, uint8_t* data, size_t len);
uint8_t* insertCOMMarker(uint8_t* original, size_t orig_len, const char* text, size_t* new_len);
// ====================== WiFi接続 ======================
bool connectWiFi() {
if (WiFi.status() == WL_CONNECTED) return true;
Serial.println("====================================");
Serial.println("Connecting to WiFi: " + String(ssid));
WiFi.disconnect(true);
WiFi.mode(WIFI_STA);
WiFi.setSleep(false);
WiFi.setTxPower(WIFI_POWER_11dBm);
WiFi.begin(ssid, password, 6);
int retry = 0;
while (WiFi.status() != WL_CONNECTED && retry < 60) {
delay(500);
Serial.print(".");
retry++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected!");
Serial.print("IP: "); Serial.println(WiFi.localIP());
return true;
} else {
Serial.println("\nWiFi connection FAILED");
return false;
}
}
// ====================== カメラ初期化 ======================
bool initCamera(framesize_t size) {
Serial.println("====================================");
Serial.print("Initializing camera: ");
Serial.println(size);
esp_camera_deinit();
delay(500);
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 = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
config.frame_size = size;
config.jpeg_quality = (size >= FRAMESIZE_UXGA) ? 15 : 12;
config.fb_count = (size >= FRAMESIZE_UXGA) ? 1 : 2;
config.fb_location = CAMERA_FB_IN_PSRAM; // PSRAM優先
config.grab_mode = CAMERA_GRAB_LATEST;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.print("Camera init failed: 0x");
Serial.println(err, HEX);
return false;
}
delay(800);
Serial.println("Camera initialized successfully.");
return true;
}
bool setResolution(framesize_t size) {
sensor_t* s = esp_camera_sensor_get();
if (!s) return false;
s->set_framesize(s, size);
delay(500);
return true;
}
// ====================== LittleFS保存 ======================
bool saveToLittleFS(const char* path, uint8_t* data, size_t len) {
if (!data || len == 0) return false;
File file = LittleFS.open(path, "w", true);
if (!file) {
Serial.println("Failed to open file for writing");
return false;
}
size_t written = file.write(data, len);
file.close();
Serial.printf("Saved %s : %d / %d bytes\n", path, written, len);
return written == len;
}
// ====================== FTPアップロード (ファイルから) ======================
bool uploadFTP(const char* remotePath, const char* localPath) {
Serial.println("====================================");
Serial.print("Uploading: "); Serial.print(localPath);
Serial.print(" → "); Serial.println(remotePath);
File file = LittleFS.open(localPath, "r");
if (!file) {
Serial.println("Failed to open local file");
return false;
}
size_t fileSize = file.size();
if (fileSize == 0) {
file.close();
return false;
}
for (int attempt = 1; attempt <= 3; attempt++) {
uint32_t start = millis();
ftp.OpenConnection();
delay(1500);
ftp.InitFile(COMMAND_XFER_TYPE_BINARY);
delay(800);
ftp.NewFile(remotePath);
delay(800);
uint8_t buffer[1024];
size_t totalSent = 0;
while (file.available() && totalSent < fileSize) {
size_t toRead = min((size_t)1024, fileSize - totalSent);
size_t read = file.read(buffer, toRead);
if (read == 0) break;
ftp.WriteData(buffer, read);
totalSent += read;
}
ftp.CloseFile();
delay(800);
ftp.CloseConnection();
file.close();
Serial.print("Transfer Time: ");
Serial.print(millis() - start);
Serial.println(" ms");
Serial.println("FTP Upload SUCCESS");
return true;
}
Serial.println("FTP Upload FAILED");
file.close();
return false;
}
// ====================== JPEGにセンサ情報を埋め込む ======================
uint8_t* insertCOMMarker(uint8_t* original, size_t orig_len, const char* text, size_t* new_len) {
if (!original || orig_len < 4) return nullptr;
if (original[0] != 0xFF || original[1] != 0xD8) return nullptr;
size_t text_len = strlen(text);
size_t marker_len = 2 + text_len;
*new_len = orig_len + marker_len + 2;
uint8_t* new_buf = (uint8_t*)ps_malloc(*new_len); // PSRAM使用
if (!new_buf) return nullptr;
size_t pos = 0;
new_buf[pos++] = 0xFF; new_buf[pos++] = 0xD8; // SOI
new_buf[pos++] = 0xFF; new_buf[pos++] = 0xFE; // COM
uint16_t length = text_len + 2;
new_buf[pos++] = (length >> 8) & 0xFF;
new_buf[pos++] = length & 0xFF;
memcpy(&new_buf[pos], text, text_len);
pos += text_len;
memcpy(&new_buf[pos], original + 2, orig_len - 2);
Serial.printf("COM Marker inserted: %d bytes\n", marker_len);
return new_buf;
}
// ====================== 撮影&LittleFS保存 ======================
bool captureAndSave(framesize_t size, const char* localPath, bool withMarker = true) {
Serial.println("====================================");
if (!initCamera(size)) return false;
if (WiFi.status() != WL_CONNECTED) connectWiFi();
delay(1000);
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Camera capture failed");
return false;
}
bool result = false;
if (withMarker) {
size_t new_len = 0;
uint8_t* marked_buf = insertCOMMarker(fb->buf, fb->len, MARKER_TEXT, &new_len);
if (marked_buf) {
result = saveToLittleFS(localPath, marked_buf, new_len);
free(marked_buf);
} else {
result = saveToLittleFS(localPath, fb->buf, fb->len);
}
} else {
result = saveToLittleFS(localPath, fb->buf, fb->len);
}
esp_camera_fb_return(fb);
delay(500);
return result;
}
// ====================== マイク録音&保存 ======================
bool saveMICToLittleFS(const char* localPath) {
Serial.println("Initializing I2S Microphone...");
i2s.setPinsPdmRx(42, 41);
if (!i2s.begin(I2S_MODE_PDM_RX, 16000, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO)) {
Serial.println("I2S initialization failed!");
return false;
}
size_t wav_size = 0;
uint8_t* wav_buffer = i2s.recordWAV(2, &wav_size); // 2秒録音(必要に応じて調整)
bool result = saveToLittleFS(localPath, wav_buffer, wav_size);
free(wav_buffer);
delay(500);
return result;
}
// ====================== Setup ======================
void setup() {
Serial.begin(9600);
delay(2000);
dht.begin();
Serial.println(F("DHT11 initialized"));
// LittleFS初期化(PSRAMが使える環境では大きな領域確保可能)
if (!LittleFS.begin(true)) { // true = format if failed
Serial.println("LittleFS Mount Failed");
while (1) delay(1000);
}
Serial.println("LittleFS mounted successfully.");
Serial.println("====================================");
Serial.println("XIAO ESP32S3 Sense - Delayed FTP Upload");
Serial.println("====================================");
Serial.print("Free Heap: "); Serial.println(ESP.getFreeHeap());
Serial.print("Free PSRAM: "); Serial.println(ESP.getFreePsram());
connectWiFi();
Serial.println("Setup completed.");
}
// ====================== Loop ======================
void loop() {
// ---------- センサ読み取り ----------
float temp = dht.readTemperature();
float humi = dht.readHumidity();
if (isnan(temp)) temp = -99.9;
if (isnan(humi)) humi = -99.9;
int cdsValue = analogRead(ANALOG_CDS);
snprintf(MARKER_TEXT, MAX_MARKER_LEN, "%d,%.1f,%.1f,0,0,0,0,0,%d",
BOARD_ID, temp, humi, cdsValue);
Serial.printf("→ Marker: %s\n", MARKER_TEXT);
// ---------- データ作成(すべてPSRAM/LittleFSに保存) ----------
Serial.println("\n######## CAPTURING ALL DATA ########");
// 音声
saveMICToLittleFS(FILE_SOUND);
// 小さい画像 (img2.jpg)
captureAndSave(FRAMESIZE_QVGA, FILE_IMG2, false);
// 高解像度画像 (img.jpg) - マーカー付き
captureAndSave(FRAMESIZE_UXGA, FILE_IMG, true);
Serial.println("All data saved to LittleFS.");
// ---------- 送信スケジュール ----------
delay(10000); // 10秒後
Serial.println("\n######## SENDING img.jpg ########");
uploadFTP("/img.jpg", FILE_IMG);
delay(15000); // 送信終了から15秒後
Serial.println("\n######## SENDING sound.wav ########");
uploadFTP("/sound.wav", FILE_SOUND);
delay(15000); // 送信終了から15秒後
Serial.println("\n######## SENDING img2.jpg ########");
uploadFTP("/img2.jpg", FILE_IMG2);
Serial.println("\nCycle complete. Waiting ~3 minutes...");
for (int i = 0; i < 170; i++) { // 合計約3分
delay(1000);
if (WiFi.status() != WL_CONNECTED) connectWiFi();
}
}
※テスト運用のため、現在は3分毎に撮影・送信を行う設定にしています。
製作・運用の注意点(Tips)
-
カメラの「緑被り」対策
ESP32系のカメラは、起動直後や1枚目の撮影時に画像全体が緑色っぽく変色する(緑被り)現象が起きやすいです。
本システムでは、ホワイトバランス等を安定させるため、フレームバッファを一度破棄(空撮り)してから、実際に使用する本番の画像を撮影する仕様にしています。 -
撮影インターバルの調整
最初は動作確認のために「3分毎」などの短いインターバルでテストすることをおすすめします。無事にM5Stack側へデータが届くことを確認できたら、家庭菜園の成長スピードに合わせて(1時間毎など)インターバルを長くしていくと安心です。
投稿者の人気記事




-
Makato-kan
さんが
前の火曜日の10:29
に
編集
をしました。
(メッセージ: 初版)
ログインしてコメントを投稿する