From ceb07804254a257b17db291c8198567f5d265c04 Mon Sep 17 00:00:00 2001 From: chenjintao Date: Tue, 25 Aug 2026 09:30:55 +0800 Subject: [PATCH] feat(control-plane): add network outage voice feedback --- components/control_plane/CMakeLists.txt | 1 + .../internal/control_plane_internal.h | 15 + .../control_plane_multimodal_runtime.c | 16 + .../runtime/control_plane_network_feedback.c | 315 ++++++++++++ .../runtime/control_plane_runtime_workers.c | 43 ++ docs/multi-wifi-credential-retention.zh-CN.md | 467 ++++++++++++++++++ 6 files changed, 857 insertions(+) create mode 100644 components/control_plane/src/runtime/control_plane_network_feedback.c create mode 100644 docs/multi-wifi-credential-retention.zh-CN.md diff --git a/components/control_plane/CMakeLists.txt b/components/control_plane/CMakeLists.txt index 93d10d5..66907e3 100644 --- a/components/control_plane/CMakeLists.txt +++ b/components/control_plane/CMakeLists.txt @@ -14,6 +14,7 @@ idf_component_register( "src/runtime/control_plane_fortune_service.c" "src/runtime/control_plane_system_events.c" "src/runtime/control_plane_light_standby.c" + "src/runtime/control_plane_network_feedback.c" "src/runtime/control_plane_key_input.c" "src/runtime/control_plane_reset_service.c" "src/runtime/control_plane_remote_log_service.c" diff --git a/components/control_plane/internal/control_plane_internal.h b/components/control_plane/internal/control_plane_internal.h index 78e67fc..c701877 100644 --- a/components/control_plane/internal/control_plane_internal.h +++ b/components/control_plane/internal/control_plane_internal.h @@ -96,6 +96,11 @@ typedef enum { CONTROL_PLANE_TRANSPORT_LOSS_PROTOCOL_ERROR, } control_plane_transport_loss_reason_t; +typedef enum { + CONTROL_PLANE_NETWORK_ACTION_CONVERSATION = 0, + CONTROL_PLANE_NETWORK_ACTION_CLOUD_CHOUQIAN, +} control_plane_network_action_t; + esp_err_t control_plane_multimodal_start(void); esp_err_t control_plane_multimodal_stop(void); bool control_plane_multimodal_is_connected(void); @@ -149,6 +154,16 @@ bool control_plane_ota_business_interactions_allowed(void); bool control_plane_runtime_is_stopping(void); void control_plane_set_runtime_stop_requested(bool stop_requested); +void control_plane_network_feedback_reset(void); +void control_plane_network_feedback_mark_wifi_down(void); +void control_plane_network_feedback_mark_wifi_recovered(void); +void control_plane_network_feedback_mark_server_down(void); +void control_plane_network_feedback_mark_service_ready(void); +void control_plane_network_feedback_service(void); +esp_err_t control_plane_network_feedback_require_ready( + control_plane_network_action_t action); +void control_plane_request_wifi_reconnect_now(void); + esp_err_t control_plane_battery_service_stop(int timeout_ms); void control_plane_battery_service_set_light_standby(bool standby); void control_plane_runtime_diagnostics_set_light_standby(bool standby); diff --git a/components/control_plane/src/multimodal/control_plane_multimodal_runtime.c b/components/control_plane/src/multimodal/control_plane_multimodal_runtime.c index 77578be..9b66798 100644 --- a/components/control_plane/src/multimodal/control_plane_multimodal_runtime.c +++ b/components/control_plane/src/multimodal/control_plane_multimodal_runtime.c @@ -690,6 +690,19 @@ esp_err_t control_plane_request_cloud_chouqian(void) return ESP_ERR_INVALID_STATE; } + portENTER_CRITICAL(&s_multimodal_runtime_lock); + bool request_pending = s_cloud_chouqian_pending; + portEXIT_CRITICAL(&s_multimodal_runtime_lock); + if (request_pending) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t network_err = control_plane_network_feedback_require_ready( + CONTROL_PLANE_NETWORK_ACTION_CLOUD_CHOUQIAN); + if (network_err != ESP_OK) { + return network_err; + } + uint32_t requested_ms = control_plane_now_ms(); portENTER_CRITICAL(&s_multimodal_runtime_lock); @@ -895,11 +908,13 @@ static void control_plane_multimodal_event_bridge(control_plane_multimodal_event switch (event) { case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_CONNECTED: { + control_plane_network_feedback_mark_service_ready(); control_plane_schedule_server_success_prompt(); break; } case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_DISCONNECTED: + control_plane_network_feedback_mark_server_down(); control_plane_clear_downstream_stall_recovery(); control_plane_clear_cloud_chouqian_pending("transport disconnected"); control_plane_multimodal_stop_uplink(); @@ -908,6 +923,7 @@ static void control_plane_multimodal_event_bridge(control_plane_multimodal_event break; case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_ERROR: + control_plane_network_feedback_mark_server_down(); control_plane_clear_downstream_stall_recovery(); control_plane_clear_cloud_chouqian_pending("transport error"); control_plane_multimodal_stop_uplink(); diff --git a/components/control_plane/src/runtime/control_plane_network_feedback.c b/components/control_plane/src/runtime/control_plane_network_feedback.c new file mode 100644 index 0000000..184d30c --- /dev/null +++ b/components/control_plane/src/runtime/control_plane_network_feedback.c @@ -0,0 +1,315 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "control_plane_internal.h" + +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" + +#define CONTROL_PLANE_SERVER_DISCONNECT_GRACE_MS 3000U +#define CONTROL_PLANE_NETWORK_PROMPT_COOLDOWN_MS 5000U + +#define CONTROL_PLANE_WIFI_INTERRUPTED_PROMPT_FILE \ + "/littlefs/audio_prompts/network_interrupted_repairing.opus" +#define CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE \ + "/littlefs/audio_prompts/network_unstable.opus" +#define CONTROL_PLANE_SERVER_INTERRUPTED_PROMPT_FILE \ + "/littlefs/audio_prompts/server_abnormal_interrupted.opus" +#define CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE \ + "/littlefs/audio_prompts/server_repair_wait_reopen.opus" +#define CONTROL_PLANE_NETWORK_RESTORED_PROMPT_FILE \ + "/littlefs/audio_prompts/signal_restored_business_resume.opus" + +typedef enum { + NETWORK_FEEDBACK_STATE_BOOTING = 0, + NETWORK_FEEDBACK_STATE_READY, + NETWORK_FEEDBACK_STATE_WIFI_DOWN, + NETWORK_FEEDBACK_STATE_SERVER_DOWN, +} network_feedback_state_t; + +typedef enum { + NETWORK_FEEDBACK_PROMPT_NONE = 0, + NETWORK_FEEDBACK_PROMPT_WIFI_INTERRUPTED, + NETWORK_FEEDBACK_PROMPT_SERVER_INTERRUPTED, + NETWORK_FEEDBACK_PROMPT_RESTORED, +} network_feedback_prompt_t; + +typedef struct { + network_feedback_state_t state; + network_feedback_prompt_t pending_prompt; + bool service_ever_ready; + bool outage_active; + bool outage_announced; + bool passive_prompt_attempted; + bool prompt_in_progress; + int64_t server_down_since_us; + int64_t last_fault_prompt_us; +} network_feedback_runtime_t; + +static const char *TAG = "cp_network_feedback"; +static network_feedback_runtime_t s_feedback; +static portMUX_TYPE s_feedback_lock = portMUX_INITIALIZER_UNLOCKED; + +static int64_t control_plane_network_feedback_now_us(void) +{ + return esp_timer_get_time(); +} + +static const char *control_plane_network_action_name(control_plane_network_action_t action) +{ + switch (action) { + case CONTROL_PLANE_NETWORK_ACTION_CONVERSATION: + return "conversation"; + case CONTROL_PLANE_NETWORK_ACTION_CLOUD_CHOUQIAN: + return "cloud_chouqian"; + default: + return "unknown"; + } +} + +static const char *control_plane_network_prompt_path(network_feedback_prompt_t prompt) +{ + switch (prompt) { + case NETWORK_FEEDBACK_PROMPT_WIFI_INTERRUPTED: + return CONTROL_PLANE_WIFI_INTERRUPTED_PROMPT_FILE; + case NETWORK_FEEDBACK_PROMPT_SERVER_INTERRUPTED: + return CONTROL_PLANE_SERVER_INTERRUPTED_PROMPT_FILE; + case NETWORK_FEEDBACK_PROMPT_RESTORED: + return CONTROL_PLANE_NETWORK_RESTORED_PROMPT_FILE; + default: + return NULL; + } +} + +static void control_plane_network_feedback_begin_outage_locked(void) +{ + if (s_feedback.outage_active) { + return; + } + + s_feedback.outage_active = true; + s_feedback.outage_announced = false; + s_feedback.passive_prompt_attempted = false; + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_NONE; +} + +static bool control_plane_network_feedback_begin_prompt_locked(int64_t now_us) +{ + if (s_feedback.prompt_in_progress) { + return false; + } + if (s_feedback.last_fault_prompt_us > 0 && + now_us - s_feedback.last_fault_prompt_us < + (int64_t)CONTROL_PLANE_NETWORK_PROMPT_COOLDOWN_MS * 1000LL) { + return false; + } + + s_feedback.prompt_in_progress = true; + s_feedback.last_fault_prompt_us = now_us; + s_feedback.outage_announced = true; + return true; +} + +static void control_plane_network_feedback_end_prompt(void) +{ + portENTER_CRITICAL(&s_feedback_lock); + s_feedback.prompt_in_progress = false; + portEXIT_CRITICAL(&s_feedback_lock); +} + +static esp_err_t control_plane_network_feedback_play(const char *path, + const char *reason) +{ + ESP_LOGI(TAG, "Play network prompt: reason=%s path=%s", reason, path); + esp_err_t err = domain_runtime_behavior_play_prompt_file(path); + if (err != ESP_OK) { + ESP_LOGW(TAG, + "Network prompt failed: reason=%s err=%s", + reason, + esp_err_to_name(err)); + } + control_plane_network_feedback_end_prompt(); + return err; +} + +void control_plane_network_feedback_reset(void) +{ + portENTER_CRITICAL(&s_feedback_lock); + s_feedback = (network_feedback_runtime_t){ + .state = NETWORK_FEEDBACK_STATE_BOOTING, + }; + portEXIT_CRITICAL(&s_feedback_lock); +} + +void control_plane_network_feedback_mark_wifi_down(void) +{ + portENTER_CRITICAL(&s_feedback_lock); + control_plane_network_feedback_begin_outage_locked(); + s_feedback.state = NETWORK_FEEDBACK_STATE_WIFI_DOWN; + s_feedback.server_down_since_us = 0; + if (!s_feedback.outage_announced) { + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_WIFI_INTERRUPTED; + s_feedback.passive_prompt_attempted = true; + } + portEXIT_CRITICAL(&s_feedback_lock); +} + +void control_plane_network_feedback_mark_wifi_recovered(void) +{ + int64_t now_us = control_plane_network_feedback_now_us(); + portENTER_CRITICAL(&s_feedback_lock); + if (s_feedback.state == NETWORK_FEEDBACK_STATE_WIFI_DOWN) { + s_feedback.state = NETWORK_FEEDBACK_STATE_SERVER_DOWN; + s_feedback.server_down_since_us = now_us; + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_NONE; + } + portEXIT_CRITICAL(&s_feedback_lock); +} + +void control_plane_network_feedback_mark_server_down(void) +{ + int64_t now_us = control_plane_network_feedback_now_us(); + portENTER_CRITICAL(&s_feedback_lock); + if (s_feedback.state != NETWORK_FEEDBACK_STATE_WIFI_DOWN) { + if (s_feedback.service_ever_ready) { + control_plane_network_feedback_begin_outage_locked(); + } + s_feedback.state = NETWORK_FEEDBACK_STATE_SERVER_DOWN; + if (s_feedback.server_down_since_us == 0) { + s_feedback.server_down_since_us = now_us; + } + } + portEXIT_CRITICAL(&s_feedback_lock); +} + +void control_plane_network_feedback_mark_service_ready(void) +{ + portENTER_CRITICAL(&s_feedback_lock); + bool should_announce_recovery = s_feedback.service_ever_ready && + s_feedback.outage_active && + s_feedback.outage_announced; + s_feedback.service_ever_ready = true; + s_feedback.state = NETWORK_FEEDBACK_STATE_READY; + s_feedback.outage_active = false; + s_feedback.passive_prompt_attempted = false; + s_feedback.server_down_since_us = 0; + s_feedback.pending_prompt = should_announce_recovery ? + NETWORK_FEEDBACK_PROMPT_RESTORED : + NETWORK_FEEDBACK_PROMPT_NONE; + portEXIT_CRITICAL(&s_feedback_lock); +} + +void control_plane_network_feedback_service(void) +{ + if (!control_plane_ota_voice_services_allowed() || + control_plane_is_mini_program_provisioning_running()) { + return; + } + + int64_t now_us = control_plane_network_feedback_now_us(); + network_feedback_prompt_t prompt = NETWORK_FEEDBACK_PROMPT_NONE; + + portENTER_CRITICAL(&s_feedback_lock); + if (s_feedback.state == NETWORK_FEEDBACK_STATE_SERVER_DOWN && + s_feedback.service_ever_ready && + s_feedback.outage_active && + !s_feedback.outage_announced && + !s_feedback.passive_prompt_attempted && + s_feedback.server_down_since_us > 0 && + now_us - s_feedback.server_down_since_us >= + (int64_t)CONTROL_PLANE_SERVER_DISCONNECT_GRACE_MS * 1000LL) { + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_SERVER_INTERRUPTED; + s_feedback.passive_prompt_attempted = true; + } + + bool fault_prompt_cooling_down = + s_feedback.pending_prompt != NETWORK_FEEDBACK_PROMPT_RESTORED && + s_feedback.last_fault_prompt_us > 0 && + now_us - s_feedback.last_fault_prompt_us < + (int64_t)CONTROL_PLANE_NETWORK_PROMPT_COOLDOWN_MS * 1000LL; + if (s_feedback.pending_prompt != NETWORK_FEEDBACK_PROMPT_NONE && + !s_feedback.prompt_in_progress && + !fault_prompt_cooling_down) { + prompt = s_feedback.pending_prompt; + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_NONE; + s_feedback.prompt_in_progress = true; + if (prompt == NETWORK_FEEDBACK_PROMPT_RESTORED) { + s_feedback.outage_announced = false; + } else { + s_feedback.last_fault_prompt_us = now_us; + s_feedback.outage_announced = true; + } + } + portEXIT_CRITICAL(&s_feedback_lock); + + const char *path = control_plane_network_prompt_path(prompt); + if (path != NULL) { + (void)control_plane_network_feedback_play(path, "state_change"); + } +} + +esp_err_t control_plane_network_feedback_require_ready( + control_plane_network_action_t action) +{ + bool wifi_connected = control_plane_is_sta_connected(); + bool server_connected = wifi_connected && control_plane_multimodal_is_connected(); + if (server_connected) { + return ESP_OK; + } + + portENTER_CRITICAL(&s_feedback_lock); + bool confirmed_outage = s_feedback.state == NETWORK_FEEDBACK_STATE_WIFI_DOWN || + s_feedback.state == NETWORK_FEEDBACK_STATE_SERVER_DOWN; + portEXIT_CRITICAL(&s_feedback_lock); + if (wifi_connected && !confirmed_outage) { + return ESP_OK; + } + + int64_t now_us = control_plane_network_feedback_now_us(); + bool should_play = false; + const char *prompt_path = NULL; + + portENTER_CRITICAL(&s_feedback_lock); + control_plane_network_feedback_begin_outage_locked(); + if (!wifi_connected) { + s_feedback.state = NETWORK_FEEDBACK_STATE_WIFI_DOWN; + s_feedback.server_down_since_us = 0; + prompt_path = CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE; + } else { + s_feedback.state = NETWORK_FEEDBACK_STATE_SERVER_DOWN; + if (s_feedback.server_down_since_us == 0) { + s_feedback.server_down_since_us = now_us; + } + prompt_path = CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE; + } + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_NONE; + s_feedback.passive_prompt_attempted = true; + should_play = control_plane_network_feedback_begin_prompt_locked(now_us); + portEXIT_CRITICAL(&s_feedback_lock); + + ESP_LOGI(TAG, + "Reject network action: action=%s wifi=%d server=%d prompt=%d", + control_plane_network_action_name(action), + wifi_connected ? 1 : 0, + server_connected ? 1 : 0, + should_play ? 1 : 0); + + if (should_play) { + (void)control_plane_network_feedback_play(prompt_path, + control_plane_network_action_name(action)); + } + if (wifi_connected) { + (void)control_plane_schedule_websocket_wait_task(); + } else { + control_plane_request_wifi_reconnect_now(); + } + return ESP_ERR_INVALID_STATE; +} diff --git a/components/control_plane/src/runtime/control_plane_runtime_workers.c b/components/control_plane/src/runtime/control_plane_runtime_workers.c index 322a73e..e8d2bbf 100644 --- a/components/control_plane/src/runtime/control_plane_runtime_workers.c +++ b/components/control_plane/src/runtime/control_plane_runtime_workers.c @@ -246,6 +246,7 @@ static QueueHandle_t s_model_cmd_queue; static bool s_model_cmd_queue_with_caps; static TaskHandle_t s_model_cmd_task; static bool s_ws_start_requested; +static bool s_wifi_reconnect_now_requested; #if CONFIG_TQ_NETWORK_AUTO_REASSOCIATION_ENABLE static uint32_t s_multimodal_network_failure_count; static bool s_wifi_reassociation_requested; @@ -594,6 +595,26 @@ static void control_plane_notify_wifi_state_task(void) } } +void control_plane_request_wifi_reconnect_now(void) +{ + portENTER_CRITICAL(&s_runtime_lock); + if (!s_runtime_stop_requested) { + s_wifi_reconnect_now_requested = true; + } + portEXIT_CRITICAL(&s_runtime_lock); + control_plane_notify_wifi_state_task(); +} + +static bool control_plane_consume_wifi_reconnect_now_request(void) +{ + bool requested = false; + portENTER_CRITICAL(&s_runtime_lock); + requested = s_wifi_reconnect_now_requested; + s_wifi_reconnect_now_requested = false; + portEXIT_CRITICAL(&s_runtime_lock); + return requested; +} + static void control_plane_request_ptt_expression( domain_expression_id_t id, domain_expression_trigger_t trigger) @@ -700,6 +721,13 @@ static void control_plane_set_ptt_active_with_trigger( } control_plane_local_wake_pause_for_foreground(); + if (control_plane_network_feedback_require_ready( + CONTROL_PLANE_NETWORK_ACTION_CONVERSATION) != ESP_OK) { + ESP_LOGI(TAG, "PTT activation rejected while network service is unavailable"); + control_plane_local_wake_resume_after_foreground(); + return; + } + ptt_state_t old_state = PTT_STATE_IDLE; ptt_state_t new_state = PTT_STATE_IDLE; uint32_t generation = 0; @@ -1705,6 +1733,7 @@ static bool control_plane_try_start_multimodal_from_state_machine(TickType_t *la #if CONFIG_TQ_NETWORK_AUTO_REASSOCIATION_ENABLE control_plane_record_multimodal_network_result(err); #endif + control_plane_network_feedback_mark_server_down(); ESP_LOGW(TAG, "Multimodal client start failed: %s", esp_err_to_name(err)); return false; } @@ -2082,6 +2111,8 @@ static void control_plane_wifi_state_task(void *arg) bool connected = control_plane_is_sta_connected(); TickType_t now_tick = xTaskGetTickCount(); + bool reconnect_now_requested = + control_plane_consume_wifi_reconnect_now_request(); #if CONFIG_TQ_MEMORY_MONITOR_ENABLE control_plane_memory_monitor_sample(now_tick, false); @@ -2099,6 +2130,7 @@ static void control_plane_wifi_state_task(void *arg) bool ptt_foreground_disconnect = control_plane_get_ptt_active(); bool disconnect_grace_elapsed = + reconnect_now_requested || ptt_foreground_disconnect || disconnected_expression_active || (now_tick - disconnect_started_tick) >= disconnect_grace_ticks; @@ -2108,8 +2140,10 @@ static void control_plane_wifi_state_task(void *arg) if (!disconnected_expression_active) { ESP_LOGI(TAG, "Wi-Fi STA disconnect persisted; stopping multimodal client"); + control_plane_network_feedback_mark_wifi_down(); (void)control_plane_stop_multimodal_client(); control_plane_stop_user_config_server(); + control_plane_network_feedback_service(); } bool refresh_due = disconnected_expression_active && @@ -2139,6 +2173,7 @@ static void control_plane_wifi_state_task(void *arg) ptt_wifi_foreground_attempts < CONTROL_PLANE_PTT_FOREGROUND_RECONNECT_MAX_ATTEMPTS; bool reconnect_due = ptt_foreground || + reconnect_now_requested || !reconnect_attempted || (now_tick - last_reconnect_attempt_tick) >= reconnect_delay_ticks; bool provisioning_running = @@ -2226,6 +2261,7 @@ static void control_plane_wifi_state_task(void *arg) if (connected && (!was_connected || disconnected_expression_active)) { ESP_LOGI(TAG, "Wi-Fi STA connected; restoring idle expression"); + control_plane_network_feedback_mark_wifi_recovered(); control_plane_start_wall_clock_sync(); control_plane_request_wifi_state_expression(DOMAIN_EXPRESSION_IDLE, DOMAIN_EXPRESSION_TRIGGER_WIFI_RECONNECTED, @@ -2280,6 +2316,8 @@ static void control_plane_wifi_state_task(void *arg) } } + control_plane_network_feedback_service(); + was_connected = connected; } @@ -2575,6 +2613,10 @@ esp_err_t control_plane_start_lcd_render_worker(void) esp_err_t control_plane_start_runtime_workers(void) { control_plane_set_runtime_stop_requested(false); + control_plane_network_feedback_reset(); + portENTER_CRITICAL(&s_runtime_lock); + s_wifi_reconnect_now_requested = false; + portEXIT_CRITICAL(&s_runtime_lock); #if CONFIG_TQ_MEMORY_MONITOR_ENABLE memset(&s_memory_monitor, 0, sizeof(s_memory_monitor)); @@ -2813,6 +2855,7 @@ void control_plane_stop_runtime_workers(void) control_plane_delete_model_cmd_queue(queue, queue_with_caps); (void)control_plane_stop_multimodal_client(); + control_plane_network_feedback_reset(); control_plane_set_runtime_stop_requested(false); } diff --git a/docs/multi-wifi-credential-retention.zh-CN.md b/docs/multi-wifi-credential-retention.zh-CN.md new file mode 100644 index 0000000..6e782ca --- /dev/null +++ b/docs/multi-wifi-credential-retention.zh-CN.md @@ -0,0 +1,467 @@ +# 多 Wi-Fi 凭据保留与有界连接策略说明 + +## 1. 提交概况 + +- 提交:`ae27d5bbaa149ae6fa2a5697cc17b70b938a3699` +- 提交标题:`feat(wifi): retain multiple network credentials` +- 目标:让设备保留多个已经验证成功的 Wi-Fi 凭据,并在启动或断网恢复时利用底层已有的多候选连接能力。 +- 默认单轮 Wi-Fi 连接周期:20 秒。 +- 最大 Wi-Fi 凭据数量:10 条。 + +## 2. 问题现象 + +底层 `SsidManager` 原本已经支持在 NVS 中保存最多 10 组 SSID 和密码,Wi-Fi Station 也能够扫描附近 AP,并从已保存列表中构建候选连接队列。 + +但是,小程序配网成功后的业务代码会先清空整个凭据列表,再保存刚刚验证成功的 Wi-Fi: + +```cpp +auto &credentials = SsidManager::GetInstance(); +credentials.Clear(); +credentials.AddSsid(ssid, password != NULL ? password : ""); +``` + +因此设备在正常使用过程中始终只保留最新的一条 Wi-Fi 信息。即使底层支持 10 条,断网后也没有其他候选网络可以尝试。 + +此外,Wi-Fi 连接与云端 HTTP/WebSocket 连接共用了 `CONFIG_MULTIMODAL_CONNECT_TIMEOUT_MS=10000`。10 秒通常足够连接单个 Wi-Fi,但在存在多个可见候选、部分密码已经失效或信号较差时,可能没有足够时间尝试后续候选。 + +## 3. 问题原因 + +### 3.1 配网成功时主动清空历史凭据 + +主要原因是试连成功后的 `Clear()`。它把多凭据存储模块退化成了单凭据存储。 + +### 3.2 种子 Wi-Fi 变化时清空旧列表 + +启动阶段如果配置的种子 SSID 与已有列表不同,旧逻辑同样会调用: + +```cpp +manager.Clear(); +``` + +这会删除设备之前在其他环境中成功使用过的网络。 + +### 3.3 Wi-Fi 与云端连接使用同一个超时配置 + +Wi-Fi 需要完成扫描、关联、认证、DHCP,并可能切换候选;云端连接则是 HTTP、TLS 和 WebSocket 建连。两者时序和恢复策略不同,不适合长期共用同一个超时参数。 + +### 3.4 成功网络没有稳定维护到列表首位 + +底层 `AddSsid()` 会把新 SSID 插入列表首位,但当 SSID 已存在时只更新密码,不会移动其位置。设备成功连接一个已有网络后,也不会主动更新该网络在持久化列表中的位置。 + +## 4. 解决方式 + +### 4.1 配网成功后保留旧凭据 + +删除 `Clear()`,只追加或更新本次验证成功的 Wi-Fi: + +```cpp +auto &credentials = SsidManager::GetInstance(); +credentials.AddSsid(ssid, password != NULL ? password : ""); +promote_saved_wifi(ssid); + +ESP_LOGI(TAG, + "Credential trial succeeded and saved Wi-Fi \"%s\" as highest priority", + ssid); +``` + +错误密码仍然不会写入 NVS。完整顺序保持为: + +```text +接收新 SSID/密码 +-> 使用新凭据进行非持久化试连 +-> 获得 IP,确认凭据有效 +-> 写入或更新凭据列表 +-> 再进入正式 Station 连接 +``` + +如果试连失败,原有列表保持不变: + +```cpp +if (!got_ip) { + ESP_LOGW(TAG, + "Credential trial failed for SSID \"%s\"; previous credentials preserved", + ssid); + return setup_err == ESP_OK ? ESP_ERR_TIMEOUT : setup_err; +} +``` + +### 4.2 种子 Wi-Fi 不再覆盖历史列表 + +启动时如果设置了种子 Wi-Fi,现在只会新增或更新该项,不再清空其他候选: + +```cpp +manager.AddSsid(ssid, password != NULL ? password : ""); +promote_saved_wifi(ssid); + +ESP_LOGI(TAG, + "Ensured configured Wi-Fi credentials for SSID \"%s\" while preserving %u previous candidate(s)", + ssid, + (unsigned)saved_count); +``` + +### 4.3 成功连接后将网络提升到列表首位 + +新增统一的提升函数: + +```cpp +static void promote_saved_wifi(const std::string &ssid) +{ + if (ssid.empty()) { + return; + } + + if (wifi_portal_lock() != ESP_OK) { + ESP_LOGW(TAG, "Could not promote connected Wi-Fi \"%s\": portal lock timed out", + ssid.c_str()); + return; + } + PortalUnlockGuard guard; + + install_wifi_storage_hooks(); + auto &credentials = SsidManager::GetInstance(); + const auto &ssid_list = credentials.GetSsidList(); + for (size_t i = 0; i < ssid_list.size(); ++i) { + if (ssid_list[i].ssid != ssid) { + continue; + } + if (i > 0U) { + credentials.SetDefaultSsid((int)i); + ESP_LOGI(TAG, "Promoted connected Wi-Fi \"%s\" to highest priority", + ssid.c_str()); + } + return; + } +} +``` + +正式获得 IP 并触发 Connected 事件后调用该函数: + +```cpp +case WifiEvent::Connected: + ESP_LOGI(TAG, "Connected to \"%s\"", data.c_str()); + promote_saved_wifi(data); + (void)apply_sta_stability_policy(); + break; +``` + +只有当成功网络不在首位时才会重新排序并写入 NVS,避免每次重连都产生无意义的 Flash 写入。 + +### 4.4 保持最多 10 条的淘汰机制 + +最大容量由底层管理器控制: + +```cpp +#define MAX_WIFI_SSID_COUNT 10 +``` + +新增网络时,如果列表已经达到 10 条,会删除末尾网络,再把新网络插入首位: + +```cpp +if (ssid_list_.size() >= MAX_WIFI_SSID_COUNT) { + ESP_LOGW(TAG, "SSID list is full, pop one"); + ssid_list_.pop_back(); +} + +ssid_list_.insert(ssid_list_.begin(), {ssid, password}); +SaveToNvs(); +``` + +### 4.5 拆分 Wi-Fi 连接周期配置 + +新增独立配置: + +```kconfig +config TQ_WIFI_CONNECT_CYCLE_TIMEOUT_MS + int "Wi-Fi candidate connection cycle timeout (ms)" + default 20000 + range 5000 120000 + help + Total time allowed for one scan and connection cycle across + visible saved Wi-Fi candidates. Failed cycles continue through + the runtime reconnect backoff policy. +``` + +默认配置为: + +```text +CONFIG_TQ_WIFI_CONNECT_CYCLE_TIMEOUT_MS=20000 +``` + +启动阶段改用新的 Wi-Fi 超时: + +```c +esp_err_t wifi_err = control_plane_bootstrap_connectivity( + CONFIG_MULTIMODAL_WIFI_SSID, + CONFIG_MULTIMODAL_WIFI_PASSWORD, + CONFIG_TQ_WIFI_CONNECT_CYCLE_TIMEOUT_MS); +``` + +运行时普通断网重连也使用 20 秒连接周期: + +```c +esp_err_t err = domain_runtime_wifi_portal_connect_sta( + CONFIG_TQ_WIFI_CONNECT_CYCLE_TIMEOUT_MS); +``` + +云端 HTTP/WebSocket 连接继续使用原来的 10 秒配置,不受本次调整影响。 + +对于“Wi-Fi 显示已关联,但公网数据通路失效”的受控重新关联,仍保留独立的 10 秒快速恢复时限: + +```c +#define CONTROL_PLANE_WIFI_REASSOCIATION_TIMEOUT_MS 10000 + +esp_err_t reassoc_err = domain_runtime_wifi_portal_reconnect_sta( + CONTROL_PLANE_WIFI_REASSOCIATION_TIMEOUT_MS); +``` + +## 5. 当前连接流程 + +修改后的完整流程如下: + +```text +设备启动或运行时检测到 Wi-Fi 断开 +-> 从 NVS 加载最多 10 条凭据 +-> 扫描附近 AP +-> 只保留“当前可见且已经保存”的网络 +-> 按扫描结果的 RSSI 从强到弱建立候选队列 +-> 尝试第一个候选 +-> 单个候选失败后,底层最多重新连接 5 次 +-> 仍失败则切换队列中的下一个候选 +-> 任一候选成功并获得 IP,停止遍历 +-> 将成功网络移动到持久化列表首位 +-> 单轮总时间达到 20 秒仍未成功,则结束本轮 +-> 进入现有指数退避流程,之后重新扫描和尝试 +``` + +需要注意:没有出现在扫描结果中的已保存 SSID,不会被逐个等待。保存 10 条并不等于每次固定执行 10 次完整连接。 + +## 6. 断网后的设备状态 + +断网后的处理分为两类:一类是设备与 Wi-Fi 路由器真正断开,另一类是 Wi-Fi 仍保持关联、但互联网或业务服务器不可达。两类情况的界面状态和恢复路径不同。 + +### 6.1 Wi-Fi 与路由器真正断开 + +设备运行时每 1 秒检查一次 Wi-Fi STA 连接状态。检测到断开后,不会立刻销毁当前业务连接,而是先进入 3 秒保护期: + +```text +检测到 Wi-Fi 断开 +-> 等待 3 秒,过滤路由器换信道、短暂弱信号等瞬时抖动 +-> 3 秒内恢复:继续保持当前业务状态,不显示断网表情 +-> 超过 3 秒仍未恢复:停止多模态客户端和本地用户配置服务 +-> 显示 Wi-Fi 断开表情 +-> 播放一次“网络中断,正在修复”提示音 +-> 使用已保存的凭据启动一轮 Wi-Fi 重连 +``` + +Wi-Fi 断开表情在离线期间每 5 秒刷新一次,但被动断网提示音在同一次故障中只播放一次。若设备此时正在小程序配网,则显示 Wi-Fi 连接中界面,并抑制普通断网提示音。 + +这里的 3 秒从状态监控任务实际检测到断开时开始计算。由于监控周期为 1 秒,从物理链路断开到系统开始计算保护期,理论上还可能有不到约 1 秒的检测延迟。 + +### 6.2 “一轮 20 秒”包含什么 + +一轮是一次有统一截止时间的完整候选连接过程,不是给每个 Wi-Fi 分配 20 秒。一轮最多持续 20 秒,包含: + +1. 扫描附近的 Wi-Fi AP。 +2. 将扫描结果与最多 10 条已保存凭据进行匹配。 +3. 按 RSSI 从强到弱生成当前可见的候选队列。 +4. 对候选网络执行认证、关联和 DHCP 获取 IP。 +5. 单个候选失败时执行底层重试,最多 5 次。 +6. 当前候选仍失败时切换到下一个候选。 +7. 任一候选成功获得 IP 后立即结束本轮。 + +所有步骤共享同一个 20 秒总预算。没有扫描到的已保存 SSID 不会被逐个等待;如果前面的候选认证很慢,20 秒也可能在遍历完全部候选之前耗尽。 + +以下时间不计入这 20 秒: + +- Wi-Fi 断开后的 3 秒保护期; +- 两轮连接之间的退避等待; +- Wi-Fi 已恢复后进行的服务器鉴权和 WebSocket 重连; +- 进入小程序 BLE 配网后的等待时间; +- Wi-Fi 仍显示已连接时触发的受控重新关联,该路径使用独立的 10 秒时限。 + +### 6.3 一轮失败后的处理 + +运行时第一次重连不额外等待,3 秒保护期结束后会立即开始。按当前默认配置,一次典型失败过程如下: + +```text +0~3 秒:Wi-Fi 断开保护期 +3~23 秒:第一轮扫描及候选连接,最多 20 秒 +23~33 秒:等待下一次重连,约 10 秒 +33~53 秒:第二轮扫描及候选连接,最多 20 秒 +第二轮仍失败:达到连续失败 2 次的阈值,进入小程序 BLE 重新配网 +``` + +上面的时间是接近最坏情况的示意值。某个候选快速成功或连接调用提前返回时,相应阶段会缩短。 + +重连等待采用指数退避,基础值为 5 秒,上限为 60 秒。由于第一次连接失败后等待值会更新为 10 秒,所以正常后台恢复流程中第一轮与第二轮之间约等待 10 秒。当前配置在连续两轮失败后会尝试进入 BLE 配网;只有 BLE 配网未启用、启动失败或流程条件不满足时,后台重连才会继续按退避策略运行。 + +### 6.4 Wi-Fi 仍连接但互联网或服务器不可达 + +如果设备仍与路由器关联,只是公网、DNS、HTTPS 或业务服务器不可用,Wi-Fi 状态检查仍会认为链路已连接,因此设备不会立刻显示 Wi-Fi 断开表情,也不会直接进入上述 20 秒多 Wi-Fi 遍历流程。 + +当 WebSocket 断开或报错时,设备会: + +```text +将当前多模态会话标记为不可用 +-> 停止音频上行及当前会话活动 +-> 关闭旧 WebSocket 传输 +-> 清除旧鉴权和会话状态 +-> 安排使用全新鉴权重新建立服务器连接 +``` + +服务器断开后保留 3 秒自恢复时间。3 秒内重新连接成功时不播报;超过 3 秒仍未恢复,则播放一次服务器异常提示音。设备空闲时主动关闭 WebSocket 属于正常节能行为,不会被当作服务器故障;下一次业务操作仍可按原流程唤醒连接。 + +如果首次相关 HTTPS 请求已经确认是网络连接类错误,当前配置还会请求一次受控 STA 重新关联。该过程使用独立的 10 秒超时,用于处理“Wi-Fi 图标仍连接,但实际数据通路已经失效”的情况。受控重新关联成功后,会清除网络失败计数并重新启动服务器连接流程。 + +因此,服务器断开不等同于 Wi-Fi 断开:前者优先重建业务连接,必要时才重新关联 STA;后者会显示断网状态并遍历当前可见的已保存 Wi-Fi。 + +### 6.5 断网时继续发起对话或抽签 + +对话和云端抽签入口统一检查 Wi-Fi 与服务器状态。已确认网络故障时,不再进入原来的 8~10 秒静默等待: + +```c +if (!control_plane_is_sta_connected()) { + play_prompt("network_unstable.opus"); + request_immediate_wifi_reconnect(); + return ESP_ERR_INVALID_STATE; +} + +if (server_outage_confirmed) { + play_prompt("server_repair_wait_reopen.opus"); + schedule_fresh_auth_websocket_reconnect(); + return ESP_ERR_INVALID_STATE; +} +``` + +Wi-Fi 不可用时播放网络不稳定提示,并在提示结束后立即唤醒 Wi-Fi 状态任务,使下一轮重连不再等待当前退避时间。Wi-Fi 正常但服务器已确认断开时,播放服务器修复提示,并立即安排全新鉴权和 WebSocket 重连。 + +本次操作会明确结束,不会长期排队。这样可以避免网络几十秒后恢复时突然执行一次用户已经遗忘的抽签,也避免对话入口显示正在聆听但实际没有上传语音。所有故障提示共享 5 秒冷却时间,用户连续点击不会反复播放。 + +### 6.6 恢复成功后的状态 + +Wi-Fi 重连并获得 IP 后,设备会: + +1. 将界面恢复为空闲表情。 +2. 重置断网计时、失败次数和退避时间。 +3. 启动系统时间同步。 +4. 重新执行服务器鉴权并建立 WebSocket。 +5. 将本次成功连接的 Wi-Fi 移动到持久化凭据列表首位。 + +Wi-Fi 恢复只代表局域网链路已经可用,服务器能力需要等鉴权和 WebSocket 重新建立后才真正恢复。只有设备此前已经成功连接过服务器、故障期间实际播放过异常提示,并且 WebSocket 已重新建立,才会播放一次“信号恢复,业务恢复”。开机首次联网仍沿用原有服务器连接成功提示,不会误播故障恢复提示。 + +当前提示音映射如下: + +| 状态 | 音频资源 | +| --- | --- | +| Wi-Fi 持续断开 | `network_interrupted_repairing.opus` | +| 用户在 Wi-Fi 断开时操作 | `network_unstable.opus` | +| 服务器持续断开 | `server_abnormal_interrupted.opus` | +| 用户在服务器断开时操作 | `server_repair_wait_reopen.opus` | +| Wi-Fi 与服务器均恢复 | `signal_restored_business_resume.opus` | + +## 7. 解决成果 + +### 7.1 功能成果 + +- 小程序成功配置新 Wi-Fi 后,历史有效 Wi-Fi 不再被清空。 +- NVS 可以逐步积累最多 10 条凭据。 +- 第 11 条新网络会淘汰列表末尾的旧网络。 +- 同一个 SSID 再次配网时会更新密码,不会产生重复项。 +- 新配置网络和最近成功网络会移动到持久化列表首位。 +- 启动和运行时普通重连拥有独立的 20 秒候选连接周期。 +- 云端连接超时与 Wi-Fi 连接超时已经解耦。 +- 新凭据仍坚持“试连成功后再保存”,不会因输入错误密码破坏已有连接能力。 +- Wi-Fi 或服务器持续断开后会播放一次对应的异常提示。 +- 用户在已确认断网时发起对话或抽签会立即得到语音反馈,不再静默等待。 +- 业务恢复提示以 WebSocket 恢复为准,不会在只有局域网链路恢复时提前播报。 + +### 7.2 构建验证 + +使用 ESP-IDF 5.5.4 完成了重新配置与完整固件构建: + +```text +CONFIG_TQ_WIFI_CONNECT_CYCLE_TIMEOUT_MS=20000 +Project build complete +TQ_Cat_Code.bin binary size 0x217d50 bytes +Smallest app partition: 0x380000 bytes +Free space: 0x1682b0 bytes(40%) +``` + +构建阶段未出现编译、链接、分区容量或 Kconfig 错误。 + +## 8. 后续影响与限制 + +### 8.1 对已有设备的兼容性 + +本次没有修改 NVS namespace 和 key 格式。原有单条凭据仍然使用: + +```text +wifi/ssid +wifi/password +``` + +后续新增项继续使用: + +```text +wifi/ssid1 ... wifi/ssid9 +wifi/password1 ... wifi/password9 +``` + +因此已有设备可以直接升级,不需要迁移或清空 NVS。升级后列表最初仍只有原来的一个网络,需要用户之后成功配置其他 Wi-Fi 才会逐步积累。 + +### 8.2 单轮 20 秒不保证极端情况下遍历完 10 个 + +底层第三方连接器对每个候选最多重新连接 5 次。如果附近有多个可见网络,但前面的网络密码失效或认证过程很慢,20 秒可能在遍历完全部候选之前耗尽。 + +这是有意保留的有界策略:设备不会为了遍历全部 10 条而让启动或运行时任务无限阻塞。超时后会进入原有退避机制,并在下一轮重新扫描。 + +### 8.3 当前实际连接顺序仍以信号强度为主 + +成功网络虽然会被移动到持久化列表首位,但底层 Station 当前先按扫描结果的 RSSI 从强到弱建立连接队列。因此“列表首位”目前主要影响: + +- 凭据管理顺序; +- 容量满时的淘汰顺序; +- 后续扩展最近成功优先策略时的数据基础。 + +它暂时不会覆盖 RSSI 排序,让最近成功网络无条件先于更强信号网络连接。 + +如果后续现场测试发现强信号但密码失效的网络长期阻塞后续候选,应在受控的本地依赖版本中增加:认证失败立即切换、最近成功加权、单候选重试次数配置。不要直接修改自动下载的 `managed_components`,否则依赖重新安装后改动会丢失。 + +### 8.4 同名 SSID 仍只保存一组密码 + +当前凭据以 SSID 字符串作为唯一键。如果两个场所使用完全相同的 SSID、但密码不同,后一次保存会覆盖前一次密码。当前设计不能把同名 SSID 作为两个独立配置保存。 + +### 8.5 NVS 和 Flash 写入增加 + +最多 10 条凭据会占用更多 NVS entry。成功连接非首位网络时会进行一次列表重排和 NVS commit。由于已经在网络位于首位时跳过写入,正常连接同一网络不会在每次启动时重复磨损 Flash。 + +### 8.6 自动重新配网策略及新增语音反馈 + +运行时连接周期连续失败达到现有阈值后,设备仍可能进入 BLE 小程序重新配网。本次没有修改重连失败阈值、断网表情和 WebSocket 鉴权机制,但新增了断网、服务器异常、离线操作拒绝及业务恢复语音。 + +语音播放接口会等待当前提示播放完成。被动 Wi-Fi 断网提示在停止旧多模态客户端后播放,播放完成再继续连接;用户主动操作的提示播放完成后再触发加速重连,避免音频播放与会话销毁或初始化并发争用。其代价是相应重连会比完全静默时晚一个提示音时长开始。 + +### 8.7 仍需要真机多 AP 回归测试 + +本次验证覆盖了配置生成和完整固件构建,尚未替代以下真机测试: + +1. 连续配置 3 个不同 Wi-Fi,重启后确认 NVS 中均被保留。 +2. 关闭当前 Wi-Fi,确认设备能连接另一条已保存且可见的 Wi-Fi。 +3. 将最强信号网络密码改错,测量切换至下一候选的实际时间。 +4. 配置超过 10 条,确认只淘汰列表末尾的一条。 +5. 重复配置相同 SSID,确认只更新密码且不产生重复项。 +6. 所有候选均不可用时,确认 20 秒后结束本轮并进入退避或 BLE 配网。 +7. Wi-Fi 恢复后,确认云端鉴权和 WebSocket 能正常重建。 +8. Wi-Fi 断开超过 3 秒,确认只播放一次网络修复提示。 +9. 断网时连续点击对话和抽签,确认操作立即结束且 5 秒内不重复播音。 +10. WebSocket 短暂断开少于 3 秒,确认不播服务器异常提示。 +11. WebSocket 持续断开后操作,确认播放服务器修复提示并触发重新鉴权。 +12. Wi-Fi 恢复但 WebSocket 尚未恢复时,确认不提前播放业务恢复提示。 +13. WebSocket 最终恢复后,确认只播放一次业务恢复提示。 +14. 空闲 WebSocket 主动关闭后首次发起对话,确认仍会唤醒连接而不是被误判为故障。 + +## 9. 结论 + +本次修改解决了“底层支持 10 条、业务却始终只保留 1 条”的核心问题,并把 Wi-Fi 候选连接周期与云端连接超时拆分开。设备现在能够逐步积累多个有效网络,并在当前网络不可用时利用底层候选队列尝试其他可见网络。 + +当前实现选择了 20 秒有界连接而不是无限等待,兼顾了多网络恢复能力和系统可用性。后续是否需要进一步优化认证失败快速切换,应以真机多 AP 测试数据为依据。