ChatGPTと対話して、M5Stack Core2 × BME688 で作る環境モニターの構築！！

## 概要
本記事では、M5Stack Core2 for AWSとBME688環境センサーユニットを使用して、リアルタイムで温度・湿度・CO2相当濃度・VOC（揮発性有機化合物）を測定・表示し、SDカードへの保存およびWebブラウザでのモニタリングが可能な環境モニターを構築する方法を紹介します。

+ 温度、湿度、CO₂、VOC をリアルタイム測定
+ データは LCD 表示 + Webサーバー経由でPCブラウザに表示
+ SDカードへCSV形式で自動バックアップ
+ CO₂濃度がしきい値を超えると警告表示とアラート音
+ バッテリー残量の確認
+ シャットダウンボタン搭載（BtnC）

<strong>使用機材・環境及び構成</strong>
| 項目|  内容|
|:-:|:-:|
| MCU  |M5Stack Core2 for AWS (M5Core2) |
| センサー|  M5Stack用 BME688搭載環境センサユニット（I2C） |
| 開発環境|  Arduino IDE 2.3.5 |
| PC  |  Windows 11 |
| 開発ポート| 　PORT A(SDA:32,SCL:33)| 
| データ保存　| microSDカード(CSV方式)| 
| 通信方式　| WiFi + HTTPサーバ| 
| 言語  |  Arduino（C++） |
| ライブラリ  | M5Unified, bsec2, ezTime, SD, WebServer など |
| 表示　| 本体LCDスプライト描画、Webブラウザ表示
| 特記事項　| 一定CO2濃度でアラート音、Web経由でセンサデータ確認可能

<strong>システム構成図（概要）</strong>
```
[BME688] ─(I2C)─> [M5Core2] ──> [Wi-Fi Router] ──> [PCブラウザ表示]
                            │
                        [SDカード: データ保存]
                            │
                   [LCD: スプライトでセンサ可視化]
```
<strong>機能概要</strong>
+ 測定データ
+ 温度
+ 湿度
+ CO₂濃度（CO₂ Equivalent(同等)）
+ VOC濃度（Breath VOC Equivalent(同等)）
+ バッテリー残量

<strong>表示方法</strong>
+ M5Stack の LCD に日本語フォント付きで定期更新表示（3秒おき）

+ Webブラウザ（PCやスマホ）にJSON形式でリアルタイムデータ配信
→ http://<本体のIPアドレス>/data

<strong>データ保存</strong>
+ 30秒ごとに data_backup.csv に追記保存（SDカード）
フォーマット例：
2025-05-13 14:23:30,24.5,58.3,892.1,0.63

<strong>CO₂アラート機能</strong>
+ しきい値（1000ppm）を超えると、以下の処理を実行：
液晶背景を赤色で警告表示
ブザーによるアラート音（3回）

<strong>電源管理</strong>
+ 起動時にバッテリーが30%未満の場合、30秒後に自動シャットダウン
+ BtnC(右ボタン)を押すとシャットダウン

<strong>Web表示（JSONデータ例）</strong>
アクセス先：
```:arduino
http://192.168.10.100/data
```
返却されるJSON：

```:json:
{
  "temp": 24.5,
  "hum": 58.3,
  "co2": 892.1,
  "voc": 0.63,
  "battery": 87
}
```
<strong>表示画面例（LCD）</strong>
```:yaml
環境モニター
    温度: 24.5 C
    湿度: 58.3 %
    CO2 : 892.1 ppm
    VOC : 0.63 ppm
Battery: 87%
```
<strong>システム構成イメージ</strong>
![ENVmonitor.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/685816/058803e9-8386-41c9-94b1-3cec0f2d65d6.jpeg)
※警告表示
+ M5Core2、LCDでは、CO₂がしきい値超過時は、赤背景で「!! CO2 ALERT !!」と表示し、警告音を発生する
+ ブラウザでは、CO₂がしきい値超過時は、警告音が発生「CO２濃度が1000ppm(しきい値)を超えました」と表示される
+ M5Core2内蔵バッテリーで動作の場合、約1時間稼働する

<strong>ソースコード</strong>

```:ENVmonitor.ino
#include <WiFi.h>
#include <Wire.h>
#include "index_html.h"
#include <WebServer.h>
#include <SD.h>
#include <ezTime.h>
#include "M5Unified.h"
#include "bsec2.h"
#include <bme68xLibrary.h>

static LGFX_Sprite sprite(&M5.Display);  //スプライト設定
// グローバル変数でセンサー出力を保持
float temp = 0.0;
float hum = 0.0;
float co2 = 0.0;
float voc = 0.0;
int battery = 0;

// Wi-Fi設定
const char* wifiSSID = "SSID"; //WiFiルータのSSID
const char* wifiPassword = "PW"; //WiFiルータのパスワード

#define SDA_PIN 32          //PORT A SDA_PIN
#define SCL_PIN 33          //PORT A SCL_PIN
#define BME688_ADDR (0x77)
#define SD_CS 4

// BSEC2 センサーオブジェクト
Bsec2 envSensor;

WebServer server(80);
Timezone myTZ;

// Wi-Fi接続
void connectWiFi() {
    WiFi.begin(wifiSSID, wifiPassword);
    M5.Lcd.setTextSize(2);
    M5.Lcd.println("Connecting to WiFi...");
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        sprite.print(".");
    }
    M5.Lcd.println("\nWiFi Connected!");
    M5.Lcd.println(WiFi.localIP());
    setServer("ntp.nict.jp");
    updateNTP(); 
    myTZ.setLocation("Asia/Tokyo");
    delay(500);
    WiFi.mode(WIFI_STA);
    IPAddress local_IP(192, 168, 10, 100);
    IPAddress gateway(192, 168, 10, 1);
    IPAddress subnet(255, 255, 255, 0);
    WiFi.config(local_IP, gateway, subnet);
}
// Webサーバー
void handleRoot() {
    server.send(200, "text/html", htmlPage);
}
void handleData() {
    String json = "{\"temp\": " + String(temp,1) +
                  ", \"hum\": " + String(hum,1) +
                  ", \"co2\": " + String(co2,1) +
                  ", \"voc\": " + String(voc,1) +
                  ", \"battery\": " + String(battery) + "}";
                  
    server.send(200, "application/json", json);
}
void handleFavicon() {
    server.send(204);
}
void backupDataToSD(String currentTime, float temp, float hum, float co2, float voc) {
    File file = SD.open("/data_backup.csv", FILE_APPEND);
    if (file) {
        file.print(currentTime);
        file.print(",");
        file.print(temp);
        file.print(",");
        file.print(hum);
        file.print(",");
        file.print(co2);
        file.print(",");
        file.print(voc);
        file.print("\n");      
        file.close();
    } else {
        Serial.println("Failed to open SD card for writing.");
    }
}
// アラート音、管理
int toneFreqs[] = {800, 1000, 1200, 1000, 800};
const int toneDuration = 100;
unsigned long toneStartTime = 0;
bool tonePlaying = false;
const float co2_threshold = 1000;
bool alertstate = false;
int tonecount = 0;
const int max_tone = 3;
unsigned long lasttonetime = 0;
const unsigned long tone_interval = 1000;
// sdカードにデータ保存
unsigned long previousWriteMillis = 0;
const unsigned long writeInterval = 30000;
unsigned long lastDisplayUpdate = 0;
const unsigned long displayInterval = 3000;  // 3秒ごとに更新
void outputReady(bsecOutputs outputs) {
    if (!outputs.nOutputs) return;
    for (int i = 0; i < outputs.nOutputs; i++) {
        const bsecData output = outputs.output[i];
        switch (output.sensor_id) {
            case BSEC_OUTPUT_RAW_TEMPERATURE:
                temp = output.signal;
                break;
            case BSEC_OUTPUT_RAW_HUMIDITY:
                hum = output.signal;
                break;
            case BSEC_OUTPUT_CO2_EQUIVALENT:
                co2 = output.signal;
                break;
            case BSEC_OUTPUT_BREATH_VOC_EQUIVALENT:
                voc = output.signal;
                break;
        }
    }
    battery = M5.Power.getBatteryLevel();
}
void setup() {
    auto cfg = M5.config();
    M5.begin(cfg);
    M5.Power.begin();
    M5.Display.begin(); 
    sprite.setColorDepth(8);
    sprite.createSprite(M5.Display.width(),   M5.Display.height());
    Serial.begin(115200);
    Wire.begin(SDA_PIN, SCL_PIN);
    M5.Lcd.setCursor(70, 50);
    M5.Lcd.setTextSize(3);
    M5.Lcd.println("Power ON\n");
    delay(5000);
    if (M5.Power.getBatteryLevel() < 30) {
        M5.Lcd.fillScreen(YELLOW);
        M5.Lcd.setTextSize(3);
        M5.Lcd.setCursor(50, 50);
        M5.Lcd.setTextColor(BLACK);
        M5.Lcd.println("Low Battery");
        M5.Lcd.setCursor(10, 100);
        M5.Lcd.println(" ShutDown 30 sec");
        delay(30000);
        M5.Power.powerOff();
    }
    connectWiFi();
    server.on("/", handleRoot);
    server.on("/data", handleData);
    server.on("/favicon.ico", handleFavicon);
    server.begin();
    Serial.println("HTTP server started");
    // SDカード
    if (!SD.begin(SD_CS)) {
        Serial.println("SD Card Mount Failed");
    }
  // BSEC2 センサ初期化
  if (!envSensor.begin(BME688_ADDR, Wire)) {
    M5.Display.println("BME688 not found");
    while (1) delay(10);
   }
  // データ出力の種類と頻度を指定
  bsecSensor sensorList[] = {
    BSEC_OUTPUT_RAW_TEMPERATURE,
    BSEC_OUTPUT_RAW_HUMIDITY,
    BSEC_OUTPUT_CO2_EQUIVALENT,
    BSEC_OUTPUT_BREATH_VOC_EQUIVALENT
  };
  envSensor.updateSubscription(sensorList, 4, BSEC_SAMPLE_RATE_LP);
}
void loop() {
    M5.update();
    server.handleClient();
    envSensor.run(); 
    const bsecOutputs* outputs = envSensor.getOutputs();
    if (outputs) {
       outputReady(*outputs);
    }
    String currentTime = myTZ.dateTime("Y-m-d H:i:s");

    unsigned long now = millis();
    if (now - lastDisplayUpdate >= displayInterval) {
        lastDisplayUpdate = now;

    sprite.fillScreen(WHITE);
    sprite.setCursor(60, 20);
    sprite.setTextFont(&fonts::efontJA_16);
    sprite.setTextSize(2);
    sprite.setTextColor(NAVY,CYAN);
    sprite.println("環境モニター");
    sprite.setTextColor(DARKGREEN);
    sprite.print("    温度");
    sprite.printf(":%.1f C\n", temp);
    sprite.print("    湿度");
    sprite.printf(":%.1f%%\n", hum);
    sprite.setTextColor(MAGENTA);
    sprite.printf("    CO2 :%.1f ppm\n", co2);
    sprite.printf("    VOC :%.1f ppm\n", voc);
    sprite.setCursor(110, 200);
    sprite.setTextColor(WHITE,DARKGREY);
    sprite.printf("Battery:%d%%", battery);
    
     // SDバックアップ処理
    if (now - previousWriteMillis >= writeInterval) {
        previousWriteMillis = now;
        backupDataToSD(currentTime, temp, hum, co2, voc);
    }
    if (!alertstate && co2 > co2_threshold) {
    alertstate = true;
    tonecount = 0;
    lasttonetime = millis();
    tonePlaying = false;
    Serial.println("Alert start");
    }
    if (alertstate) {
    unsigned long now = millis();
    if (tonecount < max_tone && now - lasttonetime >= tone_interval) {
        if (!tonePlaying) {
            M5.Speaker.setVolume(255);
            M5.Speaker.tone(toneFreqs[tonecount]);
            toneStartTime = now;
            tonePlaying = true;
        } else if (now - toneStartTime >= toneDuration) {
            M5.Speaker.stop();
            tonePlaying = false;
            lasttonetime = now;
            tonecount++;
            Serial.printf("tone %d\n", tonecount);
        }
    }   
        if (co2 <= co2_threshold) {
           alertstate = false;
           M5.Speaker.stop();
           Serial.println("Alert stop");
        }
        sprite.fillScreen(RED);
        sprite.setTextSize(2);
        sprite.setTextColor(WHITE);
        sprite.setCursor(10, 10);
        sprite.println("!! CO2 ALERT !!");
        sprite.setCursor(50, 50);
        sprite.printf("CO2: %.1f ppm\n", co2);
    }
    sprite.pushSprite(0, 0);
    //ボタンC: シャットダウン
    if (M5.BtnC.isPressed()) {
        M5.Lcd.fillScreen(GREEN);
        M5.Lcd.setTextSize(3);
        M5.Lcd.setCursor(50, 80);
        M5.Lcd.setTextColor(BLACK);
        M5.Lcd.println("Power OFF");
        Serial.println("Power OFF");
        delay(2000);
        M5.Power.powerOff();
    }
  }
}
```
```:index_html.h
const char htmlPage[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>環境モニター</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

  <script>
    let tempChart, humChart, co2Chart, vocChart;
    let audioCtx;

    function initAudio() {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      const oscillator = audioCtx.createOscillator();
      oscillator.frequency.setValueAtTime(1, audioCtx.currentTime); // 無音
      oscillator.connect(audioCtx.destination);
      oscillator.start();
      oscillator.stop(audioCtx.currentTime + 0.01); // すぐ停止

      document.getElementById("startScreen").style.display = "none";
      document.getElementById("mainContent").style.display = "block";

      setupCharts();
      fetchData(); // 初回取得
      setInterval(fetchData, 20000);
    }

    async function fetchData() {
      try {
        const response = await fetch("/data");
        const data = await response.json();

        document.getElementById('temperature').innerText = data.temp + ' °C';
        document.getElementById('humidity').innerText = data.hum + ' %';
        document.getElementById('co2').innerText = data.co2 + ' ppm';
        document.getElementById('voc').innerText = data.voc + ' ppb';
        document.getElementById('battery').innerText = data.battery + ' %';

        updateChart(data.temp, data.hum, data.co2, data.voc);
      } catch (error) {
        console.error("Failed to fetch data:", error);
      }
    }

    function updateChart(temp, hum, co2, voc) {
      const now = new Date().toLocaleTimeString();

      tempChart.data.labels.push(now);
      humChart.data.labels.push(now);
      co2Chart.data.labels.push(now);
      vocChart.data.labels.push(now);

      tempChart.data.datasets[0].data.push(temp);
      humChart.data.datasets[0].data.push(hum);
      co2Chart.data.datasets[0].data.push(co2);
      vocChart.data.datasets[0].data.push(voc);

      if (tempChart.data.labels.length > 20) {
        tempChart.data.labels.shift();
        tempChart.data.datasets[0].data.shift();
        humChart.data.labels.shift();
        humChart.data.datasets[0].data.shift();
        co2Chart.data.labels.shift();
        co2Chart.data.datasets[0].data.shift();
        vocChart.data.labels.shift();
        vocChart.data.datasets[0].data.shift();
      }

      tempChart.update();
      humChart.update();
      co2Chart.update();
      vocChart.update();

      // CO2 アラート処理
      if (co2 >= 1000) {
        alertWithBeep();
      }
    }

    function setupCharts() {
      const ctx1 = document.getElementById('tempChart').getContext('2d');
      tempChart = new Chart(ctx1, {
        type: 'line',
        data: { labels: [], datasets: [{ label: 'Temperature (°C)', data: [], borderColor: 'red', borderWidth: 2 }] }
      });

      const ctx2 = document.getElementById('humChart').getContext('2d');
      humChart = new Chart(ctx2, {
        type: 'line',
        data: { labels: [], datasets: [{ label: 'Humidity (%)', data: [], borderColor: 'blue', borderWidth: 2 }] }
      });

      const ctx3 = document.getElementById('co2Chart').getContext('2d');
      co2Chart = new Chart(ctx3, {
        type: 'line',
        data: { labels: [], datasets: [{ label: 'CO2 (ppm)', data: [], borderColor: 'green', borderWidth: 2 }] }
      });

      const ctx4 = document.getElementById('vocChart').getContext('2d');
      vocChart = new Chart(ctx4, {
        type: 'line',
        data: { labels: [], datasets: [{ label: 'VOC (ppb)', data: [], borderColor: 'purple', borderWidth: 2 }] }
      });
    }

    function alertWithBeep() {
      playMultiTone().then(() => {
        alert("CO2濃度が1000ppmを超えました！");
      });
    }

    function playMultiTone() {
      return new Promise(resolve => {
        if (!audioCtx) {
          resolve();
          return;
        }

        const freqs = [523, 659, 784]; // C5, E5, G5
        let index = 0;

        function playNext() {
          if (index >= freqs.length) {
            resolve();
            return;
          }

          const osc = audioCtx.createOscillator();
          const gain = audioCtx.createGain();
          osc.type = "sine";
          osc.frequency.setValueAtTime(freqs[index], audioCtx.currentTime);
          gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
          osc.connect(gain);
          gain.connect(audioCtx.destination);
          osc.start();
          osc.stop(audioCtx.currentTime + 0.3);

          index++;
          setTimeout(playNext, 350); // 次の音へ
        }

        playNext();
      });
    }
  </script>
</head>

<body>
  <!-- モニター開始画面（強調表示） -->
  <div id="startScreen" style="text-align:center; margin-top: 50px; background-color: #ffefef; padding: 30px; border: 2px solid red; border-radius: 10px;">
    <h2 style="color: red; font-size: 1.8em;">モニターを開始するにはボタンを押してください</h2>
    <button onclick="initAudio()" style="font-size: 20px; padding: 10px 20px; background-color: #4CAF50; color: white; border: none; border-radius: 5px;">開始</button>
  </div>

  <!-- メイン表示部 -->
  <div id="mainContent" style="display: none;">
    <h1 style="text-align: center; font-size: 2em; margin-top: 18px;">環境モニター</h1>
    <div style="display: flex; justify-content: space-around;">
      <div>
        <p>温度: <span id="temperature">--</span></p>
        <p>湿度: <span id="humidity">--</span></p>
      </div>
      <div>
        <p>CO2: <span id="co2">--</span></p>
        <p>VOC: <span id="voc">--</span></p>
        <p>バッテリー: <span id="battery">--</span></p>
      </div>
    </div>
    <canvas id="tempChart" width="400" height="200"></canvas>
    <canvas id="humChart" width="400" height="200"></canvas>
    <canvas id="co2Chart" width="400" height="200"></canvas>
    <canvas id="vocChart" width="400" height="200"></canvas>
  </div>
</body>
</html>
)rawliteral";
```
<strong>おわりに</strong>
このプロジェクトは、M5StackとBME688のシンプルな組み合わせでありながら、
実用性と拡張性を兼ね備えた環境モニタリング装置を低コストで構築できます。
CO₂警告やログ保存など、IoT環境モニタのベースとしても活用可能です。
※注記：CO2濃度測定器は、NDIRセンサーが推薦されています。

<strong>ひとこと</strong>
ChatGPTとの対話しながらプログラミングは楽しいものです。あっと言う間にプログラムが作成されそれを検証する。エラーがあればデバックを頼むな等々、無料版なので時間制限があり翌日に再開するそんな日々でした。
以前なら、分からないことはググってでしたが、的確に此方からの要望を伝える事が出来れば、満足できるプログラムが作成されます！