方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【ESP32】ハードウェアトリガーでファームウェアを初期化する方法

この記事は約25分で読めます。

はじめに

方言を話す、おしゃべり猫型ロボット「ミーア」を開発中。

ミーア
おしゃべり猫型ペットロボット「ミーア」は、100以上の種類の豊かな表情で、全国47の方言(大阪弁・博多弁・京都弁・沖縄弁など)を話します。毎日の中に、ちょっとした幸せと便利を。誕生日や記念日のプレゼントにピッタリです

前回、こちらの記事で、ESP32のOTAアップデート機能を実装した。

今回は、ファームウェアの初期化を実装したいと思う。ユーザーの長期間にわたる操作や予期しないエラーなどで、ESP32のファイルシステムが壊れた際に、ユーザー手動でファームウェアを初期化できるようにするため。

PlatformIOとTFT_eSPIを活用して、セーフモードでのディスプレイを利用した状態表示についても記載。

初期化のトリガーをどうするか

初期化のトリガーに関して、今回は以下の2つが考えられる。

MQTT経由での初期化

アプリケーションやクラウドサーバーからMQTTメッセージを送信し、そのメッセージを受け取ったESP32が初期化を行う。この方法の利点は、遠隔地からでもデバイスをリセットできること。しかし、ネット環境が良くないと途中でリセット操作が中断する可能性がある。

ハードウェアトリガー

物理的なボタンやタッチセンサーを使ってユーザーが特定の操作(例えば長押し)をすると初期化を行う方法。この方法の利点は、物理的なアクションが必要なため、誤って初期化が行われるリスクが低い。

ちなみに、Nintendo Switchは、電源ボタンを12秒以上押し続けることを、デバイスリセットとしている。

【Switch 2 / Switch】本体の電源がONになりません。どうすればよいですか?
Nintendo Switchの電源がONにならない場合は、こちらをご覧ください。
【switch】本体の電源がonになりません。どうすればよいですか?

ミーアの頭にTTP223タッチセンサーを設置しているので、今回は、ハードウェアトリガーにして、頭を5秒以上タッチし続けた場合に初期化するという方法にしたいと思う。

Espressifが推奨しているESP32の初期化は、OTAアップデート用の2つのpartitionとは別に、初期化用のfactory partitionをOTAアップデートと同じサイズであらかじめ確保しておき、そこに初期化用のデータを保持しておき、デバイスリセット時はfactoryパーティションから初期化データを読み取る方法。

Partition Tables - ESP32 - — ESP-IDF Programming Guide v5.4.1 documentation

ただし、今回は、SPIFFS領域をある程度確保しなければならず、factoryパーティションを新たに用意するスペースがない。ということで、ハードウェアトリガーではあるが、初期ファームウェアバージョン(1.0.0)を指定してOTAアップデート関数を呼び出す方法で初期化を行いたいと思う。

実装ステップ

セーフモードのトリガー条件の追加
setup()の冒頭で、TTP223タッチセンサーがタッチされている時間を計測し、指定した時間(5秒)以上タッチされた場合に、セーフモードを実行する。
セーフモードでは、システムの基本的な機能のみを起動し、ネットワークやデータベースの初期化などは行わないようにする。

ファームウェアバージョンの指定
タッチが長時間継続されたことを検出したら、初期ファームウェアバージョン(1.0.0)を指定してOTAアップデート関数を呼び出す。

OTAアップデートの実行
指定されたバージョンのファームウェアをダウンロードし、インストールする。

それでは、この順に開発していきたいと思う。

setup()関数でセーフモードを分離

電源を押して起動した時に、TTP223タッチセンサーを触ったままで5秒以上が経過すると、セーフモードをアクティブにして、通常の初期化処理はスキップするようにする。

フラグ inSafeMode は、セーフモードがアクティブかどうかを示すために使用。セーフモードがトリガーされる場面では、このフラグを true に設定する。

C++
// src/main.cpp
bool inSafeMode = false;

void setup() {
  Serial.begin(115200);
  pinMode(HEAD_BUTTON_PIN, INPUT);
  Serial.println("Starting");
  print_free_heap_size("After Starting");

  // セーフモードのトリガーをチェック
  unsigned long startTime = millis();
  while (digitalRead(HEAD_BUTTON_PIN) == HIGH) {
    if (millis() - startTime > 5000) {  // 5秒以上押されていたらセーフモード
      Serial.println("Entering Safe Mode...");
      setupSafeMode();
      return;
    }
  }

  setupCommon();
  normalSetup();
}

共通セットアップ: セーフモードと通常モード

セーフモードにおいても、デバイスの基本的な機能を保持し、Wi-Fi接続を通じてのOTAアップデートを実行できるようにするために、NVSとLittleFSの初期化、そしてWi-Fiの設定を共通関数に組み込む。

共通セットアップ以外のセットアップ機能(デバイスシャドウ初期化・DB初期化・音声初期化など)はnotmalSetup()関数として、セーフモード時には起動しないように変更。

C++
// src/main.cpp
void setupCommon() {
    // NVSの初期化
    esp_err_t err = nvs_flash_init();
    if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        esp_err_t erase_err = nvs_flash_erase();
        if (erase_err != ESP_OK) {
            Serial.println("Failed to erase NVS partition");
            return;
        }
        err = nvs_flash_init();
    }
    if (err != ESP_OK) {
        Serial.println("Failed to initialize NVS");
        return;
    }

    // LittleFSの初期化
    if (!LittleFS.begin(FORMAT_LITTLEFS_IF_FAILED)) {
        Serial.println("Failed to mount file system");
        return;
    }
    // Wi-Fi接続
    setupWiFi(ssid, password);
}

Wi-Fi対応:SmartConfig設定

セーフモードで起動中に、Wi-Fiが未接続の場合、Wi-Fi接続を行う必要があるので、SmartConfigを使用してWi-Fi接続の設定を行う。

元々、Bluetoothを用いて、アプリとESP32のWi-Fi接続を行っていたが、Bluetoothを利用した製品を販売する場合、Bluetooth SIG(Special Interest Groupの略)認証を取得しなければならず、費用が8,000ドルもかかることが判明したために、急遽SmartConfigを利用したWi-Fi接続へと変更した。

C++
// src/main.cpp
void setupSafeMode() {
  Serial.println("Entering Safe Mode: Limited functionality.");
  inSafeMode = true;

  // 共通の初期化処理を行う
  setupCommon();

  // WiFiが接続されていないか確認
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("Starting SmartConfig to setup WiFi...");
    WiFi.disconnect();
    WiFi.mode(WIFI_STA);
    WiFi.beginSmartConfig();

    // SmartConfigの完了を待つ
    while (!WiFi.smartConfigDone()) {
      delay(500);
      Serial.print(".");
    }
    Serial.println("nSmartConfig Done.");

    // WiFi接続の確立を待つ
    while (WiFi.status() != WL_CONNECTED) {
      delay(500);
      Serial.print(".");
    }
    Serial.println("nWiFi Connected.");
  } else {
    Serial.println("WiFi is already connected.");
  }
}

loop()関数でをセーフモード分離

loop() 関数では、セーフモードがアクティブかどうかをチェックし、アクティブであれば通常のループ処理をスキップして safeModeLoop() を実行する。

C++
// src/main.cpp
void loop() {
  if (inSafeMode) {
    safeModeLoop();
    return;
  }

  // 通常の処理
  monitorWiFiConnectionChange();
  if (isWiFiConnected()) {
    executeWiFiConnectedRoutines();
  }
  buttonManager.handleButtonPress();
  ExpressionService::getInstance().render();
  delay(10);
}

OTAで初期化実行

safeModeLoop() では、OTAアップデートを行い、ファームウェアのアップデートを通じてデバイスを初期化する。初期化が完了したら、デバイスを再起動して、通常のsetup()関数による処理を行う。

C++
// src/main.cpp
void safeModeLoop() {
  Serial.println("Safe Mode loop ...");
  upgradeFirmware("1.0.0");
  ESP.restart();
}

以前OTAアップデートで作成していたupgradeFirmware関数を利用。初期化のバージョンとして”1.0.0.”を事前にAWS S3に上げておき、upgradeFirmware関数の引数として指定する。

upgradeFirmware関数は下記

C++
// src/ota_update.cpp
tl::expected<void, String> upgradeFirmware(const String &firmwareVersion) {
  Serial.println("OTA firmware upgrade start");

  // ルートCA証明書を読み込む
  String cert = readRootCA();
  if (cert.length() == 0) {
    return tl::make_unexpected("Failed to load root CA certificate");
  }

  // Firmware Update URLを組み立てる
  String firmwareUpdateUrl = String(FIRMWARE_UPGRADE_URL) + firmwareVersion + ".bin";
  Serial.println("Firmware update URL: " + firmwareUpdateUrl);

  esp_http_client_config_t config = {
      .url = firmwareUpdateUrl.c_str(),
      .cert_pem = cert.c_str(),
      .timeout_ms = 600000, // 念の為に通信タイムアウトを10分に設定。
      .keep_alive_enable = true,
  };
  esp_https_ota_config_t ota_config = {
      .http_config = &config,
  };

  // OTAのエラーを格納する
  String otaError;
  int last_progress = -1;

  // OTA開始
  esp_https_ota_handle_t https_ota_handle = nullptr;
  esp_err_t err = esp_https_ota_begin(&ota_config, &https_ota_handle);
  if (err != ESP_OK) {
    otaError = "OTA begin failed: " + String(esp_err_to_name(err));
    goto ota_end;
  }

  // イメージの存在チェック(descriptionを取得)
  esp_app_desc_t app_desc;
  err = esp_https_ota_get_img_desc(https_ota_handle, &app_desc);
  if (err != ESP_OK) {
    otaError = "failed to get image description: " + String(esp_err_to_name(err));
    goto ota_end;
  }

  // OTAのダウンロードを実行
  while (true) {
    err = esp_https_ota_perform(https_ota_handle);
    if (err != ESP_ERR_HTTPS_OTA_IN_PROGRESS) {
      break;
    }
    int32_t dl = esp_https_ota_get_image_len_read(https_ota_handle);
    int32_t size = esp_https_ota_get_image_size(https_ota_handle);
    int progress = 100 * ((float)dl / (float)size);
    if (progress != last_progress) {
      Serial.printf("Firmware update progress: %d%%n", progress);
      last_progress = progress;
    }
  }

  // OTAが正常に完了したか確認
  if (err != ESP_OK) {
    otaError = "OTA finish failed: " + String(esp_err_to_name(err));
    goto ota_end;
  }

  // OTAで完全なデータが受信されたか確認
  if (esp_https_ota_is_complete_data_received(https_ota_handle) != true) {
    otaError = "OTA image was not completely received";
    goto ota_end;
  }

  // OTAの終了処理を実行
  err = esp_https_ota_finish(https_ota_handle);
  if (err == ESP_ERR_OTA_VALIDATE_FAILED) {
    otaError = "OTA image validation failed";
    goto ota_end;
  }

  Serial.println("Firmware upgrade succeeded");

  return {};

ota_end:
  esp_https_ota_abort(https_ota_handle);
  return tl::make_unexpected("OTA process ended unexpectedly: " + otaError);
}

目のディスプレイに初期化ステータスを表示

上記で、無事セーフモード時の処理を通常起動時から分離して処理することができるようになった。

ただ、Wi-Fi接続に関して、セーフモード時に仮にWi-Fi接続ができなかった場合に、ユーザーにそれを知らせる手段がないので、目のLCDディスプレイ(ST7735sを使用)に、セーフモード起動時のステータスを表示するようにする。

LCDディスプレイの描画には、TFT_eSPIライブラリを使用している。

GitHub - Bodmer/TFT_eSPI: Arduino and PlatformIO IDE compatible TFT library optimised for the Raspberry Pi Pico (RP2040), STM32, ESP8266 and ESP32 that supports different driver chips
Arduino and PlatformIO IDE compatible TFT library optimised for the Raspberry Pi Pico (RP2040), STM32, ESP8266 and ESP32...

PlatformIOでTFT_eSPIをカスタマイズ設定

実は、今回セーフモード処理の開発で、LCDディスプレイにステータスをテキストで表示する部分が一番ハマってしまったのだが、ArduinoではなくPlatformIOでVSCodeで記述しているので、platformio.iniファイルのbuild_flagsセクションにに下記のように設定する必要があった。

C++
// platformio.ini
[env]
platform = espressif32
framework = arduino
board = esp32dev
lib_deps =
    bodmer/TFT_eSPI@^2.5.34

build_flags =
    -DUSER_SETUP_LOADED=1
    -DLOAD_FONT4
    -DSMOOTH_FONT

TFT_eSPIライブラリでは、デフォルトの設定を編集する際には、Arduino IDEでUser_Setup.hというカスタム設定用のファイルを直接修正することが推奨されている。ただ、今回は、PlatformIOで開発しているのと、User_Setup.hファイル自体削除してしまったために、platformio.iniでカスタム設定する必要がある。

↓TFT_eSPIライブラリのUser_Setup.hファイル

TFT_eSPI/User_Setup.h at master · Bodmer/TFT_eSPI
Arduino and PlatformIO IDE compatible TFT library optimised for the Raspberry Pi Pico (RP2040), STM32, ESP8266 and ESP32...

DUSER_SETUP_LOADED=1

  • この定義は、TFT_eSPIライブラリに対して、デフォルトの設定ファイル(User_Setup_Select.h内の設定)を無視して、プロジェクト固有の設定を使用するよう指示する。これにより、User_Setup.hを物理的に編集または書き換えることなく、コンパイル時にカスタム設定を注入することが可能になる。

DLOAD_FONT4

  • DLOAD_FONT4は、TFT_eSPIライブラリが提供する特定のフォント(この場合はフォント4)をプログラムで使用するためにロードすることを指示する。
  • 4以外のフォント(1,2,4,6,7,8)も指定可能。詳しくは、TFT_eSPIライブラリのUser_Setup.hファイルを参照

最初、この、特定のフォントサイズを指定するのを忘れてて、コードの方だけでフォントサイズを指定していたために、ビルドしても文字が描画されず原因が分からずに、2時間ほど費やしてしまったorz

DLOAD_FONT4は4pxという意味ではなく、あくまでTFT_eSPIライブラリで規定されているフォントサイズ。DLOAD_FONT4以外にもフォントサイズが用意されているので、自分のディスプレイと表示したい文字数に応じて適宜調整。

DSMOOTH_FONT

  • DSMOOTH_FONTフラグは、TFT_eSPIライブラリのスムースフォント機能を有効にする。なくても文字は表示されるが、テキストの読みやすさを改善する。小さなディスプレイでクリアなテキスト表示が必要な場合に有効。

LCDディスプレイのテキスト表示関数作成

上記設定をplatform.iniファイルに設定後に、テキスト表示関数を作成する。

今回開発中のおしゃべり猫型ロボットでは、猫の両目にLCDディスプレイを2つ使用しており、両方のディスプレイに同じテキストを表示したいので、それぞれのディスプレイのチップセレクト(CS)ピンを出力として設定し、低電圧(LOW)に設定することでディスプレイをアクティブ化する。

displayText() 関数は、指定されたメッセージをディスプレイに表示する関数。

  • テキストの設定: tft.setCursor(10, 30, 4) を使用してカーソル位置を設定し、テキストのフォントサイズを指定する。第一引数がX座標、第二引数がY座標、第三引数がフォントサイズ。この4は、platform.iniファイルのbuild_flagsで設定したDLOAD_FONT4に相当する。もしもう少し小さいフォントサイズ(2など)を設定したい場合は、2を記載して、build_flagsにはDLOAD_FONT2を指定する。
  • メッセージの表示: tft.println(message) を呼び出して、指定されたメッセージをディスプレイに表示する。println関数はデフォルトで水平方向の文字は、デバイスの端に来たらWrapして改行表示してくれる。
  • 描画の終了: tft.endWrite() を呼び出して描画プロセスを終了し、digitalWrite(eye[e].tft_cs, HIGH) でCSピンを高電圧に戻してディスプレイの操作を終了する。
C++
// セーフモードでのディスプレイ初期化
void initSafeModeDisplay() {
  Serial.println("Initializing display for Safe Mode...");
  pinMode(TFT1_CS, OUTPUT);
  digitalWrite(TFT1_CS, LOW);
  pinMode(TFT2_CS, OUTPUT);
  digitalWrite(TFT2_CS, LOW);

  tft.init();
}

void displayText(const char* message) {
  Serial.println("Display safemode message called ...");
  for (uint8_t e = 0; e < NUM_EYES; e++) {
      digitalWrite(eye[e].tft_cs, LOW);
      tft.startWrite();
      tft.fillScreen(TFT_BLACK);
      tft.setCursor(10, 30, 4);
      tft.setTextColor(TFT_WHITE);
      tft.println(message);
      tft.endWrite();
      digitalWrite(eye[e].tft_cs, HIGH);
  }
}

セーフモード時にステータスをテキスト表示

最後に、作成したdisplayText()をセーフモード時に呼び出す。

C++

void setupSafeMode() {
    Serial.println("Entering Safe Mode: Limited functionality.");
    inSafeMode = true;
    initSafeModeDisplay();
    displayText("Safe Mode Active");

    // 共通の初期化処理を行う
    setupCommon();

    // WiFiが接続されていないか確認
    if (WiFi.status() != WL_CONNECTED) {
        Serial.println("Starting SmartConfig to setup WiFi...");
        
        // WiFi接続を初期化
        WiFi.disconnect();
        WiFi.mode(WIFI_STA);
        WiFi.beginSmartConfig();
        displayText("WiFi Initialiezd");

        // SmartConfigの完了を待つ
        while (!WiFi.smartConfigDone()) {
            delay(500);
            Serial.print(".");
            displayText("Please set up Wifi");
        }
        Serial.println("SmartConfig Done.");
        

        // WiFi接続の確立を待つ
        while (WiFi.status() != WL_CONNECTED) {
            delay(500);
            Serial.print(".");
            displayText("WiFi Connecting");
        }
        Serial.println("nWiFi Connected.");
        displayText("WiFi Connected");
    } else {
        Serial.println("WiFi is already connected.");
        displayText("WiFi is already connected");
    }
}


void safeModeLoop() {
  Serial.println("Initializing device...");
  displayText("Initializing device...");
  upgradeFirmware("0.1.13");
  displayText("Initialization complete");
  ESP.restart();
}

動作確認

起動時に、TTP223タッチセンサーを5秒以上押す。

すると、セーフモードに移行し、LCDディスプレイに「Safe Mode Active」の文字が表示される。その後、自動でWi-Fi接続を試みて、Wi-Fi未接続の時は「Please set up Wifi」の文字が表示される

「Please set up Wifi」の文字が表示された場合は、ユーザーはESP touchアプリを起動して、アプリからESP32に対してWi-Fi接続を行う。ESP touchアプリでは、ESP32との接続が完了すると完了のメッセージが表示される。

ESP32はそのままOTAアップデートを行う。OTAアップデートが完了したら「Initialization complete」の文字が表示される。

ShellScript
12:34:02.416 > Starting
12:34:02.416 > After Starting - Available heap size: 243100 bytes
12:34:07.418 > Entering Safe Mode...
12:34:07.418 > Entering Safe Mode: Limited functionality.
12:34:07.423 > Initializing display for Safe Mode...
12:34:08.642 > Connecting to WiFi with SSID and password from build flags...
12:34:08.651 > WiFi connecting ...
12:34:38.652 > WiFi connection failed
12:34:38.653 > Starting SmartConfig to setup WiFi...
12:34:38.655 > WiFi Disconnected.
12:34:39.240 > ................................................WiFi Connected.
12:35:04.744 > IP Address: 192.168.XX.XXX
12:35:05.019 > .......
12:35:08.282 > SmartConfig Done.
12:35:08.282 > 
12:35:08.282 > WiFi Connected.
12:35:08.314 > Initialize Firmware ...
12:35:08.349 > OTA firmware upgrade start
12:35:08.378 > Firmware update URL: https://XXX/1.0.0.bin
12:35:09.284 > Firmware update progress: 0%
12:35:09.920 > Firmware update progress: 1%
12:35:10.426 > Firmware update progress: 2%
...

12:36:26.589 > Firmware update progress: 99%
12:36:26.859 > Firmware update progress: 100%
12:36:28.469 > Firmware upgrade succeeded
12:36:28.512 > WiFi Disconnected.
12:36:30.134 > WiFi connecting ...
12:36:30.276 > WiFi Connected.
12:36:30.277 > IP Address: 192.168.XX.XXX
12:36:30.334 > WiFi connected
12:36:35.372 > Initialise eye objects
12:36:35.372 > Create display #0
12:36:35.375 > Create display #1
12:36:36.309 > Initialise Audio

その後、ESP32は再起動をして、正常動作に戻る。

タイトルとURLをコピーしました