# Factory CLI Wi-Fi 管理功能提交说明 ## 一、提交信息 ```text 提交:140e90447cc75ef5f1b0a56684cc0195a191cf63 标题:feat(factory-cli): add Wi-Fi credential management commands ``` 本提交为 Factory CLI 增加以下三个命令: ```text get_wifi set_wifi "" "" reset_wifi ``` ## 二、问题原因 设备原有 Factory CLI 主要提供设备身份写入、鉴权材料读取、Challenge 签名和日志管理等生产功能,没有提供 Wi-Fi 凭据的查询、设置和清除接口。 这会带来以下问题: 1. 生产或售后工具无法通过统一的串口协议查看设备保存了哪些 Wi-Fi。 2. 无法通过 Factory CLI 写入或更新 Wi-Fi,只能依赖小程序配网或物理操作。 3. 无法通过 Factory CLI 执行与物理 Wi-Fi Reset 相同的清除操作。 4. 原有参数解析仅按空白字符切分,不能正确处理带空格的 SSID 或密码。 5. Wi-Fi 凭据由底层 `SsidManager` 管理,上层不能直接访问 NVS,否则会破坏 `control_plane -> domain -> platform` 的分层依赖关系。 ## 三、解决方式 ### 3.1 增加三个 Factory CLI 命令 命令行为如下: | 命令 | 行为 | | --- | --- | | `get_wifi` | 按持久化优先级返回已保存 SSID,最多 10 个,不返回密码 | | `set_wifi` | 新增或更新 Wi-Fi;同名 SSID 更新密码并提升到最高优先级 | | `reset_wifi` | 清空全部已保存 Wi-Fi,回复 `OK` 后重启设备 | `set_wifi` 只更新持久化凭据,不会立即断开或切换当前 Wi-Fi 连接。省略密码或 传入空字符串表示开放网络。 ### 3.2 支持带空格参数 参数解析器增加单引号、双引号和反斜杠转义支持。例如: ```text set_wifi "Office WiFi" "network password" set_wifi "Guest Network" "" ``` 未闭合引号或参数数量超限时返回: ```text ERROR invalid_command_syntax ``` ### 3.3 按项目分层读取 Wi-Fi 列表 读取路径为: ```text Factory CLI -> Domain Wi-Fi 快照接口 -> Platform Wi-Fi 门面 -> Wi-Fi Portal Bridge -> SsidManager ``` 上层只获取固定长度的 SSID 快照,不读取密码,也不直接访问 NVS。 ### 3.4 保持旧 Factory CLI 协议兼容 原有命令名称、UART0、CR/LF 行结束方式以及以下返回格式均未改变: ```text OK ERROR <一行 JSON> OK ``` ## 四、具体代码 ### 4.1 Wi-Fi 快照数据结构 ```c #define DOMAIN_WIFI_SSID_MAX_LEN 33 #define DOMAIN_WIFI_SAVED_NETWORK_MAX_COUNT 10 typedef struct { size_t count; char ssids[DOMAIN_WIFI_SAVED_NETWORK_MAX_COUNT][DOMAIN_WIFI_SSID_MAX_LEN]; } domain_wifi_saved_networks_t; esp_err_t domain_runtime_wifi_portal_get_saved_networks( domain_wifi_saved_networks_t *out_networks); ``` 该结构固定最多保存 10 个 SSID,每个 SSID 最多 32 字节并保留一个字符串结束符。 结构中不包含密码字段,从接口层面避免密码泄露。 ### 4.2 Domain 到 Platform 的快照映射 ```c esp_err_t domain_runtime_wifi_portal_get_saved_networks( domain_wifi_saved_networks_t *out_networks) { if (out_networks == NULL) { return ESP_ERR_INVALID_ARG; } memset(out_networks, 0, sizeof(*out_networks)); return platform_wifi_portal_get_saved_ssids( &out_networks->ssids[0][0], sizeof(out_networks->ssids[0]), DOMAIN_WIFI_SAVED_NETWORK_MAX_COUNT, &out_networks->count); } ``` ### 4.3 Platform 层复制保存的 SSID ```cpp esp_err_t wifi_portal_get_saved_ssids(char *out_ssids, size_t ssid_stride, size_t capacity, size_t *out_count) { if (out_count == nullptr || (capacity > 0U && (out_ssids == nullptr || ssid_stride < 33U))) { return ESP_ERR_INVALID_ARG; } install_wifi_storage_hooks(); ESP_RETURN_ON_ERROR(wifi_portal_lock(), TAG, "Failed to lock Wi-Fi portal"); PortalUnlockGuard guard; const auto &ssid_list = SsidManager::GetInstance().GetSsidList(); *out_count = ssid_list.size(); if (ssid_list.size() > capacity) { return ESP_ERR_INVALID_SIZE; } for (size_t i = 0; i < ssid_list.size(); ++i) { strlcpy(out_ssids + (i * ssid_stride), ssid_list[i].ssid.c_str(), ssid_stride); } return ESP_OK; } ``` 读取过程使用 Wi-Fi Portal 互斥锁,避免连接、保存和查询同时操作列表时产生竞态。 ### 4.4 带引号和转义的参数解析 ```c static size_t split_args(char *line, char *argv[], size_t max_args) { size_t argc = 0; char *read_cursor = line; char *write_cursor = line; while (*read_cursor != '\0') { while (*read_cursor != '\0' && isspace((unsigned char)(*read_cursor))) { read_cursor++; } if (*read_cursor == '\0') { break; } if (argc >= max_args) { return SIZE_MAX; } argv[argc++] = write_cursor; char quote = '\0'; while (*read_cursor != '\0') { char ch = *read_cursor; if (quote != '\0') { if (ch == quote) { quote = '\0'; read_cursor++; continue; } } else { if (isspace((unsigned char)ch)) { break; } if (ch == '\'' || ch == '"') { quote = ch; read_cursor++; continue; } } if (ch == '\\' && read_cursor[1] != '\0') { read_cursor++; ch = *read_cursor; } *write_cursor++ = ch; read_cursor++; } if (quote != '\0') { return SIZE_MAX; } while (*read_cursor != '\0' && isspace((unsigned char)(*read_cursor))) { read_cursor++; } *write_cursor++ = '\0'; } return argc; } ``` ### 4.5 `get_wifi` 命令处理 ```c static void handle_get_wifi(void) { domain_wifi_saved_networks_t networks = {0}; esp_err_t err = domain_runtime_wifi_portal_get_saved_networks(&networks); if (err != ESP_OK) { cli_write_error("wifi_read_failed"); return; } char json[FACTORY_CLI_WIFI_JSON_MAX_LEN] = {0}; size_t offset = 0; char prefix[48] = {0}; snprintf(prefix, sizeof(prefix), "{\"count\":%u,\"networks\":[", (unsigned)networks.count); bool valid = json_append_text(json, sizeof(json), &offset, prefix); for (size_t i = 0; valid && i < networks.count; ++i) { char item_prefix[40] = {0}; snprintf(item_prefix, sizeof(item_prefix), "%s{\"priority\":%u,\"ssid\":", i == 0U ? "" : ",", (unsigned)i); valid = json_append_text(json, sizeof(json), &offset, item_prefix) && json_append_escaped_string(json, sizeof(json), &offset, networks.ssids[i]) && json_append_text(json, sizeof(json), &offset, "}"); } valid = valid && json_append_text(json, sizeof(json), &offset, "]}"); if (!valid) { cli_write_error("wifi_response_too_large"); return; } cli_write_line(json); cli_write_ok(); } ``` 返回示例: ```json {"count":2,"networks":[{"priority":0,"ssid":"Office WiFi"},{"priority":1,"ssid":"Backup"}]} ``` 随后返回: ```text OK ``` ### 4.6 `set_wifi` 命令处理 ```c static void handle_set_wifi(size_t argc, char *argv[]) { if (argc < 2 || is_text_empty(argv[1])) { cli_write_error("missing_wifi_ssid"); return; } if (argc > 3) { cli_write_error("invalid_wifi_arguments"); return; } const char *password = argc >= 3 ? argv[2] : ""; esp_err_t err = domain_runtime_wifi_portal_save_credentials(argv[1], password); if (err != ESP_OK) { cli_write_error(err == ESP_ERR_INVALID_ARG ? "invalid_wifi_credentials" : "wifi_save_failed"); return; } cli_write_ok(); } ``` 底层保存规则保持不变: - 新 SSID 插入列表首位。 - 同名 SSID 覆盖密码并提升到首位。 - 列表已满 10 个时,新增 SSID 会淘汰最低优先级项。 - SSID 最大 32 字节,密码最大 64 字节。 ### 4.7 `reset_wifi` 命令处理 ```c static void handle_reset_wifi(void) { esp_err_t err = domain_runtime_wifi_portal_clear_saved_credentials(); if (err != ESP_OK) { cli_write_error("wifi_reset_failed"); return; } cli_write_ok(); vTaskDelay(pdMS_TO_TICKS(FACTORY_CLI_RESET_REPLY_DELAY_MS)); domain_runtime_restart(); } ``` 清除成功后先发送 `OK`,等待 100 ms 给 UART 发送数据,然后重启设备。该操作与 物理 Wi-Fi Reset 的持久化效果一致,会删除全部 10 个 Wi-Fi 槽位。 ### 4.8 命令注册 ```c } else if (strcmp(cmd, "get_wifi") == 0) { handle_get_wifi(); } else if (strcmp(cmd, "set_wifi") == 0) { handle_set_wifi(argc, argv); } else if (strcmp(cmd, "reset_wifi") == 0) { handle_reset_wifi(); } ``` ## 五、解决成果 1. Factory CLI 现在可以完整执行 Wi-Fi 查询、设置和清除操作。 2. 最多可查询和管理 10 个已保存 Wi-Fi,返回顺序即连接优先级。 3. 查询结果不包含密码,避免生产工具无意获取敏感信息。 4. 支持带空格的 SSID、密码、开放网络及常见转义字符。 5. 同名 SSID 可以更新密码,并自动提升为最高连接优先级。 6. 清除 Wi-Fi 后会可靠回复并重启,与物理复位语义一致。 7. 原有 Factory CLI 命令、UART 协议和回包格式保持不变。 8. 已使用 ESP-IDF 5.5.4 完整构建并成功链接。 ## 六、后续影响与注意事项 ### 6.1 对原有生产工具的影响 正常生产工具使用的 UUID、序列号和 Base64URL 鉴权参数不受影响。新增命令通过 完整命令名匹配,不会误触发旧命令。 参数解析现在会将引号视为分组符、将反斜杠视为转义符。如果旧工具在设备 ID 或 序列号中发送字面量单引号、双引号或反斜杠,解析结果可能与旧版本不同。当前标准 生产协议不使用这些字符,因此正常流程不存在兼容性问题。 ### 6.2 `set_wifi` 不立即联网 命令成功只表示凭据已持久化,不表示设备已经连接到该网络,也不会中断当前连接。 新凭据会在后续连接或重连流程中参与候选遍历。 ### 6.3 `reset_wifi` 会重启 生产工具收到 `OK` 后应允许串口断开和设备重新枚举,不应继续在同一启动周期发送 后续命令。 ### 6.4 密码不可读取 这是有意的安全限制。若生产工具需要验证密码,只能通过实际连接结果验证,不能通过 `get_wifi` 回读。 ### 6.5 建议验证 - 使用原有生产工具跑完身份写入和鉴权流程。 - 设置普通、带空格、中文和开放网络 SSID。 - 对同一 SSID 连续设置不同密码,确认仅保留一条且优先级为 0。 - 填满 10 个网络后增加第 11 个,确认淘汰最低优先级项。 - 执行 `reset_wifi`,确认收到 `OK`、设备重启且列表为空。