Claude Media
Claude CodeでESP32のOTAとロールバックを組み、build後に機械で確認する

Claude CodeでESP32のOTAとロールバックを組み、build後に機械で確認する

ESP32のOTAをesp_https_otaと2面パーティションで組み、ロールバック設定とパーティション表の整合をbuild後にスクリプトで確かめる手順です。

ESP32のOTA更新は、2つのアプリ用スロットとotadataパーティションをフラッシュに用意し、esp_https_otaで空いているほうのスロットへ新しいイメージを書き込む構成で成立します。失敗しても元のアプリに戻れるかどうかは、ロールバック設定とパーティション表が噛み合っているかで決まります。この2点は、Claude Codeにコードを書かせたあとのbuildの成果物から機械的に確かめられます。

この記事では、パーティション表、ロールバック設定、更新処理の3つをClaude Codeに組ませ、組み終えたあとに確認用スクリプトで合否を出させる流れを書きます。ESP-IDFの基本的なコマンド運用はClaude CodeでESP-IDFのファームウェアを開発する手順にあります。ここではOTAの構成だけに絞ります。

OTAが成立する条件は何か

OTAは、動作中のファームウェアが受け取ったデータで自分自身を更新する仕組みです。アプリの更新は「安全な更新モード」に当たり、途中で電源が落ちても現在のアプリが起動できる状態が保たれます。

成立させる条件は、ESP-IDFのOTAドキュメントでは次のとおりです。

  • パーティション表にota_0とota_1の2つのOTAアプリスロットがある
  • タイプdata・サブタイプotaのOTAデータパーティションがある
  • 新しいイメージは、現在ブートに選ばれていないほうのスロットへ書かれる
  • イメージの検証が済むと、OTAデータパーティションが更新され、次回起動で新しいイメージが使われる

ブートローダーやパーティション表、NVSなどのデータパーティションは「安全でない更新モード」に分類されます。更新中の電源断で起動不能になりうるため、この記事のアプリ更新とは別物として扱います。

ここで押さえたいのは、OTAデータパーティションの役割です。どのスロットを次に起動するかはアプリのバイナリではなく、このパーティションのota_seqカウンターが指します。サイズは2フラッシュセクタ(0x2000バイト)で、電源断が書き込み中に起きても壊れにくい形になっています。工場出荷時のようにこの領域が空(全バイト0xFF)なら、ブートローダーはfactoryアプリを、factoryが無ければ最初のOTAスロット(通常はota_0)を起動します。

パーティション表をClaude Codeに組ませる

カスタムCSVを使うなら、OTA用の最小構成は次の形です。ESP-IDFのパーティションテーブルの解説にある「Factory app, two OTA definitions」の入力CSVを、factoryなしの2面構成にした例です。

# Name,   Type, SubType, Offset,   Size,  Flags
nvs,      data, nvs,     0x9000,   0x4000
otadata,  data, ota,     0xd000,   0x2000
phy_init, data, phy,     0xf000,   0x1000
ota_0,    app,  ota_0,   0x10000,  1M
ota_1,    app,  ota_1,   ,         1M

Claude Codeに書かせるときに崩れやすいのは次の3点です。いずれもドキュメントに明記された制約なので、指示文にそのまま入れておきます。

  • appタイプのオフセットは0x10000(64KB)境界に揃える。空欄にするとgen_esp32part.pyが揃えて配置し、揃っていない値を明示するとエラーになる
  • otadataのサイズは0x2000にする
  • パーティション表は0x8000に置かれ、先頭に1セクタを使うため、後続のパーティションは0x9000以降になる

factoryアプリを残す構成も選べます。ただしロールバックの対象になるのはOTAスロットだけで、factoryパーティションは巻き戻されません。factoryを置くと、そのぶんフラッシュ容量を消費する点も設計時に比べておく必要があります。

CSVを編集させたら、idf.py partition-tableでサマリーを出させ、スロットの並びを目で確認させます。ただし、サマリーの出力を読むのはClaudeの判断に頼る確認です。後の節で、同じ内容を終了コードで返すスクリプトに置き換えます。

ロールバックを有効にし、確認のタイミングを選ぶ

アプリのロールバックは、新しいアプリに致命的な不具合があったときに直前の動作中アプリへ戻る機能です。CONFIG_BOOTLOADER_APP_ROLLBACKは既定で有効です。無効にすると、失敗したOTAから自動で戻る経路が無くなります。本番デバイスで無効にするのは強く非推奨です。

確認モードは2つあります。どちらも、新しいアプリをESP_OTA_IMG_PENDING_VERIFY状態で起動する点は同じで、違いは「いつ有効と確定するか」だけです。

くらべる

ロールバックの確認モード

既定

起動時に自動確認

IDF部品の初期化が終わった直後、app_mainを呼ぶ直前に有効と確定します。設定はCONFIG_BOOTLOADER_APP_ROLLBACK_CONFIRM_ON_STARTUPです。

CONFIRM_BY_APP

アプリが確認点を決める

アプリが明示的に確定または拒否するまで保留のままです。初回起動の検出と自己診断ができます。設定はCONFIG_BOOTLOADER_APP_ROLLBACK_CONFIRM_BY_APPです。

自動確認では、app_mainの中で行う初期化や診断は検証の対象になりません。確定がapp_mainより前に済むため、アプリ側からesp_ota_get_state_partition()を呼んでも初回起動かどうかは判別できません。センサーの初期化やサーバーへの接続を「動いた」の基準にしたいなら、アプリ側で確認するモードを選びます。

なお、モードを切り替えてもブートローダーの挙動は変わりません。確認の位置が変わるだけなので、ロールバック対応のブートローダーを作り直す必要はありません。

アプリ側で確認するときの標準的な書き方は、OTAドキュメントに次の形で載っています。

const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t ota_state;
if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) {
    if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
        if (diagnostic()) {
            esp_ota_mark_app_valid_cancel_rollback();
        } else {
            esp_ota_mark_app_invalid_rollback_and_reboot();
        }
    }
}

diagnostic()の中身はプロダクト固有です。Claude Codeに書かせるときは、「何が成功したら有効と見なすか」を先に人間が決めて指示に含めます。決めないまま任せると、常にtrueを返す診断が出来上がり、ロールバックの仕組みを置いただけの状態になります。

起動直後に電源断やクラッシュが起きた場合は、未確認のまま再起動したとみなされて巻き戻されます。推奨されているのは、自己診断をできるだけ早く終えることです。新しいアプリに与えられる機会は1回で、その間に確認点へ届かなければ次の起動でESP_OTA_IMG_ABORTEDになります。

esp_https_otaで更新処理を書く

更新処理は、esp_https_otaのドキュメントにある最小例に沿えば数行です。

esp_http_client_config_t config = {
    .url = CONFIG_FIRMWARE_UPGRADE_URL,
    .cert_pem = (char *)server_cert_pem_start,
};
esp_https_ota_config_t ota_config = {
    .http_config = &config,
};
esp_err_t ret = esp_https_ota(&ota_config);
if (ret == ESP_OK) {
    esp_restart();
}

成功したあとにesp_restart()を呼ぶのは呼び出し側の責任です。この関数はダウンロードからスロット切り替えまでを1回で扱うため、同じコンポーネントの他のAPIと混ぜて呼ばないでください。途中経過を制御したいときはesp_https_ota_begin以降のAPIに分けます。

Claude Codeに書かせる際に確認させたい点は次のとおりです。

  • 検証に使う証明書は、中間証明書ではなくサーバーのルート証明書にする。ルートのほうが有効期間が長く、長期間変わらないため
  • CONFIG_ESP_HTTPS_OTA_ALLOW_HTTPを有効にしない限り、http://のURLやリダイレクト先は失敗する
  • ロールバックが有効で、実行中のアプリがPENDING_VERIFYのままだと、新しいOTAの開始はESP_ERR_OTA_ROLLBACK_INVALID_STATEで失敗する。更新の前に、実行中のアプリが確認済みである必要がある
  • 大きなイメージの書き込みを速くしたい場合はbulk_flash_eraseが使えるが、パーティションが大きいとタスクウォッチドッグが働くため、タイムアウトを延ばす

3つ目は、ロールバックと更新ロジックの接点で見落とされやすい点です。確認処理が走らない構成のまま2回目の更新を試すと、更新側が弾かれます。

build後に機械で確認する

ここまでの内容を、人間とClaudeの「目視」に頼らず確かめるために、確認用スクリプトを1本置きます。入力はパーティションCSV、sdkconfig、任意でビルド済みのアプリ.binです。見るのはOTAドキュメントに書かれた条件そのものです。

#!/usr/bin/env python3
"""OTA構成の機械確認: python3 check_ota.py partitions.csv sdkconfig [app.bin]"""
import re, sys
from pathlib import Path
 
def size(text):
    m = re.fullmatch(r"(0x[0-9a-fA-F]+|\d+)([KkMm]?)", text.strip())
    if not m:
        return None
    return int(m.group(1), 0) * {"": 1, "k": 1024, "m": 1024 * 1024}[m.group(2).lower()]
 
rows = {}
for line in Path(sys.argv[1]).read_text().splitlines():
    line = line.split("#")[0].strip()
    if line:
        c = [x.strip() for x in line.split(",")] + [""] * 6
        rows[c[0]] = (c[1], c[2], c[3], size(c[4]))
 
cfg = Path(sys.argv[2]).read_text()
errors = []
 
od = rows.get("otadata")
if not od or od[:2] != ("data", "ota"):
    errors.append("otadata (data, ota) がありません")
elif od[3] != 0x2000:
    errors.append("otadata のサイズが 0x2000 ではありません")
 
slots = [n for n in ("ota_0", "ota_1") if rows.get(n, ("",))[0] == "app"]
if len(slots) < 2:
    errors.append("app の ota_0 / ota_1 が揃っていません")
for name, (typ, sub, off, sz) in rows.items():
    if typ == "app" and off and int(off, 0) % 0x10000:
        errors.append(f"{name} のオフセットが 0x10000 境界ではありません")
 
if not re.search(r"^CONFIG_BOOTLOADER_APP_ROLLBACK(_ENABLE)?=y", cfg, re.M):
    errors.append("アプリのロールバックが有効になっていません")
mode = "app" if re.search(r"^CONFIG_BOOTLOADER_APP_ROLLBACK_CONFIRM_BY_APP=y", cfg, re.M) else "startup"
print("confirm mode:", mode)
 
if len(sys.argv) > 3 and slots:
    binsize = Path(sys.argv[3]).stat().st_size
    smallest = min(rows[n][3] for n in slots)
    print(f"image {binsize} bytes / smallest slot {smallest} bytes")
    if binsize > smallest:
        errors.append("イメージが OTA スロットに収まりません")
 
print("\n".join("NG: " + e for e in errors) or "OK")
sys.exit(1 if errors else 0)

終了コードで合否を返すので、Claudeは出力を解釈する必要がありません。NGが出れば、その行がそのまま修正指示になります。スクリプトのCONFIG_BOOTLOADER_APP_ROLLBACKの判定を(_ENABLE)?付きの正規表現にしているのは、記号名がIDFのバージョンで異なる場合を考えた保険です。お使いのIDFが生成したsdkconfigで実際のキー名を確かめ、一致させておきます。

実行はbuildの直後に1行です。

idf.py build && python3 tools/check_ota.py partitions.csv sdkconfig build/app.bin

app.binはプロジェクト名に応じた名前になるため、実際のファイル名に置き換えます。

この確認を毎回の作業に組み込むには、プロジェクトのCLAUDE.mdに手順として書きます。CLAUDE.mdはセッション開始時に読み込まれる指示で、書き方はClaude Code settings.json完全ガイドの設定と併せて整えると運用しやすくなります。

## OTA構成の検証
- `partitions.csv` / `sdkconfig.defaults` / OTA関連のCソースを変えたら、必ず
  `idf.py build && python3 tools/check_ota.py partitions.csv sdkconfig build/app.bin` を実行する
- NGが出たら該当行を直し、OKになるまで繰り返す。OKが出る前に完了と報告しない
- `CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK` と eFuse に関わる設定は変更しない

権限の許可ルールは、.claude/settings.jsonにBash(python3 tools/check_ota.py *)とBash(idf.py *)を追加します。最後の行は、後の「つまずきやすい点」で理由を書くeFuse絡みの設定を、Claudeが勝手に触らないための歯止めです。

実機で確かめられること、確かめられないこと

スクリプトで見られるのは「構成が正しいか」までです。巻き戻りが本当に起きるかは、起動シーケンスなので実機が要ります。

確かめる手順は決まっています。アプリ側確認モードで、意図的に失敗する2つ目のバージョンを作ります。診断関数の頭でabort()を呼ぶ、または確認処理に到達する前に再起動させる、といった変更です。このイメージをOTAで書き込み、再起動後に元のバージョンで立ち上がれば、巻き戻りは機能しています。ドキュメントの説明のとおり、未確認のまま再起動したPENDING_VERIFYのアプリは次の起動でABORTEDとなり、選ばれなくなるためです。

対話セッションでシリアル出力を常時監視するより、ログをファイルに残して読ませるほうが扱いやすくなります。その手順はClaude CodeでESP32の開発とシリアルデバッグを進める手順にあります。書き込みやスロットの切り替えを手元で再現する場合は、otatool.pyが使えます。otatool.pyのCLIは次の形です。

otatool.py --port "/dev/ttyUSB1" read_otadata
otatool.py --port "/dev/ttyUSB1" switch_ota_partition --slot 1
otatool.py --port "/dev/ttyUSB1" erase_otadata

1行目がOTAデータの読み出し、2行目が起動スロットの切り替え、3行目がOTAデータの消去(factoryアプリ、無ければ最初のOTAスロットへ戻す操作)です。ポート名は環境に合わせます。erase_otadataはデバイスの状態を巻き戻す操作なので、Claudeに任せるなら対象のポートを指示で固定します。

つまずきやすい点

  • 自動確認の既定のまま診断を書いた: 診断コードがapp_mainの中にあっても、確定は先に済んでいる。巻き戻したいなら、アプリ側確認モードに切り替える
  • ロールバックを無効にしてしまった: CONFIG_BOOTLOADER_APP_ROLLBACKを無効にすると、確認モードの選択肢ごと消える。デバッグ中に切った設定が残らないよう、確認スクリプトのNGで拾う
  • アンチロールバックの安易な有効化: セキュリティバージョンはeFuseに記録される。ビットは1にすると0へ戻せない。さらにアンチロールバックでは自動確認が使えず、アプリが明示的に確認する必要がある
  • 更新の連続実行: 前回の更新が未確認のまま次の更新を始めると、ESP_ERR_OTA_ROLLBACK_INVALID_STATEで止まる
  • イメージが大きすぎる: スロットに収まらないイメージは書き込めない。スクリプトの最後の判定で、build直後に気づける

アンチロールバックの設定をCLAUDE.mdで触らせない理由は、取り消せないからです。他の設定ミスはビルドし直せば直りますが、eFuseは直せません。Electronアプリの更新を検証するClaude Codeでelectron-updaterを実装し、ローカル更新サーバーで検証するでも、配布の経路を手元で再現してから本番へ出す考え方を扱っています。ファームウェアでは、その再現が実機になります。

まとめ

OTAの構成ミスは、ビルドが通っても実機に載せるまで表に出ません。2面スロット、otadata、ロールバック設定、イメージサイズという機械で判定できる部分は、終了コード付きのスクリプトでbuild直後に落とせます。巻き戻りの挙動そのものは、わざと失敗するイメージを実機で通して確かめます。

人間が決めるべきなのは、自己診断で何を「有効」と見なすかと、eFuseを触る設定を許すかの2点です。この2つをCLAUDE.mdに明文化しておけば、Claude Codeに任せられる範囲が広がります。PlatformIOで同様のファームウェア開発を進めるならClaude CodeでPlatformIOのファームウェアを開発する手順も参考になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/ota.…
  2. docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/esp_…
  3. docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/partition-tabl…
  4. code.claude.com/docs/en/memory
  5. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn