跳到主要内容

第 9 章 板级抽象:main/boards/

110 个板子目录、4250+ 行共用代码。这一层把芯片型号、网络栈(Wi-Fi vs 4G)、屏幕型号、按键布局、电源管理、外设差异全部封装在 Board 类层级里。本章先讲共用基础设施(common/),再挑 3 个代表性板子拆开看。


9.1 板级机制鸟瞰

                ┌───────────────────────────────────┐
│ Board (abstract base) │
│ GetUuid / GetAudioCodec / │
│ GetDisplay / GetLed / GetCamera │
│ GetNetwork / StartNetwork / │
│ GetSystemInfoJson / │
│ GetDeviceStatusJson / ... │
└────────────┬──────────────────────┘

┌────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌────────────────────┐
│ WifiBoard │ │ Ml307Board │ │ DualNetworkBoard │
│ (Wi-Fi) │ │ (4G Cat.1) │ │ (Wi-Fi + 4G) │
└────┬─────┘ └──────┬────────┘ └──────┬────────────┘
│ │ │
│ │ │ 内部持有
│ │ │ WifiBoard 和 Ml307Board
│ │ │ 切换调用
▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ 各板子的最终类(110 个 board.cc 文件) │
│ │
│ CompactWifiBoard : WifiBoard │
│ CompactMl307Board : DualNetworkBoard │
│ EspBox3Board : WifiBoard │
│ AtomEchoS3RBoard : WifiBoard │
│ AtkDnesp32s3Box2_4G : Ml307Board │
│ ... │
└──────────────────────────────────────────────────────────┘

共用零件 (在 main/boards/common/):
- Button 按键封装
- Backlight 背光控制
- PowerSaveTimer / SleepTimer
- SystemReset 长按复位
- PressToTalkMcpTool 按住说话 MCP 工具
- Esp32Camera 摄像头封装
- Axp2101 / Sy6970 电源管理芯片 I²C 驱动
- I2cDevice 通用 I²C 设备基类
- Knob 旋钮编码器
- LampController 灯控(MCP 演示用)
- AfskDemod 声波配网解调
- Blufi BLE 配网
- AdcBatteryMonitor ADC 测电池电压

110 个 boards/<xxx>/<xxx>_board.cc 都遵守一个套路:

  1. 继承 WifiBoard / Ml307Board / DualNetworkBoard
  2. 私有成员:按键对象、I²C bus、面板句柄、display 指针;
  3. Initialize 系列*:分别初始化 I²C / 显示屏 / 按键 / 工具;
  4. 重写GetLed() / GetAudioCodec() / GetDisplay()(这三个必须);
  5. 构造函数:按 init 函数顺序依次调用;
  6. DECLARE_BOARD(BoardClassName)注册类工厂。

9.2 Board 抽象基类详解

9.2.1 单例工厂模式

class Board {
public:
static Board& GetInstance() {
static Board* instance = static_cast<Board*>(create_board());
return *instance;
}
// ...
};

#define DECLARE_BOARD(BOARD_CLASS_NAME) \
void* create_board() { \
return new BOARD_CLASS_NAME(); \
}

create_board() 是个全局 C 函数,由具体板子在文件末尾用 DECLARE_BOARD(MyBoard) 宏定义。链接时只能有一个实现——所以编译时通过 CMake 选择板子

# main/CMakeLists.txt 大致
if(CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI)
set(BOARD_TYPE "bread-compact-wifi")
endif()
target_sources("${COMPONENT_LIB}" PRIVATE "${BUILD_PATH}/boards/${BOARD_TYPE}/${BOARD_TYPE}_board.cc")

Board::GetInstance()create_board() 返回新 new 的具体板子对象。裸 new + 永不 delete——程序运行期单例。

9.2.2 关键接口

class Board {
public:
virtual std::string GetBoardType() = 0; // "wifi" or "ml307"
virtual std::string GetUuid() { return uuid_; } // 软件 UUID
virtual Backlight* GetBacklight() { return nullptr; }
virtual Led* GetLed(); // 默认 NoLed
virtual AudioCodec* GetAudioCodec() = 0; // 必须
virtual bool GetTemperature(float& esp32temp);
virtual Display* GetDisplay(); // 默认 NoDisplay
virtual Camera* GetCamera(); // 默认 nullptr
virtual NetworkInterface* GetNetwork() = 0;
virtual void StartNetwork() = 0;
virtual void SetNetworkEventCallback(NetworkEventCallback);
virtual const char* GetNetworkStateIcon() = 0;
virtual bool GetBatteryLevel(int &level, bool& charging, bool& discharging);
virtual std::string GetSystemInfoJson();
virtual void SetPowerSaveLevel(PowerSaveLevel level) = 0;
virtual std::string GetBoardJson() = 0;
virtual std::string GetDeviceStatusJson() = 0;
};
接口默认实现设计意图
GetUuid返回 uuid_NVS 生成或读取
GetLedNoLed不会崩,板子可省略
GetDisplayNoDisplay同上
GetCameranullptrtake_photo 工具自动跳过
GetBacklightnullptrscreen.set_brightness 自动跳过
GetBatteryLevelfalse状态栏不显示电池
GetAudioCodec纯虚必须实现,没声音的板子 → NoAudioCodec
GetNetwork / StartNetwork / GetBoardJson 等纯虚网络栈相关,必须实现

9.2.3 Board::Board() 构造 —— UUID 生成

Board::Board() {
Settings settings("board", true);
uuid_ = settings.GetString("uuid");
if (uuid_.empty()) {
uuid_ = GenerateUuid();
settings.SetString("uuid", uuid_);
}
}

UUID 设计:

  • 首次启动 NVS 没有 → 调 GenerateUuid() 生成;
  • 后续启动直接读 NVS;
  • 即使设备重置 NVS(恢复出厂),下次开机会重新随机一次——所以 UUID 不是硬件唯一标识,MAC 才是

9.2.4 GenerateUuid() —— 标准 UUID v4

std::string Board::GenerateUuid() {
uint8_t uuid[16];
esp_fill_random(uuid, sizeof(uuid)); // ESP32 硬件 TRNG
uuid[6] = (uuid[6] & 0x0F) | 0x40; // 版本 4
uuid[8] = (uuid[8] & 0x3F) | 0x80; // 变体 1
char uuid_str[37];
snprintf(uuid_str, sizeof(uuid_str),
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
uuid[0], ..., uuid[15]);
return std::string(uuid_str);
}

按 RFC 4122 UUIDv4 规范:

  • 第 7 字节(uuid[6])高 4 位强制 0100(版本号 4);
  • 第 9 字节(uuid[8])高 2 位强制 10(变体 RFC 4122);
  • 其它 122 位由硬件 TRNG 填充(esp_fill_random 用芯片随机数发生器,质量好)。

输出形如 f47ac10b-58cc-4372-a567-0e02b2c3d479

9.2.5 GetSystemInfoJson() —— 设备自报家门

这个函数是 OTA 阶段 POST 给服务器的请求体,含:

  • 协议版本:"version": 2
  • 语言代码:从 Lang::CODE 拿(zh-CN / en-US / 日语等)
  • Flash 大小、剩余堆
  • MAC、UUID
  • 芯片型号(cores / revision / features 位)
  • 应用名 / 版本号 / 编译时间 / IDF 版本 / ELF SHA256
  • 分区表(label / type / address / size 每个分区)
  • 当前 OTA 运行分区 label
  • 显示信息(monochrome / width / height)
  • 板子自己附加的 JSON(GetBoardJson() 返回)

手写字符串拼接 JSON——不用 cJSON 因为是一次性序列化,自己拼性能更好:

json += R"("mac_address":")" + SystemInfo::GetMacAddress() + R"(",)";

R"(...)" 是 C++11 原始字符串字面量——\" 这种转义可以直接写 ",写 JSON 模板省心。

服务器拿到这堆信息可以做:

  • 按芯片型号下发不同 mqtt/websocket 配置;
  • 按版本号决定是否给新固件 URL;
  • 按 elf_sha256 做篡改检测(已知设备就该跑某版固件);
  • 按显示器类型给不同 emoji 资源包。

9.3 WifiBoard —— Wi-Fi 板基类

359 行实现,处理 Wi-Fi 板的核心逻辑:

9.3.1 状态机:连接 → 超时 → 配网

void WifiBoard::StartNetwork() {
auto& wifi_manager = WifiManager::GetInstance();
WifiManagerConfig config;
config.ssid_prefix = "Xiaozhi";
config.language = Lang::CODE;
wifi_manager.Initialize(config);

wifi_manager.SetEventCallback([this, &wifi_manager](WifiEvent event) {
// 把 WifiEvent 翻译成统一的 NetworkEvent
std::string ssid = wifi_manager.GetSsid();
switch (event) {
case WifiEvent::Connected: OnNetworkEvent(NetworkEvent::Connected, ssid); break;
// ...
}
});
TryWifiConnect();
}

void WifiBoard::TryWifiConnect() {
auto& ssid_manager = SsidManager::GetInstance();
bool have_ssid = !ssid_manager.GetSsidList().empty();
if (have_ssid) {
esp_timer_start_once(connect_timer_, CONNECT_TIMEOUT_SEC * 1000000ULL); // 60 秒超时
WifiManager::GetInstance().StartStation();
} else {
vTaskDelay(pdMS_TO_TICKS(1500)); // 等屏幕显示版本信息
StartWifiConfigMode();
}
}

void WifiBoard::OnWifiConnectTimeout(void* arg) {
auto* board = static_cast<WifiBoard*>(arg);
WifiManager::GetInstance().StopStation();
board->StartWifiConfigMode();
}

3 种状态:

  1. 有 SSID + 能连:60 秒内 Connected 触发 → esp_timer_stop 取消超时;
  2. 有 SSID 但连不上:60 秒到 → 进入配网模式;
  3. 没 SSID:等 1.5 秒(让屏幕显示完版本号)→ 进入配网模式。

9.3.2 三种配网方式 (Kconfig 任选)

void WifiBoard::StartWifiConfigMode() {
in_config_mode_ = true;
Application::GetInstance().SetDeviceState(kDeviceStateWifiConfiguring);

#ifdef CONFIG_USE_HOTSPOT_WIFI_PROVISIONING
// 方式 1: 设备开热点 + 网页配置
auto& wifi_manager = WifiManager::GetInstance();
wifi_manager.StartConfigAp();
Application::GetInstance().Schedule([&wifi_manager]() {
std::string hint = Lang::Strings::CONNECT_TO_HOTSPOT;
hint += wifi_manager.GetApSsid();
hint += Lang::Strings::ACCESS_VIA_BROWSER;
hint += wifi_manager.GetApWebUrl();
Application::GetInstance().Alert(Lang::Strings::WIFI_CONFIG_MODE, hint.c_str(), "gear", Lang::Sounds::OGG_WIFICONFIG);
});
#endif

#if CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING
// 方式 2: BLE 配网(手机 App)
Blufi::GetInstance().init();
#endif

#if CONFIG_USE_ACOUSTIC_WIFI_PROVISIONING
// 方式 3: 声波配网 (AFSK)
xTaskCreate([](void* arg) {
auto ch = reinterpret_cast<intptr_t>(arg);
audio_wifi_config::ReceiveWifiCredentialsFromAudio(&app, &wifi, disp, ch);
vTaskDelete(NULL);
}, "acoustic_wifi", 4096, reinterpret_cast<void*>(channel), 2, NULL);
#endif
}
配网方式实现用户操作
Hotspot 网页wifi_manager.StartConfigAp + HTTP server手机连 "Xiaozhi-xxx" 热点 → 浏览器输 SSID/密码
BLE BluFiEspressif BluFi 协议手机 App "EspBluFi" 蓝牙发 SSID/密码
声波 AFSK麦克风听 AFSK 调制信号解调手机 App 播放声音 → 设备麦克风收

三种可同时启用——用户选最方便的方式。声波最神奇:完全不需要 BLE/AP,弱网环境下也能搞定。

9.3.3 OnNetworkEvent —— 事件分发

void WifiBoard::OnNetworkEvent(NetworkEvent event, const std::string& data) {
switch (event) {
case NetworkEvent::Connected:
esp_timer_stop(connect_timer_); // 取消超时
Blufi::GetInstance().deinit(); // 释放 BluFi 资源
in_config_mode_ = false;
break;
case NetworkEvent::WifiConfigModeExit:
in_config_mode_ = false;
TryWifiConnect(); // 重新连
break;
// ...
}
if (network_event_callback_) {
network_event_callback_(event, data); // 转发给上层
}
}

业务层(Application)通过 SetNetworkEventCallback 注册自己的回调,所有网络事件都会过来。

9.3.4 GetDeviceStatusJson() —— MCP 工具用的状态查询

std::string WifiBoard::GetDeviceStatusJson() {
// 拼成类似:
// { "audio_speaker": {"volume": 70}, "screen": {"brightness": 50},
// "battery": {"level": 80, "charging": true}, "network": {"type": "wifi", "ssid": "xxx", "rssi": -55} }
// 返回给 self.get_device_status 工具
}

MCP 工具 self.get_device_status(第 6 章 6.7.2)的实现来源。

9.3.5 GetNetworkStateIcon()

const char* WifiBoard::GetNetworkStateIcon() {
if (in_config_mode_) return FONT_AWESOME_GEAR;
auto rssi = WifiManager::GetInstance().GetRssi();
if (rssi == 0) return FONT_AWESOME_WIFI_SLASH;
if (rssi > -50) return FONT_AWESOME_WIFI_HIGH;
if (rssi > -65) return FONT_AWESOME_WIFI_MEDIUM;
return FONT_AWESOME_WIFI_LOW;
}

屏幕顶部 network_label_ 显示什么 Font Awesome 图标。RSSI 阈值是经验值(-50 强、-65 中、其它弱)。


9.4 Ml307Board —— 4G 蜂窝板基类

274 行。基于上海移芯通信的 ML307R Cat.1 模块(UART AT 命令控制)。

特点(vs WiFi):

  • 无配网阶段——SIM 卡插上就能用,无需用户参与;
  • PPP 拨号上网:ESP32 通过 UART 跟 ML307 通信,ML307 跟基站建立 PPP 链路;
  • 网络速度有限(Cat.1 上行约 5Mbps 下行 10Mbps,但延迟稳定);
  • 状态比 Wi-Fi 多:SIM 卡未插 / 网络未注册 / 信号弱 / 漫游等。

9.4.1 Modem 检测和错误事件

enum class NetworkEvent {
// ... Wi-Fi 共用的事件
ModemDetecting, // 正在自动识别 baud rate + 模块型号
ModemErrorNoSim, // 没 SIM 卡
ModemErrorRegDenied, // 网络注册被运营商拒绝
ModemErrorInitFailed, // 初始化失败
ModemErrorTimeout // 超时
};

Ml307Board::StartNetwork() 内部依次:

  1. 初始化 UART(最常见 921600 baud);
  2. 发 AT 命令检测模块在线(响应 OK);
  3. 检测模块型号(AT+CGMM);
  4. 检测 SIM 卡状态(AT+CPIN?);
  5. 等待网络注册(AT+CREG? 返回 1 或 5 表示已注册本地或漫游);
  6. 启动 PPP;
  7. 触发 Connected 事件。

每一步失败都触发对应 ModemError* 事件,让屏幕显示具体原因。

9.4.2 GetNetwork() 返回的不同栈

NetworkInterface* WifiBoard::GetNetwork() {
return EspNetworkAdapter::GetInstance(); // ESP-IDF 自带 LWIP 栈
}

NetworkInterface* Ml307Board::GetNetwork() {
return Ml307AtModem::GetInstance(); // ML307 内置 TCP/IP 栈,通过 AT 命令调用
}

关键设计NetworkInterface 是抽象接口,定义了 CreateHttp / CreateWebSocket / CreateMqtt / CreateUdp 4 个工厂方法。Wi-Fi 板返回基于 LWIP 的实现,4G 板返回基于 ML307 AT 命令的实现——协议层完全感知不到差异

这就是为什么第 5 章 protocols 全部代码可以同时在 Wi-Fi 和 4G 板上跑。


9.5 DualNetworkBoard —— 双网切换

98 行,最简洁但用法巧妙。

9.5.1 思路

某些板子既有 Wi-Fi 又有 4G 模块(如 atk-dnesp32s3-box2-4g),用户可以选用其中之一:

  • 室内 Wi-Fi 信号好 → 用 Wi-Fi 省流量;
  • 户外 / Wi-Fi 不稳定 → 切到 4G 用流量包。

实现方式不是合并两套代码,而是内部包两个 sub-board

class DualNetworkBoard : public Board {
private:
std::unique_ptr<WifiBoard> wifi_board_;
std::unique_ptr<Ml307Board> ml307_board_;
NetworkType current_type_ = NetworkType::WIFI;

public:
DualNetworkBoard(int ml307_tx, int ml307_rx, gpio_num_t reset) {
// 创建两个内部子板
Settings settings("board", false);
std::string saved = settings.GetString("network_type", "wifi");
if (saved == "ml307") current_type_ = NetworkType::ML307;
}

Board& GetCurrentBoard() {
if (current_type_ == NetworkType::WIFI) return *wifi_board_;
else return *ml307_board_;
}

// 所有接口都转发给 GetCurrentBoard()
NetworkInterface* GetNetwork() override { return GetCurrentBoard().GetNetwork(); }
void StartNetwork() override { GetCurrentBoard().StartNetwork(); }
// ...

void SwitchNetworkType() {
current_type_ = (current_type_ == NetworkType::WIFI) ? NetworkType::ML307 : NetworkType::WIFI;
Settings settings("board", true);
settings.SetString("network_type", current_type_ == NetworkType::WIFI ? "wifi" : "ml307");
esp_restart(); // 简单粗暴:重启重新初始化
}
};

设计要点:

  • 当前网络类型存 NVS——下次启动直接用上次的;
  • 接口透传给当前 sub-board;
  • SwitchNetworkType() 直接重启——避免在运行时切换网络栈带来的复杂状态机问题(哪个网络断开、新的开起来、所有连接重建等)。重启代价就是几秒钟。

用户触发切换:长按某个按键、按某个组合键或菜单选项——具体哪个按键由板子决定。


9.6 共用零件:Button / Backlight / PowerSaveTimer

9.6.1 Button —— 6 种事件

class Button {
public:
Button(gpio_num_t gpio_num, bool active_high = false,
uint16_t long_press_time = 0, uint16_t short_press_time = 0,
bool enable_power_save = false);
void OnPressDown(std::function<void()>);
void OnPressUp(std::function<void()>);
void OnLongPress(std::function<void()>);
void OnClick(std::function<void()>);
void OnDoubleClick(std::function<void()>);
void OnMultipleClick(std::function<void()>, uint8_t click_count = 3);
};

底层基于 iot_button 组件(Espressif 提供的按键库):

  • 去抖:内部 debounce 算法过滤毛刺;
  • 6 类事件:按下/抬起/长按/单击/双击/多击;
  • enable_power_save:light-sleep 模式下保持响应(特殊配置);
  • active_high:按下为高电平还是低电平。

典型用法(compact_wifi_board.cc 第 103 行):

boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting) {
EnterWifiConfigMode();
return;
}
app.ToggleChatState(); // 启动后单击 boot 键开始对话
});

touch_button_.OnPressDown([this]() {
Application::GetInstance().StartListening();
});
touch_button_.OnPressUp([this]() {
Application::GetInstance().StopListening();
});

按住 touch 按键说话(PressDown → PressUp 配对),单击 boot 按键切换状态。

9.6.2 Backlight —— 屏幕背光控制

backlight.h

class Backlight {
public:
Backlight(...);
void SetBrightness(uint8_t brightness, bool save = false);
uint8_t GetBrightness() const;
void RestoreBrightness(); // 从 NVS 读回
};

背光是独立的 PWM GPIO(不是 LCD 的内部控制),用 LEDC 外设调节亮度。save = true 时写 NVS display.brightness,下次开机恢复。

9.6.3 PowerSaveTimer + SleepTimer

两个 timer 配合实现省电:

  • PowerSaveTimer:进入 idle 状态 N 秒后降频(CPU 80MHz → 40MHz);
  • SleepTimer:再 idle M 秒后进入 light_sleep(CPU 完全停,等中断唤醒)。
// 简化实现
class PowerSaveTimer {
void Start() { esp_timer_start_once(timer_, timeout * 1000000); }
void Stop() { esp_timer_stop(timer_); }
void OnTimer() {
auto state = Application::GetInstance().GetDeviceState();
if (state == kDeviceStateIdle) {
Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
}
}
};

非 Idle 状态(说话/听话/升级中)会重置 timer——保证不会在工作时降频。

电池供电板(atom-echo 等)一定要开省电否则跑不了几小时,USB 供电板可以选择性关闭。


9.7 代表板子 1:bread-compact-wifi(最小 Wi-Fi 麦克风)

定位:入门面包板套件。最便宜的配置——ESP32-S3 + OLED + 单麦克风 + 单喇叭 + 4 按键 + 1 WS2812B。

9.7.1 配置 (config.h)

#define AUDIO_INPUT_SAMPLE_RATE  16000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
#define AUDIO_I2S_METHOD_SIMPLEX

// I²S 麦克风(MEMS, 标准 SPH0645)
#define AUDIO_I2S_MIC_GPIO_WS GPIO_NUM_4
#define AUDIO_I2S_MIC_GPIO_SCK GPIO_NUM_5
#define AUDIO_I2S_MIC_GPIO_DIN GPIO_NUM_6

// I²S 喇叭(PCM5102 / MAX98357 等)
#define AUDIO_I2S_SPK_GPIO_DOUT GPIO_NUM_7
#define AUDIO_I2S_SPK_GPIO_BCLK GPIO_NUM_15
#define AUDIO_I2S_SPK_GPIO_LRCK GPIO_NUM_16

#define BUILTIN_LED_GPIO GPIO_NUM_48 // WS2812B
#define BOOT_BUTTON_GPIO GPIO_NUM_0
#define TOUCH_BUTTON_GPIO GPIO_NUM_47
#define VOLUME_UP_BUTTON_GPIO GPIO_NUM_40
#define VOLUME_DOWN_BUTTON_GPIO GPIO_NUM_39

#define DISPLAY_SDA_PIN GPIO_NUM_41
#define DISPLAY_SCL_PIN GPIO_NUM_42
#define DISPLAY_WIDTH 128

// Kconfig 选择哪种 OLED
#if CONFIG_OLED_SSD1306_128X32
#define DISPLAY_HEIGHT 32
#elif CONFIG_OLED_SSD1306_128X64
#define DISPLAY_HEIGHT 64
#elif CONFIG_OLED_SH1106_128X64
#define DISPLAY_HEIGHT 64
#define SH1106
#endif

#define LAMP_GPIO GPIO_NUM_18 // MCP 演示:控制一颗灯

Simplex I²S:用两组 I²S 引脚(一组麦克风 in,一组喇叭 out),不共享 BCLK/WS。逻辑简单,开发板布线灵活。

为什么 input 16k / output 24k

  • 输入 16k 够覆盖人声频段(8k 奈奎斯特),AFE/wake word 都按 16k 设计;
  • 输出 24k 是 LLM TTS 的常用配置(音色更好),但稍微多耗带宽。

9.7.2 实现 (compact_wifi_board.cc)

class CompactWifiBoard : public WifiBoard {
private:
i2c_master_bus_handle_t display_i2c_bus_;
esp_lcd_panel_io_handle_t panel_io_;
esp_lcd_panel_handle_t panel_;
Display* display_ = nullptr;
Button boot_button_;
Button touch_button_;
Button volume_up_button_;
Button volume_down_button_;

void InitializeDisplayI2c() {
i2c_master_bus_config_t bus_config = {
.i2c_port = 0,
.sda_io_num = DISPLAY_SDA_PIN,
.scl_io_num = DISPLAY_SCL_PIN,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags = { .enable_internal_pullup = 1 },
};
i2c_new_master_bus(&bus_config, &display_i2c_bus_);
}

void InitializeSsd1306Display() {
esp_lcd_panel_io_i2c_config_t io_config = {
.dev_addr = 0x3C, // SSD1306 I²C 地址固定 0x3C
.control_phase_bytes = 1,
.dc_bit_offset = 6,
.lcd_cmd_bits = 8,
.lcd_param_bits = 8,
.scl_speed_hz = 400 * 1000, // 400 kHz Fast Mode
};
esp_lcd_new_panel_io_i2c_v2(display_i2c_bus_, &io_config, &panel_io_);

esp_lcd_panel_dev_config_t panel_config = {};
panel_config.reset_gpio_num = -1;
panel_config.bits_per_pixel = 1;

esp_lcd_panel_ssd1306_config_t ssd1306_config = { .height = static_cast<uint8_t>(DISPLAY_HEIGHT) };
panel_config.vendor_config = &ssd1306_config;
#ifdef SH1106
esp_lcd_new_panel_sh1106(panel_io_, &panel_config, &panel_);
#else
esp_lcd_new_panel_ssd1306(panel_io_, &panel_config, &panel_);
#endif
esp_lcd_panel_reset(panel_);
if (esp_lcd_panel_init(panel_) != ESP_OK) {
display_ = new NoDisplay(); // 屏挂了 fallback
return;
}
esp_lcd_panel_disp_on_off(panel_, true);
display_ = new OledDisplay(panel_io_, panel_, DISPLAY_WIDTH, DISPLAY_HEIGHT, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y);
}

void InitializeButtons() {
boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting) {
EnterWifiConfigMode(); // 启动阶段单击进配网
return;
}
app.ToggleChatState(); // 已启动单击开始/结束对话
});
touch_button_.OnPressDown([this]() {
Application::GetInstance().StartListening();
});
touch_button_.OnPressUp([this]() {
Application::GetInstance().StopListening();
});
volume_up_button_.OnClick([this]() {
auto codec = GetAudioCodec();
auto volume = codec->output_volume() + 10;
if (volume > 100) volume = 100;
codec->SetOutputVolume(volume);
GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume));
});
volume_up_button_.OnLongPress([this]() {
GetAudioCodec()->SetOutputVolume(100);
GetDisplay()->ShowNotification(Lang::Strings::MAX_VOLUME);
});
// ... 类似 volume_down_button_
}

void InitializeTools() {
static LampController lamp(LAMP_GPIO); // 注册一个 MCP 工具控制 GPIO18 上的灯
}

public:
CompactWifiBoard() :
boot_button_(BOOT_BUTTON_GPIO),
touch_button_(TOUCH_BUTTON_GPIO),
volume_up_button_(VOLUME_UP_BUTTON_GPIO),
volume_down_button_(VOLUME_DOWN_BUTTON_GPIO) {
InitializeDisplayI2c();
InitializeSsd1306Display();
InitializeButtons();
InitializeTools();
}

Led* GetLed() override {
static SingleLed led(BUILTIN_LED_GPIO); // 单颗 WS2812B
return &led;
}

AudioCodec* GetAudioCodec() override {
static NoAudioCodecSimplex audio_codec(
AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE,
AUDIO_I2S_SPK_GPIO_BCLK, AUDIO_I2S_SPK_GPIO_LRCK, AUDIO_I2S_SPK_GPIO_DOUT,
AUDIO_I2S_MIC_GPIO_SCK, AUDIO_I2S_MIC_GPIO_WS, AUDIO_I2S_MIC_GPIO_DIN);
return &audio_codec;
}

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

DECLARE_BOARD(CompactWifiBoard);

典型流程

  1. 构造函数初始化 4 个按键句柄;
  2. 函数体里依次跑 4 个 Init 函数;
  3. 5 个虚函数重写(implicit Wi-Fi 网络)。

注意 LampController lamp(LAMP_GPIO) 是 static 局部变量——首次调用 InitializeTools() 时构造,构造里向 McpServer 注册一个 "灯开关" 工具,让 LLM 可以"打开桌上的灯"。这是项目自带的最小 MCP 演示。


9.8 代表板子 2:bread-compact-ml307(4G 双模版本)

跟 9.7 几乎一样,但继承 DualNetworkBoard

class CompactMl307Board : public DualNetworkBoard {
public:
CompactMl307Board() : DualNetworkBoard(ML307_TX_PIN, ML307_RX_PIN, GPIO_NUM_NC), ... { }
};

特有逻辑:

boot_button_.OnClick([this]() {
if (GetNetworkType() == NetworkType::WIFI) {
if (app.GetDeviceState() == kDeviceStateStarting) {
// 转换 wifi_board 调 EnterWifiConfigMode
auto& wifi_board = static_cast<WifiBoard&>(GetCurrentBoard());
wifi_board.EnterWifiConfigMode();
return;
}
}
app.ToggleChatState();
});
boot_button_.OnDoubleClick([this]() {
if (app.GetDeviceState() == kDeviceStateStarting || app.GetDeviceState() == kDeviceStateWifiConfiguring) {
SwitchNetworkType(); // 双击切换 Wi-Fi / 4G
}
});

双击切换网络类型,单击在 Wi-Fi 模式下进配网。static_cast<WifiBoard&> 是因为已经知道 GetNetworkType() == WIFI


9.9 代表板子 3:esp-box-3(旗舰彩屏板)

文件清单不一样:

esp-box-3/
├── config.h GPIO 定义
├── config.json 板子元数据
├── esp_box3_board.cc 板子实现(继承 WifiBoard)
├── emote.json EmoteDisplay 用的表情/图标/布局
├── layout.json UI 元素位置
└── README.md

特点:

  • 彩色 320×240 IPS LCD(ST7789 SPI 总线,走 SpiLcdDisplay 路径,但本板用 EmoteDisplay);
  • 电容触摸屏(GT911 I²C);
  • ES8311 音频编解码器(专业 codec 替代裸 I²S+MEMS 麦克风);
  • 背光 PWM 控制
  • 加速度计 BMI270(可选体感操作);
  • 更大 SPI Flash(16 MB) + PSRAM(必须,跑 LVGL 彩屏耗内存)。

esp_box3_board.cc 比 compact 板代码量大 3-4 倍,但套路完全一样:

  • I²C bus 初始化(共用一条 bus 接 GT911 + ES8311 + 加速度计);
  • LCD 初始化(SPI panel + ST7789 driver);
  • 触摸初始化;
  • 音频 codec 初始化(Es8311AudioCodec);
  • 按键(板上 3 个物理键);
  • Backlight;
  • 各种 MCP 工具注册。

由于代码量较大不全文复述。读者按需求点开 esp_box3_board.cc 看。关键认知:所有彩屏板都按"I²C bus + LCD panel + 触摸 + codec + 按键 + 工具"6 部分组织。


9.10 其它 107 个板子的简表

按命名前缀整理:

前缀数量类型代表
atk-dnesp32s3*7正点原子 S3 系列(含 4G)atk-dnesp32s3-box, atk-dnesp32s3m-4g
atom-* / atoms3*5M5Stack Atom Echo 系列atom-echos3r, atoms3-echo-base
bread-compact-*5面包板套件(最入门)bread-compact-wifi/-ml307/-lcd
esp-box*3Espressif 官方 Box 板esp-box, esp-box-3, esp-box-lite
df-*2DFRobot 系列df-k10, df-s3-ai-cam
esp32-*4普通 ESP32(非 S3)esp32-cgc, esp32-cgc-144
esp32-s3-touch-*多个触摸彩屏板s3-touch-amoled-1.8, s3-touch-lcd-1.46
doit-s3-aibox / du-chatx / echoear3设备厂私有板
esp-p4-function-ev-board1P4 评估板(最高端)
esp-s3-lcd-ev-board*2Espressif S3 RGB LCD 评估板
esp-spot1最便宜的 S3 套件
esp-hi1高端音质板
electron-bot1桌面机器人形态
esp-sensairshuttle1带传感器扩展板
esp-sparkbot1小机器人
其它 60+各种第三方板子看名字推测

每个板子大致这些信息

  • config.h:GPIO 定义 + 屏幕尺寸 + 采样率;
  • config.json:板子元数据(名字、描述、链接、屏幕信息);
  • <name>_board.cc:板子类实现(按 9.7 的套路);
  • 可选 emote.json / layout.json:EmoteDisplay 资源;
  • 可选 README.md:板子专属说明。

按 chip / 显示器 / 麦克风 / 喇叭 / 网络栈 / 电源 6 个维度去推断板子组合即可。


9.11 加 boards/<new_board>/ 添新板的最小步骤

  1. 复制最接近的板子目录作为模板,重命名;
  2. config.h:所有 GPIO + 屏幕尺寸 + 采样率改成新板;
  3. <name>_board.cc:类名 + 显示器 driver + codec 类型 + Init 函数;
  4. DECLARE_BOARD(NewBoardName) 在文件末尾;
  5. Kconfig.projbuildconfig BOARD_TYPE_<NEW_NAME>,并把它加入 choice BOARD_TYPE
  6. CMakeLists.txt 加对应的 if(CONFIG_BOARD_TYPE_<NEW_NAME>) set(BOARD_TYPE "<new_name>") endif()
  7. 更新 partitions/:根据 Flash 大小选 4MB / 8MB / 16MB 分区表;
  8. menuconfig 选板子 → 编译 → 烧录。

整个过程 100% 不动其他 109 个板子和上层代码。


9.12 本章用到的关键技术

技术应用
抽象基类 + 多层继承Board → WifiBoard → 具体板
DECLARE_BOARD 宏 + 链接选板编译时选板,零运行时开销
NetworkInterface 抽象Wi-Fi/4G 网络栈对协议层透明
std::unique_ptr 持有 sub-boardDualNetworkBoard 双栈
RFC 4122 UUIDv4 + esp_fill_random设备软件唯一标识
R"(...)" 原始字符串字面量手写 JSON 模板
NVS 持久化 network_type / uuid / brightness跨重启状态
iot_button + 6 类事件按键标准化
LEDC 外设 PWM 控背光屏幕亮度
I²C bus 共享多设备节省 GPIO(display + codec + touch 共一组 SDA/SCL)
esp_lcd_panel_* API屏幕驱动统一接口(SSD1306 / SH1106 / ST7789 / GC9A01 等)
dynamic_cast 检查子板类型DualNetworkBoard 切到 WifiBoard 调专属方法
Static 局部变量做 lazy initGetLed/GetAudioCodec 等的常见模式
3 种配网(Hotspot / BLE / 声波)+ Kconfig 同时启用灵活适配
Modem AT 命令 + PPP4G 板的网络初始化
PowerSaveTimer + SleepTimer自动降频 + light_sleep

9.13 看完本章你应该掌握的

  • Board 单例工厂 + DECLARE_BOARD 宏的工作原理
  • 4 个继承层级(Board / WifiBoard / Ml307Board / DualNetworkBoard)
  • 网络栈抽象 NetworkInterface 让协议层不感知 Wi-Fi vs 4G
  • Wi-Fi 板的连接→超时→配网状态机
  • 3 种配网方式(Hotspot / BluFi / Acoustic AFSK)
  • 4G 板的 Modem 检测和错误事件
  • DualNetworkBoard 双栈切换的"重启大法"
  • Board 基类的 UUID 生成逻辑
  • GetSystemInfoJson 手写 JSON 的内容结构
  • Button 6 类事件 + 典型用法
  • 一个具体板子(compact-wifi)的 Init* / GetXxx 6 个标准函数
  • 添加新板子的最小修改清单

下一章覆盖剩下的辅助资源:scripts/ / docs/ / partitions/ / Kconfig / CMake。