Files
lucky-cat/docs/factory-cli-wifi-management.zh-CN.md
T

385 lines
11 KiB
Markdown

# Factory CLI Wi-Fi 管理功能提交说明
## 一、提交信息
```text
提交:140e90447cc75ef5f1b0a56684cc0195a191cf63
标题:feat(factory-cli): add Wi-Fi credential management commands
```
本提交为 Factory CLI 增加以下三个命令:
```text
get_wifi
set_wifi "<ssid>" "<password>"
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 <reason>
<一行 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`、设备重启且列表为空。