MCP 网关:让 AI 语音助手控制局域网里的任何设备
问题
Ai-WV01-32S-Kit 是安信可的 AI 语音模块,出厂固件已烧录好,开箱即用——它有自己的 WiFi、麦克风、扬声器,跑 PalChat AI 固件,支持 UART-MCP 协议与外部 MCU 交互。用户不需要对 AI 模块做任何二次开发。
想让它关电脑上的 QQ、调客厅的灯、查询树莓派状态?做不到——除非有人帮它把"手"伸到局域网里其他设备上。
WT32-SC01 做 MCP 网关中转
我用 WT32-SC01 做中转网关,连接在 AI 模块和局域网设备之间:
- Ai-WV01-32S-Kit:出厂固件,不动它,只通过 UART 和网关通信
- WT32-SC01:跑的网关固件,同时承担三重角色
- 局域网设备:任意能跑 HTTP 服务的设备(电脑、树莓派、ESP32、智能灯)
WT32-SC01 的三重角色:
- UART-MCP 南向接口:通过串口连接 Ai-WV01-32S-Kit,向 AI 注册工具、接收指令、回传结果
- HTTP Hub 北向接口:局域网设备通过 REST API 注册自己的工具,mDNS 自动发现
- 代理转发引擎:AI 调远程设备工具时,WT32 异步入队 → HTTP 转发 → 结果回传 AI
┌─────────────────────┐
│ Ai-WV01-32S-Kit │
│ (出厂固件, 无需开发) │
│ 云端AI大脑 │
└──────────┬──────────┘
│ UART-MCP (JSON over Serial)
▼
┌─────────────────────┐
│ WT32-SC01 MCP 网关 │
│ ( 中转) │
│ │
│ ┌───────────────┐ │
│ │ 本地工具执行 │ │ screen.brightness / led.switch
│ └───────────────┘ │
│ ┌───────────────┐ │
│ │ 设备注册中心 │ │ POST/GET/DELETE /api/mcp
│ │ (HTTP + mDNS) │ │ _mcp-hub._tcp
│ └───────────────┘ │
│ ┌───────────────┐ │
│ │ 异步代理转发 │ │ remote.call → enqueue → HTTP POST → respond
│ └───────────────┘ │
└──────────┬──────────┘
│ WiFi (HTTP)
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 电脑 │ │ 智能灯 │ │ 树莓派 │
│ MCP服务 │ │ MCP服务 │ │ MCP服务 │
└──────────┘ └──────────┘ └──────────┘
对着 AI 说"关电脑上的 QQ",AI 模块通过 UART 调用网关注册的 remote.call 工具,网关转发到 PC 上的 MCP 服务执行。AI 固件零改动,局域网设备各写各的,网关负责翻译和路由。
网关核心:三层协议转换
WT32-SC01 网关的核心挑战是协议转换——Ai-WV01-32S-Kit 说 UART-MCP,局域网设备说 HTTP,两者之间需要一个翻译+路由引擎。AI 模块出厂固件已支持 UART-MCP 协议,网关只需要正确注册工具,AI 就能调用。
第一层:UART-MCP 南向接口
Ai-WV01-32S-Kit 出厂固件原生支持 UART-MCP 协议,通过串口发送 JSON 行消息。emMCP 库解析后触发回调,网关向 AI 注册 3 个 MCP 工具:
| 工具 |
类型 |
说明 |
screen.brightness |
本地 |
PWM 控制屏幕背光 0-100 |
led.switch |
本地 |
GPIO33 LED 开关 |
remote.call |
代理 |
转发到局域网远程设备 |
本地工具直接操作硬件,代理工具走异步转发引擎。
第二层:HTTP Hub 北向接口
局域网设备通过 REST API 注册到网关:
POST /api/mcp 注册/更新设备
GET /api/mcp 查询所有设备
DELETE /api/mcp?name= 删除设备
GET /api/health 健康检查
设备注册时声明自己的工具列表:
POST /api/mcp
{
"name": "my-pc",
"mcp_endpoint": "http://192.168.1.10:3000/mcp",
"tools": [
{"name": "qq.switch", "params": "{enable:bool}"},
{"name": "screen.brightness", "params": "{brightness:num 0-100}"}
]
}
网关收到注册后,立刻触发 MCP 工具重注册——重建 remote.call 的描述字符串,让 AI 模块看到最新的可用设备+工具清单。
第三层:异步代理转发引擎
这是网关最关键的部分。AI 调用 remote.call 时,数据流:
AI模块 ESP32 网关 远程设备
│ │ │
│ mcp_set remote.call │ │
│ {device, tool, params} │ │
├─────────────────────────►│ │
│ │ 回调: 验证参数 │
│ │ 查找设备 │
│ │ 入队 (不阻塞!) │
│ │ │
│ │ 主循环: 取队列请求 │
│ │ HTTP POST → mcp_endpoint │
│ ├───────────────────────────►│
│ │ │
│ │ 200 OK {result: ...} │
│ │◄───────────────────────────┤
│ │ │
│ mcp-responsive │ │
│ {result: ...} │ │
│◄─────────────────────────┤ │
关键技术实现
1. remote.call 代理模式——突破 5 工具上限
emMCP 库最多注册 5 个 MCP 工具。本地占了 2 个,只剩 3 个坑位,局域网里 10 台设备怎么接?
解法:一个 remote.call 代理工具搞定所有远程设备。AI 调用时传入 {device, tool, params},网关路由到目标设备。工具描述动态生成:
remote.call → "Call remote MCP device tools.
Devices: my-pc(qq.switch:{enable:bool},led.switch:{enable:bool}),
smart-light(set_color:{color:str,brightness:num})"
设备上下线时,描述字符串自动重建并重新注册。5 个坑位,无限扩展。
// mcp_proxy.cpp — 注册remote.call代理工具
void mcp_proxy_register_tool() {
mcp_proxy_build_description(); // 重建描述字符串(列出所有设备+工具)
mcp.addTool("remote.call", s_desc_buf ? s_desc_buf : "Call tools on remote MCP devices")
.addProperty("device", "Remote device name", EmMCPPropType_String)
.addProperty("tool", "Tool name on the remote device", EmMCPPropType_String)
.addProperty("params", "JSON object of tool parameters", EmMCPPropType_String)
.onSet(remoteCallSetHandler)
.onCheck(remoteCallCheckHandler);
}
动态描述字符串的构建——每次设备增删时重建,AI 模块据此生成正确的调用参数:
// mcp_proxy.cpp — 构建remote.call的description
void mcp_proxy_build_description() {
if (!s_desc_buf) return;
int pos = snprintf(s_desc_buf, MCP_PROXY_DESC_BUF_SIZE,
"Call remote MCP device tools. Devices: ");
int count = wifi_hub_device_count();
bool first = true;
for (int i = 0; i < count && pos < MCP_PROXY_DESC_BUF_SIZE - 2; i++) {
const McpDevice *dev = wifi_hub_get_device(i);
if (!dev) continue;
if (!first) pos += snprintf(s_desc_buf + pos, MCP_PROXY_DESC_BUF_SIZE - pos, ", ");
first = false;
pos += snprintf(s_desc_buf + pos, MCP_PROXY_DESC_BUF_SIZE - pos, "%s(", dev->name);
// 解析tools_json,支持两种格式:
// 简单格式: ["tool1","tool2"]
// 详细格式: [{"name":"tool1","params":"{enable:bool}"}, ...]
if (dev->tools_json[0]) {
cJSON *tools = cJSON_Parse(dev->tools_json);
if (tools && cJSON_IsArray(tools)) {
int arrSize = cJSON_GetArraySize(tools);
for (int j = 0; j < arrSize && pos < MCP_PROXY_DESC_BUF_SIZE - 2; j++) {
cJSON *item = cJSON_GetArrayItem(tools, j);
if (j > 0) pos += snprintf(s_desc_buf + pos, MCP_PROXY_DESC_BUF_SIZE - pos, ",");
if (cJSON_IsString(item)) {
pos += snprintf(s_desc_buf + pos, MCP_PROXY_DESC_BUF_SIZE - pos, "%s", item->valuestring);
} else if (cJSON_IsObject(item)) {
cJSON *tname = cJSON_GetObjectItemCaseSensitive(item, "name");
cJSON *tparms = cJSON_GetObjectItemCaseSensitive(item, "params");
if (tname && cJSON_IsString(tname)) {
pos += snprintf(s_desc_buf + pos, MCP_PROXY_DESC_BUF_SIZE - pos, "%s", tname->valuestring);
if (tparms && cJSON_IsString(tparms)) {
pos += snprintf(s_desc_buf + pos, MCP_PROXY_DESC_BUF_SIZE - pos, ":%s", tparms->valuestring);
}
}
}
}
}
if (tools) cJSON_Delete(tools);
}
pos += snprintf(s_desc_buf + pos, MCP_PROXY_DESC_BUF_SIZE - pos, ")");
}
}
2. 异步代理队列——不让 HTTP 请求冻住主循环
MCP 工具回调跑在 mcp.loop() 内部。如果回调里直接发 HTTP 请求(1-3 秒),UART 解析、LVGL 渲染、动画帧全部卡死。
解法:回调只入队,主循环每轮处理一个请求。队列深度 4,满了返回 proxy busy。整个系统保持响应,代理转发不阻塞。
// mcp_proxy.h — 队列结构
#define MCP_PROXY_QUEUE_SIZE 4 // 同时排队的最大代理请求数
#define MCP_PROXY_HTTP_TIMEOUT_MS 3000 // 转发HTTP请求超时
struct ProxyRequest {
char device[32]; // 目标设备名
char tool[64]; // 目标设备上的工具名
char params[256]; // 工具参数,JSON字符串
bool is_check; // true=mcp_check查询, false=mcp_set设置
bool active; // 队列槽位是否占用
};
回调——验证参数、查找设备、入队:
// mcp_proxy.cpp — remote.call回调
static void remoteCallSetHandler(cJSON *params) {
const char *device = extract_param_string(params, "device");
const char *tool = extract_param_string(params, "tool");
const char *pstr = extract_params_json(params, "params");
if (!device || !tool) {
EmMCP::respond("{\"error\":\"missing device or tool parameter\"}");
return;
}
// 在Hub设备注册表中查找,确认设备存在
int dev_count = wifi_hub_device_count();
bool found = false;
for (int i = 0; i < dev_count; i++) {
const McpDevice *dev = wifi_hub_get_device(i);
if (dev && strcmp(dev->name, device) == 0) { found = true; break; }
}
if (!found) {
char err[64];
snprintf(err, sizeof(err), "{\"error\":\"device '%s' not found\"}", device);
EmMCP::respond(err);
return;
}
// 入队,等主循环处理(不阻塞回调)
if (!mcp_proxy_enqueue(device, tool, pstr ? pstr : "{}", false)) {
EmMCP::respond("{\"error\":\"proxy busy\"}");
}
}
主循环中处理队列——每次只取 1 个请求,HTTP 转发后立即返回:
// mcp_proxy.cpp — 主循环中调用
void mcp_proxy_loop() {
for (int i = 0; i < MCP_PROXY_QUEUE_SIZE; i++) {
if (!s_queue[i].active) continue;
ProxyRequest &req = s_queue[i];
const char *endpoint = find_device_endpoint(req.device);
if (!endpoint) {
EmMCP::respond("{\"error\":\"device not found\"}");
req.active = false;
continue;
}
// 构造转发JSON: {"tool":"xxx","params":{...}}
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "tool", req.tool);
cJSON *params_json = cJSON_Parse(req.params);
if (params_json) {
cJSON_AddItemToObject(root, "params", params_json);
} else {
cJSON_AddRawToObject(root, "params", req.params);
}
char *body = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
// HTTP POST到远程设备(阻塞,最长3秒)
HTTPClient http;
WiFiClient client;
http.begin(client, endpoint);
http.addHeader("Content-Type", "application/json");
http.setTimeout(MCP_PROXY_HTTP_TIMEOUT_MS);
int httpCode = http.POST(body);
cJSON_free(body);
if (httpCode > 0) {
String response = http.getString();
// 通过PSRAM缓冲区转发响应,避免String临时对象栈溢出
if (s_resp_buf) {
strncpy(s_resp_buf, response.c_str(), MCP_PROXY_RESP_BUF_SIZE - 1);
s_resp_buf[MCP_PROXY_RESP_BUF_SIZE - 1] = '\0';
EmMCP::respond(s_resp_buf);
} else {
EmMCP::respond(response.c_str());
}
} else {
char err[48];
snprintf(err, sizeof(err), "{\"error\":\"HTTP failed: %d\"}", httpCode);
EmMCP::respond(err);
}
http.end();
req.active = false;
break; // 每次循环只处理1个请求,尽快交还主循环
}
}
3. cJSON 参数格式兼容——AI 模块传参不一致
AI 模块传参格式不固定,网关需要兼容所有情况:
// 从cJSON参数中提取字符串值
// AI模块可能以数组格式传参: "device":["电脑"] 而非 "device":"电脑"
static const char *extract_param_string(cJSON *params, const char *name) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(params, name);
if (!item) return nullptr;
if (cJSON_IsString(item)) return item->valuestring;
if (cJSON_IsArray(item)) {
cJSON *first = cJSON_GetArrayItem(item, 0);
if (first && cJSON_IsString(first)) return first->valuestring;
}
return nullptr;
}
// 从cJSON参数中提取params字段,序列化为JSON字符串
// 兼容:字符串、字符串数组、对象数组、直接对象
static const char *extract_params_json(cJSON *params, const char *name) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(params, name);
if (!item) return "{}";
if (cJSON_IsString(item)) return item->valuestring; // 直接字符串
if (cJSON_IsObject(item)) { // 直接对象
char *s = cJSON_PrintUnformatted(item);
if (s) {
strncpy(s_params_buf, s, sizeof(s_params_buf) - 1);
s_params_buf[sizeof(s_params_buf) - 1] = '\0';
cJSON_free(s);
return s_params_buf;
}
return "{}";
}
if (cJSON_IsArray(item)) { // 数组:取第一个元素
cJSON *first = cJSON_GetArrayItem(item, 0);
if (!first) return "{}";
if (cJSON_IsString(first)) return first->valuestring;
if (cJSON_IsObject(first)) {
char *s = cJSON_PrintUnformatted(first);
if (s) {
strncpy(s_params_buf, s, sizeof(s_params_buf) - 1);
s_params_buf[sizeof(s_params_buf) - 1] = '\0';
cJSON_free(s);
return s_params_buf;
}
}
}
return "{}";
}
4. 设备变更 → 工具重注册
设备增删时,网关必须通知 AI 模块更新工具列表。但重注册不能在 HTTP 处理回调中执行(emMCP 的 UART 发送不可重入),所以用标志位延迟到主循环:
// wifi_hub.cpp — 设备注册成功后
mcp_proxy_trigger_reregister(); // 设置标志位
// main.cpp — 主循环检测标志
if (mcp_proxy_needs_reregister()) {
mcp.resetTools(); // 清空emMCP工具表
register_mcp_tools(); // 重新定义本地+代理工具(含最新的remote.call描述)
mcp.registerTools(); // 通过UART发送新工具列表给AI模块
mcp_proxy_clear_reregister();
}
HTTP Hub 实现
REST API
// wifi_hub.cpp — 设备注册处理
static void handle_post_mcp() {
String body = s_server.arg("plain");
cJSON *root = cJSON_Parse(body.c_str());
cJSON *name_item = cJSON_GetObjectItemCaseSensitive(root, "name");
cJSON *endpoint_item = cJSON_GetObjectItemCaseSensitive(root, "mcp_endpoint");
// 查找槽位:已有设备覆盖(同名更新),新设备找空位
int slot = find_device_by_name(name_item->valuestring);
if (slot < 0) {
slot = find_empty_slot();
if (slot < 0) {
s_server.send(507, "application/json",
"{\"status\":\"error\",\"message\":\"device limit reached\"}");
return;
}
}
// 存储设备信息
McpDevice &dev = s_devices[slot];
store_str(dev.name, sizeof(dev.name), name_item->valuestring);
store_str(dev.mcp_endpoint, sizeof(dev.mcp_endpoint),
endpoint_item->valuestring);
// 解析tools数组,序列化存储
cJSON *tools_item = cJSON_GetObjectItemCaseSensitive(root, "tools");
if (tools_item && cJSON_IsArray(tools_item)) {
char *tools_str = cJSON_PrintUnformatted(tools_item);
store_str(dev.tools_json, sizeof(dev.tools_json), tools_str);
cJSON_free(tools_str);
}
dev.last_seen = millis();
dev.active = true;
// 触发MCP工具重注册——让AI模块看到最新的remote.call描述
mcp_proxy_trigger_reregister();
}
mDNS 自动发现
网关通过 mDNS 广播 _mcp-hub._tcp,局域网设备零配置发现:
// wifi_hub.cpp — WiFi连接成功后启动mDNS
if (MDNS.begin("wt32-ai")) {
MDNS.addService("_mcp-hub", "_tcp", 8080);
MDNS.addServiceTxt("_mcp-hub", "_tcp", "path", "/api/mcp");
MDNS.addServiceTxt("_mcp-hub", "_tcp", "version", "1");
}
WiFi 指数退避重连
// wifi_hub.cpp — WiFi断线后指数退避重连 (1s→2s→4s→...→30s)
void wifi_hub_loop() {
if (!s_wifi_connected &&
millis() - s_last_connect_attempt > (unsigned long)s_retry_interval_ms) {
WiFi.begin(kSsid, kPassword);
s_last_connect_attempt = millis();
s_retry_interval_ms = min(s_retry_interval_ms * 2, 30000);
}
s_server.handleClient();
}
emMCP:网关的 UART-MCP 协议层
emMCP(Easy MCU MCP)是安信可开源的纯 C 协议库,最小资源占用 62 字节 RAM、1.7KB Flash。Ai-WV01-32S-Kit 的出厂固件原生支持 UART-MCP 协议,emMCP 是 MCU 端的协议适配库——用它就能和 AI 模块通信,无需了解底层协议细节。
C 核心结构
// emMCP/uart-mcp/emMCP.h — 工具结构体
typedef struct emMCP_tool {
char *name; // 工具名称
char *description; // 工具描述
void (*setRequestHandler)(void *); // AI命令MCU执行的回调
void (*checkRequestHandler)(void *); // AI查询MCU状态的回调
inputSchema_t inputSchema; // 输入参数
struct emMCP_tool *next; // 链表指针
} emMCP_tool_t;
// 最多5个工具
#define MCP_SERVER_TOOL_NUMBLE_MAX 5
emMCP-arduino:Arduino 移植版
emMCP-arduino 是 emMCP 的 Arduino 移植库,将 C 核心封装为 Arduino 风格的 C++ API,提供链式 Builder 模式定义工具:
#include <emMCP.h>
EmMCP mcp(Serial1); // 传入连接AI模块的Serial口
void ledSetHandler(cJSON *params) {
bool enable = EmMCP::getParamBool(params, "enable");
digitalWrite(LED_PIN, enable ? HIGH : LOW);
EmMCP::respondOk();
}
void setup() {
Serial1.begin(115200);
mcp.begin();
mcp.addTool("led.switch", "Control LED on/off")
.addProperty("enable", "true=on, false=off", EmMCPPropType_Boolean)
.onSet(ledSetHandler)
.onCheck(ledCheckHandler);
mcp.registerTools();
mcp.wakeUp(20);
}
void loop() {
mcp.loop();
}
C/C++ 桥接:模板跳板(Template Trampoline)
C 核心只认 void(*)(void*) 函数指针,Arduino 用户希望传 C++ 回调。emMCP-arduino 用模板跳板解决——为每个工具槽位生成编译期确定的静态分发函数:
// emMCP-arduino/src/emMCP.h — 模板跳板
template <int N>
static void _setTrampoline(void *arg) {
if (_instance && _instance->_setCallbacks[N]) {
_instance->_setCallbacks[N](static_cast<cJSON *>(arg));
}
}
// 注册时根据索引填入对应的跳板
void EmMCP::_assignTrampolines(int idx) {
extern emMCP_tool_t mcp_tool_arry[];
switch (idx) {
case 0: mcp_tool_arry[idx].setRequestHandler = _setTrampoline<0>; break;
case 1: mcp_tool_arry[idx].setRequestHandler = _setTrampoline<1>; break;
// ...
}
}
C 核心调用函数指针 → 跳板函数 → 用户 C++ 回调,完成 C 到 C++ 的桥接。
UART 串口适配
// C桥接函数
extern "C" int emMCP_arduino_uart_send(const char *data, int len) {
EmMCP::_instance->_writeSerial(data, len); // Arduino的Serial.write()
return 0;
}
// 串口接收:逐字节读取,按换行符分割后喂给C核心
void EmMCP::_processSerialInput() {
while (_serial.available()) {
char c = _serial.read();
if (c == '\r') continue;
if (c == '\n' || _lineIdx >= _rxBufSize - 1) {
_lineBuf[_lineIdx] = '\0';
if (_lineIdx > 0) {
uartPortRecvData(_lineBuf, _lineIdx); // 喂给C核心
emMCP_TickHandle(0); // 立即分发
}
_lineIdx = 0;
continue;
}
_lineBuf[_lineIdx++] = c;
}
}
工具重注册
设备增删时需要重注册工具列表,resetTools() 清空内部状态:
void EmMCP::resetTools() {
_toolCount = 0;
_currentPropIdx = 0;
memset(_setCallbacks, 0, sizeof(_setCallbacks));
memset(_checkCallbacks, 0, sizeof(_checkCallbacks));
emMCP_ResetForReRegistration(); // C核心重置
}
网关的本地工具
网关自身也暴露硬件控制工具,直接操作 ESP32 的 GPIO 和 PWM:
// mcp_tools.cpp — 本地MCP工具
// 工具1: 屏幕亮度 (PWM → GPIO23)
static void brightnessSetHandler(cJSON *params) {
int val = EmMCP::getParamInt(params, "brightness");
if (val < 0) val = 0;
if (val > 100) val = 100;
current_brightness = val;
ledcWrite(0, map(val, 0, 100, 0, 255));
EmMCP::respondOk();
}
// 工具2: LED开关 (GPIO33)
static void ledSetHandler(cJSON *params) {
bool enable = EmMCP::getParamBool(params, "enable");
current_led_state = enable;
digitalWrite(LED_PIN, enable ? HIGH : LOW);
EmMCP::respondOk();
}
// 注册所有工具(本地2个 + 代理1个)
void register_mcp_tools() {
mcp.addTool("screen.brightness", "Control screen brightness")
.addProperty("brightness", "Brightness level 0-100", EmMCPPropType_Number)
.onSet(brightnessSetHandler)
.onCheck(brightnessCheckHandler);
mcp.addTool("led.switch", "Control LED on/off")
.addProperty("enable", "true=on, false=off", EmMCPPropType_Boolean)
.onSet(ledSetHandler)
.onCheck(ledCheckHandler);
mcp_proxy_register_tool(); // 注册remote.call代理工具
}
Ai-WV01-32S-Kit 事件处理
Ai-WV01-32S-Kit 出厂固件通过 UART 主动上报状态/文字/表情事件,网关解析后更新共享状态,驱动 UI 和表情模块:
// mcp_tools.cpp — AI事件回调
void onAiEvent(EmMCPEvent event, EmMCPPropType type, const char *data) {
switch (event) {
case EmMCPEvent_AiMcpText: {
// AI发来文字+表情,解析JSON中的state/text/emotion字段
cJSON *root = cJSON_Parse(data);
cJSON *state_item = cJSON_GetObjectItem(root, "state");
if (state_item && state_item->valueint) {
ai_state = STATE_SPEAKING;
cJSON *text_item = cJSON_GetObjectItem(root, "text");
if (text_item && text_item->valuestring) {
strncpy(ai_text, text_item->valuestring, sizeof(ai_text) - 1);
}
cJSON *emotion_item = cJSON_GetObjectItem(root, "emotion");
if (emotion_item && emotion_item->valuestring) {
ai_emotion = parse_emotion(emotion_item->valuestring);
}
emote_gfx_show(ai_emotion); // 立即切换表情
} else {
ai_state = STATE_LISTENING;
ai_emotion = EMOTION_NORMAL;
emote_gfx_show(EMOTION_NORMAL);
}
cJSON_Delete(root);
ui_request_update();
break;
}
case EmMCPEvent_AiWake:
ai_state = STATE_LISTENING;
ai_emotion = EMOTION_HAPPY;
emote_gfx_show(EMOTION_HAPPY);
ui_request_update();
break;
case EmMCPEvent_AiSleep:
ai_state = STATE_SLEEPING;
ai_emotion = EMOTION_SLEEPY;
emote_gfx_show(EMOTION_SLEEPY);
ui_request_update();
break;
case EmMCPEvent_AiStart:
ai_state = STATE_IDLE;
mcp.registerTools(); // AI模块启动后重新注册工具
break;
}
}
关键设计:所有状态更新通过 volatile 共享变量传递给 UI/表情模块,不在回调中直接操作 LVGL(LVGL 非线程安全)。
表情脸:网关的可视化反馈
网关自带 480x320 IPS 触摸屏,显示 AI 状态和表情脸动画。表情脸是纯程序化渲染——不用 GIF、不用贴图,120x120 RGB565 离屏帧缓冲放 PSRAM,每帧重绘完整表情后一次 pushImage 推屏。
// emote_gfx.cpp — 帧缓冲渲染管线
static uint16_t *framebuf = nullptr; // PSRAM: 120*120*2≈28.8KB
static inline void fb_pixel(int x, int y, uint16_t color) {
if (x >= 0 && x < FACE_SZ && y >= 0 && y < FACE_SZ)
framebuf[y * FACE_SZ + x] = color;
}
static void fb_fillcircle(int cx, int cy, int r, uint16_t color) {
for (int y = -r; y <= r; y++)
for (int x = -r; x <= r; x++)
if (x*x + y*y <= r*r) fb_pixel(cx + x, cy + y, color);
}
static void fb_line(int x0, int y0, int x1, int y1, uint16_t color) {
// Bresenham画线算法
int dx = abs(x1 - x0), dy = abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
while (true) {
fb_pixel(x0, y0, color);
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
}
static void fb_push() {
lcd.pushImage(FACE_OX, FACE_OY, FACE_SZ, FACE_SZ, framebuf); // 一次性推屏
}
10 种表情各有独立动画逻辑,AI 说话时解析 emotion 字段立刻切换,空闲时每 10 秒自动轮换:
| 表情 |
动画效果 |
| NORMAL |
微笑弧线 + 眨眼 |
| HAPPY |
嘴巴弹动 + 眨眼 |
| SAD |
泪珠掉落 + 倒弧嘴 |
| ANGRY |
眉毛抽搐 + 歪嘴抖动 |
| COOL |
墨镜反光 + 微笑 |
| SURPRISED |
眼睛脉冲放大缩小 + O嘴 |
| PLAYFUL |
左眼单眨 + 吐舌头 |
| LOVING |
心形眼脉冲 + 亲吻嘴 |
| MUSIC |
眼睛左右摇摆 + 嘴巴张合 |
| SLEEPY |
眯缝眼 + 周期哈欠 + Z字浮动 |
主循环编排
网关主循环的执行顺序经过精心设计,确保 UART 解析、代理转发、UI 渲染、表情动画互不冲突:
// main.cpp — 主循环
void loop() {
wifi_hub_loop(); // 1. HTTP服务器 + WiFi重连
mcp.loop(5); // 2. 解析UART-MCP消息
// 3. 设备增删时重注册工具
if (mcp_proxy_needs_reregister()) {
mcp.resetTools();
register_mcp_tools();
mcp.registerTools();
mcp_proxy_clear_reregister();
}
mcp_proxy_loop(); // 4. 处理代理队列(每次1个HTTP请求)
ui_update_loop(); // 5. 刷新LVGL控件
lv_timer_handler(); // 6. LVGL渲染
emote_gfx_cycle_tick(); // 7. 表情动画帧(必须在LVGL渲染之后)
ui_gfx_draw_devices(); // 8. 设备列表条(直接GFX,必须在LVGL之后)
delay(5);
}
顺序不可随意调换——表情脸和设备列表使用直接 GFX(pushImage),必须在 LVGL 渲染之后执行,否则 LVGL 的 flush 会覆盖。
Python 测试工具
网关附带两个 Python 工具,用于测试和开发:
mcp_device_sim.py — 模拟远程 MCP 设备
自动发现 Hub、注册设备、接收转发调用:
# 启动模拟设备(自动发现Hub,注册名称"电脑",端口3000)
python3 mcp_device_sim.py
# 指定名称/端口
python3 mcp_device_sim.py serve --name my-pc --port 3000
# 对AI说: "打开电脑的qq" / "查询电脑状态"
# 终端会显示WT32转发来的HTTP POST
模拟 4 个工具:qq.switch、screen.brightness、status.query、echo。
mcp_hub_client.py — Hub 管理客户端
python3 mcp_hub_client.py discover # mDNS发现Hub
python3 mcp_hub_client.py list # 查询已注册设备
python3 mcp_hub_client.py remove my-pc # 删除设备
python3 mcp_hub_client.py health # 健康检查
PSRAM 策略
WT32-SC01 搭载 ESP32-WROVER-B,有 8MB PSRAM。网关把大缓冲区全部放 PSRAM,内部 RAM 留给实时逻辑:
| 缓冲区 |
大小 |
用途 |
| LVGL 双缓冲 |
~90KB (2 × 480×48×2) |
显示渲染 |
| 表情帧缓冲 |
~28KB (120×120×2) |
离屏动画 |
| HTTP 响应缓冲 |
1KB |
代理转发响应 |
| 工具描述缓冲 |
512B |
remote.call 动态描述 |
| UART 行缓冲 |
512B |
串口接收 |
硬件
- WT32-SC01:ESP32-WROVER-B,双核 240MHz,8MB PSRAM,480x320 IPS 触摸屏——跑的网关固件
- Ai-WV01-32S-Kit:安信可 AI 语音模块,出厂固件已烧录好(PalChat AI),自带 WiFi、麦克风、扬声器——不需要二次开发,只需通过 UART 连接 WT32-SC01 即可
| 功能 |
GPIO |
| SPI MOSI |
13 |
| SPI SCLK |
14 |
| SPI CS |
15 |
| SPI DC |
21 |
| SPI RST |
22 |
| Backlight (PWM) |
23 |
| I2C SDA (Touch) |
18 |
| I2C SCL (Touch) |
19 |
| Touch INT |
39 |
| AI UART TX |
4 |
| AI UART RX |
27 |
| LED |
33 |
项目结构
| 组件 |
路径 |
说明 |
wt32-ai-assistant |
MCP/wt32-ai-assistant/ |
WT32-SC01 MCP 网关固件 |
emMCP |
MCP/emMCP/ |
安信可 UART-MCP 协议 C 核心库 |
emMCP-arduino |
MCP/emMCP-arduino/ |
emMCP 的 Arduino 移植版,链式 Builder API |
esp_emote_gfx |
MCP/esp_emote_gfx/ |
Espressif 轻量图形库参考 |
网关 API 总结
UART-MCP 工具(Ai-WV01-32S-Kit 调用)
| 工具名 |
参数 |
功能 |
| screen.brightness |
Number 0-100 |
控制屏幕背光亮度(本地) |
| led.switch |
Boolean |
控制 GPIO33 LED 开关(本地) |
| remote.call |
device/tool/params |
调用远程设备的 MCP 工具(代理转发) |
HTTP REST API(局域网设备调用)
| 方法 |
路径 |
功能 |
| POST |
/api/mcp |
注册/更新设备 |
| GET |
/api/mcp |
查询所有已注册设备 |
| DELETE |
/api/mcp?name= |
删除设备 |
| GET |
/api/health |
健康检查(WiFi/IP/设备数/PSRAM) |
mDNS 服务
| 服务类型 |
端口 |
TXT 记录 |
| _mcp-hub._tcp |
8080 |
path=/api/mcp, version=1 |
Ai-WV01-32S-Kit 事件
| 事件 |
状态变化 |
表情 |
| AiWake |
→ LISTENING |
HAPPY |
| AiSleep |
→ SLEEPING |
SLEEPY |
| AiStart |
→ IDLE |
NORMAL |
| AiMcpText (state=1) |
→ SPEAKING |
解析 emotion 字段 |
| AiMcpText (state=0) |
→ LISTENING |
NORMAL |
代理错误处理
| 场景 |
响应 |
| 设备未找到 |
{"error":"device '...' not found"} |
| 缺少参数 |
{"error":"missing device or tool parameter"} |
| 队列满 |
{"error":"proxy busy"} |
| HTTP 失败/超时 |
{"error":"HTTP failed: <code>"} |
项目仓库:https://github.com/LTLyaoni/MCP-_Gateway.git


