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
buildsarray inconfig.jsonto produce a distinct firmware name with differentsdkconfigmacros. Usepython 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 andscripts/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-board2. 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 matchestype.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 AECThe 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:
- Class declaration: derive from
WifiBoardorMl307Board. - Initialization helpers: I2C, display, buttons, IoT/MCP tools, etc.
- Virtual overrides:
GetAudioCodec(),GetDisplay(),GetBacklight(), … - 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
endchoiceNotes:
- The identifier must be uppercase and underscore-separated.
depends onrestricts 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 displayFont 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
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 # ESP32Clean stale configuration:
idf.py fullcleanSelect the board via menuconfig:
idf.py menuconfigNavigate to
Xiaozhi Assistant -> Board Typeand choose your board.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-boardLanguage and wake-word selection are user build options:
python scripts/build.py my-custom-board \
--language en-US \
--wake-word wn9_jarvis_ttsThe 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 --jsonThe 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-boardsto list board types and variants. - Prompts for a variant when the selected board has multiple builds. In
non-interactive environments, pass
--name <variant>. - Reads
targetfromconfig.json, cleans an existing build directory only when its target differs, then configures the target, board name, defaults, and selected build’ssdkconfig_appendin oneidf.py reconfigurecall. The followingidf.py buildreuses that configuration. - Passes the selected build’s
nameas the reported firmware variant name. - Builds
build/merged-binary.binwithout creating a ZIP by default. Pass--zipto recreatereleases/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 buildThis 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)Es8374AudioCodecEs8388AudioCodecEs8389AudioCodecBoxAudioCodec(ES7210 mic array + codec combo used on ESP-Box boards)NoAudioCodec(direct I2S without external codec)DummyAudioCodec(placeholder for boards without audio)
Power management
Axp2101power management IC helpers.Sy6970battery 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).EspVideohelpers 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 classWifiBoard- WiFi-connected boardMl307Board/Nt26Board- 4G modem boardsDualNetworkBoard- WiFi + 4G switchable boardRndisBoard- RNDIS-over-USB board
Tips
- Start from a similar board - copying and tweaking an existing board is usually faster than starting from scratch.
- Bring up incrementally - get the display up first, then audio, then the full stack.
- Double check pin assignments - every pin defined in
config.hmust match your schematic. - Check hardware compatibility - especially codec / PMIC / touch controller combinations.
Troubleshooting
- Display looks wrong - verify SPI configuration, mirroring, and color inversion.
- No audio - check I2S wiring, PA enable pin, and codec I2C address.
- Cannot connect to WiFi - re-check WiFi credentials and provisioning method.
- Cannot reach the server - verify the WebSocket / MQTT endpoint configuration.
References
- ESP-IDF documentation: https://docs.espressif.com/projects/esp-idf/
- LVGL documentation: https://docs.lvgl.io/
- ESP-SR documentation: https://github.com/espressif/esp-sr