基于ESP32-S3的AI语音交互与环境控制系统:从语音助手到智能家居
若该文为原创文章,转载请注明原文出处。
一、项目背景与定位
在AIoT和边缘计算快速融合的背景下,ESP32-S3凭借其双核32位LX7处理器、Wi-Fi/蓝牙双模通信能力和丰富的外设接口,成为轻量级AI语音设备的理想平台。本项目的目标是在小智AI语音交互系统的基础上,集成DHT11温湿度传感器和继电器控制模块,构建一个既能语音对话、又能感知环境、还能控制外部设备的完整智能家居入口。
从技术架构来看,小智AI采用流式ASR(自动语音识别)+ LLM(大语言模型)+ TTS(文本转语音) 的云端协同架构,设备端负责音频采集与播放、唤醒词检测、设备控制,云端负责语义理解和响应生成。本项目的核心创新在于通过MCP(模型上下文协议) 将设备端的传感器和执行器暴露给AI大模型,使AI能够“感知”环境温度湿度并“操作”物理设备。
二、硬件系统设计
2.1 核心板选型
项目使用ESP32-S3-S3核心板作为主控,该核心板引出两排21脚排针(P2、P3),便于连接外围设备。其核心资源包括:
-
处理器:Xtensa双核32位LX7,最高240MHz,带单精度浮点单元
-
内存:512KB SRAM + 16KB RTC SRAM,支持最高16MB Octal PSRAM
-
存储:内置Flash,用于存储固件和离线语音模型
-
无线:2.4GHz Wi-Fi(802.11b/g/n)+ Bluetooth 5
-
GPIO:33个可编程GPIO,支持I2S、I2C、UART、SPI等丰富外设
2.2 音频子系统
音频输入采用I2S标准模式麦克风,引脚配置如下:
| 信号 | GPIO | 功能 |
|---|---|---|
| SCK | GPIO 6 | I2S位时钟(BCLK) |
| WS | GPIO 5 | 声道选择(WCLK) |
| SD | GPIO 4 | 串行数据输出 |
音频输出采用I2S标准模式扬声器,使用独立的I2S控制器:
| 信号 | GPIO | 功能 |
|---|---|---|
| SCK | GPIO 9 | I2S位时钟 |
| WS | GPIO 10 | 声道选择 |
| SD | GPIO 11 | 串行数据输出 |
I2S(Inter-IC Sound)是专为数字音频设计的串行总线协议,ESP32-S3内置两个独立的I2S控制器,可以分别驱动麦克风和扬声器,实现全双工音频流。
2.3 显示子系统
OLED显示屏(SSD1306,128×64像素)通过I2C总线连接:
| 信号 | GPIO | 功能 |
|---|---|---|
| SDA | GPIO 14 | I2C数据线 |
| SCL | GPIO 13 | I2C时钟线 |
I2C总线配置为400kHz高速模式,使用内部上拉电阻。显示屏用于实时展示AI对话状态、聊天内容和系统信息。
2.4 传感与执行子系统
DHT11温湿度传感器通过单总线协议与ESP32通信,数据引脚连接GPIO 15。DHT11采用单总线通信,数据收发共用一根信号线,通过严格的时序控制实现。其工作时序为:主机拉低总线至少18ms发送起始信号,然后释放总线等待DHT11响应,DHT11依次发送40位数据(湿度整数+湿度小数+温度整数+温度小数+校验和)。
继电器模块控制引脚连接GPIO 47,用于控制外部负载(如灯具)。GPIO 47为纯GPIO,无JTAG等复用功能,是理想的控制引脚。继电器模块需要独立5V供电,通过GPIO输出高低电平控制继电器线圈的通断,进而驱动负载。
2.5 引脚资源分配总览
| 功能模块 | 引脚 | 类型 | 说明 |
|---|---|---|---|
| 麦克风 SCK/WS/SD | 6/5/4 | I2S | 音频输入 |
| 扬声器 SCK/WS/SD | 9/10/11 | I2S | 音频输出 |
| OLED SDA/SCL | 14/13 | I2C | 显示控制 |
| DHT11 DATA | 15 | 单总线 | 温湿度采集 |
| 继电器 IN | 47 | GPIO | 负载控制 |
| 按键 BOOT | 0 | GPIO | 配网/唤醒 |
| 板载 LED | 2 | GPIO | 状态指示 |
所有引脚互不冲突,音频链路(I2S)、显示链路(I2C)、传感链路(单总线)和控制链路(GPIO)物理隔离,保证了系统稳定性。
三、软件架构与核心流程
3.1 整体架构
AI VOX是小智AI的Arduino版本实现,采用分层架构设计。从上到下依次为:
-
应用层:MCP工具注册与处理、显示控制、传感器读取
-
引擎层:AI VOX Engine,管理状态机、WebSocket通信、事件分发
-
设备层:音频输入/输出设备、显示设备、GPIO控制
-
硬件层:ESP32-S3外设驱动
3.2 启动流程
系统启动依次经历以下阶段:
-
硬件初始化:配置LED、DHT11、继电器引脚
-
按键初始化:创建BOOT按键设备,注册配网回调
-
显示初始化:I2C总线初始化、SSD1306面板驱动、LVGL显示
-
WiFi配置:优先使用预设SSID连接,失败则进入SmartConfig配网模式
-
MCP工具注册:将音量、LED、传感器、继电器等工具注册到引擎
-
AI引擎启动:创建音频输入设备,配置WebSocket连接,启动引擎
3.3 事件驱动主循环
主循环采用事件驱动模式,通过g_observer->PopEvents()获取引擎状态变化事件,依次处理:
-
TextReceivedEvent:收到云端识别的文本
-
ActivationEvent:设备激活码事件
-
StateChangedEvent:对话状态切换(Idle→Standby→Listening→Speaking)
-
EmotionEvent:AI情感表情
-
ChatMessageEvent:用户/助手的聊天消息
-
McpToolCallEvent:AI发起的工具调用请求
3.4 语音交互状态机
AI对话遵循严格的状态机:
text
Idle → Initted → Loading → Standby
↓
Connecting → Listening → Speaking → Standby
-
Standby:待命状态,等待唤醒词
-
Listening:聆听状态,麦克风采集音频上传云端
-
Speaking:说话状态,扬声器播放TTS音频
唤醒词“你好小智”由AI VOX引擎内部的wake_net模块独立检测,运行在独立的FreeRTOS任务中,不受主循环影响。
四、MCP工具调用机制
4.1 什么是MCP
MCP(模型上下文协议)是由Anthropic提出的开放协议,旨在标准化AI应用与外部工具和数据源之间的集成。MCP使用客户端-服务器架构,AI应用(主机)通过MCP客户端连接到多个MCP服务器,获取上下文数据或执行工具调用。
在小智AI项目中,设备端充当MCP服务器,将本地能力(音量控制、LED、传感器、继电器等)暴露为工具,云端AI大模型作为MCP客户端,通过WebSocket与设备端通信,实现自然语言到设备控制的映射。
4.2 工具注册
每个MCP工具通过AddMcpTool()注册,包含三个要素:
-
工具名称:如
self.sensor.get_temperature,采用self.前缀表示设备端工具 -
工具描述:自然语言描述工具功能,帮助大模型理解何时调用
-
参数模式:定义参数名称、类型(bool/string/int64_t)和约束(最小值、最大值、默认值)
例如继电器工具注册为:
engine.AddMcpTool(
"self.relay.set_state",
"Turn the connected light or relay on or off. "
"Use true to turn on the light, false to turn off. "
"This controls a relay connected to GPIO 47.",
{
{
"state",
ai_vox::ParamSchema<bool>{ .default_value = std::nullopt }
}
}
);
工具描述中明确指出了物理连接(GPIO 47),帮助AI在用户说“打开房间的灯”时正确匹配意图。
4.3 工具调用流程
当用户说出“现在温度是多少”时,完整调用链如下:
-
麦克风采集语音 → I2S传输 → 引擎上传云端
-
云端ASR识别文本 → LLM理解意图 → 匹配到
self.sensor.get_temperature工具 -
云端通过WebSocket下发
McpToolCallEvent -
设备端主循环解析事件,执行对应处理函数
-
处理结果通过
SendMcpCallResponse()返回云端 -
云端LLM生成自然语言响应 → TTS合成语音 → 设备端播放
4.4 DHT11缓存机制
DHT11单次读取需要约20-30ms的阻塞时间(单总线协议要求严格的时序控制),如果每次AI调用都实时读取,会阻塞主循环影响语音响应。为此代码实现了缓存机制:
void UpdateDhtCache(bool force = false) {
const uint32_t now = millis();
if (!force && g_last_dht_read_ms != 0 &&
(now - g_last_dht_read_ms) < kDhtRefreshIntervalMs) {
return; // 未到刷新时间,直接用缓存
}
g_last_dht_read_ms = now;
const float t = dht.readTemperature();
const float h = dht.readHumidity();
if (!std::isnan(t)) g_cached_temperature = t;
if (!std::isnan(h)) g_cached_humidity = h;
}
主循环每轮调用UpdateDhtCache(),但只在距上次读取超过2秒时才真正读取传感器。MCP工具调用时直接返回缓存值,几乎零延迟。如果缓存从未成功过(首次启动),则强制阻塞读取一次。
5 传感器与执行器集成
5.1 DHT11温湿度传感器
DHT11是一款低成本数字温湿度传感器,测量范围:温度0-50°C(±2°C精度),湿度20-90%RH(±5%精度)。其单总线协议要求:
-
总线空闲时由上拉电阻拉高
-
主机拉低总线至少18ms发送起始信号
-
主机释放总线20-40μs后读取DHT11的响应
-
DHT11发送40位数据(湿度8位+湿度小数8位+温度8位+温度小数8位+校验和8位)
由于SendMcpCallResponse()的响应类型只支持std::string、int64_t和bool,不支持浮点数,温度/湿度值通过snprintf()格式化为字符串后返回:
char buf[32];
snprintf(buf, sizeof(buf), "%.1f", g_cached_temperature);
engine.SendMcpCallResponse(mcp_tool_call_event->id, std::string(buf));
5.2 继电器控制
继电器模块通过GPIO 47控制。代码中定义了RELAY_ON_LEVEL和RELAY_OFF_LEVEL宏,适配不同触发电平的继电器模块:
#define RELAY_ON_LEVEL HIGH
#define RELAY_OFF_LEVEL LOW
初始化时调用gpio_reset_pin()释放引脚复用功能,然后配置为输出并默认关闭:
gpio_reset_pin(RELAY_PIN);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, RELAY_OFF_LEVEL);
继电器控制响应逻辑:
digitalWrite(RELAY_PIN, on ? RELAY_ON_LEVEL : RELAY_OFF_LEVEL);
5.3 音频提示音播放
系统启动阶段需要播放MP3提示音(配网模式、网络已连接等)。PlayMp3()函数通过esp_audio_simple_dec解码MP3数据,然后通过I2S输出PCM音频。为避免与AI引擎的音频输出冲突,函数中增加了播放锁:
static bool s_mp3_playing = false;
if (s_mp3_playing) {
printf("PlayMp3 skipped: another prompt is playing\n");
return;
}
提示音播放仅在setup()阶段(AI引擎启动前)使用,不存在并发冲突。
6 功能演示与使用场景
6.1 语音对话
系统上电后自动连接WiFi,进入待命状态。用户说出唤醒词“你好小智”,OLED屏幕切换为“聆听中”,LED状态更新。唤醒后可以说任何自然语言问题,如“今天天气怎么样”“讲个笑话”等,AI引擎通过云端大模型生成响应并通过扬声器播放。
6.2 环境感知
用户可以直接用自然语言查询环境数据:
-
“现在温度是多少?” → AI调用
self.sensor.get_temperature→ 返回温度值并语音播报 -
“湿度多少?” → AI调用
self.sensor.get_humidity→ 返回湿度值
6.3 设备控制
用户可以通过语音控制继电器:
-
“打开灯” → AI调用
self.relay.set_state(state=true)→ 继电器吸合,灯亮 -
“关灯” → AI调用
self.relay.set_state(state=false)→ 继电器断开,灯灭
6.4 音量控制
-
“音量调到50” → AI调用
self.audio_speaker.set_volume(volume=50) -
“现在音量多少” → AI调用
self.audio_speaker.get_volume
7 开发注意事项与优化建议
7.1 引脚选择
GPIO 1在部分ESP32-S3核心板上与串口调试功能共用,可能导致DHT11读取失败或串口乱码。建议使用纯GPIO(如GPIO 15、16、18)连接DHT11。GPIO 47为纯GPIO,无JTAG等复用功能,适合控制继电器。
7.2 电源管理
继电器模块线圈电流较大(通常70mA以上),必须使用外部5V电源驱动,不可从ESP32的3.3V引脚取电,否则可能导致系统复位。继电器模块的GND必须与ESP32共地。
7.3 DHT11读取稳定性
DHT11上电后需要约1秒稳定时间。如果启动后立即查询温度可能返回失败,等待片刻再试即可。代码中的UpdateDhtCache(true)会在缓存无效时自动强制读取一次。
7.4 继电器触发电平适配
不同继电器模块的触发电平不同。如果发现“开灯”操作导致灯关闭,说明触发电平相反,将RELAY_ON_LEVEL和RELAY_OFF_LEVEL对调即可。
8 总结
本文介绍了一个基于ESP32-S3的AI语音交互与环境控制系统的完整实现。系统以AI VOX引擎为核心,通过I2S总线实现音频输入输出,I2C总线驱动OLED显示,单总线协议读取DHT11温湿度,GPIO控制继电器,并通过MCP协议将设备能力暴露给云端大模型。
从技术架构来看,该方案实现了语音交互、环境感知、设备控制三位一体的功能整合。MCP工具调用机制是核心创新点,它使AI大模型能够自然语言理解用户意图并操作物理设备,为智能家居、环境监测、语音控制等场景提供了可落地的技术方案。
该系统的设计思路具有通用性:任何传感器或执行器都可以通过注册MCP工具的方式接入AI语音交互链路,开发者只需关注硬件驱动和工具描述,无需修改引擎核心代码。随着大模型能力的持续提升和边缘计算硬件的迭代,这种“语音+感知+控制”的架构将在智能家居和AIoT领域发挥更大的价值。
附代码:
#include <Arduino.h>
#include <WiFi.h>
#include <driver/i2c_master.h>
#include <esp_lcd_io_i2c.h>
#include <esp_lcd_panel_ops.h>
#include <esp_lcd_panel_ssd1306.h>
#include "ai_vox_engine.h"
#include "audio_device/audio_input_device_i2s_std.h"
#include "audio_device/audio_input_device_pdm.h"
#include "audio_device/audio_output_device_i2s_std.h"
#include "components/espressif/button/button_gpio.h"
#include "components/espressif/button/iot_button.h"
#include "components/espressif/esp_audio_codec/esp_audio_simple_dec.h"
#include "components/espressif/esp_audio_codec/esp_mp3_dec.h"
#include "components/wifi_configurator/wifi_configurator.h"
#include "display.h"
#include "network_config_mode_mp3.h"
#include "network_connected_mp3.h"
#include "notification_0_mp3.h"
#include <DHT.h>
#include <cmath>
#include <string>
#ifndef ARDUINO_ESP32S3_DEV
#error "This example only supports ESP32S3-Dev board."
#endif
#define AUDIO_INPUT_DEVICE_TYPE_PDM (0)
#define AUDIO_INPUT_DEVICE_TYPE_I2S_STD (1)
#define AUDIO_INPUT_DEVICE_TYPE AUDIO_INPUT_DEVICE_TYPE_I2S_STD
// #define AUDIO_INPUT_DEVICE_TYPE AUDIO_INPUT_DEVICE_TYPE_PDM
/**
* 如果开机自动连接WiFi而不需要配置WiFi, 请注释掉下面的宏, 然后请修改下面的WIFI_SSID和WIFI_PASSWORD
*
* ⚠️ 安全提示:请勿将真实 WiFi 密码提交到公开仓库,建议改用 menuconfig 或外部配置文件注入。
*/
#define WIFI_SSID "yifeng18"
#define WIFI_PASSWORD "112233445566"
// ============================================================
// 新增:传感器与执行器引脚定义
// ============================================================
#define DHT_PIN 15 // DHT11 数据引脚(P3 第 8 脚,纯 GPIO 无复用)
#define DHT_TYPE DHT11 // 传感器型号
// 注意:gpio_reset_pin() 参数类型是 gpio_num_t,
// 因此这里必须写成 GPIO_NUM_47 而不是 42。
#define RELAY_PIN GPIO_NUM_47 // 继电器/灯控制引脚(GPIO 39-42 默认为 JTAG,需在 setup 中释放)
// 继电器触发方式:
// 高电平触发模块:RELAY_ON_LEVEL = HIGH, RELAY_OFF_LEVEL = LOW
// 低电平触发模块:RELAY_ON_LEVEL = LOW, RELAY_OFF_LEVEL = HIGH
#define RELAY_ON_LEVEL HIGH
#define RELAY_OFF_LEVEL LOW
// 新增:DHT 对象
DHT dht(DHT_PIN, DHT_TYPE);
// ============================================================
// 新增:DHT 缓存(避免在 loop 中阻塞 20-30ms 影响 AI 实时响应)
// ============================================================
namespace {
constexpr uint32_t kDhtRefreshIntervalMs = 2000; // DHT11 最高 1Hz,用 2 秒间隔更稳
float g_cached_temperature = NAN;
float g_cached_humidity = NAN;
uint32_t g_last_dht_read_ms = 0;
/**
* 刷新 DHT 缓存。
* - 正常情况下由 loop() 每 2 秒调用一次,非阻塞(只在需要时真正读取)
* - force = true 时强制立即读取一次(首次读取或缓存无效时使用,会阻塞约 20-30ms)
*/
void UpdateDhtCache(bool force = false) {
const uint32_t now = millis();
if (!force && g_last_dht_read_ms != 0 && (now - g_last_dht_read_ms) < kDhtRefreshIntervalMs) {
return; // 未到刷新时间,直接用缓存
}
g_last_dht_read_ms = now;
const float t = dht.readTemperature();
const float h = dht.readHumidity();
if (!std::isnan(t)) g_cached_temperature = t;
if (!std::isnan(h)) g_cached_humidity = h;
}
} // namespace
namespace {
/**
* SC_TYPE_ESPTOUCH protocol: ESPTouch
* SC_TYPE_AIRKISS, protocol: AirKiss
* SC_TYPE_ESPTOUCH_AIRKISS, protocol: ESPTouch and AirKiss
* SC_TYPE_ESPTOUCH_V2, protocol: ESPTouch v2
*/
constexpr smartconfig_type_t kSmartConfigType = SC_TYPE_ESPTOUCH_AIRKISS; // ESPTouch and AirKiss
// Microphone pin configurations
#if AUDIO_INPUT_DEVICE_TYPE == AUDIO_INPUT_DEVICE_TYPE_I2S_STD
constexpr gpio_num_t kMicPinSck = GPIO_NUM_6; // SCK (BCK, BCLK): Serial-Data Clock for I²S Interface
constexpr gpio_num_t kMicPinWs = GPIO_NUM_5; // WS (WR, WCLK): Serial Data-Word Select for I²S Interface
constexpr gpio_num_t kMicPinSd = GPIO_NUM_4; // SD (DIN,DOUT, DI, DO, DATA): Serial-Data Output for I²S Interface
#elif AUDIO_INPUT_DEVICE_TYPE == AUDIO_INPUT_DEVICE_TYPE_PDM
constexpr gpio_num_t kMicPinSck = GPIO_NUM_15; // SCK (BCK, BCLK): Serial-Data Clock for I²S Interface
constexpr gpio_num_t kMicPinSd = GPIO_NUM_7; // SD (DIN,DOUT, DI, DO, DATA): Serial-Data Output for I²S Interface
#endif
constexpr gpio_num_t kSpeakerPinSck = GPIO_NUM_9; // SCK (BCK, BCLK): Serial-Data Clock for I²S Interface
constexpr gpio_num_t kSpeakerPinWs = GPIO_NUM_10; // WS (WR, WCLK): Serial Data-Word Select for I²S Interface
constexpr gpio_num_t kSpeakerPinSd = GPIO_NUM_11; // SD (DIN,DOUT, DI, DO, DATA): Serial-Data Output for I²S Interface
constexpr gpio_num_t kButtonBoot = GPIO_NUM_0;
constexpr gpio_num_t kI2cPinSda = GPIO_NUM_14;
constexpr gpio_num_t kI2cPinScl = GPIO_NUM_13;
constexpr gpio_num_t kLedPin = GPIO_NUM_2;
constexpr uint32_t kDisplayWidth = 128;
constexpr uint32_t kDisplayHeight = 64;
constexpr bool kDisplayMirrorX = true;
constexpr bool kDisplayMirrorY = true;
std::unique_ptr<Display> g_display;
auto g_observer = std::make_shared<ai_vox::Observer>();
auto g_audio_output_device = std::make_shared<ai_vox::AudioOutputDeviceI2sStd>(kSpeakerPinSck, kSpeakerPinWs, kSpeakerPinSd);
button_handle_t g_button_boot_handle = nullptr;
void InitDisplay() {
printf("InitDisplay\n");
i2c_master_bus_handle_t display_i2c_bus;
i2c_master_bus_config_t bus_config = {
.i2c_port = I2C_NUM_0,
.sda_io_num = kI2cPinSda,
.scl_io_num = kI2cPinScl,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.intr_priority = 0,
.trans_queue_depth = 0,
.flags =
{
.enable_internal_pullup = 1,
.allow_pd = false,
},
};
ESP_ERROR_CHECK(i2c_new_master_bus(&bus_config, &display_i2c_bus));
esp_lcd_panel_io_handle_t panel_io = nullptr;
esp_lcd_panel_io_i2c_config_t io_config = {
.dev_addr = 0x3C,
.on_color_trans_done = nullptr,
.user_ctx = nullptr,
.control_phase_bytes = 1,
.dc_bit_offset = 6,
.lcd_cmd_bits = 8,
.lcd_param_bits = 8,
.flags =
{
.dc_low_on_data = 0,
.disable_control_phase = 0,
},
.scl_speed_hz = 400 * 1000,
};
ESP_ERROR_CHECK(esp_lcd_new_panel_io_i2c_v2(display_i2c_bus, &io_config, &panel_io));
esp_lcd_panel_handle_t panel = nullptr;
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>(kDisplayHeight),
};
panel_config.vendor_config = &ssd1306_config;
ESP_ERROR_CHECK(esp_lcd_new_panel_ssd1306(panel_io, &panel_config, &panel));
ESP_ERROR_CHECK(esp_lcd_panel_reset(panel));
ESP_ERROR_CHECK(esp_lcd_panel_init(panel));
ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel, true));
g_display = std::make_unique<Display>(panel_io, panel, kDisplayWidth, kDisplayHeight, kDisplayMirrorX, kDisplayMirrorY);
g_display->Start();
}
#ifdef PRINT_HEAP_INFO_INTERVAL
void PrintMemInfo() {
if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) > 0) {
const auto total_size = heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
const auto free_size = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
const auto min_free_size = heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM);
printf("SPIRAM total size: %zu B (%zu KB), free size: %zu B (%zu KB), minimum free size: %zu B (%zu KB)\n",
total_size,
total_size >> 10,
free_size,
free_size >> 10,
min_free_size,
min_free_size >> 10);
}
if (heap_caps_get_total_size(MALLOC_CAP_INTERNAL) > 0) {
const auto total_size = heap_caps_get_total_size(MALLOC_CAP_INTERNAL);
const auto free_size = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
const auto min_free_size = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL);
printf("IRAM total size: %zu B (%zu KB), free size: %zu B (%zu KB), minimum free size: %zu B (%zu KB)\n",
total_size,
total_size >> 10,
free_size,
free_size >> 10,
min_free_size,
min_free_size >> 10);
}
if (heap_caps_get_total_size(MALLOC_CAP_DEFAULT) > 0) {
const auto total_size = heap_caps_get_total_size(MALLOC_CAP_DEFAULT);
const auto free_size = heap_caps_get_free_size(MALLOC_CAP_DEFAULT);
const auto min_free_size = heap_caps_get_minimum_free_size(MALLOC_CAP_DEFAULT);
printf("DRAM total size: %zu B (%zu KB), free size: %zu B (%zu KB), minimum free size: %zu B (%zu KB)\n",
total_size,
total_size >> 10,
free_size,
free_size >> 10,
min_free_size,
min_free_size >> 10);
}
}
#endif
/**
* 播放 MP3 提示音。
*
* ⚠️ 注意:本函数直接使用 g_audio_output_device,与 AI 引擎共享同一个 I2S 输出设备。
* 目前仅在 setup() 阶段(AI 引擎启动前)使用,因此不存在并发冲突。
* 如果将来需要在 loop() 中调用,请务必先确保 AI 引擎处于非 Speaking 状态,或增加互斥锁。
*/
void PlayMp3(const uint8_t* data, size_t size) {
static bool s_mp3_playing = false;
if (s_mp3_playing) {
printf("PlayMp3 skipped: another prompt is playing\n");
return;
}
s_mp3_playing = true;
auto ret = esp_mp3_dec_register();
if (ret != ESP_AUDIO_ERR_OK) {
printf("Failed to register mp3 decoder: %d\n", ret);
s_mp3_playing = false;
abort();
}
esp_audio_simple_dec_handle_t decoder = nullptr;
esp_audio_simple_dec_cfg_t audio_dec_cfg{
.dec_type = ESP_AUDIO_SIMPLE_DEC_TYPE_MP3,
.dec_cfg = nullptr,
.cfg_size = 0,
};
ret = esp_audio_simple_dec_open(&audio_dec_cfg, &decoder);
if (ret != ESP_AUDIO_ERR_OK) {
printf("Failed to open mp3 decoder: %d\n", ret);
esp_audio_dec_unregister(ESP_AUDIO_TYPE_MP3);
s_mp3_playing = false;
abort();
}
g_audio_output_device->OpenOutput(16000);
esp_audio_simple_dec_raw_t raw = {
.buffer = const_cast<uint8_t*>(data),
.len = size,
.eos = true,
.consumed = 0,
.frame_recover = ESP_AUDIO_SIMPLE_DEC_RECOVERY_NONE,
};
uint8_t* frame_data = (uint8_t*)malloc(4096);
esp_audio_simple_dec_out_t out_frame = {
.buffer = frame_data,
.len = 4096,
.needed_size = 0,
.decoded_size = 0,
};
while (raw.len > 0) {
const auto ret = esp_audio_simple_dec_process(decoder, &raw, &out_frame);
if (ret == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) {
out_frame.buffer = reinterpret_cast<uint8_t*>(realloc(out_frame.buffer, out_frame.needed_size));
if (out_frame.buffer == nullptr) {
break;
}
out_frame.len = out_frame.needed_size;
continue;
}
if (ret != ESP_AUDIO_ERR_OK) {
break;
}
g_audio_output_device->Write(reinterpret_cast<int16_t*>(out_frame.buffer), out_frame.decoded_size >> 1);
raw.len -= raw.consumed;
raw.buffer += raw.consumed;
}
free(frame_data);
g_audio_output_device->CloseOutput();
esp_audio_simple_dec_close(decoder);
esp_audio_dec_unregister(ESP_AUDIO_TYPE_MP3);
s_mp3_playing = false;
}
void ConfigureWifi() {
printf("configure wifi\n");
auto wifi_configurator = std::make_unique<WifiConfigurator>(WiFi, kSmartConfigType);
ESP_ERROR_CHECK(iot_button_register_cb(
g_button_boot_handle,
BUTTON_PRESS_DOWN,
nullptr,
[](void*, void* data) {
printf("boot button pressed\n");
static_cast<WifiConfigurator*>(data)->StartSmartConfig();
},
wifi_configurator.get()));
g_display->ShowStatus("网络配置中");
PlayMp3(kNotification0mp3, sizeof(kNotification0mp3));
#if defined(WIFI_SSID) && defined(WIFI_PASSWORD)
printf("wifi config start with wifi: %s, %s\n", WIFI_SSID, WIFI_PASSWORD);
wifi_configurator->Start(WIFI_SSID, WIFI_PASSWORD);
#else
printf("wifi config start\n");
wifi_configurator->Start();
#endif
while (true) {
const auto state = wifi_configurator->WaitStateChanged();
if (state == WifiConfigurator::State::kConnecting) {
printf("wifi connecting\n");
g_display->ShowStatus("网络连接中");
} else if (state == WifiConfigurator::State::kSmartConfiguring) {
printf("wifi smart configuring\n");
g_display->ShowStatus("配网模式");
PlayMp3(kNetworkConfigModeMp3, sizeof(kNetworkConfigModeMp3));
} else if (state == WifiConfigurator::State::kFinished) {
break;
}
}
iot_button_unregister_cb(g_button_boot_handle, BUTTON_PRESS_DOWN, nullptr);
printf("wifi connected\n");
printf("- mac address: %s\n", WiFi.macAddress().c_str());
printf("- bssid: %s\n", WiFi.BSSIDstr().c_str());
printf("- ssid: %s\n", WiFi.SSID().c_str());
printf("- ip: %s\n", WiFi.localIP().toString().c_str());
printf("- gateway: %s\n", WiFi.gatewayIP().toString().c_str());
printf("- subnet mask: %s\n", WiFi.subnetMask().toString().c_str());
g_display->ShowStatus("网络已连接");
PlayMp3(kNetworkConnectedMp3, sizeof(kNetworkConnectedMp3));
}
void InitMcpTools() {
auto& engine = ai_vox::Engine::GetInstance();
engine.AddMcpTool("self.audio_speaker.set_volume", // tool name
"Set the volume of the audio speaker.", // tool description
{
{
"volume", // parameter name
ai_vox::ParamSchema<int64_t>{
// parameter type can be bool, std::string or int64_t
.default_value = std::nullopt, // default value, set to std::nullopt if not specified
.min = 0, // minimum value, set to std::nullopt if not specified
.max = 100, // maximum value, set to std::nullopt if not specified
},
},
// add more parameter schema as needed
} // parameter schema
);
engine.AddMcpTool("self.audio_speaker.get_volume", // tool name
"Get the volume of the audio speaker.", // tool description
{
// empty
} // parameter schema
);
engine.AddMcpTool("self.led.set", // tool name
"Set the state of the LED, true for on, false for off.", // tool description
{
{
"state", // parameter name
ai_vox::ParamSchema<bool>{
// parameter type can be bool, std::string or int64_t
.default_value = std::nullopt, // default value, set to std::nullopt if not specified
}, // parameter type
},
// add more parameter schema as needed
} // parameter schema
);
engine.AddMcpTool("self.led.get", // tool name
"Get the state of the LED, true for on, false for off.", // tool description
{
// empty
} // parameter schema
);
// --- 新增:温湿度与继电器工具 ---
engine.AddMcpTool(
"self.sensor.get_temperature",
"Get the current ambient temperature in Celsius from the onboard DHT11 sensor.",
{}
);
engine.AddMcpTool(
"self.sensor.get_humidity",
"Get the current relative humidity percentage from the onboard DHT11 sensor.",
{}
);
engine.AddMcpTool(
"self.relay.set_state",
"Turn the connected light or relay on or off. "
"Use true to turn on the light, false to turn off. "
"This controls a relay connected to GPIO 42.",
{
{
"state",
ai_vox::ParamSchema<bool>{ .default_value = std::nullopt }
}
}
);
// --------------------------------
}
} // namespace
void setup() {
Serial.begin(115200);
printf("setup\n");
pinMode(kLedPin, OUTPUT);
digitalWrite(kLedPin, LOW);
// --- 新增代码:初始化传感器与执行器 ---
// 初始化 DHT11 传感器(上电后需稳定 ~1 秒,loop 中第一次读取如果失败会自动重试)
dht.begin();
// GPIO 39-42 默认为 JTAG 引脚,作为普通 GPIO 使用前需要先 reset
// 注意:RELAY_PIN 已定义为 GPIO_NUM_47(gpio_num_t 类型),可直接传入 gpio_reset_pin
gpio_reset_pin(RELAY_PIN);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, RELAY_OFF_LEVEL); // 默认关闭继电器
printf("init button\n");
const button_config_t btn_cfg = {
.long_press_time = 1000,
.short_press_time = 50,
};
const button_gpio_config_t gpio_cfg = {
.gpio_num = kButtonBoot,
.active_level = 0,
.enable_power_save = false,
.disable_pull = false,
};
ESP_ERROR_CHECK(iot_button_new_gpio_device(&btn_cfg, &gpio_cfg, &g_button_boot_handle));
InitDisplay();
g_display->ShowStatus("初始化");
ConfigureWifi();
InitMcpTools();
#if AUDIO_INPUT_DEVICE_TYPE == AUDIO_INPUT_DEVICE_TYPE_I2S_STD
auto audio_input_device = std::make_shared<ai_vox::AudioInputDeviceI2sStd>(kMicPinSck, kMicPinWs, kMicPinSd);
#elif AUDIO_INPUT_DEVICE_TYPE == AUDIO_INPUT_DEVICE_TYPE_PDM
auto audio_input_device = std::make_shared<ai_vox::PdmAudioInputDevice>(kMicPinSck, kMicPinSd);
#endif
auto& ai_vox_engine = ai_vox::Engine::GetInstance();
ai_vox_engine.SetObserver(g_observer);
ai_vox_engine.SetOtaUrl("https://api.tenclass.net/xiaozhi/ota/");
ai_vox_engine.ConfigWebsocket("wss://api.tenclass.net/xiaozhi/v1/",
{
{"Authorization", "Bearer test-token"},
});
printf("engine starting\n");
g_display->ShowStatus("AI引擎启动中");
ai_vox_engine.Start(audio_input_device, g_audio_output_device);
printf("engine started\n");
ESP_ERROR_CHECK(iot_button_register_cb(
g_button_boot_handle,
BUTTON_PRESS_DOWN,
nullptr,
[](void* button_handle, void* usr_data) {
printf("boot button pressed\n");
ai_vox::Engine::GetInstance().Advance();
},
nullptr));
g_display->ShowStatus("AI引擎已启动");
}
void loop() {
#ifdef PRINT_HEAP_INFO_INTERVAL
static uint32_t s_print_heap_info_time = 0;
if (s_print_heap_info_time == 0 || millis() - s_print_heap_info_time >= PRINT_HEAP_INFO_INTERVAL) {
s_print_heap_info_time = millis();
PrintMemInfo();
}
#endif
// --- 新增:周期刷新 DHT 缓存(每 2 秒最多阻塞 20-30ms,避免影响 AI 实时性)---
UpdateDhtCache();
// ---------------------------------------------------------------------------
auto& engine = ai_vox::Engine::GetInstance();
const auto events = g_observer->PopEvents();
for (auto& event : events) {
if (auto text_received_event = std::get_if<ai_vox::TextReceivedEvent>(&event)) {
printf("on text received: %s\n", text_received_event->content.c_str());
} else if (auto activation_event = std::get_if<ai_vox::ActivationEvent>(&event)) {
printf("activation code: %s, message: %s\n", activation_event->code.c_str(), activation_event->message.c_str());
g_display->ShowStatus("激活设备");
g_display->SetChatMessage(activation_event->message);
} else if (auto state_changed_event = std::get_if<ai_vox::StateChangedEvent>(&event)) {
switch (state_changed_event->new_state) {
case ai_vox::ChatState::kIdle: {
printf("Idle\n");
break;
}
case ai_vox::ChatState::kInitted: {
printf("Initted\n");
g_display->ShowStatus("初始化完成");
break;
}
case ai_vox::ChatState::kLoading: {
printf("Loading...\n");
g_display->ShowStatus("加载协议中");
break;
}
case ai_vox::ChatState::kLoadingFailed: {
printf("Loading failed, please retry\n");
g_display->ShowStatus("加载协议失败,请重试");
break;
}
case ai_vox::ChatState::kStandby: {
printf("Standby\n");
g_display->ShowStatus("待命");
break;
}
case ai_vox::ChatState::kConnecting: {
printf("Connecting...\n");
g_display->ShowStatus("连接中...");
break;
}
case ai_vox::ChatState::kListening: {
printf("Listening...\n");
g_display->ShowStatus("聆听中");
break;
}
case ai_vox::ChatState::kSpeaking: {
printf("Speaking...\n");
g_display->ShowStatus("说话中");
break;
}
default: {
break;
}
}
} else if (auto emotion_event = std::get_if<ai_vox::EmotionEvent>(&event)) {
printf("emotion: %s\n", emotion_event->emotion.c_str());
g_display->SetEmotion(emotion_event->emotion);
} else if (auto chat_message_event = std::get_if<ai_vox::ChatMessageEvent>(&event)) {
switch (chat_message_event->role) {
case ai_vox::ChatRole::kAssistant: {
printf("role: assistant, content: %s\n", chat_message_event->content.c_str());
g_display->SetChatMessage(chat_message_event->content);
break;
}
case ai_vox::ChatRole::kUser: {
printf("role: user, content: %s\n", chat_message_event->content.c_str());
g_display->SetChatMessage(chat_message_event->content);
break;
}
}
} else if (auto mcp_tool_call_event = std::get_if<ai_vox::McpToolCallEvent>(&event)) {
printf("on mcp tool call: %s\n", mcp_tool_call_event->ToString().c_str());
if ("self.audio_speaker.set_volume" == mcp_tool_call_event->name) {
const auto volume_ptr = mcp_tool_call_event->param<int64_t>("volume");
if (volume_ptr != nullptr) {
printf("on mcp tool call: self.audio_speaker.set_volume, volume: %" PRId64 "\n", *volume_ptr);
g_audio_output_device->set_volume(*volume_ptr);
engine.SendMcpCallResponse(mcp_tool_call_event->id, true);
} else {
engine.SendMcpCallError(mcp_tool_call_event->id, "Missing valid argument: volume");
}
} else if ("self.audio_speaker.get_volume" == mcp_tool_call_event->name) {
const auto volume = g_audio_output_device->volume();
printf("on mcp tool call: self.audio_speaker.get_volume, volume: %" PRIu16 "\n", volume);
engine.SendMcpCallResponse(mcp_tool_call_event->id, volume);
} else if ("self.led.set" == mcp_tool_call_event->name) {
const auto state_ptr = mcp_tool_call_event->param<bool>("state");
if (state_ptr != nullptr) {
printf("on mcp tool call: self.led.set, state: %d\n", *state_ptr);
digitalWrite(kLedPin, *state_ptr);
engine.SendMcpCallResponse(mcp_tool_call_event->id, true);
} else {
engine.SendMcpCallError(mcp_tool_call_event->id, "Missing valid argument: state");
}
} else if ("self.led.get" == mcp_tool_call_event->name) {
const auto state = digitalRead(kLedPin) == HIGH;
printf("on mcp tool call: self.led.get, state: %d\n", state);
engine.SendMcpCallResponse(mcp_tool_call_event->id, state);
}
// --- 新增:处理温湿度与继电器 ---
else if ("self.sensor.get_temperature" == mcp_tool_call_event->name) {
// 若缓存从未成功过,则强制阻塞读取一次;否则直接使用缓存
if (std::isnan(g_cached_temperature)) {
UpdateDhtCache(true);
}
if (std::isnan(g_cached_temperature)) {
engine.SendMcpCallError(mcp_tool_call_event->id, "DHT sensor not ready or read failed");
} else {
printf("Temperature (cached): %.1f C\n", g_cached_temperature);
// 注意:SendMcpCallResponse 只接受 std::string / int64_t / bool,
// 不支持 float,因此这里格式化为字符串(含单位)返回。
char buf[32];
snprintf(buf, sizeof(buf), "%.1f", g_cached_temperature);
engine.SendMcpCallResponse(mcp_tool_call_event->id, std::string(buf));
}
} else if ("self.sensor.get_humidity" == mcp_tool_call_event->name) {
if (std::isnan(g_cached_humidity)) {
UpdateDhtCache(true);
}
if (std::isnan(g_cached_humidity)) {
engine.SendMcpCallError(mcp_tool_call_event->id, "DHT sensor not ready or read failed");
} else {
printf("Humidity (cached): %.1f %%\n", g_cached_humidity);
char buf[32];
snprintf(buf, sizeof(buf), "%.1f", g_cached_humidity);
engine.SendMcpCallResponse(mcp_tool_call_event->id, std::string(buf));
}
} else if ("self.relay.set_state" == mcp_tool_call_event->name) {
const auto state_ptr = mcp_tool_call_event->param<bool>("state");
if (state_ptr != nullptr) {
const bool on = *state_ptr;
printf("on mcp tool call: self.relay.set_state, state: %d\n", on);
digitalWrite(RELAY_PIN, on ? RELAY_ON_LEVEL : RELAY_OFF_LEVEL);
engine.SendMcpCallResponse(mcp_tool_call_event->id, true);
} else {
engine.SendMcpCallError(mcp_tool_call_event->id, "Missing valid argument: state");
}
}
// --------------------------------------------------------------------------
}
}
}
如有侵权,或需要完整代码,请及时联系博主。
更多推荐



所有评论(0)