From 140e90447cc75ef5f1b0a56684cc0195a191cf63 Mon Sep 17 00:00:00 2001 From: chenjintao Date: Tue, 25 Aug 2026 14:09:25 +0800 Subject: [PATCH] feat(factory-cli): add Wi-Fi credential management commands --- .../src/runtime/control_plane_factory_cli.c | 214 +++++++++++++++++- components/domain/include/domain.h | 14 +- .../src/runtime/domain_runtime_adapters.c | 15 ++ components/platform/include/platform.h | 8 + components/platform/internal/wifi_portal.h | 12 + .../src/runtime/platform_behavior_runtime.c | 11 + .../platform/src/wifi_portal_bridge.cpp | 28 +++ docs/factory-cli-wifi-commands.md | 73 ++++++ 8 files changed, 362 insertions(+), 13 deletions(-) create mode 100644 docs/factory-cli-wifi-commands.md diff --git a/components/control_plane/src/runtime/control_plane_factory_cli.c b/components/control_plane/src/runtime/control_plane_factory_cli.c index cabf7e2..83416e5 100644 --- a/components/control_plane/src/runtime/control_plane_factory_cli.c +++ b/components/control_plane/src/runtime/control_plane_factory_cli.c @@ -13,6 +13,7 @@ #include "control_plane_internal.h" #include +#include #include #include #include @@ -29,7 +30,9 @@ #define FACTORY_CLI_DEVICE_ID_MAX_LEN 64 #define FACTORY_CLI_SERIAL_MAX_LEN 64 #define FACTORY_CLI_JSON_MAX_LEN 640 +#define FACTORY_CLI_WIFI_JSON_MAX_LEN 3072 #define FACTORY_CLI_READ_TIMEOUT_MS 100 +#define FACTORY_CLI_RESET_REPLY_DELAY_MS 100 #ifndef CONFIG_TQ_FACTORY_CLI_TASK_STACK_BYTES #define CONFIG_TQ_FACTORY_CLI_TASK_STACK_BYTES (10U * 1024U) #endif @@ -184,23 +187,56 @@ static void trim_text(char *value) static size_t split_args(char *line, char *argv[], size_t max_args) { size_t argc = 0; - char *cursor = line; - while (*cursor != '\0' && argc < max_args) { - while (*cursor != '\0' && isspace((unsigned char)(*cursor))) { - cursor++; + char *read_cursor = line; + char *write_cursor = line; + + while (*read_cursor != '\0') { + while (*read_cursor != '\0' && isspace((unsigned char)(*read_cursor))) { + read_cursor++; } - if (*cursor == '\0') { + if (*read_cursor == '\0') { break; } - argv[argc++] = cursor; - while (*cursor != '\0' && !isspace((unsigned char)(*cursor))) { - cursor++; + if (argc >= max_args) { + return SIZE_MAX; } - if (*cursor == '\0') { - break; + + 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++; } - *cursor = '\0'; - cursor++; + + if (quote != '\0') { + return SIZE_MAX; + } + while (*read_cursor != '\0' && isspace((unsigned char)(*read_cursor))) { + read_cursor++; + } + *write_cursor++ = '\0'; } return argc; } @@ -226,6 +262,150 @@ static void cli_write_error(const char *message) cli_write_line(line); } +static bool json_append_text(char *out, + size_t out_len, + size_t *offset, + const char *text) +{ + if (out == NULL || offset == NULL || text == NULL || *offset >= out_len) { + return false; + } + size_t text_len = strlen(text); + if (text_len >= out_len - *offset) { + return false; + } + memcpy(out + *offset, text, text_len); + *offset += text_len; + out[*offset] = '\0'; + return true; +} + +static bool json_append_escaped_string(char *out, + size_t out_len, + size_t *offset, + const char *value) +{ + if (!json_append_text(out, out_len, offset, "\"")) { + return false; + } + + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor != '\0'; + ++cursor) { + char escape[8] = {0}; + const char *encoded = escape; + switch (*cursor) { + case '"': + encoded = "\\\""; + break; + case '\\': + encoded = "\\\\"; + break; + case '\b': + encoded = "\\b"; + break; + case '\f': + encoded = "\\f"; + break; + case '\n': + encoded = "\\n"; + break; + case '\r': + encoded = "\\r"; + break; + case '\t': + encoded = "\\t"; + break; + default: + if (*cursor < 0x20U) { + snprintf(escape, sizeof(escape), "\\u%04x", (unsigned)*cursor); + } else { + escape[0] = (char)*cursor; + escape[1] = '\0'; + } + break; + } + if (!json_append_text(out, out_len, offset, encoded)) { + return false; + } + } + return json_append_text(out, out_len, offset, "\""); +} + +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(); +} + +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(); +} + +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(); +} + static esp_err_t resolve_device_identity(char *device_id, size_t device_id_len) { char serial_number[FACTORY_CLI_SERIAL_MAX_LEN] = {0}; @@ -406,6 +586,10 @@ static void handle_command_line(char *line) char *argv[FACTORY_CLI_MAX_ARGC] = {0}; size_t argc = split_args(line, argv, FACTORY_CLI_MAX_ARGC); + if (argc == SIZE_MAX) { + cli_write_error("invalid_command_syntax"); + return; + } if (argc == 0) { return; } @@ -443,6 +627,12 @@ static void handle_command_line(char *line) cli_write_ok(); } else if (strcmp(cmd, "save_config") == 0) { handle_save_config(); + } 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(); } else if (strcmp(cmd, "get_auth_material") == 0 || strcmp(cmd, "auth_material") == 0 || strcmp(cmd, "get_device_auth_material") == 0 || diff --git a/components/domain/include/domain.h b/components/domain/include/domain.h index 90550fd..a3c4b6b 100644 --- a/components/domain/include/domain.h +++ b/components/domain/include/domain.h @@ -548,6 +548,18 @@ esp_err_t domain_runtime_behavior_play_prompt_file(const char *path); void domain_runtime_wifi_portal_set_behavior_hooks(const domain_wifi_behavior_hooks_t *hooks); esp_err_t domain_runtime_wifi_portal_init_defaults(const char *seed_ssid, const char *seed_password); bool domain_runtime_wifi_portal_has_saved_credentials(void); + +#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; + +/** Copy saved SSIDs in persistent priority order. Passwords are not exposed. */ +esp_err_t domain_runtime_wifi_portal_get_saved_networks( + domain_wifi_saved_networks_t *out_networks); bool domain_runtime_wifi_portal_sta_is_connected(void); esp_err_t domain_runtime_wifi_portal_start_config_ap(const char *ssid_prefix); bool domain_runtime_wifi_connect_error_keeps_credentials(esp_err_t err); @@ -579,7 +591,7 @@ bool domain_runtime_is_network_connect_error(esp_err_t err); esp_err_t domain_runtime_wifi_portal_save_credentials(const char *ssid, const char *password); esp_err_t domain_runtime_wifi_portal_clear_saved_credentials(void); -#define DOMAIN_WIFI_PROVISIONING_SSID_MAX_LEN 33 +#define DOMAIN_WIFI_PROVISIONING_SSID_MAX_LEN DOMAIN_WIFI_SSID_MAX_LEN #define DOMAIN_WIFI_PROVISIONING_PASSWORD_MAX_LEN 65 typedef struct { diff --git a/components/domain/src/runtime/domain_runtime_adapters.c b/components/domain/src/runtime/domain_runtime_adapters.c index 14148a6..afd3f93 100644 --- a/components/domain/src/runtime/domain_runtime_adapters.c +++ b/components/domain/src/runtime/domain_runtime_adapters.c @@ -1286,6 +1286,21 @@ bool domain_runtime_wifi_portal_has_saved_credentials(void) return platform_wifi_portal_has_saved_credentials(); } +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); +} + bool domain_runtime_wifi_portal_sta_is_connected(void) { return platform_wifi_portal_sta_is_connected(); diff --git a/components/platform/include/platform.h b/components/platform/include/platform.h index 2e43bc0..a296a4c 100644 --- a/components/platform/include/platform.h +++ b/components/platform/include/platform.h @@ -359,6 +359,14 @@ esp_err_t platform_behavior_play_prompt_file(const char *path); void platform_wifi_portal_set_behavior_hooks(const platform_wifi_behavior_hooks_t *hooks); esp_err_t platform_wifi_portal_init_defaults(const char *seed_ssid, const char *seed_password); bool platform_wifi_portal_has_saved_credentials(void); +/** + * Copy saved SSIDs in persistent priority order without exposing passwords. + * `out_ssids` contains `capacity` rows of `ssid_stride` bytes. + */ +esp_err_t platform_wifi_portal_get_saved_ssids(char *out_ssids, + size_t ssid_stride, + size_t capacity, + size_t *out_count); bool platform_wifi_portal_sta_is_connected(void); /** Apply standby modem power save, or restore the configured active policy. */ esp_err_t platform_wifi_portal_set_light_standby(bool standby); diff --git a/components/platform/internal/wifi_portal.h b/components/platform/internal/wifi_portal.h index b59bb69..f810275 100644 --- a/components/platform/internal/wifi_portal.h +++ b/components/platform/internal/wifi_portal.h @@ -2,6 +2,7 @@ #include "esp_err.h" #include +#include #ifdef __cplusplus extern "C" { @@ -26,6 +27,17 @@ esp_err_t wifi_portal_init_defaults(const char *seed_ssid, const char *seed_pass */ bool wifi_portal_has_saved_credentials(void); +/** + * @brief Copy saved SSIDs in persistent priority order. + * + * Passwords are intentionally not exposed. `out_ssids` contains `capacity` + * rows of `ssid_stride` bytes. `out_count` receives the total saved count. + */ +esp_err_t wifi_portal_get_saved_ssids(char *out_ssids, + size_t ssid_stride, + size_t capacity, + size_t *out_count); + /** * @brief Check whether the STA interface is connected (has an IP). * diff --git a/components/platform/src/runtime/platform_behavior_runtime.c b/components/platform/src/runtime/platform_behavior_runtime.c index 1f5f1a3..7b58028 100644 --- a/components/platform/src/runtime/platform_behavior_runtime.c +++ b/components/platform/src/runtime/platform_behavior_runtime.c @@ -128,6 +128,17 @@ bool platform_wifi_portal_has_saved_credentials(void) return wifi_portal_has_saved_credentials(); } +esp_err_t platform_wifi_portal_get_saved_ssids(char *out_ssids, + size_t ssid_stride, + size_t capacity, + size_t *out_count) +{ + return wifi_portal_get_saved_ssids(out_ssids, + ssid_stride, + capacity, + out_count); +} + bool platform_wifi_portal_sta_is_connected(void) { return wifi_portal_sta_is_connected(); diff --git a/components/platform/src/wifi_portal_bridge.cpp b/components/platform/src/wifi_portal_bridge.cpp index 6c750be..12437d2 100644 --- a/components/platform/src/wifi_portal_bridge.cpp +++ b/components/platform/src/wifi_portal_bridge.cpp @@ -298,6 +298,34 @@ bool wifi_portal_has_saved_credentials(void) return !SsidManager::GetInstance().GetSsidList().empty(); } +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; +} + bool wifi_portal_sta_is_connected(void) { if (!s_wifi_manager_initialized) { diff --git a/docs/factory-cli-wifi-commands.md b/docs/factory-cli-wifi-commands.md new file mode 100644 index 0000000..393d765 --- /dev/null +++ b/docs/factory-cli-wifi-commands.md @@ -0,0 +1,73 @@ +# Factory CLI Wi-Fi Commands + +The Factory CLI accepts commands terminated by CR, LF, or CRLF on UART0. A +successful command returns `OK`. Commands that return data emit one JSON line +before `OK`. Failures return `ERROR `. + +## Read Saved Networks + +```text +get_wifi +``` + +The response lists saved SSIDs in connection-priority order. Passwords are +never returned. Up to 10 networks can be stored. + +```text +{"count":2,"networks":[{"priority":0,"ssid":"Office WiFi"},{"priority":1,"ssid":"Backup"}]} +OK +``` + +An empty list is returned as: + +```text +{"count":0,"networks":[]} +OK +``` + +## Save a Network + +```text +set_wifi "Office WiFi" "network-password" +``` + +The SSID is required and may contain up to 32 bytes. The password may contain +up to 64 bytes. Quote arguments that contain spaces. A missing or explicitly +empty password saves an open network: + +```text +set_wifi "Guest Network" +set_wifi "Guest Network" "" +``` + +Backslash escapes the following character inside or outside quoted arguments. +To include a literal backslash, enter it twice. + +Saving a new SSID inserts it at priority 0. Saving an existing SSID replaces +its password and moves it to priority 0. If 10 networks are already saved, +adding a new SSID removes the lowest-priority entry. This command only updates +persistent credentials; it does not disconnect or immediately reconnect the +current station. + +## Clear Saved Networks + +```text +reset_wifi +``` + +This command clears all saved Wi-Fi credentials, returns `OK`, allows 100 ms +for the UART reply to be transmitted, and restarts the device. It has the same +persistence effect as the physical Wi-Fi reset action. + +## Error Responses + +| Response | Meaning | +| --- | --- | +| `ERROR invalid_command_syntax` | An argument quote was not closed or the command contains too many arguments. | +| `ERROR wifi_read_failed` | Saved networks could not be read. | +| `ERROR wifi_response_too_large` | The saved network response exceeded the internal reply buffer. | +| `ERROR missing_wifi_ssid` | `set_wifi` did not receive a non-empty SSID. | +| `ERROR invalid_wifi_arguments` | `set_wifi` received more than an SSID and password. | +| `ERROR invalid_wifi_credentials` | The SSID or password exceeded its supported length. | +| `ERROR wifi_save_failed` | Credentials could not be saved. | +| `ERROR wifi_reset_failed` | Saved credentials could not be cleared. |