11 KiB
Factory CLI Wi-Fi 管理功能提交说明
一、提交信息
提交:140e90447cc75ef5f1b0a56684cc0195a191cf63
标题:feat(factory-cli): add Wi-Fi credential management commands
本提交为 Factory CLI 增加以下三个命令:
get_wifi
set_wifi "<ssid>" "<password>"
reset_wifi
二、问题原因
设备原有 Factory CLI 主要提供设备身份写入、鉴权材料读取、Challenge 签名和日志管理等生产功能,没有提供 Wi-Fi 凭据的查询、设置和清除接口。
这会带来以下问题:
- 生产或售后工具无法通过统一的串口协议查看设备保存了哪些 Wi-Fi。
- 无法通过 Factory CLI 写入或更新 Wi-Fi,只能依赖小程序配网或物理操作。
- 无法通过 Factory CLI 执行与物理 Wi-Fi Reset 相同的清除操作。
- 原有参数解析仅按空白字符切分,不能正确处理带空格的 SSID 或密码。
- 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 支持带空格参数
参数解析器增加单引号、双引号和反斜杠转义支持。例如:
set_wifi "Office WiFi" "network password"
set_wifi "Guest Network" ""
未闭合引号或参数数量超限时返回:
ERROR invalid_command_syntax
3.3 按项目分层读取 Wi-Fi 列表
读取路径为:
Factory CLI
-> Domain Wi-Fi 快照接口
-> Platform Wi-Fi 门面
-> Wi-Fi Portal Bridge
-> SsidManager
上层只获取固定长度的 SSID 快照,不读取密码,也不直接访问 NVS。
3.4 保持旧 Factory CLI 协议兼容
原有命令名称、UART0、CR/LF 行结束方式以及以下返回格式均未改变:
OK
ERROR <reason>
<一行 JSON>
OK
四、具体代码
4.1 Wi-Fi 快照数据结构
#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 的快照映射
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
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 带引号和转义的参数解析
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 命令处理
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();
}
返回示例:
{"count":2,"networks":[{"priority":0,"ssid":"Office WiFi"},{"priority":1,"ssid":"Backup"}]}
随后返回:
OK
4.6 set_wifi 命令处理
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 命令处理
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 命令注册
} 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();
}
五、解决成果
- Factory CLI 现在可以完整执行 Wi-Fi 查询、设置和清除操作。
- 最多可查询和管理 10 个已保存 Wi-Fi,返回顺序即连接优先级。
- 查询结果不包含密码,避免生产工具无意获取敏感信息。
- 支持带空格的 SSID、密码、开放网络及常见转义字符。
- 同名 SSID 可以更新密码,并自动提升为最高连接优先级。
- 清除 Wi-Fi 后会可靠回复并重启,与物理复位语义一致。
- 原有 Factory CLI 命令、UART 协议和回包格式保持不变。
- 已使用 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、设备重启且列表为空。