ESP32とAWS IoT Coreで構築するセキュアなリモートマイコン制御システム

今日のIoT時代において、物理的なデバイスを遠隔から制御するニーズはますます高まっています。自宅の照明をスマートフォンから操作したり、離れた場所にあるセンサーデータをリアルタイムで監視したりと、IoTデバイスの可能性は無限大です。

本記事では、手軽に扱えるマイクロコントローラーであるESP32をコアに、Amazon Web Services (AWS) のIoT CoreLambdaAPI Gatewayといったマネージドサービスを組み合わせることで、セキュアで堅牢なリモートマイコン制御システムを構築する方法を詳しく解説します。

1. システムアーキテクチャの全体像

私たちが今回構築するシステムの全体像は以下の図のようになります。

[外部アプリケーション/Webサービス]
      (HTTP/S リクエスト)

     [Amazon API Gateway]
(Lambdaトリガー)
       [AWS Lambda]
(MQTT Publish)
       [AWS IoT Core]
(MQTT Subscribe)
          [ESP32]
(GPIO制御など)
       [マイコン制御]
(MQTT Publish)
       [AWS IoT Core]

このアーキテクチャでは、外部からのHTTPリクエストがAPI Gateway、Lambdaを経由してAWS IoT CoreへMQTTメッセージとして送信されます。AWS IoT Coreは、このメッセージを購読しているESP32デバイスにルーティングし、ESP32はそれを受け取ってマイコン(今回はLED)を制御します。さらに、ESP32は自身の状態を定期的にAWS IoT Coreへ報告します。

ESP32側では、Wi-Fi接続の堅牢性を高めるため、複数のWi-Fi設定に対応し、切断時の自動再接続機能を実装しています。これにより、ネットワーク環境の変化にも対応できる信頼性の高いシステムを目指します。

2. AWS IoT Core の設定:デバイスとクラウドの玄関口

AWS IoT Coreは、数百万のデバイスとクラウドアプリケーションがセキュアかつ双方向で通信できるためのマネージドサービスです。ここでは、ESP32がIoT Coreに接続するための基盤を設定します。

2.1. モノ (Thing) の作成

AWS IoT Coreにおいて、デバイスは「モノ (Thing)」として登録されます。これがクラウド上のデバイスの仮想表現となります。

(1)AWS マネジメントコンソールにログインし、IoT Core サービスへ移動します。
(2)左側のナビゲーションペインで [管理] > [モノ] を選択し、[モノを登録] をクリックします。
(3)[1 つのモノを作成] を選択し、モノの名前を my-esp32-device のように分かりやすい名前に設定します。これがデバイスのMQTTクライアントIDとしても利用されます。

2.2. 証明書とキーの生成・管理

デバイスとAWS IoT Core間の通信には、X.509証明書プライベートキーを用いた厳格な認証が必須です。これにより、不正なデバイスからのアクセスを防ぎ、セキュアな通信を確立します。

モノの作成フローの中で、[証明書を自動生成 (推奨)] を選択し、証明書を生成します。このとき、以下の3つのファイルを必ずダウンロードし、安全な場所に保管してください。これらは後でESP32に組み込みます。

  • xxxxxx-certificate.pem.crt (デバイス証明書)
  • xxxxxx-private.pem.key (プライベートキー)
  • root-CA.pem (Amazon Root CA 証明書)

2.3. IAMポリシーの作成とアタッチ

IAMポリシーは、デバイスがAWS IoT Coreに対してどのような操作を許可されるかを定義するアクセスルールです。ここでは、ESP32がMQTTメッセージを公開・購読し、接続するために必要な最小限の権限を与えます。

証明書の生成後、[ポリシーを作成] を選択し、新しいポリシーを作成します。ポリシー名を esp32-iot-policy のように設定し、以下のJSONドキュメントを記述します。

JSON

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "iot:Connect"
      ],
      "Resource": "arn:aws:iot:YOUR_REGION:YOUR_ACCOUNT_ID:client/my-esp32-device"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iot:Publish"
      ],
      "Resource": "arn:aws:iot:YOUR_REGION:YOUR_ACCOUNT_ID:topic/esp32/status"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iot:Receive"
      ],
      "Resource": "arn:aws:iot:YOUR_REGION:YOUR_ACCOUNT_ID:topic/esp32/commands"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iot:Subscribe"
      ],
      "Resource": "arn:aws:iot:YOUR_REGION:YOUR_ACCOUNT_ID:topicfilter/esp32/commands"
    }
  ]
}

ポイント:

  • YOUR_REGIONYOUR_ACCOUNT_ID は、ご自身のAWSアカウント情報に置き換えてください。
  • client/my-esp32-device: iot:Connect は、指定された Thing Name (my-esp32-device) をクライアントIDとして接続することを許可します。
  • topic/esp32/status: ESP32が自身の状態を公開 (iot:Publish) するトピックです。
  • topic/esp32/commands: ESP32がクラウドからのコマンドを受信 (iot:Receive) するトピックです。

topicfilter/esp32/commands: ESP32が指定されたトピックパターンを購読 (iot:Subscribe) することを許可します。

【補足】topictopicfilter の違い:

topic はMQTTメッセージを公開 (Publish) する際の具体的なアドレスを示し、ワイルドカードは使えません。一方、topicfilter はメッセージを購読 (Subscribe) する際のパターンを示し、+ (シングルレベルワイルドカード) や # (マルチレベルワイルドカード) を使って複数のトピックをまとめて購読できます。IAMポリシーでは、Publishには topic/、Subscribeには topicfilter/ を使用します。

ポリシーを作成したら、生成した証明書にこのポリシーをアタッチし、有効化します。

2.4. MQTTトピック設計

システムのMQTTトピックは、以下の2つを主に使用します。

  • esp32/commands: クラウドからESP32へのコマンドを送信するトピック。LEDのON/OFFなどの制御コマンドをJSON形式で送信します。
  • esp32/status: ESP32が自身の状態(LEDの状態、稼働時間など)をクラウドに報告するトピック。

3. ESP32側の実装:MQTT通信とデバイス制御

ここからは、ESP32にプログラムを書き込み、AWS IoT Coreと通信できるようにします。

3.1. 開発環境の準備

Arduino IDEを使用します。

(1)Arduino IDEのインストール
(2)ESP32ボードのサポート追加: ファイル > 環境設定 > 追加のボードマネージャのURLhttps://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json を追加。
(3)ボードマネージャから esp32 をインストール (ツール > ボード > ボードマネージャ)。
(4)必要なライブラリのインストール: スケッチ > ライブラリをインクルード > ライブラリを管理 から以下を検索・インストール。

  • WiFiClientSecure (通常はESP32ボードパッケージに含まれる)
  • MQTTClient (by Imroy) – PubSubClientではないので注意
  • ArduinoJson (by Benoit Blanchon)

3.2. プロジェクト構造と secrets.h

セキュリティのため、Wi-FiのSSID/パスワードやAWS IoT Coreの認証情報(証明書、秘密鍵、エンドポイント)はコード本体に直接記述せず、secrets.h という別ファイルに分離します。このファイルはGitなどのバージョン管理システムから除外(.gitignore に追加)することで、機密情報の漏洩を防ぎます。

secrets.h の内容例:

C++

// secrets.h
#ifndef SECRETS_H
#define SECRETS_H

// --- Wi-Fi設定 ---
struct WifiConfig {
  const char* ssid;
  const char* password;
};

// 複数のWi-Fi設定を配列で定義
// デバイスが接続を試みるWi-Fiネットワークの優先順位順に記述してください。
WifiConfig wifiConfigs[] = {
  {"YOUR_WIFI_SSID_1", "YOUR_WIFI_PASSWORD_1"}, // プライマリ Wi-Fi の SSID とパスワード
  {"YOUR_WIFI_SSID_2", "YOUR_WIFI_PASSWORD_2"}  // セカンダリ Wi-Fi の SSID とパスワード
};
// Wi-Fi設定の数を自動計算
const int NUM_WIFI_CONFIGS = sizeof(wifiConfigs) / sizeof(wifiConfigs[0]);

// --- AWS IoT Core 設定 ---
// AWS IoT Coreで作成したモノの名前。MQTTクライアントIDとしても使用されます。
#define THINGNAME "my-esp32-device"
// AWS IoT CoreのエンドポイントURL。AWS IoT Coreコンソールの「設定」で確認できます。
#define AWS_IOT_ENDPOINT "YOUR_IOT_CORE_ENDPOINT"

// --- AWS IoT Core 証明書 ---
// root-CA.pem の内容を貼り付けます。これはAWS IoT Coreの信頼性を保証する証明書です。
const char AWS_CERT_CA[] = R"EOF(
-----BEGIN CERTIFICATE-----
// ここにダウンロードした root-CA.pem の内容を貼り付け
-----END CERTIFICATE-----
)EOF";

// xxxxxx-certificate.pem.crt の内容を貼り付けます。これはデバイス固有の証明書です。
const char AWS_CERT_CRT[] = R"EOF(
-----BEGIN CERTIFICATE-----
// ここにダウンロードした xxxxxx-certificate.pem.crt の内容を貼り付け
-----END CERTIFICATE-----
)EOF";

// xxxxxx-private.pem.key の内容を貼り付けます。デバイスの秘密鍵であり、厳重に管理してください。
const char AWS_CERT_PRIVATE[] = R"EOF(
-----BEGIN RSA PRIVATE KEY-----
// ここにダウンロードした xxxxxx-private.pem.key の内容を貼り付け
-----END RSA PRIVATE KEY-----
)EOF";

#endif

3.3. メインスケッチの全体構造

ESP32のメインスケッチ (.ino ファイル) は以下のようになります。各機能が独立した関数として定義され、loop() 関数は全体の流れを制御するテンプレートメソッドとして機能します。

C++

// main.ino (メインのArduinoスケッチファイル)

// --- ヘッダーファイルのインクルード ---
#include "secrets.h"          // Wi-Fi設定、AWS IoTエンドポイント、認証情報などを読み込む

#include <WiFiClientSecure.h> // HTTPS通信に必要なライブラリ (SSL/TLS通信)
#include <MQTTClient.h>       // MQTT通信のためのライブラリ (Imroy版)
#include <ArduinoJson.h>      // JSONデータのパースと生成に利用 (コマンドやステータスをJSONで送受信)
#include <time.h>             // NTP時刻同期に必要なライブラリ
#include <WiFi.h>             // ESP32のWi-Fiライブラリ

// --- 定数定義 ---
constexpr int LED_PIN = 25;                     // LEDが接続されているGPIOピン番号
constexpr unsigned long PUBLISH_INTERVAL_MS = 60000; // デバイス状態をクラウドに定期送信する間隔 (ミリ秒)
constexpr int WIFI_RECONNECT_DELAY_MS = 500;    // Wi-Fi接続試行間の待機時間 (ミリ秒)
constexpr int MQTT_RECONNECT_DELAY_MS = 500;    // MQTT接続試行間の待機時間 (ミリ秒)
constexpr int NTP_SYNC_TIMEOUT_MS = 5000;       // NTPサーバーとの時刻同期のタイムアウト時間 (ミリ秒)
constexpr int MAX_RETRIES = 10;                 // Wi-FiおよびMQTT接続の最大試行回数

// --- MQTTトピック設定 ---
#define AWS_IOT_PUBLISH_TOPIC   "esp32/status"   // LEDの状態やデバイス情報を送信するMQTTトピック
#define AWS_IOT_SUBSCRIBE_TOPIC "esp32/commands" // クラウドからの制御コマンドを受信するMQTTトピック

// --- グローバル変数 ---
WiFiClientSecure net;                           // SSL/TLS通信のためのクライアントオブジェクト
MQTTClient client(256);                         // MQTTクライアントオブジェクト (バッファサイズ256バイト)
bool ledState = false;                          // LEDの現在の状態 (false: OFF, true: ON)
unsigned long lastMsg = 0;                      // 最後にステータスを送信した `millis()` の値
bool isWiFiConnected = false;                   // 現在のWi-Fi接続状態を示すフラグ

// --- NTP 時刻同期関連 ---
const char* ntpServer = "ntp.nict.jp";          // 日本標準時を提供するNTPサーバーのアドレス
const long gmtOffset_sec = 9 * 3600;            // GMTからのオフセット (日本標準時は+9時間)

// --- 関数プロトタイプ ---
// ハードウェアの初期設定を行う関数
void initializeHardware();
// Wi-Fiネットワークへの接続を管理する関数
void connectToWiFi();
// NTPサーバーと時刻同期を行う関数
void syncTimeWithNTP();
// MQTTクライアントの初期設定を行う関数 (証明書設定など)
void initializeMQTT();
// MQTTブローカーへの接続を試み、成功/失敗を返す関数
bool safeMQTTConnect();
// Wi-FiとMQTTの接続状態を監視し、必要に応じて再接続をトリガーする関数
void reconnectIfRequired();
// 受信したMQTTメッセージ(コマンド)を処理するハンドラ関数
void handleIncomingCommands(String &topic, String &payload);
// LEDの現在の状態をMQTTでクラウドに送信する関数
void publishLEDStatus();

// --- setup 関数:デバイスの初期設定と初期接続 ---
void setup() {
  Serial.begin(115600);      // シリアル通信を初期化 (デバッグ出力用)
  initializeHardware();      // ハードウェアの初期化 (LEDピンの設定など)
  connectToWiFi();           // Wi-Fiネットワークへの接続を試みる

  // Wi-Fi接続が成功した場合のみ、後続のネットワーク関連サービスを初期化
  if (isWiFiConnected) {
    syncTimeWithNTP();         // NTPサーバーと時刻同期を行い、正確なタイムスタンプを取得
    initializeMQTT();          // MQTTクライアントの設定とAWS IoT Coreへの初期接続を試みる
  } else {
    // setup()フェーズでWi-Fi接続に失敗した場合の処理
    Serial.println("Initial WiFi connection failed in setup. Restarting ESP...");
    ESP.restart(); // Wi-Fi接続ができない場合はデバイスを再起動し、再試行
  }
}

// --- loop 関数:デバイスの主要な処理ループ (テンプレートメソッドパターン) ---
void loop() {
  // 1. ネットワーク (Wi-Fi) と MQTT の接続状態を常に監視し、必要に応じて再接続処理をトリガー
  // このメソッドがデバイスの接続性維持に関する全てのロジックをカプセル化します。
  reconnectIfRequired();

  // 2. MQTTクライアントがAWS IoT Coreに接続されている場合のみ、
  //    MQTT通信のループ処理と定期的なステータス発行を実行
  if (client.connected()) {
    client.loop();           // MQTT通信を処理 (メッセージの送受信、Keep-Aliveの維持など)
    unsigned long now = millis();
    // 設定された間隔 (PUBLISH_INTERVAL_MS) ごとにLED状態をクラウドに送信
    if (now - lastMsg >= PUBLISH_INTERVAL_MS) {
      lastMsg = now;
      publishLEDStatus();
    }
  }

  // ここに、センサーデータの読み取り、ボタン状態のチェック、
  // その他のデバイス固有のメインループ処理を追加できます。
  // 例: readSensorData();
  // 例: checkButtonState();
}

// --- ハードウェアの初期化 ---
/**
 * @brief デバイスのハードウェア初期化を行います。
 * @details LEDピンを出力モードに設定し、初期状態では消灯させます。
 */
void initializeHardware() {
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW); // LEDを消灯状態から開始
  Serial.println("Hardware initialized. LED is OFF.");
}

// --- Wi-Fi接続処理 ---
/**
 * @brief Wi-Fiネットワークへの接続を試行します。
 * @details secrets.hで定義された複数のWi-Fi設定を順番に試します。
 * 各設定で最大試行回数 (MAX_RETRIES) まで接続を試み、接続に成功したらフラグを設定して終了します。
 * 全ての設定で接続に失敗した場合は、ESP32を再起動します。
 */
void connectToWiFi() {
  WiFi.mode(WIFI_STA); // Wi-Fiモジュールをステーションモードに設定
  Serial.println("Attempting to connect to WiFi...");

  // secrets.h で定義された複数のWi-Fi設定をループで試行
  for (int i = 0; i < NUM_WIFI_CONFIGS; i++) {
    Serial.printf("Trying WiFi connection to SSID: %s\n", wifiConfigs[i].ssid);
    WiFi.begin(wifiConfigs[i].ssid, wifiConfigs[i].password); // Wi-Fi接続開始

    // 各Wi-Fi設定での接続試行ループ
    for (int retry = 0; retry < MAX_RETRIES; retry++) {
      if (WiFi.status() == WL_CONNECTED) {
        isWiFiConnected = true; // Wi-Fi接続成功フラグを立てる
        Serial.println("WiFi connected successfully!");
        Serial.printf("IP Address: %s\n", WiFi.localIP().toString().c_str());
        return; // 接続成功したら関数を終了
      }
      delay(WIFI_RECONNECT_DELAY_MS); // 再試行までの待機
      Serial.print(".");
    }
    // MAX_RETRIES回試行しても接続できなかった場合、このWi-Fi設定をリセットして次を試す
    Serial.printf("\nFailed to connect to SSID: %s after %d retries.\n", wifiConfigs[i].ssid, MAX_RETRIES);
    WiFi.disconnect(true);   // 接続失敗時にWi-Fiを完全にリセット
  }

  // 全ての設定を試しても接続できなかった場合、最終手段としてESPを強制リスタート
  Serial.println("\nFailed to connect to any WiFi network after all attempts. Restarting ESP...");
  ESP.restart();
}

// --- 時刻同期処理 ---
/**
 * @brief NTPサーバーと時刻同期を行います。
 * @details 起動後の正確なタイムスタンプ取得のために、設定されたNTPサーバーと通信します。
 * NTP_SYNC_TIMEOUT_MS で設定された時間内に時刻が取得できない場合は失敗と判断します。
 */
void syncTimeWithNTP() {
  Serial.println("Synchronizing time with NTP server...");
  // NTPサーバーとGMTオフセットを設定して時刻同期を開始
  configTime(gmtOffset_sec, 0, ntpServer); // 夏時間オフセットは0

  struct tm timeinfo;
  unsigned long start = millis();
  // 時刻が取得できるまで、またはタイムアウトするまで待機
  while (!getLocalTime(&timeinfo)) {
    delay(100); // 短い待機
    if (millis() - start > NTP_SYNC_TIMEOUT_MS) {
      Serial.println("NTP time synchronization failed due to timeout.");
      return; // タイムアウトしたら関数を終了
    }
    Serial.print(".");
  }
  Serial.println("\nNTP time synchronized successfully.");
  // 取得した現在時刻をシリアルに出力
  Serial.printf("Current Time: %s\n", asctime(&timeinfo));
}

// --- MQTTの初期化 ---
/**
 * @brief MQTTクライアントの初期設定を行います。
 * @details AWS IoT Coreへの接続に必要な証明書と秘密鍵を設定し、MQTTブローカーのエンドポイントとポートを設定します。
 * また、受信メッセージを処理するハンドラ関数を設定します。
 */
void initializeMQTT() {
  // AWS IoT Coreへの接続に必要な証明書と秘密鍵を設定
  net.setCACert(AWS_CERT_CA);
  net.setCertificate(AWS_CERT_CRT);
  net.setPrivateKey(AWS_CERT_PRIVATE);

  // MQTTブローカーのアドレスとポートを設定し、SSL/TLS通信を有効化
  client.begin(AWS_IOT_ENDPOINT, 8883, net);
  // 受信したMQTTメッセージを処理するためのハンドラ関数を設定
  client.onMessage(handleIncomingCommands);

  // MQTTへの初期接続を試みる。失敗した場合はESP32を再起動。
  if (!safeMQTTConnect()) {
    Serial.println("Initial MQTT connection failed. Restarting ESP...");
    ESP.restart();
  }
}

// --- MQTTの安全な接続処理 ---
/**
 * @brief AWS IoT CoreへのMQTT接続を安全に試行します。
 * @details MAX_RETRIES で設定された回数まで接続を試み、成功した場合は true を返します。
 * 全ての試行が失敗した場合は false を返します。
 */
bool safeMQTTConnect() {
  for (int retry = 0; retry < MAX_RETRIES; retry++) {
    Serial.println("Connecting to AWS IoT Core...");
    // ThingNameをクライアントIDとして使用してMQTT接続を試行
    if (client.connect(THINGNAME)) {
      Serial.println("MQTT Connected!");
      // コマンド受信用のトピックを購読
      client.subscribe(AWS_IOT_SUBSCRIBE_TOPIC);
      Serial.printf("Subscribed to topic: %s\n", AWS_IOT_SUBSCRIBE_TOPIC);
      publishLEDStatus(); // 接続成功時に現在のLED状態をクラウドに報告
      return true; // 接続成功したら true を返す
    }
    Serial.printf("MQTT connection failed (Error: %d). Retrying...\n", client.lastError());
    delay(MQTT_RECONNECT_DELAY_MS); // 再試行までの待機
  }
  Serial.println("Failed to connect to AWS IoT Core after maximum retries.");
  return false; // 最大試行回数を超えても接続できなかった場合
}

// --- 再接続確認 ---
/**
 * @brief Wi-FiおよびMQTTの接続状態を監視し、必要に応じて再接続をトリガーします。
 * @details loop()関数から定期的に呼び出され、デバイスのネットワーク接続性とMQTT接続性を維持します。
 */
void reconnectIfRequired() {
  // Wi-Fiが現在接続されていない場合
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("Wi-Fi disconnected. Attempting to reconnect...");
    isWiFiConnected = false; // Wi-Fi接続フラグをリセット
    // MQTTクライアントも切断されていれば、明示的に切断
    if (client.connected()) {
      client.disconnect();
      Serial.println("MQTT client disconnected gracefully.");
    }
    connectToWiFi(); // Wi-Fiの再接続を試行
    if (isWiFiConnected) { // Wi-Fi再接続成功後
      syncTimeWithNTP(); // 時刻同期を再試行
      safeMQTTConnect(); // AWS IoT CoreへのMQTT接続を再試行
    }
  }
  // Wi-Fiは接続されているが、MQTTが切断されている場合
  else if (!client.connected()) {
    Serial.println("MQTT disconnected. Attempting to reconnect...");
    safeMQTTConnect(); // MQTT接続を再試行
  }
}

// --- 受信メッセージの処理 ---
/**
 * @brief 受信したMQTTメッセージを処理するハンドラ関数。
 * @details MQTTClientライブラリによってメッセージが受信された際に呼び出されます。
 * JSONペイロードをパースし、'command' キーの値に基づいてLEDの状態を制御します。
 * @param topic 受信したメッセージのMQTTトピック
 * @param payload 受信したメッセージのJSONペイロード
 */
void handleIncomingCommands(String &topic, String &payload) {
  Serial.printf("Message received [Topic: %s]: %s\n", topic.c_str(), payload.c_str());

  // JSONドキュメントのバッファを作成 (StaticJsonDocumentのサイズはペイロードの最大サイズより大きくする)
  StaticJsonDocument<256> doc;
  // JSONペイロードをパース。失敗した場合はエラーメッセージを出力。
  if (deserializeJson(doc, payload)) { // deserializeJsonは成功時に0を返すので、失敗時はtrueを返す
    Serial.println("Failed to parse JSON payload. Invalid JSON format.");
    return;
  }

  // 'command' キーの値を取得
  const char* command = doc["command"];
  if (command) { // 'command' キーが存在する場合
    if (strcmp(command, "turn_on_led") == 0) { // "turn_on_led" コマンドの場合
      digitalWrite(LED_PIN, HIGH); // LED点灯
      ledState = true;
      Serial.println("LED turned ON.");
    } else if (strcmp(command, "turn_off_led") == 0) { // "turn_off_led" コマンドの場合
      digitalWrite(LED_PIN, LOW); // LED消灯
      ledState = false;
      Serial.println("LED turned OFF.");
    } else {
      Serial.printf("Unknown command received: %s\n", command); // 未知のコマンドの場合
    }
    publishLEDStatus(); // LED状態変更後、現在の状態をクラウドにパブリッシュ
  } else {
    Serial.println("JSON payload does not contain 'command' key."); // 'command' キーがない場合
  }
}

// --- LED状態の送信 ---
/**
 * @brief LEDの現在の状態やその他の情報をAWS IoT Coreにパブリッシュする関数。
 * @details LEDのON/OFF状態、起動からのミリ秒 (稼働時間)、NTPで取得した現在時刻などをJSON形式で送信します。
 */
void publishLEDStatus() {
  StaticJsonDocument<128> doc; // JSONドキュメントのバッファを作成
  doc["state"] = ledState ? "ON" : "OFF"; // LEDのON/OFF状態を設定
  doc["timestamp_ms"] = millis(); // 起動からのミリ秒 (稼働時間) を追加
  
  struct tm timeinfo;
  // NTP時刻が取得できていれば、その情報も追加
  if (getLocalTime(&timeinfo)) {
    char timeStr[30];
    // ISO 8601 形式で時刻をフォーマット (例: 2024-07-20T10:30:00+09:00)
    // NTP同期がJSTで行われているため、この形式で出力
    strftime(timeStr, sizeof(timeStr), "%Y-%m-%dT%H:%M:%S+09:00", &timeinfo);
    doc["current_time"] = timeStr;
  }

  char jsonPayload[128]; // JSONシリアライズ用のバッファ
  serializeJson(doc, jsonPayload); // JSONドキュメントを文字列に変換

  client.publish(AWS_IOT_PUBLISH_TOPIC, jsonPayload); // MQTTでステータスを公開
  Serial.printf("Published status: %s\n", jsonPayload); // シリアルに出力
}

4. AWS Lambda と API Gateway でAPIを構築:外部からの制御

ESP32を遠隔からHTTPリクエストで制御できるように、API GatewayとLambdaを設定します。

4.1. Lambda 関数の作成:コマンド送信ロジック

API GatewayからのHTTPリクエストを受け取り、AWS IoT CoreへMQTTメッセージを公開するLambda関数を作成します。

(1)Lambda 関数の作成: AWS Lambdaコンソールで [関数の作成] をクリックし、関数名を send-iot-command-function、ランタイムを Python 3.9 以上で作成します。
(2)IAMロールの権限設定: Lambdaの実行ロールに、AWS IoT CoreへのMQTT Publish権限を付与します。

  • [設定] > [アクセス権限] でロール名をクリックし、IAMコンソールでロールの詳細ページを開きます。
  • [権限を追加] > [インラインポリシーを作成] を選択し、以下のJSONポリシーを貼り付けます。YOUR_REGIONYOUR_ACCOUNT_ID をご自身の情報に置き換えてください。

 JSON

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iot:Publish"
            ],
            "Resource": "arn:aws:iot:YOUR_REGION:YOUR_ACCOUNT_ID:topic/esp32/commands"
        }
    ]

(3)Lambda 関数のコード: 以下のPythonコードを貼り付けます。

  • [設定] > [環境変数] IOT_CORE_ENDPOINT キーを追加し、値をAWS IoT CoreのエンドポイントURLに設定することを強く推奨します。

Python

# send-iot-command-function (lambda_function.py)
import json
import boto3
import os

# 環境変数から IoT Core エンドポイントを取得
# (例: xxxxxxxxxxxxxx-ats.iot.YOUR_REGION.amazonaws.com)
IOT_CORE_ENDPOINT = os.environ.get('IOT_CORE_ENDPOINT')

# IoT Core のデータプレーンAPIを操作するクライアントを初期化
iot_data_client = boto3.client('iot-data', endpoint_url=f"https://{IOT_CORE_ENDPOINT}")

def lambda_handler(event, context):
    print(f"Received event: {json.dumps(event)}")

    try:
        # API Gateway のパスパラメータから thingName を取得 (今回は未使用だが、今後の拡張用)
        # API Gatewayの設定で /things/{thing} パスを使用している場合、thingNameがここに格納される
        thing_name = event.get('pathParameters', {}).get('thing')
        
        # HTTP リクエストボディをパース (API Gatewayからのボディは文字列として渡される)
        body = json.loads(event['body'])
        command = body.get('command')
        value = body.get('value') # 必要に応じて追加のパラメータ

        if command is None: # 'command' キーは必須とする
            return {
                'statusCode': 400,
                'headers': { 'Content-Type': 'application/json' },
                'body': json.dumps({'message': 'Command not provided in request body.'})
            }

        # ESP32 に送信する MQTT メッセージペイロードを構築
        # ESP32のmessageHandlerが 'command' と 'state' (ON/OFF) を期待するため、
        # ここでは 'command'の形式でペイロードを作成
        mqtt_payload = {
            "command": command
        }
        payload_str = json.dumps(mqtt_payload)

        # MQTT トピックは 'esp32/commands' に固定、複数台に分ける場合はURLパラメタを使用して各デバイスごとのtopicに変更
        publish_topic = "esp32/commands"

        # AWS IoT Core に MQTT メッセージを公開
        response = iot_data_client.publish(
            topic=publish_topic,
            qos=0, # QoS 0 (At most once) で送信 (QoS 1 も選択可能)
            payload=payload_str
        )
        print(f"Published to topic {publish_topic}: {payload_str}")
        print(f"IoT Publish Response: {response}")

        # 成功レスポンスを返す
        return {
            'statusCode': 200,
            'headers': {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': '*' # CORS対応が必要な場合 (本番では特定のドメインを指定)
            },
            'body': json.dumps({'message': 'Command sent successfully to device.', 'topic': publish_topic, 'payload': mqtt_payload})
        }

    except json.JSONDecodeError:
        # リクエストボディが不正なJSON形式の場合
        return {
            'statusCode': 400,
            'headers': { 'Content-Type': 'application/json' },
            'body': json.dumps({'message': 'Invalid JSON in request body.'})
        }
    except Exception as e:
        # その他の予期せぬエラーが発生した場合
        print(f"Error: {e}")
        return {
            'statusCode': 500,
            'headers': { 'Content-Type': 'application/json' },
            'body': json.dumps({'message': 'Internal server error', 'error': str(e)})
        }

4.2. Amazon API Gateway の設定

外部からのHTTPリクエストをLambda関数にルーティングするAPIエンドポイントを構築します。

(1)API の作成: AWS API Gatewayコンソールで [API を作成] をクリックし、[REST API][構築] を選択します。API名を Esp32ControlAPI のように設定します。
(2)リソースの作成:

  • APIダッシュボードで [アクション] > [リソースの作成] を選択し、リソース名 things、リソースパス /things を作成します。
  • 作成した /things リソースを選択し、再度 [アクション] > [リソースの作成] を選択します。
  • 今回はパスパラメータを受け取るため、リソース名 thingリソースパス {thing} (ブレース {} で囲む) を作成します。この {thing} が、API Gatewayでパスパラメータとして認識され、Lambda関数に渡されます。

(3)POST メソッドの作成:

  • 作成した /things/{thing} リソースを選択し、[アクション] > [メソッドの作成] を選択します。
  • HTTPメソッドとして [POST] を選択します。
  • [統合タイプ][Lambda 関数] を選択し、[Lambda プロキシ統合の使用] にチェックを入れます。この設定により、API Gatewayは受信したHTTPリクエストの全てをLambda関数にそのまま渡し、Lambda関数が返すレスポンスをそのままHTTPレスポンスとしてクライアントに返します。
  • [Lambda 関数] に、先ほど作成した send-iot-command-function を入力・選択します。
  • [保存] をクリックし、表示される確認ダイアログで [OK] をクリックしてLambda関数の実行権限をAPI Gatewayに付与します。

(4)API のデプロイ:

  • APIダッシュボードで [アクション] > [API のデプロイ] を選択します。
  • [デプロイされるステージ][新しいステージ] を選択し、ステージ名 v1 を入力して [デプロイ] をクリックします。
  • デプロイ後、「呼び出し URL」が表示されます。これが外部からアクセスするAPIエンドポイントです(例: https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/things/{thingName})。

4.3. APIへのIP制限:開発中のセキュリティ強化

公開前のAPIへのアクセスを制限するため、API Gatewayのリソースポリシーを利用してIPアドレス制限を設定します。これにより、指定したIPアドレスからのみAPIへのアクセスが可能になります。

(1)API Gatewayのダッシュボードで、IP制限をかけたいAPIを選択します。
(2)左側のナビゲーションペインで [リソースポリシー] をクリックします。
(3)以下のJSONポリシーを貼り付けます。YOUR_API_ARNXXX.XXX.XXX.XXX/32 を、ご自身のAPIのARNと、アクセスを許可したいIPアドレスに置き換えてください。

JSON

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "YOUR_API_ARN",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "XXX.XXX.XXX.XXX/32"
                }
            }
        }
    ]
}
  • YOUR_API_ARN は、API Gatewayの [設定] タブで確認できます。(例: arn:aws:execute-api:ap-northeast-1:123456789012:/v1/*
  • XXX.XXX.XXX.XXX/32 は、ご自身の現在のグローバルIPアドレス に変更します。/32 は単一のIPアドレスを意味します。複数のIPを許可する場合は、カンマ区切りで配列に追加します(例: ["IP1/32", "IP2/32"])。

(4)[ポリシーを保存] をクリックします。これにより、指定したIPアドレス以外からのアクセスは拒否されます。

4.4. APIのテスト

デプロイされたAPIが正しく動作するか確認します。

(1)cURLコマンドでテスト:

 Bash

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"command": "turn_on_led"}' \
  https://YOUR_API_GATEWAY_ID.execute-api.YOUR_REGION.amazonaws.com/v1/things/my-esp32-device
  • URLはご自身のAPI Gatewayの呼び出しURLに、my-esp32-device は対象のThing Nameに置き換えてください。
  • -d オプションのJSONは、ESP32の handleIncomingCommands が期待する形式(commandstate)に合わせています。

(2)AWS IoT Core MQTT テストクライアントで確認:

  • AWS IoT Coreコンソールの [テスト] > [MQTT テストクライアント] を開き、esp32/commands をサブスクライブします。
  • 上記のAPIを呼び出した後、MQTTテストクライアントにメッセージが届いていることを確認できます。

5. まとめと今後の展望

本記事では、ESP32とAWS IoT Coreを核に、LambdaおよびAPI Gatewayを組み合わせることで、セキュアで堅牢なリモートマイコン制御システムを構築しました。

  • ESP32: 複数のWi-Fi設定に対応し、切断時には自動再接続を行うことで、ネットワークの堅牢性を高めました。証明書認証を用いたセキュアなMQTT通信を実現しています。
  • AWS IoT Core: デバイスとクラウドの間のMQTT通信を仲介し、セキュアな認証とルーティングを提供しました。
  • AWS Lambda: API GatewayからのHTTPリクエストをIoT CoreへのMQTT Publishに変換するロジックを実装しました。
  • Amazon API Gateway: 外部からのHTTPリクエストを受け付け、Lambdaをトリガーすることで、柔軟なAPI制御を可能にしました。また、リソースポリシーによるIP制限で開発中のセキュリティも確保しました。

今後の展望

このシステムはあくまで基盤であり、ここからさらに機能拡張や堅牢性強化が可能です。

  • デバイスシャドウの利用: デバイスがオフラインの状態でも、その「望ましい状態」や「最終報告された状態」をクラウドに保存・取得できます。
  • 機密情報の暗号化: WiFiの接続情報や証明書などの機密情報をデバイスを物理的に解析されても複合できないようにします。
  • OTA (Over-The-Air) アップデート: ESP32のファームウェアをクラウド経由で更新する仕組みを導入し、リモートでのメンテナンスを容易にします。
  • フリートインデックス: 大量のデバイスを管理する際に、デバイスの属性、シャドウ、接続状態などに基づいて柔軟な検索や分析を可能にします。
  • より高度なAPI認証: 本番運用においては、API GatewayのLambdaオーソライザーやCognitoユーザープールオーソライザーなどを導入し、APIへのアクセスを厳密に制限する必要があります。
  • モニタリングとログ: CloudWatch LogsやCloudWatch Metricsを活用し、デバイスやLambda、API Gatewayの稼働状況を継続的に監視する仕組みを構築します。