Руководство по пользовательским платам

В этом руководстве описано, как добавить новую плату в проект голосового помощника XiaoZhi AI. XiaoZhi AI поддерживает множество плат серии ESP32; у каждой есть собственный каталог в main/boards/.

Важно

Предупреждение: для пользовательской платы, конфигурация IO которой отличается от существующей, ни в коем случае не перезаписывайте конфигурацию исходной платы. Всегда создавайте новый тип платы — или используйте массив builds в config.json, чтобы получить отдельное имя прошивки с другими макросами 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 и выводы аудиокодека.
  • Выводы кнопок и светодиодов.
  • Параметры и выводы дисплея.

Пример (из 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

По умолчанию проект использует 16 МБ флеш-памяти и partitions/v2/16m.csv для подходящих целей. Не повторяйте значения, которые уже совпадают с действующими значениями по умолчанию для проекта и цели; используйте sdkconfig_append только для действительных переопределений, специфичных для платы.

Не выбирайте язык или конкретное слово пробуждения в config.json платы. Это пользовательские параметры сборки, и их нужно задавать единообразно через menuconfig или параметры скрипта сборки, чтобы CLI, агент и онлайн-сборки пользовались одним интерфейсом. scripts/build.py также выбирает движок слова пробуждения, поэтому не добавляйте записи CONFIG_USE_*_WAKE_WORD и выбор моделей в пресеты платы или чипа. Плата, на которой нужно отключить обнаружение слова пробуждения из-за ограничений памяти, флеш-памяти или производительности, может задать CONFIG_WAKE_WORD_DISABLED=y в sdkconfig_append. Скрипт сборки сохраняет этот отказ и сообщает для варианта 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

Рекомендации по шрифтам и эмодзи:

Выберите размер шрифта по разрешению дисплея:

  • Малый (OLED 128x64): 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. Два псевдонима выбирают модель, подходящую для цели. Цели ESP32-C3/C5/C6 используют модели WakeNet9s (wn9s_*); ESP32-S3/P4/S31 используют движок AFE и требуют PSRAM. ESP32 тоже требует PSRAM и использует движок без AFE. Запросы на включение обнаружения слова пробуждения на неподдерживаемой или явно отключённой плате отклоняются до настройки SDK.

Пользовательские assets и assets Emote сохраняют свои ресурсы интерфейса, но упакованные в них речевые модели заменяются моделями, выбранными в текущей 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>.
  • Читает target из config.json, очищает существующий каталог сборки только если его цель отличается, затем одним вызовом idf.py reconfigure настраивает цель, имя платы, значения по умолчанию и sdkconfig_append выбранной сборки. Следующий idf.py build использует эту конфигурацию.
  • Передаёт name выбранной сборки как сообщаемое имя варианта прошивки.
  • По умолчанию собирает build/merged-binary.bin без создания ZIP. Чтобы заново создать releases/v<version>_<name>.zip, передайте --zip.

Windows: слишком длинная командная строка

Если сборка в Windows завершается ошибкой Ninja CreateProcess из-за того, что команда компилятора превышает предел в 32 767 символов, включите файлы ответов (response files) 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>/. Это рекомендуемая раскладка, когда один производитель выпускает несколько вариантов — например, main/boards/waveshare/esp32-p4-nano/ или main/boards/lceda-course-examples/eda-tv-pro/.

Для платы в подкаталоге производителя добавьте в config.json то же значение, например "manufacturer": "waveshare". Прошивка сообщает его как board.manufacturer вместе с board.type и board.name. Плоские платы сообщества без этого поля сообщают пустую строку вместо производителя.

Задайте 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 с /, заменёнными на -.

Практические правила:

  • Используйте раскладку по производителям, если у вас две или больше платы одного производителя, у которых общие драйверы, assets или документация.
  • Для единичных плат и примеров сообщества используйте плоскую раскладку.
  • Имена каталогов — строчные буквы и дефисы (например, waveshare, lceda-course-examples).

Общие компоненты плат

В main/boards/common/ лежат несколько повторно используемых компонентов. Их можно подключать прямо из класса платы:

Драйверы дисплеев

Поддерживаются, в частности, такие семейства LCD:

  • ST7789 (SPI)
  • ILI9341 (SPI)
  • SH8601 (QSPI)
  • и многие другие.

Аудиокодеки

  • Es8311AudioCodec (самый распространённый)
  • Es8374AudioCodec
  • Es8388AudioCodec
  • Es8389AudioCodec
  • BoxAudioCodec (сочетание массива микрофонов ES7210 и кодека, используемое на платах ESP-Box)
  • NoAudioCodec (прямой I2S без внешнего кодека)
  • DummyAudioCodec (заглушка для плат без звука)

Управление питанием

  • Вспомогательные средства для микросхемы управления питанием Axp2101.
  • Вспомогательные средства для зарядного устройства Sy6970.
  • AdcBatteryMonitor — простой монитор напряжения батареи на основе ADC.
  • PowerSaveTimer / SleepTimer — помощники для планирования лёгкого сна.

Сеть

  • WifiBoard — базовый класс только для Wi-Fi.
  • Ml307Board / Nt26Board — базовые классы для 4G-модемов.
  • DualNetworkBoard — базовый класс с переключением Wi-Fi / 4G.
  • RndisBoard — сеть RNDIS поверх USB (ESP32-S3 / ESP32-P4).
  • Помощники EspVideo для ESP-Video на ESP32-S3 / ESP32-P4.

Вспомогательные средства ввода

  • Button — стандартные кнопки (щелчок, долгое нажатие, несколько щелчков).
  • Knob — обёртка над поворотным энкодером.
  • PressToTalkMcpTool — инструмент «нажми и говори», который регистрирует себя через MCP.
  • SystemReset — помощник, выполняющий безопасный сброс к заводским настройкам, когда кнопка удерживается при загрузке.

Интеграция с MCP

Любая плата может регистрировать пользовательские инструменты — управление динамиком, яркость экрана, показания батареи, управление освещением и т. д. См. Использование MCP для управления IoT.

Иерархия классов плат

  • Board — базовый класс
    • WifiBoard — плата с подключением по Wi-Fi
    • Ml307Board / Nt26Board — платы с 4G-модемом
    • DualNetworkBoard — плата с переключением Wi-Fi и 4G
    • RndisBoard — плата с RNDIS поверх USB

Советы

  1. Начните с похожей платы — копировать и подправлять существующую плату обычно быстрее, чем начинать с нуля.
  2. Запускайте по частям — сначала дисплей, затем звук, затем весь стек.
  3. Перепроверьте назначение выводов — каждый вывод, определённый в config.h, должен соответствовать вашей схеме.
  4. Проверьте совместимость оборудования — особенно сочетания кодека, PMIC и контроллера сенсора.

Устранение неполадок

  1. Дисплей работает неправильно — проверьте конфигурацию SPI, зеркалирование и инверсию цвета.
  2. Нет звука — проверьте проводку I2S, вывод включения PA и адрес I2C кодека.
  3. Не удаётся подключиться к Wi-Fi — перепроверьте учётные данные Wi-Fi и способ настройки.
  4. Не удаётся достучаться до сервера — проверьте настройки конечной точки WebSocket / MQTT.

Справочные материалы

Источник: docs/custom-board.md в 78/xiaozhi-esp32