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

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 凭据的查询、设置和清除接口。

这会带来以下问题:

  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 支持带空格参数

参数解析器增加单引号、双引号和反斜杠转义支持。例如:

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();
}

五、解决成果

  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、设备重启且列表为空。