Custom Board Guide

This guide describes how to add a new board to the XiaoZhi AI voice assistant project. XiaoZhi AI supports a large number of ESP32-series boards; each one lives in its own directory under main/boards/.

Important

Warning: for a custom board whose IO configuration differs from an existing board, never overwrite the original board’s configuration. Always create a new board type - or use the builds array in config.json to produce a distinct firmware name with different sdkconfig macros. Use python scripts/build.py [board-directory] to build the firmware.

Overwriting an existing board’s configuration is dangerous because OTA updates may replace your custom firmware with the stock firmware for the original board. Every board must have a unique identity and its own firmware update channel.

Directory Layout

A board directory typically contains:

  • xxx_board.cc - board-level initialization and glue code.
  • config.h - pin assignments and board-level settings.
  • config.json - reported board type and release configuration consumed by CMake and scripts/build.py.
  • README.md - board-specific notes.

Boards can live directly under main/boards/ or be grouped by manufacturer under main/boards/<manufacturer>/<board>/ (see Manufacturer Sub-directories below).

Steps

1. Create the Board Directory

Create a new directory under main/boards/ using the [vendor]-[model] naming style (e.g. m5stack-tab5):

mkdir main/boards/my-custom-board

2. Create the Configuration Files

config.h

Define all hardware settings in config.h:

  • Audio sample rates and I2S pin mapping.
  • Audio codec I2C address and pins.
  • Button and LED pins.
  • Display parameters and pins.

Example (from 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 defines the compatibility-sensitive reported type and drives 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\""
            ]
        }
    ]
}

Fields:

  • type: compatibility-sensitive board family reported by the firmware. Keep it stable after release.
  • target: target chip, must match the real hardware (esp32, esp32s3, esp32c3, esp32c6, esp32p4, …).
  • name: compatibility-sensitive firmware variant name reported by release builds; typically matches type.
  • sdkconfig_append: extra sdkconfig lines merged into the defaults.

Both type and name must contain only lowercase letters, digits, periods (.), and hyphens (-). Underscores, spaces, and uppercase letters are not allowed.

Common sdkconfig_append entries:

// 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

The project defaults to 16MB flash and partitions/v2/16m.csv on applicable targets. Do not repeat values that already match the effective project and target defaults; use sdkconfig_append only for actual board-specific overrides.

Do not select a language or a specific wake word in a board config.json. Those are user build options and must be configured consistently through menuconfig or build-script parameters so CLI, agent, and online builds can share the same interface. scripts/build.py also selects the wake-word engine, so do not add CONFIG_USE_*_WAKE_WORD entries or model selections to board or chip presets. A board that must disable wake-word detection for memory, flash, or performance reasons may set CONFIG_WAKE_WORD_DISABLED=y in sdkconfig_append. The build script preserves this opt-out and reports the variant’s wake_word_supported as false.

3. Implement the Board Class

Create my_custom_board.cc containing the board-level implementation.

A basic board class has:

  1. Class declaration: derive from WifiBoard or Ml307Board.
  2. Initialization helpers: I2C, display, buttons, IoT/MCP tools, etc.
  3. Virtual overrides: GetAudioCodec(), GetDisplay(), GetBacklight(), …
  4. Board registration: 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. Hook Up the Build System

Add a Kconfig entry

In main/Kconfig.projbuild, add an entry to the choice BOARD_TYPE block:

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

Notes:

  • The identifier must be uppercase and underscore-separated.
  • depends on restricts the entry to the correct target (IDF_TARGET_ESP32S3, IDF_TARGET_ESP32C3, …).
  • The label can be localized.

Add a branch in CMakeLists.txt

Open main/CMakeLists.txt and extend the board-type chain:

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

Font and emoji guidance:

Pick a font size that matches the display resolution:

  • Small (128x64 OLED): font_puhui_basic_14_1 / font_awesome_14_1
  • Small-medium (240x240): font_puhui_basic_16_4 / font_awesome_16_4
  • Medium (240x320): font_puhui_basic_20_4 / font_awesome_20_4
  • Large (480x320+): font_puhui_basic_30_4 / font_awesome_30_4

Emoji collections:

  • twemoji_32 - 32x32 pixels (small screens).
  • twemoji_64 - 64x64 pixels (large screens).

5. Build and Flash

Option A - use idf.py manually

  1. Set the target chip (first time, or when switching targets):

    idf.py set-target esp32s3     # ESP32-S3
    idf.py set-target esp32c3     # ESP32-C3
    idf.py set-target esp32       # ESP32
  2. Clean stale configuration:

    idf.py fullclean
  3. Select the board via menuconfig:

    idf.py menuconfig

    Navigate to Xiaozhi Assistant -> Board Type and choose your board.

  4. Build and flash:

    idf.py build
    idf.py flash monitor

Option B - use build.py (recommended)

If the board directory contains a config.json, you can configure and build it automatically:

python scripts/build.py my-custom-board

Language and wake-word selection are user build options:

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

The project’s Kconfig default language is English (en-US). scripts/build.py also selects English when --language is omitted, including when the previous build used another language. Pass --language zh-CN for Chinese, or another locale listed under main/assets/locales/. This selects interface text and voice prompts. When --wake-word is omitted on a supported board, the script selects “你好小智” for zh-CN and zh-TW, and “Hi ESP” for every other language. Unsupported boards and variants with an explicit board opt-out remain disabled.

--wake-word overrides the language-based default. It accepts an ESP-SR model name, nihaoxiaozhi, hiesp, or disabled. The two aliases select a compatible model for the target. ESP32-C3/C5/C6 targets use WakeNet9s (wn9s_*) models; ESP32-S3/P4/S31 use the AFE engine and require PSRAM. ESP32 also requires PSRAM and uses the engine without AFE. Requests to enable wake-word detection on an unsupported or explicitly disabled board are rejected before SDK configuration.

Custom and Emote assets retain their UI resources, but their packed speech models are replaced with the models selected in the current sdkconfig (or removed when wake-word detection is disabled). The resulting package must fit the assets partition. When building directly with idf.py, select the wake-word implementation and models explicitly in menuconfig; chip defaults do not select a model, and a fresh manual configuration leaves wake-word detection disabled.

Query the accepted values in text or machine-readable form:

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

The wake-word list is read from the currently resolved ESP-SR component, so run idf.py reconfigure first if managed_components/ has not been populated.

The script:

  • Prints help when run without arguments. Use --list-boards to list board types and variants.
  • Prompts for a variant when the selected board has multiple builds. In non-interactive environments, pass --name <variant>.
  • Reads target from config.json, cleans an existing build directory only when its target differs, then configures the target, board name, defaults, and selected build’s sdkconfig_append in one idf.py reconfigure call. The following idf.py build reuses that configuration.
  • Passes the selected build’s name as the reported firmware variant name.
  • Builds build/merged-binary.bin without creating a ZIP by default. Pass --zip to recreate releases/v<version>_<name>.zip.

Windows: command line too long

If a Windows build fails with a Ninja CreateProcess error because the compiler command exceeds the 32,767-character limit, enable CMake/Ninja response files. In the same project directory and ESP-IDF terminal where the build failed, run:

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

This passes compiler arguments via an @file instead of expanding them on the command line. Repeat the reconfigure command after idf.py fullclean or clearing the CMake cache. Moving the project to a shorter path (for example E:\xz) may also help; enabling Windows long-path support does not raise the command-line length limit. See the workaround in #2340.

6. Write the README

In README.md, describe the board, hardware requirements, build instructions, and any special notes.

Manufacturer Sub-directories

Boards can be grouped by manufacturer under main/boards/<manufacturer>/<board>/. This is the recommended layout when a single vendor ships several variants - for example main/boards/waveshare/esp32-p4-nano/ or main/boards/lceda-course-examples/eda-tv-pro/.

For a board in a manufacturer sub-directory, add the same value to config.json, for example "manufacturer": "waveshare". The firmware reports it as board.manufacturer together with board.type and board.name. Flat community boards without this field report an empty manufacturer string.

Set BOARD_DIR to the complete path relative to 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)

The build system loads sources from main/boards/${BOARD_DIR}/ and reads the reported board type from that directory’s config.json. If config.json or its top-level type is absent, the full BOARD_DIR with / replaced by - is used as the fallback type.

Rules of thumb:

  • Use the manufacturer layout when you have two or more boards from the same vendor that share drivers, assets, or documentation.
  • Use the flat layout for one-off boards and community examples.
  • Directory names use lowercase with dashes (e.g. waveshare, lceda-course-examples).

Common Board Components

Several reusable components live in main/boards/common/. You can include them directly from your board class:

Display drivers

Supported LCD families include:

  • ST7789 (SPI)
  • ILI9341 (SPI)
  • SH8601 (QSPI)
  • and many more.

Audio codecs

  • Es8311AudioCodec (most common)
  • Es8374AudioCodec
  • Es8388AudioCodec
  • Es8389AudioCodec
  • BoxAudioCodec (ES7210 mic array + codec combo used on ESP-Box boards)
  • NoAudioCodec (direct I2S without external codec)
  • DummyAudioCodec (placeholder for boards without audio)

Power management

  • Axp2101 power management IC helpers.
  • Sy6970 battery charger helpers.
  • AdcBatteryMonitor - simple ADC-based battery voltage monitor.
  • PowerSaveTimer / SleepTimer - helpers for light-sleep scheduling.

Networking

  • WifiBoard - WiFi-only base class.
  • Ml307Board / Nt26Board - 4G modem base classes.
  • DualNetworkBoard - switchable WiFi / 4G base class.
  • RndisBoard - RNDIS-over-USB networking (ESP32-S3 / ESP32-P4).
  • EspVideo helpers for ESP-Video on ESP32-S3 / ESP32-P4.

Input helpers

  • Button - standard push buttons (click, long-press, multi-click).
  • Knob - rotary encoder wrapper.
  • PressToTalkMcpTool - push-to-talk tool that registers itself through MCP.
  • SystemReset - helper that performs a safe factory reset when a button is held at boot.

MCP integration

Any board can register custom tools - speaker control, screen brightness, battery readout, light control, etc. See MCP IoT control usage.

Board Class Hierarchy

  • Board - base class
    • WifiBoard - WiFi-connected board
    • Ml307Board / Nt26Board - 4G modem boards
    • DualNetworkBoard - WiFi + 4G switchable board
    • RndisBoard - RNDIS-over-USB board

Tips

  1. Start from a similar board - copying and tweaking an existing board is usually faster than starting from scratch.
  2. Bring up incrementally - get the display up first, then audio, then the full stack.
  3. Double check pin assignments - every pin defined in config.h must match your schematic.
  4. Check hardware compatibility - especially codec / PMIC / touch controller combinations.

Troubleshooting

  1. Display looks wrong - verify SPI configuration, mirroring, and color inversion.
  2. No audio - check I2S wiring, PA enable pin, and codec I2C address.
  3. Cannot connect to WiFi - re-check WiFi credentials and provisioning method.
  4. Cannot reach the server - verify the WebSocket / MQTT endpoint configuration.

References

Source: docs/custom-board.md in 78/xiaozhi-esp32