发帖
1 0 0

【电子DIY作品】MCP 网关 + Ai-WV01-32S 让 AI 语音助手控制局域网里的任何设备

妖猊
论坛元老

20

主题

3301

回帖

6814

积分

论坛元老

积分
6814
电子DIY 15 1 2 小时前

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 的三重角色:

  1. UART-MCP 南向接口:通过串口连接 Ai-WV01-32S-Kit,向 AI 注册工具、接收指令、回传结果
  2. HTTP Hub 北向接口:局域网设备通过 REST API 注册自己的工具,mDNS 自动发现
  3. 代理转发引擎: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.switchscreen.brightnessstatus.queryecho

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

ai_voice_assistant_VISUAL.png
image.png
image.png

──── 0人觉得很赞 ────

使用道具 举报

妖哥厉害
您需要登录后才可以回帖 立即登录
高级模式
返回
统计信息
  • 会员数: 31215 个
  • 话题数: 42288 篇