在上一篇,我们介绍了TF卡的挂载工作。ESP32设备已经能够访问TF卡中的配置文件:
/sdcard/ex_mcp.cfg
可以看到文件名称非常短小,使用的是早年间DOS系统下的 8.3 文件名称规范。这是由于ESP IDF架构为了减少对内存资源的消耗,默认是不开启长文件名支持的。
对于实际项目来说,其实更重要的问题是:
如何让设备在不修改固件、不重新编译、不重新烧录的情况下获得新的能力?
本文将介绍我们为小智AI设计的一套“配置驱动扩展架构”,实现通过TF卡配置文件动态扩展设备能力。
一、总体架构设计
整个运行时扩展系统的结构如下:
TF Card
│
└── ex_mcp.cfg
│
▼
SdExtensionManager
│
├── ParseExternalInitializers()
│
└── ParseMcpTools()
工作流程如下:
设备启动
│
▼
挂载TF卡
│
▼
读取配置文件
│
▼
解析JSON
│
▼
GPIO初始化
│
▼
MCP Tool注册
整个过程由 SdExtensionManager::LoadDynamicConfiguration() 负责完成。该函数的职责比较明晰,只做三件事:
1. 读取配置文件
2. 解析JSON结构
3. 分发给不同解析模块
二、配置文件结构设计
本项目采用JSON作为配置格式。示例配置文件如下:
{ "board_name": "lonrock-esp32s3-audio", "l_code": "982002702", "gpio_initializers": [ { "pin": 8, "mode": "output", "level": 1 } ], "mcp_tools": [ { "name": "sd.fan_switch", "description": "控制散热风扇", "pin": 7 } ] }整体结构分为三部分:
合法性校验
gpio_initializers
mcp_tools
合法性校验
board_name 表明了当前设备的类型,l_code是我司为设备确定的唯一序列号。这些字段用来确认TF卡上的配置文件确实是为对应的设备所准备。
如果TF卡是为同一类型的设备准备,那么代码里就可以不校验l_code。
合法性校验还可以用其他方式,例如校验设备的MAC地址。
gpio_initializers
用于设备启动后的GPIO初始化。例如:
{ "pin": 8, "mode": "output", "level": 1 }对应:GPIO8,设置为输出模式,上电后置高电平
mcp_tools
用于动态注册MCP工具。例如:
{ "name":"sd.fan_switch", "description":"控制散热风扇", "pin":7 }启动后系统会自动生成对应的MCP 工具。
该工具的名称是sd.fan_switch,通过描述告诉大模型这个工具用来控制散热风扇的起停,我们在程序中会使用GPIO7来控制风扇起停。
三、为什么选择JSON
1. 可读性好,即使没有编程经验的用户也能快速理解:
{ "pin": 8, "mode": "output" }2. 支持嵌套结构,例如:
{ "gpio_initializers":[...], "mcp_tools":[...] }3. 小智代码已经有解析实例
小智代码中已经有非常成熟的使用cJSON解析JSON数据的功能模块,在解析服务器数据时尤其稳定,因此使用cJSON库来解析TF卡上的JSON文件非常方便。
四、JSON解析中的注意事项
虽然JSON解析本身并不复杂,但有几个细节必须注意。
检查节点是否存在
不要假设配置一定正确。必须对节点进行检查,否则可能导致运行异常。
代码示例:
cJSON* pin = cJSON_GetObjectItem(item, "pin"); if(pin == nullptr) { return; }检查数据类型
下面两种写法并不相同:
{ "pin":5 }和
{ "pin":"5" }前者是数字。后者是字符串。因此解析前必须检查:
cJSON_IsNumber(pin)避免错误配置导致系统异常。
释放JSON资源
这是C++/C的初学者最容易忽略的问题。如果不释放资源,系统内存被无谓占用,导致其他功能可以分配的内存减少,在ESP32这种资源有限的单片机上面,是非常大的损失。
释放资源只需要一条语句,把整个JSON的根节点释放即可:
cJSON* root = cJSON_Parse(buffer); // 获取根节点 if(root == nullptr) { return; } // 处理JSON内容 cJSON_Delete(root); // 释放根节点五、完整函数代码
// 头部需要引入cJSON库的头文件 #include "cJSON.h" // JSON字段常量以及必要的常量 namespace { // String constants centralized here to ensure they reside in .rodata (flash) // and to avoid per-translation-unit pointer objects which would consume RAM. static const char MOUNT_POINT[] = "/sdcard"; static const char CONFIG_PATH[] = "/sdcard/ex_mcp.cfg"; static const char kJsonBoardName[] = "board_name"; static const char kJsonLCode[] = "l_code"; static const char kJsonGpioInitializers[] = "gpio_initializers"; static const char kJsonPin[] = "pin"; static const char kJsonMode[] = "mode"; static const char kJsonLevel[] = "level"; static const char kJsonPull[] = "pull"; static const char kJsonPullUp[] = "up"; static const char kJsonPullDown[] = "down"; static const char kGpioModeInput[] = "input"; static const char kGpioModeOutput[] = "output"; static const char kJsonMcpTools[] = "mcp_tools"; static const char kJsonName[] = "name"; static const char kJsonDescription[] = "description"; static const char kMcpToolPrefix[] = "sd."; static const char kParamAction[] = "action"; static const int8_t kToolPrefixLength = 3; // "sd." 长度 static const int8_t kMaxMcpToolNameLength = 32; static const int8_t kMaxMcpToolQuantity = 10; // 限制 MCP Tool 数量,防止滥用 static const int16_t kMaxFileSize = 8 * 1024; // 限制配置文件大小,防止内存耗尽 } void SdExtensionManager::LoadDynamicConfiguration() { if (!is_sdcard_found_) return; FILE *f = fopen(CONFIG_PATH, "r"); if (f == NULL) { ESP_LOGW(TAG, "Configuration file %s not found. Skipping dynamic setup.", CONFIG_PATH); return; } fseek(f, 0, SEEK_END); long fsize = ftell(f); if (fsize < 0 || fsize > kMaxFileSize) { ESP_LOGE(TAG, "Configuration file size %ld is invalid or exceeds limit (%d bytes).", fsize, kMaxFileSize); fclose(f); return; } fseek(f, 0, SEEK_SET); char *json_buf = (char *)malloc(fsize + 1); fread(json_buf, 1, fsize, f); json_buf[fsize] = '\0'; fclose(f); cJSON *root = cJSON_Parse(json_buf); free(json_buf); if (root == NULL) { ESP_LOGE(TAG, "JSON parse error before: [%s]", cJSON_GetErrorPtr()); return; } // 安全校验:设备名称与 LCID 必须匹配 cJSON *board_name_obj = cJSON_GetObjectItem(root, kJsonBoardName); cJSON *lcode_obj = cJSON_GetObjectItem(root, kJsonLCode); if (!board_name_obj || !lcode_obj || GetBoardName() != board_name_obj->valuestring || GetLCode() != lcode_obj->valuestring) { ESP_LOGE(TAG, "Device validation failed! Target: BoardName=%s, LCODE=%s. File rejected.", board_name_obj ? board_name_obj->valuestring : "Unknown", lcode_obj ? lcode_obj->valuestring : "Unknown"); cJSON_Delete(root); return; } ESP_LOGI(TAG, "Device validation passed. Processing rules..."); ParseExternalInitializers(cJSON_GetObjectItem(root, kJsonGpioInitializers)); ParseMcpTools(cJSON_GetObjectItem(root, kJsonMcpTools)); cJSON_Delete(root); }下篇介绍
下一篇我们将详细介绍如何通过配置文件动态初始化GPIO,以及MCP工具的作用和注册。