7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub ActionsとAWSでM5StickS3のファームウェアを遠隔更新する

7
Last updated at Posted at 2026-10-08

こんにちは

ソーイ株式会社Webエンジニア2年目の村上です。

M5StickS3で開発を行うのは楽しいのですが、プログラムを更新するたびにM5StickS3をPCへ接続して書き込む必要があります。
出先で機器の状況がログ保存などで確認できても、それに伴う変更が必要な場合に実機、コード、書き込みを行うPCの最低3つが必要になるのが少し不便に感じました。

そこで今回は、外出先などM5StickS3が手元にない環境からでも、自分で作成したプログラムを更新できる仕組みを作成しました。

こんな人におすすめ

  • M5StickS3を使った開発を行っている方
  • M5StickS3のプログラムを遠隔から更新してみたい方
  • GitHub Actionsを使ったビルド・デプロイの自動化に興味がある方
  • AWSを利用したIoTデバイスの遠隔更新に興味がある方

今回作成した構成

業務ではWebアプリケーションの開発を行っており、CIを利用する機会もあります。
今回はその業務との関連も踏まえ、GitHub Actionsを利用してビルドからデプロイまでを自動化する構成としました。

指定したブランチに対してPull Requestを作成・更新すると、GitHub ActionsでPlatformIOを使用したビルドを実行します。ビルドに成功した場合のみMergeできるようにし、Merge後はGitHub Actionsから更新用のファームウェアをAmazon S3へアップロードします。

その後、AWS IoT Jobsを利用してM5StickS3へ更新を通知し、M5StickS3がS3に保存されたファームウェアを取得して更新・再起動することで、変更したプログラムを実機へ反映します。

イメージ図

image.png

使用するサービス・環境

今回の構成で使用したサービス・環境は以下のとおりです。

サービス・環境 用途
M5StickS3 プログラムの更新対象となるデバイス
PlatformIO M5StickS3用プログラムのビルド
GitHub ソースコードの管理、Pull Requestの作成・Merge
GitHub Actions Pull Request時のビルド確認、Merge後の更新処理
Amazon S3 ビルドしたファームウェアの保存
AWS IoT Core M5StickS3とAWS間の通信
AWS IoT Jobs M5StickS3への更新処理の通知・管理

費用について

今回使用するAWSの各サービスやGitHub Actionsは、利用状況やプランなどによって料金が発生する場合があります。

今回実験と検証のために7回ほど反映確認を行いましたが、IoT Device Managementで$0.01の請求がある程度でした。

料金の詳細については、以下の公式ページを確認してください。

  • GitHub Actionsの料金

  • Amazon S3の料金

  • AWS IoT Coreの料金

M5StickS3をOTA更新に対応させる

まず、M5StickS3がネットワーク経由で取得したファームウェアを使用して、自身のプログラムを更新できるようにします。

今回はESP32が持つ OTA(Over The Air) の仕組みを利用します。OTAを利用することで、USBケーブルを使用してPCから直接プログラムを書き込むのではなく、ネットワーク経由で取得したファームウェアからプログラムを更新できます。

ESP32のOTAの仕組みについては、以下の公式ドキュメントを参考にしてください。

最終的にはAWS IoT Jobsからの通知をきっかけに更新を開始しますが、まずはM5StickS3からAmazon S3へ接続し、ファームウェアの取得からOTA更新まで正常に行えることを確認します。

今回は動作確認用として、M5StickS3のBtnAを押したことを更新開始のトリガーとします。

PlatformIOプロジェクトを作成する

まず、PlatformIOでM5StickS3用のプロジェクトを作成します。

VS CodeでPlatformIO Homeを開き、New Projectから新しいプロジェクトを作成します。

作成したプロジェクトのplatformio.iniを以下のように設定します。

[env:esp32-s3-devkitc-1]
platform = espressif32@6.12.0
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200

lib_deps =
    m5stack/M5Unified

M5Unifiedは、M5Stackシリーズのディスプレイやボタンなどを共通のAPIから操作するためのライブラリです。

今回はM5StickS3のディスプレイへのバージョン表示と、BtnAの入力を取得するために使用します。

OTA更新の仕組み

Arduino環境のESP32では、Updateライブラリを利用してOTAによるファームウェアの書き換えを行えます。

OTA更新では、ネットワーク経由で取得したfirmware.binを現在実行しているファームウェアとは別の領域へ書き込みます。

基本的には以下の流れで更新します。

  1. 更新用のfirmware.binを取得する
  2. Update.begin()で書き込みを開始する
  3. 取得したファームウェアのデータを書き込む
  4. Update.end()で書き込みを完了する
  5. 書き込みに成功した場合はESP.restart()で再起動する
  6. 再起動後、新しいファームウェアから起動する

今回はAmazon S3に配置したfirmware.binをM5StickS3から取得し、この仕組みを利用して書き込みます。

また、OTAによってプログラムが更新されたことを確認しやすくするため、ファームウェアにバージョン情報を持たせます。

最初にUSBから書き込むファームウェアをv1.0.0、S3から取得してOTA更新するファームウェアをv1.0.1とし、M5StickS3のディスプレイへ現在のバージョンを表示して確認します。

AWSからM5StickS3を更新する

次にAmazon S3へ更新用ファームウェアを配置し、M5StickS3から取得してOTA更新できることを確認します。

今回確認する流れは以下のとおりです。

M5StickS3 v1.0.0
    ↓
BtnAを押す
    ↓
Amazon S3へHTTPSで接続
    ↓
firmware.binを取得
    ↓
OTA領域へ書き込み
    ↓
M5StickS3を再起動
    ↓
M5StickS3 v1.0.1

Amazon S3バケットを作成する

まず、更新用のファームウェアを保存するAmazon S3バケットを作成します。

AWSマネジメントコンソールからS3を開き、バケットを作成を選択します。

今回は以下の設定でバケットを作成します。

項目 設定
AWSリージョン アジアパシフィック(東京)ap-northeast-1
バケットタイプ 汎用
オブジェクト所有者 ACL無効
パブリックアクセス すべてブロック
バケットのバージョニング 有効

バケット名は管理できる任意の名前を設定してください。

今回はファームウェアをインターネットへ公開せず、パブリックアクセスをすべてブロックした状態で使用します。

また、更新前のファームウェアを残せるよう、バケットのバージョニングを有効にしました。

設定内容を確認してバケットを作成を選択します。

OTA更新用のプログラムを作成する

次に、M5StickS3からS3へ接続し、取得したfirmware.binを使用してOTA更新するプログラムを作成します。

今回のプログラムでは、起動時にWi-Fiへ接続し、BtnAが押されるとS3からfirmware.binを取得してOTA更新を実行します。

src/main.cppを以下のように記述します。

main.cppの全文を表示する
#include <Arduino.h>
#include <M5Unified.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <Update.h>

#define FIRMWARE_VERSION "1.0.0"

const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";

// firmware.binをS3へアップロードした後に発行する署名付きURLを設定
const char* FIRMWARE_URL = "YOUR_PRESIGNED_URL";

void connectWiFi()
{
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

    Serial.print("Connecting to Wi-Fi");

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

    Serial.println();
    Serial.println("Wi-Fi connected");
    Serial.println(WiFi.localIP());
}

void updateFirmware()
{
    if (WiFi.status() != WL_CONNECTED)
    {
        Serial.println("Wi-Fi is not connected");
        return;
    }

    WiFiClientSecure client;

    // 今回は接続確認のため証明書検証を省略
    client.setInsecure();

    HTTPClient http;

    Serial.println("Downloading firmware...");

    if (!http.begin(client, FIRMWARE_URL))
    {
        Serial.println("Failed to start HTTP connection");
        return;
    }

    int httpCode = http.GET();

    if (httpCode != HTTP_CODE_OK)
    {
        Serial.printf("HTTP GET failed: %d\n", httpCode);
        http.end();
        return;
    }

    int firmwareSize = http.getSize();

    if (firmwareSize <= 0)
    {
        Serial.println("Invalid firmware size");
        http.end();
        return;
    }

    Serial.printf("Firmware size: %d bytes\n", firmwareSize);

    if (!Update.begin(firmwareSize))
    {
        Serial.println("Failed to start OTA update");
        Update.printError(Serial);
        http.end();
        return;
    }

    WiFiClient* stream = http.getStreamPtr();
    size_t written = Update.writeStream(*stream);

    Serial.printf(
        "Written: %u / %d bytes\n",
        static_cast<unsigned int>(written),
        firmwareSize
    );

    if (written != static_cast<size_t>(firmwareSize))
    {
        Serial.println("Firmware write failed");
        Update.abort();
        http.end();
        return;
    }

    if (!Update.end())
    {
        Serial.println("OTA update failed");
        Update.printError(Serial);
        http.end();
        return;
    }

    if (!Update.isFinished())
    {
        Serial.println("OTA update was not completed");
        http.end();
        return;
    }

    http.end();

    Serial.println("OTA update completed");
    Serial.println("Restarting...");

    delay(1000);
    ESP.restart();
}

void setup()
{
    auto cfg = M5.config();
    M5.begin(cfg);

    Serial.begin(115200);

    M5.Display.setTextSize(2);
    M5.Display.setCursor(10, 20);
    M5.Display.println("Firmware");
    M5.Display.printf("v%s", FIRMWARE_VERSION);

    connectWiFi();

    M5.Display.setCursor(10, 70);
    M5.Display.println("Press BtnA");
    M5.Display.println("to update");
}

void loop()
{
    M5.update();

    if (M5.BtnA.wasPressed())
    {
        Serial.println("BtnA pressed");
        updateFirmware();
    }

    delay(10);
}

WIFI_SSIDとWIFI_PASSWORDには、M5StickS3を接続するWi-FiのSSIDとパスワードを設定します。

const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";

起動するとconnectWiFi()が実行され、指定したWi-Fiへ接続します。

接続に成功すると、Serial Monitorへ以下のように表示されます。

Connecting to Wi-Fi....
Wi-Fi connected
192.168.x.x

BtnAの押下はloop()内の以下の処理で検出しています。

if (M5.BtnA.wasPressed())
{
    Serial.println("BtnA pressed");
    updateFirmware();
}

BtnAが押されるとupdateFirmware()を呼び出します。

updateFirmware()ではHTTPClientを使用して署名付きURLへGETリクエストを送り、S3からfirmware.binを取得します。

取得したファームウェアはUpdate.writeStream()を使用してOTA領域へ書き込みます。

WiFiClient* stream = http.getStreamPtr();
size_t written = Update.writeStream(*stream);

書き込みが正常に完了するとESP.restart()を実行し、M5StickS3を再起動します。

ESP.restart();

また、今回はS3からファームウェアを取得できることを確認するため、WiFiClientSecure::setInsecure()を使用してサーバー証明書の検証を省略しています。

client.setInsecure();

setInsecure()を使用するとHTTPS通信時に接続先の証明書を検証しません。

今回はOTAの動作確認を目的として使用していますが、実際に運用する場合はサーバー証明書を適切に検証する構成にする必要があります。

Wi-FiのSSIDやパスワードなどの認証情報をGitHubの公開リポジトリへそのままコミットしないよう注意してください。

更新用ファームウェアを作成する

次に、S3へ配置するv1.0.1のファームウェアを作成します。

先ほど作成したsrc/main.cppのFIRMWARE_VERSIONのみ、1.0.0から1.0.1へ変更します。

#define FIRMWARE_VERSION "1.0.1"

今回はバージョンが変更されたことを画面から確認するために変更しています。

この段階ではM5StickS3へのUploadは実行せず、VS Code下部のPlatformIOからBuildのみ実行します。

ビルドに成功すると、PlatformIOのビルドディレクトリ内にfirmware.binが生成されます。

今回の環境では以下の場所に生成されます。

.pio/build/esp32-s3-devkitc-1/firmware.bin

このfirmware.binが、OTAによってM5StickS3へ書き込む更新用ファームウェアです。

この時点ではv1.0.1をUSBからM5StickS3へ書き込みません。
Buildのみ実行します。

firmware.binをS3へアップロードする

作成したS3バケットを開き、アップロードを選択します。

S3上のオブジェクトキーは以下のようになります。

firmware.bin

PlatformIOで生成したfirmware.binをバケット直下へアップロードします。

今回はS3バケット作成時にバージョニングを有効にしているため、今後新しいfirmware.binを同じオブジェクトキーへアップロードした場合でも、過去のオブジェクトバージョンを保持できます。

そのため、ファームウェアのバージョンごとにフォルダを分けず、firmware.binを更新用ファームウェアの保存先として使用します。

アップロードが完了したら、S3のオブジェクト一覧から以下の場所にfirmware.binが保存されていることを確認します。

firmware.bin

署名付きURLを発行する

作成したS3バケットはパブリックアクセスを無効にしているため、M5StickS3から通常のオブジェクトURLへアクセスしてもfirmware.binを取得できません。

そこで、今回の動作確認ではAmazon S3の 署名付きURL(Presigned URL) を使用します。

署名付きURLを使用すると、S3バケットを公開することなく、指定したオブジェクトに対して有効期限付きのアクセスを許可できます。

S3のオブジェクト一覧から、先ほどアップロードした以下のfirmware.binを選択します。

firmware.bin

オブジェクトアクションから署名付きURLで共有を選択し、有効期限を設定して署名付きURLを作成します。

作成されたURLはM5StickS3からfirmware.binを取得する際に使用するため、コピーしておきます。

署名付きURLを知っているユーザーは、有効期限内であれば対象のオブジェクトへアクセスできます。
また、今回生成するfirmware.binには、プログラムのビルド時に設定したWi-FiのパスワードやAWS IoT Coreへ接続するための秘密鍵などの認証情報も含まれています。
そのため、GitHubなどの公開リポジトリへ署名付きURLをコミットしないことに加えて、firmware.binやS3バケットについても外部へ公開しないよう注意してください。

v1.0.0をM5StickS3へ書き込む

更新用のv1.0.1のfirmware.binをS3へ配置できたため、OTA更新前のv1.0.0をM5StickS3へ書き込みます。

まず、FIRMWARE_VERSIONを1.0.0へ戻します。

#define FIRMWARE_VERSION "1.0.0"

続いて、FIRMWARE_URLへ先ほど発行した署名付きURLを設定します。

const char* FIRMWARE_URL = "発行した署名付きURL";

M5StickS3をPCへUSB接続し、PlatformIOのUploadを実行してプログラムを書き込みます。

書き込み完了後、M5StickS3が起動し、ディスプレイに以下のように表示されることを確認します。

Firmware
v1.0.0

Press BtnA
to update

PlatformIOのSerial Monitorも開きます。

Wi-Fiへの接続に成功すると、以下のように表示されます。

Connecting to Wi-Fi....
Wi-Fi connected
192.168.x.x

これでOTA更新を実行する準備は完了です。

BtnAからOTA更新を実行する

M5StickS3がv1.0.0で起動している状態でBtnAを押します。

BtnAを押すとupdateFirmware()が実行され、署名付きURLを使用してS3のfirmware.binを取得します。

処理の流れは以下のとおりです。

BtnAを押す
    ↓
署名付きURLへHTTPSで接続
    ↓
S3からfirmware.binを取得
    ↓
Updateでファームウェアを書き込む
    ↓
書き込み完了
    ↓
M5StickS3を再起動
    ↓
v1.0.1で起動

Serial Monitorでは、ファームウェアの取得や書き込み状況を確認します。

正常にOTA更新が完了すると、以下のメッセージが表示された後にM5StickS3が再起動します。

OTA update completed
Restarting...

再起動後、ディスプレイのバージョン表示を確認します。

Firmware
v1.0.1

v1.0.0からv1.0.1へ変更されていれば、M5StickS3からAmazon S3に保存したファームウェアを取得し、USB接続を使用せずにOTA更新できたことを確認できます。

確認用にボタンを押下し更新するようにしていますが、次はAWS IoT Jobsから更新通知を受け取り、BtnAを押さなくてもOTA更新を開始できるようにします。

AWS IoT JobsからOTA更新を実行する

ここまでの動作確認では、M5StickS3のBtnAを押したことをきっかけにOTA更新を開始していました。

次に、AWS IoT Jobsから更新を通知し、M5StickS3が通知を受け取ったことをきっかけにOTA更新を開始できるようにします。

AWS IoT Jobsは、AWS IoT Coreに接続されたデバイスに対して、ソフトウェアの更新などの処理を遠隔から実行・管理するための機能です。

今回は、AWS IoT JobsからM5StickS3へ更新を通知し、通知を受け取ったM5StickS3がAmazon S3からfirmware.binを取得してOTA更新を実行する構成に変更します。

AWS IoT Jobs
    ↓
M5StickS3がJobを受信
    ↓
Job documentを取得
    ↓
Amazon S3からfirmware.binを取得
    ↓
OTA領域へ書き込み
    ↓
M5StickS3を再起動
    ↓
新しいファームウェアで起動

AWS IoT Jobsを利用するため、M5StickS3がAWS IoT Coreへ接続できる状態にしておく必要があります。

M5StickS3からAWS IoT Coreへ接続するためのThing、証明書、IoT Policyなどの設定については、以前作成した以下の記事で行っているため、今回は詳細な手順を省略します。

以降は、M5StickS3からAWS IoT CoreへMQTT接続できる状態として進めます。

AWS IoT Jobs用の権限を追加する

前回の記事では、M5StickS3からAWS IoT Coreへ接続し、取得したWi-Fi情報をMQTTでPublishするためのIoT Policyを作成しました。

今回は同じIoT Policyを引き続き使用し、AWS IoT Jobsを利用するために必要な権限を追加します。

今回M5StickS3では、以下の流れでJobを処理します。

Jobの追加
    ↓
notify-nextで通知を受信
    ↓
start-nextでJobを開始・Job documentを取得
    ↓
OTA更新を実行
    ↓
updateでJobの実行結果を送信

また、後ほど動作確認でJobの状態を取得するためにjobs/getも使用します。

そのため今回は、AWS IoT Jobsで使用するjobs/*配下のトピックに対して、iot:Publish、iot:Subscribe、iot:Receiveを許可します。

Thing名を確認する

AWS IoT Jobsでは、Jobの実行対象としてAWS IoT CoreのThingを使用します。

今回は前回の記事で作成したThingをそのまま使用します。

AWS IoT Coreのコンソールを開き、左側のメニューから「すべてのデバイス」→「モノ」を選択します。

M5StickS3で使用しているThingを開き、Thing名を確認します。

以降のIoT Policyでは、THING_NAMEをここで確認したThing名へ置き換えます。

例えばThing名がM5StickS3-WiFi-Monitorの場合、

$aws/things/M5StickS3-WiFi-Monitor/jobs/notify-next

のようになります。

また、AWS_ACCOUNT_IDには自身のAWSアカウントIDを設定します。

IoT Policyを変更する

AWS IoT Coreのコンソールを開き、左側のメニューから「セキュリティ」→「ポリシー」を選択します。

前回の記事で作成したM5StickS3用のIoT Policyを開き、ポリシードキュメントを編集します。

変更後のIoT Policyは以下のようになります。

変更後のIoT Policy全文を表示する
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:Connect",
      "Resource": "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:client/M5StickS3-WiFi-Monitor"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Publish",
      "Resource": "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:topic/m5sticks3/wifi"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iot:Publish",
        "iot:Subscribe",
        "iot:Receive"
      ],
      "Resource": [
        "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:topic/$aws/things/THING_NAME/jobs/*",
        "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:topicfilter/$aws/things/THING_NAME/jobs/*"
      ]
    }
  ]
}

AWS_ACCOUNT_IDとTHING_NAMEを自身の環境に合わせて変更し、IoT Policyを保存します。

既存のiot:Connectとm5sticks3/wifiへのiot:Publishは、前回作成した処理でも使用するためそのまま残しています。

topicはPublishやReceive、topicfilterはSubscribeで使用します。今回はAWS IoT Jobsで使用するトピックをjobs/*としてまとめて指定しています。

これで、前回使用していたAWS IoT Coreへの接続・Publishに加えて、AWS IoT Jobsで使用するMQTTトピックへアクセスするための権限を追加できました。

AWS IoT Jobsで使用するMQTTトピックの詳細については、以下のAWS公式ドキュメントを参照してください。

S3からファームウェアを取得するためのIAMロールを作成する

AWS IoT JobsからOTA更新を行うため、Amazon S3に保存したfirmware.binを取得するためのIAMロールを作成します。

AWSマネジメントコンソールからIAMを開き、「ロール」→「ロールを作成」を選択します。

今回はロール名を以下としました。

M5StickS3-IoTJobs-S3Role

エンティティタイプから「カスタム信頼ポリシー」を選択して、AWS IoT Jobsからこのロールを使用できるよう、信頼ポリシーを以下のように設定します。

信頼ポリシーの全文を表示する
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "iot.amazonaws.com"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "AWS_ACCOUNT_ID"
        },
        "ArnLike": {
          "aws:SourceArn": "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:*"
        }
      }
    }
  ]
}

AWS_ACCOUNT_IDは自身のAWSアカウントIDへ置き換えます。

次に、Amazon S3に保存しているfirmware.binを取得できるように権限を追加します。

作成したロールの「許可」タブから「許可を追加」→「インラインポリシーを作成」を選択し、JSONエディターへ以下を設定します。

インラインポリシーの全文を表示する
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::BUCKET_NAME/firmware.bin"
    }
  ]
}

BUCKET_NAMEは、前の手順でfirmware.binを保存したS3バケット名へ置き換えます。

今回はOTA更新で使用するfirmware.binのみ取得できるようにしています。

インラインポリシー名は以下としました。

M5StickS3-Firmware-S3-Read

内容を確認してインラインポリシーを作成します。

これで、AWS IoT JobsからAmazon S3のfirmware.binへアクセスするためのIAMロールを作成できました。

このIAMロールは、後ほどAWS IoT Jobを作成する際に、Amazon S3に保存したファームウェアの署名付きURLを生成するためのロールとして使用します。

Job documentを作成する

続いて、AWS IoT JobsからM5StickS3へ実行する処理を伝えるためのJob documentを作成します。

Job documentは、AWS IoT JobsでJobを作成するときに指定するJSON形式のドキュメントです。

AWS IoT JobsからM5StickS3へ直接firmware.binを送信するのではなく、M5StickS3はJob documentを受け取り、その中に記載された情報をもとに処理を実行します。

今回は以下の流れで使用します。

AWS IoT Jobを作成
        ↓
Job documentをM5StickS3へ送信
        ↓
M5StickS3がJob documentを確認
        ↓
firmwareUrlからfirmware.binを取得
        ↓
OTA更新を実行

Job documentの内容はAWS側で決められたOTA更新専用の形式ではなく、デバイス側のプログラムに合わせて定義できます。

今回はM5StickS3側で以下の2つの値を読み取れるようにします。

項目 内容
operation M5StickS3に実行させる処理
firmwareUrl firmware.binの取得先

Job documentのJSONファイルを作成する

今回はJob documentをローカルにJSONファイルとして作成し、後ほどAWS IoT Jobを作成するときに使用します。

PlatformIOプロジェクトのルートディレクトリにjob-document.jsonを作成します。

job-document.json

job-document.jsonへ以下を記述します。

{
  "operation": "firmware_update",
  "firmwareUrl": "${aws:iot:s3-presigned-url-v2:https://YOUR_BUCKET_NAME.s3.YOUR_REGION.amazonaws.com/firmware.bin}"
}

YOUR_BUCKET_NAMEはfirmware.binを保存したS3バケット名、YOUR_REGIONはS3バケットを作成したリージョンへ置き換えます。

例えば、東京リージョンを使用している場合、YOUR_REGIONは以下になります。

ap-northeast-1

operationは、今回M5StickS3側のプログラムで使用するために定義した独自の項目です。

firmwareUrlには、Amazon S3へ保存した以下のオブジェクトを指定します。

firmware.bin

ここでは通常の署名付きURLを直接記述するのではなく、AWS IoT Jobsの署名付きURL用プレースホルダーを使用します。

${aws:iot:s3-presigned-url-v2:...}

後ほどAWS IoT Jobを作成するときに、前の手順で作成したIAMロールを指定します。

AWS IoT JobsがJob documentをM5StickS3へ送信する際、このプレースホルダーをS3のfirmware.binへアクセスできる署名付きURLへ置き換えます。

そのため、以前の動作確認で行ったように、S3コンソールから手動で署名付きURLを発行してM5StickS3のプログラムへ設定する必要はありません。

これで、AWS IoT JobsからM5StickS3へOTA更新の情報を渡すためのJob documentを作成できました。

M5StickS3をAWS IoT Jobsに対応させる

次に、M5StickS3側のプログラムをAWS IoT Jobsに対応させます。

ここまで使用していたプログラムでは、BtnAが押された場合にupdateFirmware()を実行していました。

if (M5.BtnA.wasPressed())
{
    updateFirmware();
}

今回はこの処理を、AWS IoT JobsからJobを受信したことをきっかけに実行するよう変更します。

M5StickS3はAWS IoT CoreへMQTT接続し、AWS IoT JobsからJobが追加されたことを受信します。

その後、Job documentからfirmwareUrlを取得し、これまで使用していたOTA更新処理へ渡します。

AWS IoT CoreへMQTT接続
    ↓
AWS IoT JobsからJobを受信
    ↓
Job documentを解析
    ↓
firmwareUrlを取得
    ↓
updateFirmware()
    ↓
Jobの実行結果を送信

AWS IoT Jobsとデバイス間の通信には、AWS IoT Jobs用に用意されているMQTTトピックを使用します。

AWS IoT Coreへの接続情報を設定する

AWS IoT Jobsとの通信にはAWS IoT CoreへのMQTT接続を使用します。

AWS IoT Coreで使用するモノ、デバイス証明書、IoTポリシーなどの作成については、以前作成した以下の記事で行っているため、今回は作成手順を省略します。

また、Wi-FiやAWS IoT Coreへの接続情報についても、前回と同じようにsrc/secrets.hへ分離して管理します。

M5StickS3プロジェクト/
├── src/
│   ├── main.cpp
│   └── secrets.h
├── job-document.json
└── platformio.ini

main.cppからsecrets.hを読み込み、AWS IoT Coreへの接続に使用します。

#include "secrets.h"

なお、secrets.hにはWi-FiのパスワードやAWS IoT Coreへ接続するための秘密鍵などが含まれるため、GitHubへそのままPushしないようにします。

GitHubでの認証情報の管理については、後ほどGitHub Actionsを設定する際に対応します。

必要なライブラリを追加する

AWS IoT CoreとのMQTT通信にはPubSubClient、受信したJSONの解析にはArduinoJsonを使用します。

platformio.iniのlib_depsへ以下を追加します。

[env:esp32-s3-devkitc-1]
platform = espressif32@6.12.0
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200

lib_deps =
    m5stack/M5Unified
    knolleary/PubSubClient
    bblanchon/ArduinoJson

プログラムを変更する

main.cppを変更し、AWS IoT CoreへのMQTT接続とAWS IoT JobsからJobを受信する処理を追加します。

今回のプログラムでは、AWS IoT Coreへ接続した後にAWS IoT Jobsの通知を受信します。

Jobが追加された場合は次に実行するJobを取得し、受信したJob documentを解析します。

Job documentのoperationがfirmware_updateの場合はfirmwareUrlを取得し、OTA更新を実行します。

また、OTA更新の結果に応じて、AWS IoT JobsへJobの実行結果を送信します。

後述しますが、不具合などの解消と検証を繰り返した修正後のmain.cpp全体は以下になります。

main.cppの全文を表示する
#include <Arduino.h>
#include <M5Unified.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Update.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>

#include "secrets.h"

#define FIRMWARE_VERSION "1.0.1"

WiFiClientSecure mqttClientSecure;
PubSubClient mqttClient(mqttClientSecure);

String topicNotifyNext;
String topicStartNext;
String topicStartNextAccepted;
String topicStartNextRejected;

void connectWiFi();
void connectAWS();
void subscribeJobTopics();
void requestNextJob();
void mqttCallback(char* topic, byte* payload, unsigned int length);
void handleJob(const JsonDocument& document);
bool updateFirmware(const char* firmwareUrl);
void updateJobStatus(const String& jobId, const char* status);

void connectWiFi()
{
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

    Serial.print("Connecting to Wi-Fi");

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

    Serial.println();
    Serial.println("Wi-Fi connected");
    Serial.println(WiFi.localIP());
}

void subscribeJobTopics()
{
    mqttClient.subscribe(topicNotifyNext.c_str());
    mqttClient.subscribe(topicStartNextAccepted.c_str());
    mqttClient.subscribe(topicStartNextRejected.c_str());

    Serial.println("Subscribed to AWS IoT Jobs topics");
}

void requestNextJob()
{
    Serial.println("Requesting next Job");

    if (!mqttClient.publish(topicStartNext.c_str(), "{}"))
    {
        Serial.println("Failed to request next Job");
    }
}

void connectAWS()
{
    mqttClientSecure.setCACert(AWS_ROOT_CA);
    mqttClientSecure.setCertificate(AWS_DEVICE_CERT);
    mqttClientSecure.setPrivateKey(AWS_PRIVATE_KEY);

    mqttClient.setServer(AWS_IOT_ENDPOINT, 8883);
    mqttClient.setCallback(mqttCallback);

    Serial.print("Connecting to AWS IoT Core");

    while (!mqttClient.connected())
    {
        if (mqttClient.connect(AWS_IOT_CLIENT_ID))
        {
            Serial.println();
            Serial.println("AWS IoT Core connected");

            subscribeJobTopics();

            // 起動前から待機しているJobも取得する
            requestNextJob();
        }
        else
        {
            Serial.print(".");
            delay(1000);
        }
    }
}

void updateJobStatus(const String& jobId, const char* status)
{
    String topic =
        "$aws/things/" +
        String(AWS_IOT_THING_NAME) +
        "/jobs/" +
        jobId +
        "/update";

    JsonDocument document;
    document["status"] = status;

    String payload;
    serializeJson(document, payload);

    if (mqttClient.publish(topic.c_str(), payload.c_str()))
    {
        Serial.printf(
            "Job %s: %s\n",
            jobId.c_str(),
            status
        );

        // Publishしたメッセージを送信する時間を確保
        mqttClient.loop();
        delay(500);
    }
    else
    {
        Serial.println("Failed to update Job status");
    }
}

bool updateFirmware(const char* firmwareUrl)
{
    if (WiFi.status() != WL_CONNECTED)
    {
        Serial.println("Wi-Fi is not connected");
        return false;
    }

    WiFiClientSecure client;

    // 現段階ではこれまでのOTA確認と同様に証明書検証を省略
    client.setInsecure();

    HTTPClient http;

    Serial.println("Downloading firmware...");

    if (!http.begin(client, firmwareUrl))
    {
        Serial.println("Failed to start HTTP connection");
        return false;
    }

    int httpCode = http.GET();

    if (httpCode != HTTP_CODE_OK)
    {
        Serial.printf("HTTP GET failed: %d\n", httpCode);
        http.end();
        return false;
    }

    int firmwareSize = http.getSize();

    if (firmwareSize <= 0)
    {
        Serial.println("Invalid firmware size");
        http.end();
        return false;
    }

    Serial.printf(
        "Firmware size: %d bytes\n",
        firmwareSize
    );

    if (!Update.begin(firmwareSize))
    {
        Serial.println("Failed to start OTA update");
        Update.printError(Serial);
        http.end();
        return false;
    }

    WiFiClient* stream = http.getStreamPtr();
    size_t written = Update.writeStream(*stream);

    Serial.printf(
        "Written: %u / %d bytes\n",
        static_cast<unsigned int>(written),
        firmwareSize
    );

    if (written != static_cast<size_t>(firmwareSize))
    {
        Serial.println("Firmware write failed");
        Update.abort();
        http.end();
        return false;
    }

    if (!Update.end())
    {
        Serial.println("OTA update failed");
        Update.printError(Serial);
        http.end();
        return false;
    }

    if (!Update.isFinished())
    {
        Serial.println("OTA update was not completed");
        http.end();
        return false;
    }

    http.end();

    Serial.println("OTA update completed");

    return true;
}

void handleJob(const JsonDocument& document)
{
    if (!document["execution"].is<JsonObjectConst>())
    {
        Serial.println("No pending Job");
        return;
    }

    String jobId =
        document["execution"]["jobId"].as<String>();

    JsonObjectConst jobDocument =
        document["execution"]["jobDocument"].as<JsonObjectConst>();

    const char* operation =
        jobDocument["operation"];

    const char* firmwareUrl =
        jobDocument["firmwareUrl"];

    if (operation == nullptr || firmwareUrl == nullptr)
    {
        Serial.println("Invalid Job document");

        updateJobStatus(jobId, "FAILED");
        return;
    }

    Serial.printf(
        "Job ID: %s\n",
        jobId.c_str()
    );

    Serial.printf(
        "Operation: %s\n",
        operation
    );

    if (strcmp(operation, "firmware_update") != 0)
    {
        Serial.println("Unsupported operation");

        updateJobStatus(jobId, "FAILED");
        return;
    }

    Serial.println("Starting firmware update");

    bool success = updateFirmware(firmwareUrl);

    if (!success)
    {
        updateJobStatus(jobId, "FAILED");
        return;
    }

    updateJobStatus(jobId, "SUCCEEDED");

    Serial.println("Restarting...");

    delay(1000);
    ESP.restart();
}

void mqttCallback(
    char* topic,
    byte* payload,
    unsigned int length
)
{
    String receivedTopic = topic;

    Serial.printf(
        "MQTT message: %s\n",
        receivedTopic.c_str()
    );

    if (receivedTopic == topicNotifyNext)
    {
        Serial.println("Job notification received");

        requestNextJob();
        return;
    }

    if (receivedTopic == topicStartNextRejected)
    {
        Serial.println("Start next Job rejected");
        return;
    }

    if (receivedTopic != topicStartNextAccepted)
    {
        return;
    }

    JsonDocument document;

    DeserializationError error =
        deserializeJson(document, payload, length);

    if (error)
    {
        Serial.print("JSON parse failed: ");
        Serial.println(error.c_str());
        return;
    }

    handleJob(document);
}

void setup()
{
    auto cfg = M5.config();
    M5.begin(cfg);

    Serial.begin(115200);

    M5.Display.setTextSize(2);
    M5.Display.setCursor(10, 20);
    M5.Display.println("Firmware");
    M5.Display.printf("v%s", FIRMWARE_VERSION);

    topicNotifyNext =
        "$aws/things/" +
        String(AWS_IOT_THING_NAME) +
        "/jobs/notify-next";

    topicStartNext =
        "$aws/things/" +
        String(AWS_IOT_THING_NAME) +
        "/jobs/start-next";

    topicStartNextAccepted =
        topicStartNext +
        "/accepted";

    topicStartNextRejected =
        topicStartNext +
        "/rejected";

    connectWiFi();
    connectAWS();

    M5.Display.setCursor(10, 70);
    M5.Display.println("AWS IoT");
    M5.Display.println("connected");
}

void loop()
{
    if (WiFi.status() != WL_CONNECTED)
    {
        connectWiFi();
    }

    if (!mqttClient.connected())
    {
        connectAWS();
    }

    mqttClient.loop();

    delay(10);
}

今回のプログラムでは、AWS IoT Jobsが用意している以下のMQTTトピックを使用します。

$aws/things/{Thing名}/jobs/notify-next
$aws/things/{Thing名}/jobs/start-next
$aws/things/{Thing名}/jobs/start-next/accepted
$aws/things/{Thing名}/jobs/start-next/rejected
$aws/things/{Thing名}/jobs/{Job ID}/update

notify-nextでJobの追加を受信した後、start-nextへPublishして次に実行するJobを取得します。

start-next/acceptedで受信したデータには、Job IDとJob documentが含まれています。

Job documentから以下の値を取得します。

{
  "operation": "firmware_update",
  "firmwareUrl": "署名付きURL"
}

operationがfirmware_updateであることを確認した後、firmwareUrlをOTA更新処理へ渡します。

Jobを受信
    ↓
operationを確認
    ↓
firmware_update
    ↓
firmwareUrlを取得
    ↓
firmware.binをダウンロード
    ↓
OTA更新

OTA更新に成功した場合はSUCCEEDED、ファームウェアの取得や書き込みに失敗した場合はFAILEDとして、対象のJobへ実行結果を送信します。

$aws/things/{Thing名}/jobs/{Job ID}/update

これにより、M5StickS3からAWS IoT JobsへOTA更新の実行結果を返せるようになります。

AWS IoT Jobs対応版をUSBから書き込む

ここまでで、AWS IoT JobsからJobを受信してOTA更新を実行するプログラムを作成できました。

ただし、現在M5StickS3に書き込まれているv1.0.1はBtnAからOTA更新を行うプログラムであり、AWS IoT Jobsからの通知を受信する処理は含まれていません。

そのため、まずは今回作成したAWS IoT Jobs対応版のプログラムを、USB経由でM5StickS3へ書き込みます。

FIRMWARE_VERSIONは1.0.1のままとし、M5StickS3をPCへUSB接続してPlatformIOからUploadを実行します。

書き込みが完了してM5StickS3のディスプレイにv1.0.1と表示されることを確認したら準備完了です。M5StickS3がAWS IoT Jobsから更新通知を受け取り、OTA更新を実行できる状態になりました。

次は、AWS IoT Jobsを使用してv1.0.2へ更新できることを確認します。

ビルドする

AWS IoT JobsによるOTA更新を確認するため、M5StickS3に表示するファームウェアバージョンをv1.0.1からv1.0.2へ変更します。

main.cppのFIRMWARE_VERSIONを以下のように変更しましょう。

#define FIRMWARE_VERSION "1.0.2"

プログラムを変更したら、PlatformIOからBuildを実行します。

ビルドに成功すると、以下のパスにfirmware.binが生成されます。

.pio/build/esp32-s3-devkitc-1/firmware.bin

この時点ではAWS IoT Jobをまだ作成していないため、OTA更新の実行確認は行いません。

次に、ここで生成したfirmware.binをAmazon S3へアップロードします。

IoT Jobs動作確認用としてfirmware.binをS3へアップロードする

確認のため、作成したfirmware.binを、先ほど作成したS3バケットへアップロードします。

同じオブジェクトキーへアップロードするため、既存のfirmware.binは新しいファイルに置き換わります。

今回作成したS3バケットではバージョニングを有効にしているため、以前アップロードしたfirmware.binについてもオブジェクトのバージョンとして保持されます。

AWS IoT Jobを作成する

firmware.binの準備ができたら、AWS IoT CoreのコンソールからM5StickS3に対して実行するJobを作成します。

AWS IoT Coreのコンソールを開き、リモートアクション → ジョブへ移動します。

ジョブを作成を選択し、今回は作成済みのJob documentを使用するため、カスタムジョブを作成を選択して次へ進みます。

ジョブ実行タイプにはスナップショットを選択します。今回は1台のM5StickS3に対して1回のOTA更新を実行するため、その他の追加設定は変更せずデフォルトのままとします。

Jobのプロパティを設定する

まず、作成するJobの名前を入力します。

今回はv1.0.2への更新であることが分かるよう、以下の名前にします。

m5sticks3-firmware-update-1-0-2

説明とタグは任意のため、今回は設定せず次へ進みます。

ターゲットを設定する

Jobを実行する対象として、M5StickS3で使用しているモノを選択します。

今回は以下のモノを選択します。

M5StickS3-WiFi-Monitor

今回は1台のM5StickS3を対象としているため、モノのグループではなくモノを直接指定します。

Job documentとIAMロールを指定する

Job documentには、先ほど作成したOTA更新用のjob-document.jsonを指定します。

また、署名付きURLの生成に使用するロールとして、先ほど作成したAWS IoT Jobs用のIAMロールを指定します。

M5StickS3-IoTJobs-S3Role

これまでの手順で作成したJob documentとIAMロールをここで指定したら、次へ進みます。

Jobの設定を行う

JobタイプにはSnapshotを選択します。

Snapshotでは、Job作成時に指定したターゲットに対してJobが実行され、対象となるJob executionの処理が完了するとJobも完了します。

今回は特定のM5StickS3をv1.0.2へ更新する1回限りのJobとして使用するため、Snapshotを選択します。

ロールアウト、スケジュール、タイムアウト、再試行、Abortなどの追加設定は今回使用しないため、デフォルトのまま進みます。

Jobを作成する

最後に設定内容を確認し、Jobを作成します。

Jobを作成すると、ターゲットとして指定したM5StickS3-WiFi-Monitorに対してJob executionが作成されます。

M5StickS3がAWS IoT Coreへ接続していると、先ほど実装したAWS IoT Jobs用のMQTT通信によってJobを受信します。

次に、M5StickS3がJobを受信し、v1.0.1からv1.0.2へOTA更新できることを確認します。

OTA更新時に発生した問題と対応

AWS IoT Jobsを使用したOTA更新を実装する中で、Jobを作成してもM5StickS3のファームウェアが更新されない問題が発生しました。

ここでは、実際に問題を切り分けていった順番に、発生した内容と対応方法を記載します。

Serial Monitorにログが表示されない

まず、M5StickS3とAWS IoT Jobsの通信状況を確認するため、PlatformIOのSerial Monitorを起動しました。

しかし、Serial Monitorを起動してもログが表示されませんでした。

一方、M5StickS3の画面にはAWS IoT Coreへの接続が完了していることが表示されていたため、プログラム自体は動作しており、Serial Monitorへの出力部分に問題があると考えました。

M5StickS3で使用されているESP32-S3でUSB経由のSerial出力を使用するため、platformio.iniに以下のbuild_flagsを追加しました。

build_flags =
    -D ARDUINO_USB_CDC_ON_BOOT=1
    -D ARDUINO_USB_MODE=1

ARDUINO_USB_CDC_ON_BOOTは起動時からUSB CDCを有効にする設定で、ARDUINO_USB_MODEはESP32-S3のUSB機能を使用するためのモードを指定する設定です。これらを設定することで、USB経由でSerial Monitorへログを出力できるようになります。

よく確認するとなんで最初に入れなかったのか不思議なぐらい重要な要素でしたねこれ。

設定を追加した状態でM5StickS3へ再度プログラムを書き込み、Serial Monitorを起動するとログが表示されるようになりました。
これで、M5StickS3からAWS IoT Coreへの接続や、AWS IoT JobsとのMQTT通信をSerial Monitorから確認できるようになりました。

Jobを作成してもOTA更新が開始されない

Serial Monitorから処理を確認できるようになったため、AWS IoT CoreからJobを作成してOTA更新を実行しました。

しかし、Jobを作成してもM5StickS3のファームウェアは更新されませんでした。

そこで、AWS IoT Jobsで使用するMQTTトピックへのSubscribeや、受信したメッセージをSerial Monitorへ出力して処理状況を確認しました。
また、AWS IoT Jobsから返されるレスポンスにはJob documentや署名付きURLなどが含まれるため、PubSubClientで受信できるデータサイズを広げるために以下の設定を追加しました。

mqttClient.setBufferSize(2048);

確認を進めると、M5StickS3からstart-nextをPublishした後、AWS IoT Jobsからstart-next/acceptedを受信できていることが分かりました。

つまり、この時点でM5StickS3とAWS IoT Jobs間のMQTT通信自体は行えており、Jobの取得要求もAWS IoT Jobsに受け付けられていることが確認できました。

そのため、原因をさらに切り分けるため、AWS IoT Jobsが認識しているJobの状態をM5StickS3側から確認することにしました。

jobs/getでJobの状態を確認する

AWS IoT JobsがM5StickS3に対してどのJobを保持しているのか確認するため、デバッグ用としてjobs/getを使用する処理を追加しました。

以下のトピックを使用します。

$aws/things/{thingName}/jobs/get
$aws/things/{thingName}/jobs/get/accepted
$aws/things/{thingName}/jobs/get/rejected

M5StickS3からjobs/getへ空のJSONをPublishします。

mqttClient.publish(topicJobsGet.c_str(), "{}");

また、レスポンスを受信できるようにjobs/get/acceptedとjobs/get/rejectedをSubscribeしました。

これによりAWS IoT Jobs側のJob executionを確認し、M5StickS3側の処理と比較できるようにしました。

しかし、この処理を追加すると、今度はMQTT接続が切断され、再接続を繰り返すようになりました。

jobs/getを追加するとMQTT接続が切断される

jobs/getを追加してJobの状態を確認しようとしたところ、MQTT接続が切断される問題が発生しました。

原因を確認したところ、開発当初に設定していたIoT Policyでは、AWS IoT Jobsで使用するトピックを個別に指定していたため、後から追加したjobs/getに対する権限が不足していました。

必要なトピックを個別に追加することもできますが、動作確認の中で使用するトピックが増えるたびにIoT Policyへ追加する必要があります。

そのため、最終的にはAWS IoT Jobsで使用するjobs/*配下のトピックをまとめて許可するように変更しました。

{
  "Effect": "Allow",
  "Action": [
    "iot:Publish",
    "iot:Subscribe",
    "iot:Receive"
  ],
  "Resource": [
    "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:topic/$aws/things/M5StickS3-WiFi-Monitor/jobs/*",
    "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:topicfilter/$aws/things/M5StickS3-WiFi-Monitor/jobs/*"
  ]
}

なお、前述の「AWS IoT Jobs用の権限を追加する」では、この問題を反映したjobs/*を使用するIoT Policyを最初から設定しています。

start-next/acceptedを受信してもJobを処理できない

AWS IoT Jobs側でJobがIN_PROGRESSへ移行することは確認できましたが、M5StickS3ではstart-next/acceptedを受信した後に以下のログが表示され、OTA更新が開始されませんでした。

No pending Job

一方、jobs/getでは対象のJobがIN_PROGRESSになっていました。

そのため、AWS IoT JobsからJobを取得できていないのではなく、受信したJSONをM5StickS3側で正しく判定できていない可能性があると考えました。

start-next/acceptedのレスポンスを解析している箇所では、以下のようにexecutionがJsonObjectであるかを確認していました。

if (!document["execution"].is<JsonObject>())
{
    Serial.println("No pending Job");
    return;
}

ここで使用しているdocumentから取得するオブジェクトはconstとして扱われるため、JsonObjectConstを使用するように修正しました。

if (!document["execution"].is<JsonObjectConst>())
{
    Serial.println("No pending Job");
    return;
}

JsonObjectConst execution =
    document["execution"].as<JsonObjectConst>();

JsonObjectConst jobDocument =
    execution["jobDocument"].as<JsonObjectConst>();

修正したプログラムをM5StickS3へ書き込み、新しいJobを作成して再度実行しました。

すると、start-next/acceptedからJobの情報を正常に取得できるようになり、Job documentに含まれるfirmwareUrlを使用してS3からfirmware.binのダウンロードが開始されました。

その後、ダウンロードしたfirmware.binがM5StickS3へ書き込まれて再起動し、画面に表示しているファームウェアバージョンが以下のように変化しました。

v1.0.1
↓
v1.0.2

これで、AWS IoT JobsでJobを作成し、M5StickS3がJobを取得してS3からfirmware.binをダウンロードし、OTAによってファームウェアを更新するところまで確認できました。

次はこの流れをGitHub Actionsで行えるようにします。

GitHub Actionsから更新を実行する

ここまでの手順で、AWS IoT Jobsを使用してM5StickS3をOTA更新できることを確認しました。

しかし、現状ではコードを変更するたびにPlatformIOでfirmware.binをビルドし、S3へアップロードした後、AWS IoT Jobを作成する必要があります。

そこで、ここからはGitHub Actionsを使用してこれらの処理を自動化します。

最終的に、更新したコードのPull Requestを作成し、ビルドに成功したコードをMergeすることでM5StickS3をOTA更新できるようにします。

GitHub Actionsによる更新の流れ

GitHub Actionsでは、Pull Request作成時とMerge後でそれぞれ異なるWorkflowを実行します。

Pull Requestの作成・更新時にはPlatformIOでファームウェアをビルドし、正常にビルドできることを確認します。ビルドに成功した場合のみ、mainブランチへMergeできるようにします。

Merge後は再度ファームウェアをビルドし、生成されたfirmware.binをS3へアップロードします。その後、AWS IoT Jobを作成してM5StickS3へ更新を通知します。

処理の流れは以下のようになります。

更新用ブランチでコードを変更
        ↓
Pull Requestを作成
        ↓
GitHub ActionsでPlatformIO Build
        ↓
ビルド成功
        ↓
mainブランチへMerge
        ↓
GitHub Actionsでfirmware.binをビルド
        ↓
S3へfirmware.binをアップロード
        ↓
AWS IoT Jobを作成
        ↓
M5StickS3がJobを取得
        ↓
OTA更新

この構成にすることで、M5StickS3をUSB接続してファームウェアを書き込んだり、AWSマネジメントコンソールから手動でIoT Jobを作成したりすることなく、GitHubへのコード変更を起点としてOTA更新できるようにします。

GitHubリポジトリを準備する

GitHub Actionsからファームウェアをビルド・デプロイできるように、これまで作成したプロジェクトをGitHubで管理できる形に整理します。

今回は以下の構成としました。

M5StickS3プロジェクト/
├── src/
│   ├── main.cpp
│   ├── ota.cpp
│   ├── ota.h
│   ├── secrets.h
│   └── secrets.example.h
├── job-document.json
├── platformio.ini
└── .gitignore

OTA処理を別ファイルへ分離する

これまではM5StickS3の動作とOTA更新に必要な処理を同じコード内に記述していましたが、今後別のプログラムからもOTA更新機能を利用しやすいように、OTA関連の処理をota.cppとota.hへ分離しました。

ota.cppの全文を表示する
// ota.cpp

#include "ota.h"

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Update.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>

#include "secrets.h"

WiFiClientSecure mqttClientSecure;
PubSubClient mqttClient(mqttClientSecure);

String topicNotifyNext;
String topicStartNext;
String topicStartNextAccepted;
String topicStartNextRejected;

void connectWiFi();
void connectAWS();
void subscribeJobTopics();
void requestNextJob();
void mqttCallback(char* topic, byte* payload, unsigned int length);
void handleJob(const JsonDocument& document);
bool updateFirmware(const char* firmwareUrl);
void updateJobStatus(const String& jobId, const char* status);

void connectWiFi()
{
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

    Serial.print("Connecting to Wi-Fi");

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

    Serial.println();
    Serial.println("Wi-Fi connected");
    Serial.println(WiFi.localIP());
}

void subscribeJobTopics()
{
    bool notifyResult =
        mqttClient.subscribe(topicNotifyNext.c_str());

    bool acceptedResult =
        mqttClient.subscribe(topicStartNextAccepted.c_str());

    bool rejectedResult =
        mqttClient.subscribe(topicStartNextRejected.c_str());

    Serial.printf(
        "Subscribe notify-next: %s\n",
        notifyResult ? "OK" : "FAILED"
    );

    Serial.printf(
        "Subscribe start-next/accepted: %s\n",
        acceptedResult ? "OK" : "FAILED"
    );

    Serial.printf(
        "Subscribe start-next/rejected: %s\n",
        rejectedResult ? "OK" : "FAILED"
    );
}

void requestNextJob()
{
    Serial.println("Requesting next Job");

    if (!mqttClient.publish(topicStartNext.c_str(), "{}"))
    {
        Serial.println("Failed to request next Job");
    }
}

void connectAWS()
{
    mqttClientSecure.setCACert(AWS_ROOT_CA);
    mqttClientSecure.setCertificate(AWS_DEVICE_CERT);
    mqttClientSecure.setPrivateKey(AWS_PRIVATE_KEY);

    mqttClient.setServer(AWS_IOT_ENDPOINT, 8883);
    mqttClient.setCallback(mqttCallback);

    Serial.print("Connecting to AWS IoT Core");

    while (!mqttClient.connected())
    {
        if (mqttClient.connect(AWS_IOT_CLIENT_ID))
        {
            Serial.println();
            Serial.println("AWS IoT Core connected");

            subscribeJobTopics();

            // 起動時点ですでに待機中のJobがある場合にも対応
            requestNextJob();
        }
        else
        {
            Serial.print(".");
            delay(1000);
        }
    }
}

void updateJobStatus(const String& jobId, const char* status)
{
    String topic =
        "$aws/things/" +
        String(AWS_IOT_THING_NAME) +
        "/jobs/" +
        jobId +
        "/update";

    JsonDocument document;
    document["status"] = status;

    String payload;
    serializeJson(document, payload);

    if (mqttClient.publish(topic.c_str(), payload.c_str()))
    {
        Serial.printf(
            "Job %s: %s\n",
            jobId.c_str(),
            status
        );

        mqttClient.loop();
        delay(500);
    }
    else
    {
        Serial.println("Failed to update Job status");
    }
}

bool updateFirmware(const char* firmwareUrl)
{
    if (WiFi.status() != WL_CONNECTED)
    {
        Serial.println("Wi-Fi is not connected");
        return false;
    }

    WiFiClientSecure client;

    // 現段階では証明書検証を省略
    client.setInsecure();

    HTTPClient http;

    Serial.println("Downloading firmware...");

    if (!http.begin(client, firmwareUrl))
    {
        Serial.println("Failed to start HTTP connection");
        return false;
    }

    int httpCode = http.GET();

    if (httpCode != HTTP_CODE_OK)
    {
        Serial.printf("HTTP GET failed: %d\n", httpCode);
        http.end();
        return false;
    }

    int firmwareSize = http.getSize();

    if (firmwareSize <= 0)
    {
        Serial.println("Invalid firmware size");
        http.end();
        return false;
    }

    Serial.printf(
        "Firmware size: %d bytes\n",
        firmwareSize
    );

    if (!Update.begin(firmwareSize))
    {
        Serial.println("Failed to start OTA update");
        Update.printError(Serial);
        http.end();
        return false;
    }

    WiFiClient* stream = http.getStreamPtr();
    size_t written = Update.writeStream(*stream);

    Serial.printf(
        "Written: %u / %d bytes\n",
        static_cast<unsigned int>(written),
        firmwareSize
    );

    if (written != static_cast<size_t>(firmwareSize))
    {
        Serial.println("Firmware write failed");
        Update.abort();
        http.end();
        return false;
    }

    if (!Update.end())
    {
        Serial.println("OTA update failed");
        Update.printError(Serial);
        http.end();
        return false;
    }

    if (!Update.isFinished())
    {
        Serial.println("OTA update was not completed");
        http.end();
        return false;
    }

    http.end();

    Serial.println("OTA update completed");

    return true;
}

void handleJob(const JsonDocument& document)
{
    if (!document["execution"].is<JsonObjectConst>())
    {
        Serial.println("No pending Job");
        return;
    }

    String jobId =
        document["execution"]["jobId"].as<String>();

    JsonObjectConst jobDocument =
        document["execution"]["jobDocument"].as<JsonObjectConst>();

    const char* operation =
        jobDocument["operation"];

    const char* firmwareUrl =
        jobDocument["firmwareUrl"];

    if (operation == nullptr || firmwareUrl == nullptr)
    {
        Serial.println("Invalid Job document");

        updateJobStatus(jobId, "FAILED");
        return;
    }

    Serial.printf(
        "Job ID: %s\n",
        jobId.c_str()
    );

    Serial.printf(
        "Operation: %s\n",
        operation
    );

    if (strcmp(operation, "firmware_update") != 0)
    {
        Serial.println("Unsupported operation");

        updateJobStatus(jobId, "FAILED");
        return;
    }

    Serial.println("Starting firmware update");

    bool success = updateFirmware(firmwareUrl);

    if (!success)
    {
        updateJobStatus(jobId, "FAILED");
        return;
    }

    updateJobStatus(jobId, "SUCCEEDED");

    Serial.println("Restarting...");

    delay(1000);
    ESP.restart();
}

void mqttCallback(
    char* topic,
    byte* payload,
    unsigned int length
)
{
    String receivedTopic = topic;

    Serial.printf(
        "MQTT message: %s\n",
        receivedTopic.c_str()
    );

    if (receivedTopic == topicNotifyNext)
    {
        Serial.println("Job notification received");

        requestNextJob();
        return;
    }

    if (receivedTopic == topicStartNextRejected)
    {
        Serial.println("Start next Job rejected");

        for (unsigned int i = 0; i < length; i++)
        {
            Serial.print(static_cast<char>(payload[i]));
        }

        Serial.println();
        return;
    }

    if (receivedTopic != topicStartNextAccepted)
    {
        return;
    }

    JsonDocument document;

    DeserializationError error =
        deserializeJson(document, payload, length);

    if (error)
    {
        Serial.print("JSON parse failed: ");
        Serial.println(error.c_str());
        return;
    }

    handleJob(document);
}

void setupOTA()
{
    // AWS IoT Jobsの長いレスポンスを受信できるよう拡張
    mqttClient.setBufferSize(2048);

    topicNotifyNext =
        "$aws/things/" +
        String(AWS_IOT_THING_NAME) +
        "/jobs/notify-next";

    topicStartNext =
        "$aws/things/" +
        String(AWS_IOT_THING_NAME) +
        "/jobs/start-next";

    topicStartNextAccepted =
        topicStartNext +
        "/accepted";

    topicStartNextRejected =
        topicStartNext +
        "/rejected";

    connectWiFi();
    connectAWS();
}

void handleOTA()
{
    if (WiFi.status() != WL_CONNECTED)
    {
        connectWiFi();
    }

    if (!mqttClient.connected())
    {
        connectAWS();
    }

    mqttClient.loop();
}

ota.hには、main.cppから使用する関数を定義します。

#pragma once

void setupOTA();
void handleOTA();

main.cppではota.hを読み込み、setup()からsetupOTA()、loop()からhandleOTA()を呼び出します。

main.cppの全文を表示する
#include <Arduino.h>
#include <M5Unified.h>

#include "ota.h"

#define FIRMWARE_VERSION "1.0.2"

void setup()
{
    auto cfg = M5.config();
    M5.begin(cfg);

    Serial.begin(115200);

    delay(5000);

    Serial.println();
    Serial.println("=== M5StickS3 START ===");

    M5.Display.setTextSize(2);
    M5.Display.setCursor(10, 20);
    M5.Display.println("Firmware");
    M5.Display.printf("v%s", FIRMWARE_VERSION);

    setupOTA();

    M5.Display.setCursor(10, 70);
    M5.Display.println("AWS IoT");
    M5.Display.println("connected");
}

void loop()
{
    handleOTA();
    delay(10);
}

AWS IoT Coreへの接続やAWS IoT JobsからのJob取得、OTA更新などの処理はota.cppへ移動しています。

これにより、main.cppではM5StickS3で実行する処理を記述し、AWS IoT Jobsへの接続やOTA更新に関する処理はota.cpp側で管理できます。

なお、OTA更新後も引き続き遠隔更新を行うためには、新しく書き込むファームウェアにもsetupOTA()とhandleOTA()を呼び出す処理が必要です。OTA処理を含まないファームウェアへ更新した場合、次回以降は再びUSB接続による書き込みが必要になります。

GitHubへ公開しないファイルを設定する

secrets.hにはWi-FiのSSID・パスワードやAWS IoT Coreへ接続するための証明書・秘密鍵が含まれています。

そのため、.gitignoreへ以下を追加し、secrets.hをGitHubへPushしないようにします。

.pio/
.vscode/

src/secrets.h

GitHub Actions用のsecrets.hを準備する

Pull Request作成時にはGitHub Actions上でPlatformIOによるビルドを行います。

しかし、secrets.hはGitHubへPushしないため、そのままではGitHub Actions上でビルドできません。

そこで、実際の認証情報を含まないsecrets.example.hを作成します。

#pragma once

const char* WIFI_SSID = "dummy";
const char* WIFI_PASSWORD = "dummy";

const char* AWS_IOT_ENDPOINT = "dummy.iot.ap-northeast-1.amazonaws.com";

const char* AWS_IOT_CLIENT_ID = "M5StickS3-WiFi-Monitor";
const char* AWS_IOT_TOPIC = "m5sticks3/wifi";

const char* AWS_IOT_THING_NAME = "M5StickS3-WiFi-Monitor";

const char AWS_ROOT_CA[] = R"EOF(
dummy
)EOF";

const char AWS_DEVICE_CERT[] = R"KEY(
dummy
)KEY";

const char AWS_PRIVATE_KEY[] = R"KEY(
dummy
)KEY";

secrets.example.hは認証情報を含まないためGitHubで管理します。

Pull Request作成時のビルドでは、このファイルをsecrets.hとしてコピーすることで、実際の認証情報を使用せずにコードが正常にビルドできることを確認します。

Pull Request作成時に実行するGitHub ActionsのWorkflowは、以下のbuild.ymlで設定します。

build.ymlの全文を表示する
name: PlatformIO Build

on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.x"

      - name: Install PlatformIO
        run: pip install platformio

      - name: Create secrets.h for build
        run: cp src/secrets.example.h src/secrets.h

      - name: Build firmware
        run: pio run

このWorkflowはPull Requestの作成・更新をきっかけに実行され、secrets.example.hからsecrets.hを作成した後、PlatformIOでファームウェアをビルドします。

一方、Merge後に実際のファームウェアを生成する際には、本物のsecrets.hが必要になります。この認証情報については、後ほどGitHub Secretsへ登録します。

GitHub ActionsからAWSへ接続する

Merge後のWorkflowでは、生成したfirmware.binのS3へのアップロードとAWS IoT Jobの作成を行います。

GitHub ActionsからAWSへ接続するため、今回はOpenID Connect(OIDC)を使用しました。

OIDCを使用することで、AWSのアクセスキーとシークレットアクセスキーをGitHub Secretsへ保存せず、GitHub Actionsの実行時にIAMロールを一時的に引き受けてAWSへアクセスできます。

GitHub Actions用のOIDC Providerを作成する

AWSマネジメントコンソールからIAMを開き、IDプロバイダから新しいプロバイダを追加します。

以下の内容で作成しました。

プロバイダのタイプ:OpenID Connect

プロバイダのURL:
https://token.actions.githubusercontent.com

対象者(Audience):
sts.amazonaws.com

これにより、GitHub Actionsが発行するOIDCトークンをAWS側で利用できるようになります。

GitHub Actions用のIAMロールを作成する

続いて、GitHub Actionsが引き受けるIAMロールを作成します。

今回は以下の名前で作成しました。

M5StickS3-GitHubActions-Deploy

このIAMロールでは、先ほど作成したOIDC Providerを信頼されたIDプロバイダとして設定します。

今回使用した信頼ポリシーは以下のようになります。

信頼ポリシーの全文を表示する
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "GITHUB_OIDC_SUB"
        }
      }
    }
  ]
}

AWS_ACCOUNT_IDには自身のAWSアカウントIDを指定します。

GITHUB_OIDC_SUBには、GitHub Actionsが発行するOIDCトークンのsubに合わせた値を指定します。

今回の環境では以下の形式となっていました。

repo:OWNER@OWNER_ID/REPOSITORY@REPOSITORY_ID:ref:refs/heads/main

subをmainブランチに限定することで、このIAMロールを使用できるGitHubリポジトリとブランチを制限しています。

デプロイに必要な権限を設定する

作成したIAMロールには、Merge後のWorkflowで必要となる以下の権限を設定します。

  • S3へfirmware.binをアップロードする
  • AWS IoT Jobを作成する
  • AWS IoT JobsへS3アクセス用のIAMロールを渡す

今回はIAMロールに以下のインラインポリシーを設定しました。

インラインポリシーの全文を表示する
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "UploadFirmwareToS3",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::m5sticks3-autoupload-firmware/firmware.bin"
    },
    {
      "Sid": "CreateIoTJob",
      "Effect": "Allow",
      "Action": "iot:CreateJob",
      "Resource": [
        "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:job/*",
        "arn:aws:iot:ap-northeast-1:AWS_ACCOUNT_ID:thing/M5StickS3-WiFi-Monitor"
      ]
    },
    {
      "Sid": "PassIoTJobsRole",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::AWS_ACCOUNT_ID:role/M5StickS3-IoTJobs-S3Role"
    }
  ]
}

M5StickS3-IoTJobs-S3Roleは、前章でAWS IoT JobsからS3上のfirmware.binを取得するために作成したIAMロールです。

ここで作成したM5StickS3-GitHubActions-Deployとは役割が異なります。

GitHub Actions
      ↓
M5StickS3-GitHubActions-Deploy
      ↓
S3へfirmware.binをアップロード
      ↓
AWS IoT Jobを作成
      ↓
M5StickS3-IoTJobs-S3Role
      ↓
S3からfirmware.binを取得

これで、GitHub ActionsからAWSへ接続し、ファームウェアのアップロードとIoT Jobの作成を行うための準備ができました。

Merge後にファームウェアを自動デプロイする

続いて、Pull RequestをmainブランチへMergeしたことをきっかけに、ファームウェアのビルドからAWS IoT Jobの作成までを自動で行うWorkflowを作成します。

GitHub Secretsにsecrets.hを登録する

PR作成時のビルドではsecrets.example.hを使用しましたが、実際にM5StickS3へ書き込むファームウェアにはWi-FiやAWS IoT Coreへ接続するための認証情報が必要です。

今回はsrc/secrets.hの内容をGitHub Secretsへ登録し、Workflow実行時にsecrets.hを生成するようにしました。

GitHubリポジトリのSettingsからSecrets and variables → Actionsを開き、Repository secretを追加します。

Secret名は以下としました。

DEVICE_SECRETS_H

値には、ローカル環境で使用しているsrc/secrets.hの内容を登録します。

secrets.hには秘密鍵などが含まれているため、内容をリポジトリへ直接Commitしないよう注意してください。

deploy.ymlを作成する

.github/workflowsにdeploy.ymlを作成します。

.github/
└── workflows/
    ├── build.yml
    └── deploy.yml

今回は以下のWorkflowを作成しました。

deploy.ymlの全文を表示する
name: Deploy Firmware

on:
  push:
    branches:
      - main

permissions:
  contents: read
  id-token: write

env:
  AWS_REGION: ap-northeast-1
  S3_BUCKET: m5sticks3-autoupload-firmware
  THING_NAME: M5StickS3-WiFi-Monitor

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.x"

      - name: Install PlatformIO
        run: pip install platformio

      - name: Create secrets.h for deploy
        shell: bash
        env:
          DEVICE_SECRETS_H: ${{ secrets.DEVICE_SECRETS_H }}
        run: printf '%s' "$DEVICE_SECRETS_H" > src/secrets.h

      - name: Build firmware
        run: pio run

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::AWS_ACCOUNT_ID:role/M5StickS3-GitHubActions-Deploy
          aws-region: ${{ env.AWS_REGION }}

      - name: Upload firmware to S3
        run: |
          aws s3 cp \
            .pio/build/esp32-s3-devkitc-1/firmware.bin \
            s3://${S3_BUCKET}/firmware.bin

      - name: Create AWS IoT Job
        run: |
          JOB_ID="firmware-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"

          aws iot create-job \
            --job-id "${JOB_ID}" \
            --targets "arn:aws:iot:${AWS_REGION}:AWS_ACCOUNT_ID:thing/${THING_NAME}" \
            --document file://job-document.json \
            --presigned-url-config "roleArn=arn:aws:iam::AWS_ACCOUNT_ID:role/M5StickS3-IoTJobs-S3Role,expiresInSec=3600"

AWS_ACCOUNT_IDには自身のAWSアカウントIDを指定します。

このWorkflowはmainブランチへのPushをきっかけに実行されます。そのため、Pull RequestをMergeすると自動的にデプロイ処理が開始されます。

実際のsecrets.hを生成する

最初にGitHub Secretsへ登録したDEVICE_SECRETS_Hからsrc/secrets.hを生成します。

- name: Create secrets.h for deploy
  shell: bash
  env:
    DEVICE_SECRETS_H: ${{ secrets.DEVICE_SECRETS_H }}
  run: printf '%s' "$DEVICE_SECRETS_H" > src/secrets.h

その後、PR作成時と同様にPlatformIOでファームウェアをビルドします。

- name: Build firmware
  run: pio run

今回は実際のsecrets.hを使用しているため、ここで生成されるfirmware.binがM5StickS3へ配信するファームウェアになります。

OIDCを使用してAWSへ接続する

AWSへの接続には、前項で作成したIAMロールを使用します。

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v6
  with:
    role-to-assume: arn:aws:iam::AWS_ACCOUNT_ID:role/M5StickS3-GitHubActions-Deploy
    aws-region: ${{ env.AWS_REGION }}

id-token: writeを設定しているため、GitHub ActionsはOIDCトークンを取得し、IAMロールを一時的に引き受けます。

ここで指定するIAMロールのARNは、AWS側で作成したIAMロールのARNと一致している必要があります。

firmware.binをS3へアップロードする

PlatformIOで生成されたfirmware.binを、これまでOTA更新に使用していたS3バケットへアップロードします。

- name: Upload firmware to S3
  run: |
    aws s3 cp \
      .pio/build/esp32-s3-devkitc-1/firmware.bin \
      s3://${S3_BUCKET}/firmware.bin

同じfirmware.binへアップロードするため、MergeされるたびにOTAで使用するファームウェアが更新されます。

S3バケットではバージョニングを有効にしているため、同じオブジェクトキーへアップロードした場合でも過去のバージョンを保持できます。

AWS IoT Jobを自動作成する

最後にAWS CLIからIoT Jobを作成します。

- name: Create AWS IoT Job
  run: |
    JOB_ID="firmware-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"

    aws iot create-job \
      --job-id "${JOB_ID}" \
      --targets "arn:aws:iot:${AWS_REGION}:AWS_ACCOUNT_ID:thing/${THING_NAME}" \
      --document file://job-document.json \
      --presigned-url-config "roleArn=arn:aws:iam::AWS_ACCOUNT_ID:role/M5StickS3-IoTJobs-S3Role,expiresInSec=3600"

Job IDにはGitHub ActionsのGITHUB_RUN_IDとGITHUB_RUN_ATTEMPTを使用しました。

これによりWorkflowを再実行した場合でも異なるJob IDが生成され、新しいIoT Jobとして作成できます。

Job documentには前章で作成したjob-document.jsonを使用します。

IoT Jobが作成されると、M5StickS3がJobを取得し、S3へアップロードされたfirmware.binを使用してOTA更新を開始します。

これで、Pull RequestをmainブランチへMergeした後に必要だった以下の操作をGitHub Actionsから自動で実行できるようになりました。

mainへMerge
    ↓
firmware.binをビルド
    ↓
S3へアップロード
    ↓
AWS IoT Jobを作成
    ↓
M5StickS3をOTA更新

mainブランチへのMerge条件を設定する

ここまでの設定で、Pull Request作成時のビルドと、mainブランチへのMerge後のデプロイを自動化しました。

しかし、このままではビルドに失敗しているコードをmainブランチへMergeすることもできます。

そこでGitHubのRulesetを使用し、Pull Requestの作成とGitHub Actionsによるビルド成功をMergeの条件にします。

Rulesetを作成する

GitHubリポジトリのSettings → Rules → Rulesetsを開き、New ruleset → New branch rulesetを選択します。

今回はRuleset名を以下としました。

m5stick-build-rule

Enforcement statusはActiveに設定します。

続いてTarget branchesでmainブランチを対象に設定します。

PRとビルド成功をMergeの条件にする

Branch rulesでは、以下のルールを有効にします。

  • Require a pull request before merging
  • Require status checks to pass
    • Required status checks にGitHub Actionsのbuildを追加

Require a pull request before mergingを有効にすることで、mainブランチへ直接変更を反映するのではなく、Pull Requestを経由してMergeするようにします。

続いてRequire status checks to passで、必須のStatus checkとして以下を追加します。

build

これは前項で作成したbuild.ymlのJob名です。

jobs:
  build:
    runs-on: ubuntu-latest

これにより、Pull Requestを作成するとPlatformIO Buildが実行され、buildが成功するまでMergeできなくなります。

処理の流れは以下のようになります。

更新用ブランチ
      ↓
Pull Request
      ↓
PlatformIO Build
      ↓
   ┌─────────────┐
   │             │
 失敗           成功
   │             │
Merge不可      Merge可能
                 ↓
               main
                 ↓
          Deploy Firmware

これで、正常にビルドできることを確認したコードだけをmainブランチへMergeし、Merge後にM5StickS3へのデプロイを開始する構成になりました。

GitHubからM5StickS3を遠隔更新する

ここまでの設定で、Pull Request作成時のビルドから、Merge後のfirmware.binのS3へのアップロード、AWS IoT Jobの作成までを自動化しました。

最後に、GitHub上でファームウェアのバージョンを変更し、M5StickS3が実際にOTA更新されることを確認します。

前章までの動作確認で、M5StickS3にはv1.0.2のファームウェアが書き込まれています。

今回はGitHub上でv1.0.3へ変更し、以下の流れでOTA更新を行います。

M5StickS3:v1.0.2
        ↓
更新用ブランチを作成
        ↓
v1.0.3へ変更
        ↓
Pull Requestを作成
        ↓
PlatformIO Build
        ↓
ビルド成功後にMerge
        ↓
Deploy Firmware
        ↓
firmware.binをS3へアップロード
        ↓
AWS IoT Jobを作成
        ↓
M5StickS3がJobを取得
        ↓
v1.0.3へOTA更新

更新用ブランチでコードを変更する

GitHub上で更新用のブランチを作成し、src/main.cppのFIRMWARE_VERSIONを変更します。

変更前は以下のようになっています。

#define FIRMWARE_VERSION "1.0.2"

これを1.0.3へ変更します。

#define FIRMWARE_VERSION "1.0.3"

変更後のコードをCommitし、GitHubへ反映します。

Pull Requestを作成してビルドを確認する

作成したブランチからmainブランチへPull Requestを作成します。

Pull Requestを作成すると、build.ymlで設定したPlatformIO Buildが自動的に実行されます。

Checksから実行結果を確認し、buildが成功していることを確認します。

Rulesetでbuildの成功をMergeの条件としているため、ビルドに失敗している場合はmainブランチへMergeできません。

Pull RequestをMergeする

PlatformIO Buildが成功したことを確認したら、Pull RequestをmainブランチへMergeします。

Mergeによってmainブランチが更新されると、deploy.ymlで設定したDeploy Firmwareが自動的に実行されます。

Deploy Firmwareの実行結果を確認する

GitHubリポジトリのActionsからDeploy Firmwareを開き、各処理が正常に完了していることを確認します。

正常に完了すると、以下の処理がGitHub Actions上で実行されています。

secrets.hを生成
        ↓
PlatformIOでビルド
        ↓
AWSへOIDCで接続
        ↓
firmware.binをS3へアップロード
        ↓
AWS IoT Jobを作成

すべての処理が成功すると、Workflowに緑色のチェックが表示されます。

ただし、このチェックはGitHub Actionsによるデプロイ処理が完了したことを表しており、M5StickS3側のOTA更新完了を表すものではありません。

M5StickS3がOTA更新されたことを確認する

AWS IoT Jobが作成されると、M5StickS3がJobを取得してOTA更新を開始します。

更新が完了するとM5StickS3が再起動し、ディスプレイに今回変更してMergeしたv1.0.3という表記に変わります。

Firmware
v1.0.2

↓

Firmware
v1.0.3

これで、M5StickS3をUSB接続することなく、GitHub上でコードを変更してPull RequestをMergeすることでファームウェアを遠隔更新できるようになりました。

なお、この方法でOTA更新を行うためには、M5StickS3にあらかじめOTA更新に対応したファームウェアが書き込まれている必要があります。最初の一度はUSB接続による書き込みが必要ですが、それ以降はOTA機能を含むファームウェアを使用し続けることで、GitHubを起点として更新できます。

まとめ

今回は、GitHub ActionsとAWS IoT Jobsを使用して、GitHubへのコード変更を起点にM5StickS3のファームウェアを遠隔更新できる環境を構築しました。

実際に作ってみると、最初はUSBから書き込む必要があることや、更新後もOTA処理をファームウェアに含め続ける必要があるなど、今回の構成ならではの制約も分かり学びがありましたが、若干不自由な状態のものが出来上がってしまったことが少し悔しいです。

とはいえ、開発ではCIを利用する機会も多いため、GitHub Actionsを使ったビルドからOTA更新までの流れを実際に構築できたのは良い勉強になりました。

また、今回の実装を振り返ると、setInsecure()を使用しているためサーバー証明書を検証していないことや、更新後に正常起動できなかった場合のロールバックについても考慮できていないなど、設計の面で甘い部分が多くあったと感じています。

今後は実装して終わりにするのではなく、正常系だけでなく異常系やセキュリティの観点からも自分の設計を振り返るようにし、次回以降の開発に活かしていきます。良い経験になりました。

次やりたいことは、エラーが起こっている時の内容が把握しづらかったので、M5StickS3の動作ログを保存し、エラーが発生した場合に通知できるといった監視の仕組みも作ってみたいなとか考えてます。

ではまた。

お知らせ

技術ブログを週1〜2本更新中、ソーイをフォローして最新記事をチェック!
https://qiita.com/organizations/sewii

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?