カスタムボードガイド

このガイドでは、小智 AI 音声アシスタントのプロジェクトに新しいボードを追加する方法を説明します。小智 AI は多数の ESP32 シリーズのボードに対応しており、それぞれが main/boards/ 配下に専用のディレクトリを持っています。

重要

警告:IO の構成が既存のボードと異なるカスタムボードでは、元のボードの設定を決して上書きしないでください。必ず新しいボードタイプを作るか、config.json の builds 配列を使って、sdkconfig のマクロが異なる別のファームウェア名を生成してください。ファームウェアのビルドには python scripts/build.py [ボードのディレクトリ] を使います。

既存のボードの設定を上書きすると、OTA アップデートによって、自分のカスタムファームウェアが元のボード向けの標準ファームウェアに置き換えられるおそれがあり、危険です。すべてのボードに固有の識別情報と、専用のファームウェア更新チャネルが必要です。

ディレクトリ構成

ボードのディレクトリには、通常次のものが含まれます。

  • xxx_board.cc - ボード単位の初期化と連携コード。
  • config.h - ピン割り当てとボード単位の設定。
  • config.json - 報告するボードタイプと、CMake と scripts/build.py が読み込むリリース設定。
  • README.md - ボード固有の注意事項。

ボードは main/boards/ の直下に置くことも、main/boards/<manufacturer>/<board>/ のようにメーカーごとにまとめることもできます(下のメーカー別のサブディレクトリを参照)。

手順

1. ボードのディレクトリを作成する

main/boards/ 配下に、[vendor]-[model] の命名スタイル(例:m5stack-tab5)で新しいディレクトリを作ります。

mkdir main/boards/my-custom-board

2. 設定ファイルを作成する

config.h

ハードウェアの設定はすべて config.h に定義します。

  • オーディオのサンプルレートと I2S のピン割り当て。
  • オーディオコーデックの I2C アドレスとピン。
  • ボタンと LED のピン。
  • ディスプレイのパラメーターとピン。

例(lichuang-c3-dev より):

#ifndef _BOARD_CONFIG_H_
#define _BOARD_CONFIG_H_

#include <driver/gpio.h>

// Audio
#define AUDIO_INPUT_SAMPLE_RATE  24000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000

#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_10
#define AUDIO_I2S_GPIO_WS   GPIO_NUM_12
#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_8
#define AUDIO_I2S_GPIO_DIN  GPIO_NUM_7
#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_11

#define AUDIO_CODEC_PA_PIN       GPIO_NUM_13
#define AUDIO_CODEC_I2C_SDA_PIN  GPIO_NUM_0
#define AUDIO_CODEC_I2C_SCL_PIN  GPIO_NUM_1
#define AUDIO_CODEC_ES8311_ADDR  ES8311_CODEC_DEFAULT_ADDR

// Buttons
#define BOOT_BUTTON_GPIO        GPIO_NUM_9

// Display
#define DISPLAY_SPI_SCK_PIN     GPIO_NUM_3
#define DISPLAY_SPI_MOSI_PIN    GPIO_NUM_5
#define DISPLAY_DC_PIN          GPIO_NUM_6
#define DISPLAY_SPI_CS_PIN      GPIO_NUM_4

#define DISPLAY_WIDTH   320
#define DISPLAY_HEIGHT  240
#define DISPLAY_MIRROR_X true
#define DISPLAY_MIRROR_Y false
#define DISPLAY_SWAP_XY true

#define DISPLAY_OFFSET_X  0
#define DISPLAY_OFFSET_Y  0

#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_2
#define DISPLAY_BACKLIGHT_OUTPUT_INVERT true

#endif // _BOARD_CONFIG_H_

config.json

config.json は、互換性に影響する報告用のタイプを定義し、scripts/build.py を駆動します。

{
    "type": "my-custom-board",
    "target": "esp32s3",
    "builds": [
        {
            "name": "my-custom-board",
            "sdkconfig_append": [
                "CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y",
                "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\""
            ]
        }
    ]
}

フィールド:

  • type:ファームウェアが報告する、互換性に影響するボードファミリー。リリース後は変更しないでください。
  • target:ターゲットチップ。実際のハードウェアと一致していなければなりません(esp32、esp32s3、esp32c3、esp32c6、esp32p4 など)。
  • name:リリースビルドが報告する、互換性に影響するファームウェアバリアント名。通常は type と一致させます。
  • sdkconfig_append:既定値にマージされる、追加の sdkconfig の行。

type と name には、小文字の英字、数字、ピリオド(.)、ハイフン(-)だけを使えます。アンダースコア、スペース、大文字は使えません。

よく使う sdkconfig_append の項目:

// Flash size
"CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y"
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y"

// Partition table
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/4m.csv\""
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\""

// Audio pipeline
"CONFIG_USE_DEVICE_AEC=y"          // enable on-device AEC

プロジェクトの既定は、該当するターゲットで 16MB の Flash と partitions/v2/16m.csv です。有効なプロジェクトとターゲットの既定値とすでに一致している値は繰り返さないでください。sdkconfig_append は、実際にボード固有の上書きが必要な場合にだけ使います。

ボードの config.json で、言語や特定のウェイクワードを選んではいけません。これらはユーザーのビルドオプションで、CLI、エージェント、オンラインビルドが同じインターフェースを共有できるよう、menuconfig かビルドスクリプトのパラメーターで一貫して設定する必要があります。scripts/build.py はウェイクワードエンジンも選択するため、ボードやチップのプリセットに CONFIG_USE_*_WAKE_WORD の項目やモデルの選択を追加しないでください。メモリ、Flash、性能の理由でウェイクワード検出を無効にしなければならないボードは、sdkconfig_append に CONFIG_WAKE_WORD_DISABLED=y を設定できます。ビルドスクリプトはこの無効化を維持し、そのバリアントの wake_word_supported を false として報告します。

3. ボードクラスを実装する

ボード単位の実装を含む my_custom_board.cc を作成します。

基本的なボードクラスには、次のものがあります。

  1. クラス宣言:WifiBoard か Ml307Board から派生します。
  2. 初期化ヘルパー:I2C、ディスプレイ、ボタン、IoT/MCP ツールなど。
  3. 仮想関数のオーバーライド:GetAudioCodec()、GetDisplay()、GetBacklight() など。
  4. ボードの登録:DECLARE_BOARD(ClassName)。
#include "wifi_board.h"
#include "codecs/es8311_audio_codec.h"
#include "display/lcd_display.h"
#include "application.h"
#include "button.h"
#include "config.h"
#include "mcp_server.h"

#include <esp_log.h>
#include <driver/i2c_master.h>
#include <driver/spi_common.h>

#define TAG "MyCustomBoard"

class MyCustomBoard : public WifiBoard {
private:
    i2c_master_bus_handle_t codec_i2c_bus_;
    Button boot_button_;
    LcdDisplay* display_;

    void InitializeI2c() {
        i2c_master_bus_config_t i2c_bus_cfg = {
            .i2c_port = I2C_NUM_0,
            .sda_io_num = AUDIO_CODEC_I2C_SDA_PIN,
            .scl_io_num = AUDIO_CODEC_I2C_SCL_PIN,
            .clk_source = I2C_CLK_SRC_DEFAULT,
            .glitch_ignore_cnt = 7,
            .intr_priority = 0,
            .trans_queue_depth = 0,
            .flags = {
                .enable_internal_pullup = 1,
            },
        };
        ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &codec_i2c_bus_));
    }

    void InitializeSpi() {
        spi_bus_config_t buscfg = {};
        buscfg.mosi_io_num = DISPLAY_SPI_MOSI_PIN;
        buscfg.miso_io_num = GPIO_NUM_NC;
        buscfg.sclk_io_num = DISPLAY_SPI_SCK_PIN;
        buscfg.quadwp_io_num = GPIO_NUM_NC;
        buscfg.quadhd_io_num = GPIO_NUM_NC;
        buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t);
        ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
    }

    void InitializeButtons() {
        boot_button_.OnClick([this]() {
            auto& app = Application::GetInstance();
            if (app.GetDeviceState() == kDeviceStateStarting) {
                EnterWifiConfigMode();
                return;
            }
            app.ToggleChatState();
        });
    }

    void InitializeDisplay() {
        esp_lcd_panel_io_handle_t panel_io = nullptr;
        esp_lcd_panel_handle_t panel = nullptr;

        esp_lcd_panel_io_spi_config_t io_config = {};
        io_config.cs_gpio_num = DISPLAY_SPI_CS_PIN;
        io_config.dc_gpio_num = DISPLAY_DC_PIN;
        io_config.spi_mode = 2;
        io_config.pclk_hz = 80 * 1000 * 1000;
        io_config.trans_queue_depth = 10;
        io_config.lcd_cmd_bits = 8;
        io_config.lcd_param_bits = 8;
        ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI2_HOST, &io_config, &panel_io));

        esp_lcd_panel_dev_config_t panel_config = {};
        panel_config.reset_gpio_num = GPIO_NUM_NC;
        panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB;
        panel_config.bits_per_pixel = 16;
        ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel));

        esp_lcd_panel_reset(panel);
        esp_lcd_panel_init(panel);
        esp_lcd_panel_invert_color(panel, true);
        esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY);
        esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y);

        display_ = new SpiLcdDisplay(panel_io, panel,
                                    DISPLAY_WIDTH, DISPLAY_HEIGHT,
                                    DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y,
                                    DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY);
    }

    void InitializeTools() {
        // Register MCP tools here; see docs/mcp-usage.md.
    }

public:
    MyCustomBoard() : boot_button_(BOOT_BUTTON_GPIO) {
        InitializeI2c();
        InitializeSpi();
        InitializeDisplay();
        InitializeButtons();
        InitializeTools();
        GetBacklight()->SetBrightness(100);
    }

    virtual AudioCodec* GetAudioCodec() override {
        static Es8311AudioCodec audio_codec(
            codec_i2c_bus_,
            I2C_NUM_0,
            AUDIO_INPUT_SAMPLE_RATE,
            AUDIO_OUTPUT_SAMPLE_RATE,
            AUDIO_I2S_GPIO_MCLK,
            AUDIO_I2S_GPIO_BCLK,
            AUDIO_I2S_GPIO_WS,
            AUDIO_I2S_GPIO_DOUT,
            AUDIO_I2S_GPIO_DIN,
            AUDIO_CODEC_PA_PIN,
            AUDIO_CODEC_ES8311_ADDR);
        return &audio_codec;
    }

    virtual Display* GetDisplay() override {
        return display_;
    }

    virtual Backlight* GetBacklight() override {
        static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT);
        return &backlight;
    }
};

DECLARE_BOARD(MyCustomBoard);

4. ビルドシステムに組み込む

Kconfig の項目を追加する

main/Kconfig.projbuild の choice BOARD_TYPE ブロックに項目を追加します。

choice BOARD_TYPE
    prompt "Board Type"
    default BOARD_TYPE_BREAD_COMPACT_WIFI
    help
        Board type.

    # ... other entries ...

    config BOARD_TYPE_MY_CUSTOM_BOARD
        bool "My Custom Board"
        depends on IDF_TARGET_ESP32S3  # pick the matching target
endchoice

補足:

  • 識別子は大文字で、アンダースコア区切りにします。
  • depends on で、項目を正しいターゲット(IDF_TARGET_ESP32S3、IDF_TARGET_ESP32C3 など)に限定します。
  • ラベルはローカライズできます。

CMakeLists.txt に分岐を追加する

main/CMakeLists.txt を開き、ボードタイプの分岐に追加します。

elseif(CONFIG_BOARD_TYPE_MY_CUSTOM_BOARD)
    set(BOARD_DIR "my-custom-board")
    set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)     # pick a font for the display
    set(BUILTIN_ICON_FONT font_awesome_20_4)
    set(DEFAULT_EMOJI_COLLECTION twemoji_64)         // optional, for emoji display

フォントと絵文字の目安:

ディスプレイの解像度に合ったフォントサイズを選びます。

  • 小(128x64 OLED):font_puhui_basic_14_1 / font_awesome_14_1
  • 小〜中(240x240):font_puhui_basic_16_4 / font_awesome_16_4
  • 中(240x320):font_puhui_basic_20_4 / font_awesome_20_4
  • 大(480x320 以上):font_puhui_basic_30_4 / font_awesome_30_4

絵文字のコレクション:

  • twemoji_32 - 32x32 ピクセル(小さい画面向け)。
  • twemoji_64 - 64x64 ピクセル(大きい画面向け)。

5. ビルドして書き込む

方法 A - idf.py を手作業で使う

  1. ターゲットチップを設定します(初回、またはターゲットを切り替えるとき):

    idf.py set-target esp32s3     # ESP32-S3
    idf.py set-target esp32c3     # ESP32-C3
    idf.py set-target esp32       # ESP32
  2. 古い設定を消去します:

    idf.py fullclean
  3. menuconfig でボードを選びます:

    idf.py menuconfig

    Xiaozhi Assistant -> Board Type に進み、自分のボードを選びます。

  4. ビルドして書き込みます:

    idf.py build
    idf.py flash monitor

方法 B - build.py を使う(推奨)

ボードのディレクトリに config.json があれば、自動で設定してビルドできます。

python scripts/build.py my-custom-board

言語とウェイクワードの選択は、ユーザーのビルドオプションです。

python scripts/build.py my-custom-board \
  --language en-US \
  --wake-word wn9_jarvis_tts

プロジェクトの Kconfig の既定の言語は英語(en-US)です。scripts/build.py も、--language を省略すると、前回のビルドで別の言語を使っていた場合も含めて、英語を選びます。中国語にするには --language zh-CN を、それ以外は main/assets/locales/ にあるロケールを指定します。これでインターフェースの文言と音声プロンプトが選ばれます。対応するボードで --wake-word を省略すると、スクリプトは zh-CN と zh-TW では「你好小智」を、それ以外のすべての言語では「Hi ESP」を選びます。非対応のボードと、ボード側で明示的に無効化されているバリアントは、無効のままです。

--wake-word は、言語に基づく既定値を上書きします。ESP-SR のモデル名、nihaoxiaozhi、hiesp、disabled のいずれかを指定できます。2 つのエイリアスは、ターゲットに適合するモデルを選びます。ESP32-C3/C5/C6 のターゲットは WakeNet9s(wn9s_*)のモデルを使い、ESP32-S3/P4/S31 は AFE エンジンを使うため PSRAM が必要です。ESP32 も PSRAM が必要で、AFE なしのエンジンを使います。非対応のボードや明示的に無効化されたボードでウェイクワード検出を有効にしようとすると、SDK の設定の前に拒否されます。

カスタムと Emote の assets は UI のリソースを保持しますが、パッケージ内の音声モデルは、現在の sdkconfig で選択したモデルに置き換えられます(ウェイクワード検出が無効なら削除されます)。できあがったパッケージは assets パーティションに収まる必要があります。idf.py で直接ビルドするときは、menuconfig でウェイクワードの実装とモデルを明示的に選んでください。チップの既定値ではモデルは選ばれず、新しく手作業で設定した状態ではウェイクワード検出は無効のままです。

受け付ける値は、テキストでも機械可読な形式でも取得できます。

python scripts/build.py --list-languages
python scripts/build.py --list-languages --json
python scripts/build.py --list-wake-words
python scripts/build.py --list-wake-words --json

ウェイクワードの一覧は、現在解決されている ESP-SR コンポーネントから読み取られます。managed_components/ がまだ用意されていない場合は、先に idf.py reconfigure を実行してください。

このスクリプトは次のように動きます。

  • 引数なしで実行するとヘルプを表示します。ボードタイプとバリアントを一覧するには --list-boards を使います。
  • 選択したボードに複数のビルドがある場合は、バリアントを尋ねます。非対話の環境では --name <variant> を渡します。
  • config.json から target を読み取り、既存のビルドディレクトリは、ターゲットが異なる場合にだけ消去します。そのうえで、ターゲット、ボード名、既定値、選択したビルドの sdkconfig_append を、1 回の idf.py reconfigure 呼び出しでまとめて設定します。続く idf.py build はこの設定を再利用します。
  • 選択したビルドの name を、報告するファームウェアのバリアント名として渡します。
  • 既定では、ZIP を作らずに build/merged-binary.bin をビルドします。releases/v<version>_<name>.zip を作り直すには --zip を渡します。

Windows:コマンドラインが長すぎる

Windows でのビルドが、コンパイラのコマンドが 32,767 文字の上限を超えたために Ninja の CreateProcess エラーで失敗する場合は、CMake/Ninja のレスポンスファイルを有効にしてください。ビルドが失敗したのと同じプロジェクトのディレクトリと ESP-IDF のターミナルで、次を実行します。

idf.py -D CMAKE_NINJA_FORCE_RESPONSE_FILE=ON reconfigure
idf.py build

これでコンパイラの引数は、コマンドラインに展開される代わりに @file で渡されます。idf.py fullclean のあとや CMake のキャッシュを消したあとは、reconfigure のコマンドをもう一度実行してください。プロジェクトをより短いパス(例:E:\xz)に移すのも有効なことがあります。Windows の長いパスのサポートを有効にしても、コマンドラインの長さの上限は上がりません。#2340 での回避策を参照してください。

6. README を書く

README.md に、ボードの説明、ハードウェア要件、ビルド手順、特記事項を記載します。

メーカー別のサブディレクトリ

ボードは、main/boards/<manufacturer>/<board>/ のように、メーカーごとにまとめられます。1 つのベンダーが複数のバリアントを出荷しているときに推奨されるレイアウトです。例:main/boards/waveshare/esp32-p4-nano/、main/boards/lceda-course-examples/eda-tv-pro/。

メーカーのサブディレクトリにあるボードには、config.json に同じ値を追加します(例:"manufacturer": "waveshare")。ファームウェアはこれを board.type、board.name と合わせて board.manufacturer として報告します。このフィールドのないフラットなコミュニティのボードは、メーカーとして空の文字列を報告します。

BOARD_DIR には、main/boards/ からの完全な相対パスを設定します。

elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_NANO)
    set(BOARD_DIR "waveshare/esp32-p4-nano")
    set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
    set(BUILTIN_ICON_FONT font_awesome_30_4)
    set(DEFAULT_EMOJI_COLLECTION twemoji_64)

ビルドシステムは main/boards/${BOARD_DIR}/ からソースを読み込み、そのディレクトリの config.json から報告するボードタイプを読み取ります。config.json またはそのトップレベルの type がない場合は、BOARD_DIR 全体の / を - に置き換えたものが、フォールバックのタイプとして使われます。

経験則:

  • 同じベンダーのボードが 2 つ以上あり、ドライバー、assets、ドキュメントを共有するなら、メーカーのレイアウトを使います。
  • 単発のボードやコミュニティの例には、フラットなレイアウトを使います。
  • ディレクトリ名は小文字とダッシュにします(例:waveshare、lceda-course-examples)。

共通のボードコンポーネント

再利用できるコンポーネントがいくつか main/boards/common/ にあります。ボードクラスから直接インクルードできます。

ディスプレイドライバー

対応する LCD のファミリーには次のものがあります。

  • ST7789 (SPI)
  • ILI9341 (SPI)
  • SH8601 (QSPI)
  • ほか多数。

オーディオコーデック

  • Es8311AudioCodec(最も一般的)
  • Es8374AudioCodec
  • Es8388AudioCodec
  • Es8389AudioCodec
  • BoxAudioCodec(ESP-Box のボードで使われる、ES7210 マイクアレイとコーデックの組み合わせ)
  • NoAudioCodec(外部コーデックなしの直接 I2S)
  • DummyAudioCodec(音声のないボード向けのプレースホルダー)

電源管理

  • Axp2101 電源管理 IC のヘルパー。
  • Sy6970 バッテリー充電のヘルパー。
  • AdcBatteryMonitor - ADC を使った簡易的なバッテリー電圧モニター。
  • PowerSaveTimer / SleepTimer - ライトスリープのスケジューリングのヘルパー。

ネットワーク

  • WifiBoard - Wi-Fi 専用の基底クラス。
  • Ml307Board / Nt26Board - 4G モデムの基底クラス。
  • DualNetworkBoard - Wi-Fi / 4G を切り替えられる基底クラス。
  • RndisBoard - USB 経由の RNDIS ネットワーク(ESP32-S3 / ESP32-P4)。
  • EspVideo - ESP32-S3 / ESP32-P4 の ESP-Video 向けヘルパー。

入力ヘルパー

  • Button - 標準のプッシュボタン(クリック、長押し、複数回クリック)。
  • Knob - ロータリーエンコーダーのラッパー。
  • PressToTalkMcpTool - MCP を通じて自分自身を登録する、プッシュトゥトークのツール。
  • SystemReset - 起動時にボタンを押し続けると、安全にファクトリーリセットを行うヘルパー。

MCP 連携

どのボードも、スピーカー制御、画面の明るさ、バッテリー残量の取得、照明の制御などのカスタムツールを登録できます。MCP IoT 制御の使い方を参照してください。

ボードクラスの階層

  • Board - 基底クラス
    • WifiBoard - Wi-Fi 接続のボード
    • Ml307Board / Nt26Board - 4G モデムのボード
    • DualNetworkBoard - Wi-Fi と 4G を切り替えられるボード
    • RndisBoard - USB 経由の RNDIS のボード

ヒント

  1. 似たボードから始める - 既存のボードをコピーして調整するほうが、ゼロから始めるより通常は速いです。
  2. 段階的に立ち上げる - まずディスプレイ、次に音声、最後にスタック全体を動かします。
  3. ピン割り当てを再確認する - config.h で定義したすべてのピンが、回路図と一致していなければなりません。
  4. ハードウェアの互換性を確認する - 特にコーデック、PMIC、タッチコントローラーの組み合わせです。

トラブルシューティング

  1. ディスプレイの表示がおかしい - SPI の設定、ミラーリング、色の反転を確認します。
  2. 音が出ない - I2S の配線、PA のイネーブルピン、コーデックの I2C アドレスを確認します。
  3. Wi-Fi に接続できない - Wi-Fi の認証情報と設定方法をもう一度確認します。
  4. サーバーに接続できない - WebSocket / MQTT のエンドポイントの設定を確認します。

参考資料

  • ESP-IDF ドキュメント:https://docs.espressif.com/projects/esp-idf/
  • LVGL ドキュメント:https://docs.lvgl.io/
  • ESP-SR ドキュメント:https://github.com/espressif/esp-sr

出典:78/xiaozhi-esp32 の docs/custom-board.md