若该文为原创文章,转载请注明原文出处。

一、项目背景与定位

在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功能
SCKGPIO 6I2S位时钟(BCLK)
WSGPIO 5声道选择(WCLK)
SDGPIO 4串行数据输出

音频输出采用I2S标准模式扬声器,使用独立的I2S控制器:

信号GPIO功能
SCKGPIO 9I2S位时钟
WSGPIO 10声道选择
SDGPIO 11串行数据输出

I2S(Inter-IC Sound)是专为数字音频设计的串行总线协议,ESP32-S3内置两个独立的I2S控制器,可以分别驱动麦克风和扬声器,实现全双工音频流。

2.3 显示子系统

OLED显示屏(SSD1306,128×64像素)通过I2C总线连接:

信号GPIO功能
SDAGPIO 14I2C数据线
SCLGPIO 13I2C时钟线

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/SD6/5/4I2S音频输入
扬声器 SCK/WS/SD9/10/11I2S音频输出
OLED SDA/SCL14/13I2C显示控制
DHT11 DATA15单总线温湿度采集
继电器 IN47GPIO负载控制
按键 BOOT0GPIO配网/唤醒
板载 LED2GPIO状态指示

所有引脚互不冲突,音频链路(I2S)、显示链路(I2C)、传感链路(单总线)和控制链路(GPIO)物理隔离,保证了系统稳定性。

三、软件架构与核心流程

3.1 整体架构

AI VOX是小智AI的Arduino版本实现,采用分层架构设计。从上到下依次为:

  • 应用层:MCP工具注册与处理、显示控制、传感器读取

  • 引擎层:AI VOX Engine,管理状态机、WebSocket通信、事件分发

  • 设备层:音频输入/输出设备、显示设备、GPIO控制

  • 硬件层:ESP32-S3外设驱动

3.2 启动流程

系统启动依次经历以下阶段:

  1. 硬件初始化:配置LED、DHT11、继电器引脚

  2. 按键初始化:创建BOOT按键设备,注册配网回调

  3. 显示初始化:I2C总线初始化、SSD1306面板驱动、LVGL显示

  4. WiFi配置:优先使用预设SSID连接,失败则进入SmartConfig配网模式

  5. MCP工具注册:将音量、LED、传感器、继电器等工具注册到引擎

  6. 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 工具调用流程

当用户说出“现在温度是多少”时,完整调用链如下:

  1. 麦克风采集语音 → I2S传输 → 引擎上传云端

  2. 云端ASR识别文本 → LLM理解意图 → 匹配到self.sensor.get_temperature工具

  3. 云端通过WebSocket下发McpToolCallEvent

  4. 设备端主循环解析事件,执行对应处理函数

  5. 处理结果通过SendMcpCallResponse()返回云端

  6. 云端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");
        }
      }
      // --------------------------------------------------------------------------
    }
  }
}

如有侵权,或需要完整代码,请及时联系博主。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐