🔥

【XIAO ESP32S3】ファイルサーバー化でmicroSDカードのファイルをダウンロードする

に公開

目的

こちらの記事で温湿度センサーDHT20の値をmicroSDカードに保存しましたが、
データを確認するにはmicroSDカードを取り出してPCに挿入する必要があり、確認中は温湿度センサーの値を記録できませんでした。
そこで、温湿度センサーでデータを保存しつつ、ESP32S3をファイルサーバー化してWebブラウザからmicroSDカードのファイルをダウンロードできるようにしたいと思います。
また、データを保存する時間についても、起動してからの経過時間ではなく、リアルタイムで記録できるようにしたいと思います。

ピン・配線図

使用するピンは変更ありません。
上記の記事から、付属のアンテナを接続しただけになります。

ブレッドボードでの配線

ライブラリをインストール

追加ライブラリはありません。

スケッチ

スケッチ
#include <WiFi.h>
#include <WebServer.h>
#include <SD.h>
#include <SPI.h>
#include <Wire.h>
#include <DHT20.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "time.h"

// --- 設定 (環境に合わせて書き換えてください) ---
const char* ssid     = "your_ssid";
const char* password = "your_password";

// NTPサーバー設定
const char* ntpServer = "pool.ntp.org";
const long  gmtOffset_sec = 9 * 3600; // JST (UTC+9)
const int   daylightOffset_sec = 0;

// Webサーバー設定 (ポート80)
WebServer server(80);

// OLED設定
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET    -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// DHT20設定
DHT20 dht20;

// SDカード設定
// Seeed Studio XIAO ESP32S3 SenseのデフォルトSPIピン(CS=21)
#define SD_CS_PIN 21
#define LOG_FILENAME "/data_log.csv"

// タイマー管理
const unsigned long displayInterval = 1000; // 1秒
unsigned long lastLogTime = 0;
const unsigned long logInterval = 60000; // 1分 (60秒)

// --- グローバル変数 ---
float temperature = 0.0;
float humidity = 0.0;
bool wifiConnected = false;

// FreeRTOS ハンドル
SemaphoreHandle_t sdMutex;
TaskHandle_t loggerTaskHandle;

// --- 関数プロトタイプ宣言 ---
void handleRoot();
void handleFileDownload();
void printDirectory(File dir, String& html);
String getContentType(String filename);
void updateDisplay();
void logDataToSD();
void loggerTask(void * pvParameters);

void setup() {
  Serial.begin(115200);
  // while (!Serial); // デバッグ時は有効化

  // Mutex作成
  sdMutex = xSemaphoreCreateMutex();

  // Wire初期化 (I2C)
  // I2CはloggerTask(Core 0)で使用するため、setup(Core 1)で初期化を行うが、
  // 排他制御が必要な場合がある。今回はloggerTaskのみがI2Cを使用するため、
  // setup完了後にloggerTaskを開始すれば競合はしない。
  Wire.begin();

  // --- OLED初期化 ---
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println(F("SSD1306 allocation failed"));
    for (;;);
  }
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println(F("Initializing..."));
  display.display();

  // --- DHT20初期化 ---
  dht20.begin();
  delay(1000); // センサー安定化待ち

  // --- SDカード初期化 ---
  Serial.print("Initializing SD card...");
  // SDカードはloggerTaskとserver(Core 1)の両方で使用するためMutexで保護するが、
  // setup中はまだマルチタスクではないためそのままアクセス可能
  if (!SD.begin(SD_CS_PIN)) {
    Serial.println("Card Mount Failed");
    display.println(F("SD Init Failed!"));
    display.display();
    return;
  }
  Serial.println("SD Card initialized.");

  // ログファイルのヘッダー書き込み (ファイルが存在しない場合のみ)
  if (!SD.exists(LOG_FILENAME)) {
    File file = SD.open(LOG_FILENAME, FILE_WRITE);
    if (file) {
      file.println("Timestamp,Temperature (C),Humidity (%RH)");
      file.close();
    }
  }

  // --- WiFi接続 ---
  Serial.print("Connecting to ");
  Serial.println(ssid);
  display.println(F("Connecting WiFi..."));
  display.display();

  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("WiFi connected.");
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
  wifiConnected = true;

  // OLEDにIP表示
  display.clearDisplay();
  display.setCursor(0, 0);
  display.println(F("WiFi Connected!"));
  display.print(F("IP: "));
  display.println(WiFi.localIP());
  display.display();

  // 時刻同期
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);

  // --- Webサーバー設定 ---
  // ルートパスへのアクセス時
  server.on("/", handleRoot);

  // その他のパスへのアクセス時 (ファイルダウンロード用)
  server.onNotFound(handleFileDownload);

  server.begin();
  Serial.println("HTTP server started");

  delay(2000); // IPアドレスなどの表示を確認するため少し待機
  display.clearDisplay();

  // --- Logger Task 作成 (Core 0) ---
  xTaskCreatePinnedToCore(
    loggerTask,   // タスク関数
    "LoggerTask", // タスク名
    8192,         // スタックサイズ (必要に応じて調整)
    NULL,         // パラメータ
    1,            // 優先度
    &loggerTaskHandle, // ハンドル
    0             // Core 0
  );
  Serial.println("Logger Task started on Core 0");
}

void loop() {
  // Core 1 (デフォルト)
  // Webサーバーのリクエスト処理のみを担当
  server.handleClient();
  delay(1); // Watchdogタイマーリセットのため少し待つ
}

// --- Logger Task (Core 0) ---
void loggerTask(void * pvParameters) {
  while (true) {
    unsigned long currentMillis = millis();

    // --- 1秒ごとにセンサー読み取り & ディスプレイ更新 ---
    if (currentMillis - dht20.lastRead() >= displayInterval) {
      updateDisplay();
    }

    // --- 1分ごとにSDカードへログ保存 ---
    if (currentMillis - lastLogTime >= logInterval) {
      lastLogTime = currentMillis;
      logDataToSD();
    }

    vTaskDelay(10 / portTICK_PERIOD_MS); // 少し待機して他の処理に譲る
  }
}

// --- Webサーバー リクエストハンドラ ---

// ルートパス(/)へのアクセス: ファイル一覧を表示
// SDカードへアクセスするためMutexを使用
void handleRoot() {
  if (xSemaphoreTake(sdMutex, portMAX_DELAY) == pdTRUE) {
    File root = SD.open("/");
    if (!root) {
      server.send(500, "text/plain", "Failed to open directory");
      xSemaphoreGive(sdMutex);
      return;
    }
    if (!root.isDirectory()) {
      server.send(500, "text/plain", "Not a directory");
      xSemaphoreGive(sdMutex);
      return;
    }

    String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>SD Card Files</title></head><body>";
    html += "<h1>SD Card Files</h1>";
    html += "<p><a href='/'>Refresh</a></p>";
    html += "<ul>";

    printDirectory(root, html);

    html += "</ul>";
    html += "</body></html>";

    server.send(200, "text/html", html);
    root.close();
    xSemaphoreGive(sdMutex);
  } else {
    server.send(500, "text/plain", "SD Card Busy");
  }
}

// ファイル一覧をHTMLリストに追加 (再帰呼び出し対応だが今回はフラット)
void printDirectory(File dir, String& html) {
  while (true) {
    File entry = dir.openNextFile();
    if (!entry) {
      // no more files
      break;
    }
    
    if (!entry.isDirectory()) {
      String fileName = entry.name();
      if (fileName.startsWith(".")) {
          entry.close();
          continue;
      }

      html += "<li><a href='/";
      html += fileName;
      html += "'>";
      html += fileName;
      html += "</a> (";
      html += entry.size();
      html += " bytes)</li>";
    }
    entry.close();
  }
}

// ファイルダウンロード処理
// SDカードへアクセスするためMutexを使用
void handleFileDownload() {
  String path = server.uri();
  
  if (path.indexOf("/..") != -1) {
    server.send(403, "text/plain", "Forbidden");
    return;
  }

  if (xSemaphoreTake(sdMutex, portMAX_DELAY) == pdTRUE) {
    if (SD.exists(path)) {
      File file = SD.open(path, FILE_READ);
      String contentType = getContentType(path);
      server.streamFile(file, contentType);
      file.close();
    } else {
      server.send(404, "text/plain", "File Not Found");
    }
    xSemaphoreGive(sdMutex);
  } else {
    server.send(500, "text/plain", "SD Card Busy");
  }
}

String getContentType(String filename) {
  if (server.hasArg("download")) return "application/octet-stream";
  else if (filename.endsWith(".htm")) return "text/html";
  else if (filename.endsWith(".html")) return "text/html";
  else if (filename.endsWith(".css")) return "text/css";
  else if (filename.endsWith(".js")) return "application/javascript";
  else if (filename.endsWith(".png")) return "image/png";
  else if (filename.endsWith(".gif")) return "image/gif";
  else if (filename.endsWith(".jpg")) return "image/jpeg";
  else if (filename.endsWith(".ico")) return "image/x-icon";
  else if (filename.endsWith(".xml")) return "text/xml";
  else if (filename.endsWith(".pdf")) return "application/x-pdf";
  else if (filename.endsWith(".zip")) return "application/x-zip";
  else if (filename.endsWith(".csv")) return "text/csv";
  return "text/plain";
}

// --- ロガー関連関数 ---

// センサー読み取りとOLED表示 (Core 0で実行)
void updateDisplay() {
  int status = dht20.read();
  
  // I2C (Wire) は thread-safe ではない場合があるが、
  // 今回は Core 0 のみで Wire を使用するため競合はしない。
  // もし Core 1 でも Wire を使う場合は Mutex が必要。
  
  display.clearDisplay();
  display.setCursor(0, 0);

  if (status == DHT20_OK) {
    temperature = dht20.getTemperature();
    humidity = dht20.getHumidity();

    display.setTextSize(1);
    display.println(F("DHT20 Sensor"));
    display.println(F("----------------"));
    
    display.setTextSize(2);
    display.print(temperature, 1);
    display.println(F(" C"));
    display.print(humidity, 1);
    display.println(F(" %"));
  } else {
    display.setTextSize(1);
    display.println(F("Sensor Error"));
    display.print(F("Status: "));
    display.println(status);
  }

  // 現在時刻の表示
  if (wifiConnected) {
     struct tm timeinfo;
     if(getLocalTime(&timeinfo)){
        char timeStr[9];
        strftime(timeStr, sizeof(timeStr), "%H:%M:%S", &timeinfo);
        display.setTextSize(1);
        display.println(timeStr);
     }
  }
  // ipアドレスの表示
  display.print(F("IP: "));
  display.println(WiFi.localIP());

  display.display();
}

// データログ保存 (Core 0で実行)
// SDカードへアクセスするためMutexを使用
void logDataToSD() {
  if (!wifiConnected) return;

  struct tm timeinfo;
  if(!getLocalTime(&timeinfo)){
    Serial.println(F("Failed to obtain time"));
    return;
  }

  char timeStr[25];
  strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);

  String dataString = String(timeStr) + "," + String(temperature, 2) + "," + String(humidity, 2);

  // Mutex取得
  if (xSemaphoreTake(sdMutex, portMAX_DELAY) == pdTRUE) {
    File file = SD.open(LOG_FILENAME, FILE_APPEND);
    if (file) {
      file.println(dataString);
      file.close();
      Serial.println("Logged (Core 0): " + dataString);
    } else {
      Serial.println(F("Failed to open file for append"));
    }
    xSemaphoreGive(sdMutex);
  } else {
    Serial.println("Failed to take mutex for SD logging");
  }
}

動作確認

ブレッドボード

ファイルサーバーが正常に起動しました。(IPアドレス:192.168.3.16

Webブラウザ

ESP32S3のIPアドレスにアクセスします。

microSDカードに保存されているファイル一覧が表示されます。
data_log.csvをクリックします。

ファイルがダウンロードされます。

ダウンロードしたファイルを確認すると、記録された時間、温度、湿度が記録されています。

Discussion