385 lines
11 KiB
Markdown
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`、设备重启且列表为空。
|
|
|