From 220e4fab27b7f419b3e89e2f2d28ae789aac09d7 Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期四, 08 十月 2026 16:29:55 +0800
Subject: [PATCH] 5种场景模式家长端ok

---
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c |  665 ++++++++++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 528 insertions(+), 137 deletions(-)

diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
index f0a4cb4..bf7f4d2 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
@@ -4,27 +4,32 @@
 #include <stdlib.h>
 #include <string.h>
 #include <strings.h>
+#include <sys/stat.h>
 #include <time.h>
 #include <unistd.h>
 #include <cJSON.h>
 #include "esp_heap_caps.h"
 #include "esp_log.h"
 #include "esp_timer.h"
+#include "esp_mac.h"
 #include "esp_wifi.h"
 #include "freertos/FreeRTOS.h"
 #include "freertos/task.h"
 #include "mqtt_client.h"
 #include "system.h"
 #include "app_task.h"
+#include "parent_schedule.h"
 
 #define BOARD_MQTT_KEEPALIVE_SEC 120
-#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 30000
-#define BOARD_MQTT_RECONNECT_DELAY_MS 30000
+#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 60000
+#define BOARD_MQTT_RECONNECT_RETRY_MS 5000
+#define BOARD_MQTT_RECONNECT_MIN_INTERNAL_HEAP 49152
 #define BOARD_MQTT_NETWORK_TIMEOUT_MS 15000
 #define BOARD_MQTT_FINAL_REPORT_WAIT_MS 10000
 #define BOARD_MQTT_VOICE_YIELD_WAIT_MS 120000
+#define BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US 15000000
+#define BOARD_MQTT_SYNC_DISPLAY_INTERVAL_US 2000000
 #define BOARD_MQTT_TASK_PRIORITY 5
-#define BOARD_MQTT_RECONNECT_TASK_PRIORITY 4
 #define BOARD_MQTT_TOPIC_MAX_LEN 128
 #define BOARD_MQTT_PAYLOAD_MAX_LEN 8192
 #define BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN 256
@@ -47,13 +52,19 @@
 static esp_mqtt_client_handle_t s_mqtt_client = NULL;
 static bool s_mqtt_connected = false;
 static char s_device_id[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_mac_id[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_cmd_ctl_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_cmd_task_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_cmd_ctl_mac_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_cmd_task_mac_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_log_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_ack_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_progress_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_state_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_rpt_state_mac_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_event_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static const char BOARD_MQTT_LWT_PAYLOAD[] =
+    "{\"msg_type\":\"state\",\"payload\":{\"online\":false,\"net\":\"offline\"}}";
 typedef enum {
     BOARD_MQTT_TASK_NONE = 0,
     BOARD_MQTT_TASK_DOWNLOAD,
@@ -68,11 +79,28 @@
 static volatile bool s_download_voice_yield_requested = false;
 static volatile bool s_download_cancel_requested = false;
 static portMUX_TYPE s_task_lock = portMUX_INITIALIZER_UNLOCKED;
-static TaskHandle_t s_reconnect_task_handle = NULL;
 static TaskHandle_t s_state_task_handle = NULL;
 static TaskHandle_t s_playlist_task_handle = NULL;
 static volatile bool s_playlist_stop_requested = false;
 static uint32_t s_state_sequence = 0;
+
+typedef struct {
+    bool active;
+    uint32_t gen;
+    char app_id[32];
+    char task_id[32];
+    char source[24];
+    char *params_json;
+} board_mqtt_queued_app_task_t;
+
+static board_mqtt_queued_app_task_t s_queued_app_task = {0};
+static uint32_t s_queued_app_gen = 0;
+
+typedef struct {
+    bool success;
+    int error_code;
+    const char *info;
+} board_mqtt_action_result_t;
 
 static void BoardMqtt_HandleCommand(const char *topic, int topic_len, const char *data, int data_len);
 static bool BoardMqtt_CopyString(char *dest, size_t dest_size, const char *src);
@@ -82,6 +110,10 @@
 static void BoardMqtt_FinishTask(board_mqtt_task_type_t type);
 static void BoardMqtt_StopPlaylistTask(void);
 static esp_err_t BoardMqtt_StartPlaylistTask(void);
+static board_mqtt_action_result_t BoardMqtt_HandleUpdateManifest(const char *req_id, cJSON *payload);
+static void BoardMqtt_ClearQueuedAppTask(void);
+static void BoardMqtt_StartQueuedAppTaskIfReady(bool preload_ok, uint32_t queued_gen);
+static bool s_bind_manifest_to_queue = false;
 
 typedef struct {
     char topic[BOARD_MQTT_TOPIC_MAX_LEN];
@@ -98,12 +130,6 @@
     BOARD_MQTT_TOPIC_CTL,
     BOARD_MQTT_TOPIC_TASK,
 } board_mqtt_topic_type_t;
-
-typedef struct {
-    bool success;
-    int error_code;
-    const char *info;
-} board_mqtt_action_result_t;
 
 static board_mqtt_action_result_t BoardMqtt_HandleUnsetAlarm(cJSON *payload);
 
@@ -126,6 +152,8 @@
     char *manifest_json;
     size_t asset_count;
     bool replace_existing_download;
+    bool start_queued_app;
+    uint32_t queued_gen;
     board_mqtt_manifest_asset_t assets[];
 } board_mqtt_manifest_job_t;
 
@@ -160,7 +188,9 @@
     size_t asset_index;
     size_t asset_count;
     int64_t last_publish_us;
+    int64_t last_display_us;
     int last_progress;
+    bool ignore_voice_yield;
 } board_mqtt_manifest_progress_ctx_t;
 
 static bool BoardMqtt_IsDownloadTask(board_mqtt_task_type_t type)
@@ -175,6 +205,11 @@
     type = s_task_type;
     portEXIT_CRITICAL(&s_task_lock);
     return type;
+}
+
+bool Board_IsLessonSyncing(void)
+{
+    return BoardMqtt_IsDownloadTask(BoardMqtt_GetTaskType());
 }
 
 static bool BoardMqtt_BeginTask(board_mqtt_task_type_t type)
@@ -300,9 +335,9 @@
     return (long)now;
 }
 
-static void BoardMqtt_PublishJson(const char *topic, cJSON *json, int qos)
+static void BoardMqtt_PublishJsonTo(const char *topic, cJSON *json, int qos, int retain)
 {
-    if (!s_mqtt_client || !s_mqtt_connected || !topic || !json) {
+    if (!s_mqtt_client || !s_mqtt_connected || !topic || !topic[0] || !json) {
         return;
     }
 
@@ -312,7 +347,7 @@
         return;
     }
 
-    int msg_id = esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, qos, 0);
+    int msg_id = esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, qos, retain);
     if (msg_id >= 0) {
         ESP_LOGI(TAG, "Published MQTT message, topic=%s, msg_id=%d", topic, msg_id);
     } else {
@@ -321,8 +356,17 @@
     cJSON_free(payload);
 }
 
+static void BoardMqtt_PublishJson(const char *topic, cJSON *json, int qos)
+{
+    BoardMqtt_PublishJsonTo(topic, json, qos, 0);
+}
+
 static void BoardMqtt_WaitConnectedForFinalReport(void)
 {
+    if (heap_caps_get_free_size(MALLOC_CAP_INTERNAL) < BOARD_MQTT_RECONNECT_MIN_INTERNAL_HEAP) {
+        ESP_LOGW(TAG, "Skip MQTT final report wait, internal heap low");
+        return;
+    }
     int waited_ms = 0;
     while (s_mqtt_client && !s_mqtt_connected && waited_ms < BOARD_MQTT_FINAL_REPORT_WAIT_MS) {
         ESP_LOGW(TAG, "Waiting MQTT reconnect before final report, waited=%dms", waited_ms);
@@ -359,11 +403,18 @@
     cJSON_AddNumberToObject(payload, "cpu", Board_Get_CPU_Usage());
     cJSON_AddNumberToObject(payload, "mem", mem_percent);
     cJSON_AddNumberToObject(payload, "battery", Battery_Get_Value());
+    cJSON_AddTrueToObject(payload, "online");
     cJSON_AddStringToObject(payload, "net", wifi_connected ? "wifi" : "offline");
     cJSON_AddStringToObject(payload, "mode", task_type != BOARD_MQTT_TASK_NONE ? "task" : (Board_Is_Voice_Session_Active() ? "voice" : "idle"));
+    if (s_mac_id[0]) {
+        cJSON_AddStringToObject(payload, "mac", s_mac_id);
+    }
     cJSON_AddItemToObject(root, "payload", payload);
 
-    BoardMqtt_PublishJson(s_rpt_state_topic, root, BOARD_MQTT_QOS);
+    BoardMqtt_PublishJsonTo(s_rpt_state_topic, root, BOARD_MQTT_QOS, 1);
+    if (s_rpt_state_mac_topic[0] && strcmp(s_rpt_state_mac_topic, s_rpt_state_topic) != 0) {
+        BoardMqtt_PublishJsonTo(s_rpt_state_mac_topic, root, BOARD_MQTT_QOS, 1);
+    }
     cJSON_Delete(root);
 }
 
@@ -485,6 +536,23 @@
     cJSON_Delete(root);
 }
 
+static void BoardMqtt_ShowLessonSyncProgress(int percent, size_t asset_index, size_t asset_count)
+{
+    char msg[96];
+    if (percent < 0) {
+        percent = 0;
+    } else if (percent > 100) {
+        percent = 100;
+    }
+    if (asset_count == 0) {
+        snprintf(msg, sizeof(msg), "姝e湪鍚屾璇炬枃锛岃绋嶅��");
+    } else {
+        snprintf(msg, sizeof(msg), "姝e湪涓嬭浇 %u/%u锛屽凡瀹屾垚 %d%%",
+                 (unsigned)(asset_index + 1), (unsigned)asset_count, percent);
+    }
+    Board_Show_Alert("姝e湪鍚屾", msg, "cloud_arrow_down");
+}
+
 static char *BoardMqtt_CopySegment(const char *data, int data_len)
 {
     if (!data || data_len <= 0) {
@@ -583,6 +651,43 @@
     return written > 0 && (size_t)written < dest_size;
 }
 
+static bool BoardMqtt_SdRegularFileReady(const char *business_path)
+{
+    char sd_path[SDCARD_PATH_MAX_LEN];
+    if (!business_path || business_path[0] == '\0') {
+        return false;
+    }
+    if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+        return false;
+    }
+    struct stat st;
+    if (stat(sd_path, &st) != 0) {
+        return false;
+    }
+    return S_ISREG(st.st_mode) && st.st_size > 0;
+}
+
+static bool BoardMqtt_JoinDownloadUrl(char *dest, size_t dest_size, const char *base_url, const char *rel)
+{
+    if (!dest || dest_size == 0 || !base_url || !rel || rel[0] == '\0') {
+        return false;
+    }
+    size_t base_len = strlen(base_url);
+    while (base_len > 0 && base_url[base_len - 1] == '/') {
+        --base_len;
+    }
+    if (base_len == 0) {
+        return false;
+    }
+    int written;
+    if (rel[0] == '/') {
+        written = snprintf(dest, dest_size, "%.*s%s", (int)base_len, base_url, rel);
+    } else {
+        written = snprintf(dest, dest_size, "%.*s/%s", (int)base_len, base_url, rel);
+    }
+    return written > 0 && (size_t)written < dest_size;
+}
+
 static bool BoardMqtt_IsSupportedResourcePath(const char *sd_path)
 {
     const char *dot = sd_path ? strrchr(sd_path, '.') : NULL;
@@ -610,6 +715,88 @@
     return BoardMqtt_CopyString(dest, dest_size, md5->valuestring);
 }
 
+static bool BoardMqtt_IsLessonAppTask(const char *app_id, const char *task_id)
+{
+    return app_id && task_id && strcmp(app_id, "english_learning") == 0 &&
+           (strcmp(task_id, "repeater") == 0 || strcmp(task_id, "shadowing") == 0);
+}
+
+static void BoardMqtt_ClearQueuedAppTask(void)
+{
+    free(s_queued_app_task.params_json);
+    memset(&s_queued_app_task, 0, sizeof(s_queued_app_task));
+    s_queued_app_gen++;
+}
+
+static bool BoardMqtt_QueueAppTask(const char *app_id, const char *task_id, const char *source, const char *params_json)
+{
+    BoardMqtt_ClearQueuedAppTask();
+    if (!BoardMqtt_CopyString(s_queued_app_task.app_id, sizeof(s_queued_app_task.app_id), app_id) ||
+        !BoardMqtt_CopyString(s_queued_app_task.task_id, sizeof(s_queued_app_task.task_id), task_id) ||
+        !BoardMqtt_CopyString(s_queued_app_task.source, sizeof(s_queued_app_task.source), source && source[0] ? source : "mqtt_immediate")) {
+        BoardMqtt_ClearQueuedAppTask();
+        return false;
+    }
+    const char *params = (params_json && params_json[0]) ? params_json : "{}";
+    size_t params_len = strlen(params) + 1;
+    s_queued_app_task.params_json = (char *)malloc(params_len);
+    if (!s_queued_app_task.params_json) {
+        BoardMqtt_ClearQueuedAppTask();
+        return false;
+    }
+    memcpy(s_queued_app_task.params_json, params, params_len);
+    s_queued_app_task.active = true;
+    s_queued_app_task.gen = s_queued_app_gen;
+    return true;
+}
+
+static bool BoardMqtt_PackageCached(cJSON *files)
+{
+    if (!cJSON_IsArray(files) || cJSON_GetArraySize(files) <= 0) {
+        return false;
+    }
+    cJSON *file = NULL;
+    cJSON_ArrayForEach(file, files) {
+        if (!cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') {
+            return false;
+        }
+        char business_path[SDCARD_PATH_MAX_LEN];
+        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), file->valuestring)) {
+            return false;
+        }
+        if (!BoardMqtt_SdRegularFileReady(business_path)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static void BoardMqtt_StartQueuedAppTaskIfReady(bool preload_ok, uint32_t queued_gen)
+{
+    if (!s_queued_app_task.active || s_queued_app_task.gen != queued_gen) {
+        return;
+    }
+    if (!preload_ok) {
+        Board_ReleaseHoldIdle();
+        ESP_LOGW(TAG, "preload failed, drop queued %s/%s", s_queued_app_task.app_id, s_queued_app_task.task_id);
+        Board_Show_Alert("鍚屾澶辫触", "璇炬枃涓嬭浇澶辫触锛岃绋嶅悗閲嶈瘯", "sad");
+        BoardMqtt_ClearQueuedAppTask();
+        return;
+    }
+    ESP_LOGI(TAG, "package READY, start AI task %s/%s", s_queued_app_task.app_id, s_queued_app_task.task_id);
+    char app_id[32];
+    char task_id[32];
+    char source[24];
+    char *params = s_queued_app_task.params_json;
+    s_queued_app_task.params_json = NULL;
+    BoardMqtt_CopyString(app_id, sizeof(app_id), s_queued_app_task.app_id);
+    BoardMqtt_CopyString(task_id, sizeof(task_id), s_queued_app_task.task_id);
+    BoardMqtt_CopyString(source, sizeof(source), s_queued_app_task.source);
+    BoardMqtt_ClearQueuedAppTask();
+    Board_StartAppTask(app_id, task_id, params, source);
+    free(params);
+}
+
 static bool BoardMqtt_ActionIsCtl(const char *action)
 {
     return strcmp(action, "SET_VOLUME") == 0 || strcmp(action, "SET_POWER") == 0 ||
@@ -617,6 +804,7 @@
         strcmp(action, "STOP_MEDIA") == 0 || strcmp(action, "DELETE_FILE") == 0 ||
         strcmp(action, "SET_ALARM") == 0 || strcmp(action, "UNSET_ALARM") == 0 ||
         strcmp(action, "REBOOT") == 0 || strcmp(action, "START_APP_TASK") == 0 ||
+        strcmp(action, "SYNC_SCHEDULE") == 0 ||
         strcmp(action, "ENSURE_APP_DIRS") == 0;
 }
 
@@ -1229,16 +1417,6 @@
     if (err != ESP_OK) {
         return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
     }
-
-    cJSON *app_id = cJSON_GetObjectItem(payload, "app_id");
-    cJSON *task_id = cJSON_GetObjectItem(payload, "task_id");
-    if (cJSON_IsString(app_id) && app_id->valuestring && app_id->valuestring[0]) {
-        const char *tid = (cJSON_IsString(task_id) && task_id->valuestring) ? task_id->valuestring : "";
-        cJSON *params = cJSON_GetObjectItem(payload, "params");
-        char *params_json = params ? cJSON_PrintUnformatted(params) : NULL;
-        Board_SavePendingTask(app_id->valuestring, tid, params_json, "schedule", id->valuestring);
-        free(params_json);
-    }
     return BoardMqtt_ResultOk("alarm updated");
 }
 
@@ -1331,7 +1509,8 @@
     if (!force && progress == progress_ctx->last_progress) {
         return true;
     }
-    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
+    if (!force && progress_ctx->last_publish_us > 0
+        && now - progress_ctx->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
         return true;
     }
 
@@ -1352,7 +1531,7 @@
         ESP_LOGI(TAG, "Cancel manifest download by request");
         return false;
     }
-    if (Board_Is_Voice_Session_Active()) {
+    if (!progress_ctx->ignore_voice_yield && Board_Is_Voice_Session_Active()) {
         s_download_voice_yield_requested = true;
         ESP_LOGI(TAG, "Cancel manifest download for voice session");
         return false;
@@ -1366,11 +1545,21 @@
     int aggregate_progress = (int)(((progress_ctx->asset_index * 100) + (size_t)progress) / progress_ctx->asset_count);
 
     int64_t now = esp_timer_get_time();
-    bool force = progress <= 0 || progress >= 100 || aggregate_progress >= 100;
+    bool show = progress_ctx->last_display_us == 0
+        || now - progress_ctx->last_display_us >= BOARD_MQTT_SYNC_DISPLAY_INTERVAL_US
+        || aggregate_progress >= 100
+        || progress <= 0;
+    if (show) {
+        progress_ctx->last_display_us = now;
+        BoardMqtt_ShowLessonSyncProgress(aggregate_progress, progress_ctx->asset_index, progress_ctx->asset_count);
+    }
+
+    bool force = progress >= 100 || progress_ctx->last_progress < 0 || aggregate_progress >= 100;
     if (!force && aggregate_progress == progress_ctx->last_progress) {
         return true;
     }
-    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
+    if (!force && progress_ctx->last_publish_us > 0
+        && now - progress_ctx->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
         return true;
     }
 
@@ -1395,7 +1584,8 @@
     if (!force && progress == job->last_progress) {
         return;
     }
-    if (!force && job->last_publish_us > 0 && now - job->last_publish_us < 2000000) {
+    if (!force && job->last_publish_us > 0
+        && now - job->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
         return;
     }
 
@@ -1467,6 +1657,24 @@
 
     Board_Acquire_Sleep_Inhibit();
     s_download_voice_yield_requested = false;
+    if (job->start_queued_app) {
+        ESP_LOGI(TAG, "lesson preload keeps downloading during listen/speaking");
+    }
+
+    bool need_download = false;
+    for (size_t i = 0; i < job->asset_count; ++i) {
+        if (!BoardMqtt_SdRegularFileReady(job->assets[i].target_path)) {
+            need_download = true;
+            break;
+        }
+    }
+    if (need_download) {
+        Board_SetLessonDownloadBoost(true);
+        vTaskDelay(pdMS_TO_TICKS(80));
+        if (job->start_queued_app) {
+            Board_AnnounceLessonSyncAndWait();
+        }
+    }
 
     char final_path[SDCARD_PATH_MAX_LEN] = {0};
     char part_path[SDCARD_PATH_MAX_LEN] = {0};
@@ -1475,6 +1683,7 @@
 
     vTaskDelay(pdMS_TO_TICKS(100));
     BoardMqtt_PublishProgress(job->req_id, 0, "saving manifest");
+    BoardMqtt_ShowLessonSyncProgress(0, 0, job->asset_count);
     err = SDCard_Save_Manifest(job->course_id, job->manifest_json);
     if (err != ESP_OK) {
         snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
@@ -1485,6 +1694,11 @@
         memset(final_path, 0, sizeof(final_path));
         memset(part_path, 0, sizeof(part_path));
         memset(error_info, 0, sizeof(error_info));
+
+        if (BoardMqtt_SdRegularFileReady(asset->target_path)) {
+            ESP_LOGI(TAG, "skip manifest asset, already on sd: %s", asset->target_path);
+            continue;
+        }
 
         err = SDCard_Prepare_Business_File(asset->target_path, final_path, sizeof(final_path), part_path, sizeof(part_path));
         if (err != ESP_OK) {
@@ -1497,11 +1711,17 @@
             .asset_index = i,
             .asset_count = job->asset_count,
             .last_publish_us = 0,
+            .last_display_us = 0,
             .last_progress = -1,
+            .ignore_voice_yield = job->start_queued_app,
         };
         err = Board_Download_File_To_Path(asset->url, part_path, asset->md5, BoardMqtt_ManifestProgressCallback,
                                           &progress_ctx, error_info, sizeof(error_info));
         if (err != ESP_OK) {
+            ESP_LOGW(TAG, "manifest asset failed %u/%u path=%s err=%s info=%s",
+                     (unsigned)(i + 1), (unsigned)job->asset_count,
+                     asset->target_path, esp_err_to_name(err),
+                     error_info[0] ? error_info : "");
             SDCard_Remove_File_If_Exists(part_path);
             if (s_download_voice_yield_requested || s_download_cancel_requested) {
                 int canceled_progress = progress_ctx.last_progress >= 0 ? progress_ctx.last_progress : 0;
@@ -1534,13 +1754,26 @@
         if (err == ESP_OK) {
             BoardMqtt_PublishProgress(job->req_id, 100, "manifest assets downloaded");
             BoardMqtt_PublishAck(job->req_id, true, 0, "manifest saved and assets downloaded");
+            if (job->start_queued_app) {
+                Board_Show_Alert("鍚屾瀹屾垚", "寮�濮嬫挱鏀�", "happy");
+                BoardMqtt_StartQueuedAppTaskIfReady(true, job->queued_gen);
+            }
+            Board_Schedule_OnLessonFilesUpdated();
         } else {
             BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED,
                                  error_info[0] ? error_info : esp_err_to_name(err));
+            if (job->start_queued_app) {
+                BoardMqtt_StartQueuedAppTaskIfReady(false, job->queued_gen);
+            } else {
+                Board_Show_Alert("鍚屾澶辫触", error_info[0] ? error_info : "璇炬枃涓嬭浇澶辫触", "sad");
+            }
         }
     }
 
     s_download_voice_yield_requested = false;
+    if (need_download) {
+        Board_SetLessonDownloadBoost(false);
+    }
     Board_Release_Sleep_Inhibit();
     BoardMqtt_FinishTask(BOARD_MQTT_TASK_MANIFEST);
     cJSON_free(job->manifest_json);
@@ -1558,6 +1791,8 @@
 
     Board_Acquire_Sleep_Inhibit();
     s_download_voice_yield_requested = false;
+    Board_SetLessonDownloadBoost(true);
+    vTaskDelay(pdMS_TO_TICKS(80));
 
     char final_path[SDCARD_PATH_MAX_LEN] = {0};
     char part_path[SDCARD_PATH_MAX_LEN] = {0};
@@ -1608,6 +1843,7 @@
     }
 
     s_download_voice_yield_requested = false;
+    Board_SetLessonDownloadBoost(false);
     BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD);
     Board_Release_Sleep_Inhibit();
     free(job);
@@ -1641,6 +1877,10 @@
     }
     if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
         return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg download is supported");
+    }
+    if (BoardMqtt_SdRegularFileReady(business_path)) {
+        ESP_LOGI(TAG, "skip download, already on sd: %s", business_path);
+        return BoardMqtt_ResultOk("already on sd");
     }
     BoardMqtt_StopPlaylistTask();
     if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_DOWNLOAD)) {
@@ -1724,11 +1964,25 @@
 {
     cJSON *course_id = cJSON_GetObjectItem(payload, "course_id");
     cJSON *assets = cJSON_GetObjectItem(payload, "assets");
+    cJSON *files = cJSON_GetObjectItem(payload, "files");
+    cJSON *base_url = cJSON_GetObjectItem(payload, "base_url");
+    bool has_assets = cJSON_IsArray(assets) && cJSON_GetArraySize(assets) > 0;
+    bool has_files = cJSON_IsString(base_url) && base_url->valuestring && base_url->valuestring[0] != '\0' &&
+                     cJSON_IsArray(files) && cJSON_GetArraySize(files) > 0;
     if (!cJSON_IsString(course_id) || !BoardMqtt_IsSafeCourseId(course_id->valuestring) ||
-        !cJSON_IsArray(assets) || cJSON_GetArraySize(assets) <= 0) {
+        (!has_assets && !has_files)) {
         return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest payload");
     }
-    BoardMqtt_StopPlaylistTask();
+    if (has_files && !BoardMqtt_HasPrefix(base_url->valuestring, "http://") &&
+        !BoardMqtt_HasPrefix(base_url->valuestring, "https://")) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest base_url");
+    }
+    if (has_files && !BoardMqtt_PackageCached(files)) {
+        Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾璇炬枃锛岃绋嶅��", "cloud_arrow_down");
+    }
+    if (s_bind_manifest_to_queue) {
+        BoardMqtt_StopPlaylistTask();
+    }
 
     bool replace_existing_download = false;
     board_mqtt_task_type_t current_type = BoardMqtt_GetTaskType();
@@ -1751,13 +2005,14 @@
         return BoardMqtt_ResultFail((code), (message)); \
     } while (0)
 
-    int asset_count_int = cJSON_GetArraySize(assets);
+    int asset_count_int = has_assets ? cJSON_GetArraySize(assets) : cJSON_GetArraySize(files);
     size_t source_asset_count = (size_t)asset_count_int;
-    if (source_asset_count > SIZE_MAX / 2) {
+    if (has_assets && source_asset_count > SIZE_MAX / 2) {
         BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large");
     }
-    size_t max_asset_count = source_asset_count * 2;
-    if (max_asset_count > (SIZE_MAX - sizeof(board_mqtt_manifest_job_t)) / sizeof(board_mqtt_manifest_asset_t)) {
+    size_t max_asset_count = has_assets ? source_asset_count * 2 : source_asset_count;
+    if (max_asset_count == 0 ||
+        max_asset_count > (SIZE_MAX - sizeof(board_mqtt_manifest_job_t)) / sizeof(board_mqtt_manifest_asset_t)) {
         BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large");
     }
 
@@ -1774,6 +2029,8 @@
     }
     job->manifest_json = manifest_json;
     job->replace_existing_download = replace_existing_download;
+    job->start_queued_app = s_bind_manifest_to_queue && s_queued_app_task.active;
+    job->queued_gen = s_queued_app_task.gen;
 
 #define BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(code, message) do { \
         cJSON_free(job->manifest_json); \
@@ -1787,73 +2044,103 @@
     }
 
     size_t index = 0;
-    cJSON *asset = NULL;
-    cJSON_ArrayForEach(asset, assets) {
-        cJSON *url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "url") : NULL;
-        cJSON *target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "target_path") : NULL;
-        cJSON *md5 = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "md5") : NULL;
-        if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset");
+    if (has_files && !has_assets) {
+        cJSON *file = NULL;
+        cJSON_ArrayForEach(file, files) {
+            if (!cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest file path");
+            }
+            char business_path[SDCARD_PATH_MAX_LEN];
+            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), file->valuestring)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+            }
+            char sd_path[SDCARD_PATH_MAX_LEN];
+            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+            }
+            if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
+            }
+            char joined_url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+            if (!BoardMqtt_JoinDownloadUrl(joined_url, sizeof(joined_url), base_url->valuestring, business_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset url too long");
+            }
+            board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
+            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), joined_url) ||
+                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
+            }
+            ++index;
         }
-        if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset url");
-        }
-        if (!BoardMqtt_ValidateOptionalMd5(md5)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset md5");
-        }
+    } else {
+        cJSON *asset = NULL;
+        cJSON_ArrayForEach(asset, assets) {
+            cJSON *url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "url") : NULL;
+            cJSON *target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "target_path") : NULL;
+            cJSON *md5 = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "md5") : NULL;
+            if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset");
+            }
+            if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset url");
+            }
+            if (!BoardMqtt_ValidateOptionalMd5(md5)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset md5");
+            }
 
-        char business_path[SDCARD_PATH_MAX_LEN];
-        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
-        }
+            char business_path[SDCARD_PATH_MAX_LEN];
+            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+            }
 
-        char sd_path[SDCARD_PATH_MAX_LEN];
-        if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
-        }
-        if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
-        }
+            char sd_path[SDCARD_PATH_MAX_LEN];
+            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+            }
+            if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
+            }
 
-        board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
-        if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), url->valuestring) ||
-            !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path) ||
-            !BoardMqtt_CopyOptionalMd5(job_asset->md5, sizeof(job_asset->md5), md5)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
-        }
-        ++index;
+            board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
+            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), url->valuestring) ||
+                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path) ||
+                !BoardMqtt_CopyOptionalMd5(job_asset->md5, sizeof(job_asset->md5), md5)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
+            }
+            ++index;
 
-        cJSON *cover_url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_url") : NULL;
-        cJSON *cover_target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_target_path") : NULL;
-        if (!cJSON_IsString(cover_url) || !cJSON_IsString(cover_target_path) ||
-            cover_url->valuestring[0] == '\0' || cover_target_path->valuestring[0] == '\0') {
-            continue;
-        }
-        if (!BoardMqtt_HasPrefix(cover_url->valuestring, "http://") && !BoardMqtt_HasPrefix(cover_url->valuestring, "https://")) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover url");
-        }
-        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), cover_target_path->valuestring)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
-        }
-        if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
-        }
-        const char *cover_dot = strrchr(sd_path, '.');
-        if (!cover_dot || strcasecmp(cover_dot, ".jpg") != 0) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only jpg cover assets are supported");
-        }
+            cJSON *cover_url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_url") : NULL;
+            cJSON *cover_target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_target_path") : NULL;
+            if (!cJSON_IsString(cover_url) || !cJSON_IsString(cover_target_path) ||
+                cover_url->valuestring[0] == '\0' || cover_target_path->valuestring[0] == '\0') {
+                continue;
+            }
+            if (!BoardMqtt_HasPrefix(cover_url->valuestring, "http://") && !BoardMqtt_HasPrefix(cover_url->valuestring, "https://")) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover url");
+            }
+            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), cover_target_path->valuestring)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
+            }
+            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
+            }
+            const char *cover_dot = strrchr(sd_path, '.');
+            if (!cover_dot || strcasecmp(cover_dot, ".jpg") != 0) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only jpg cover assets are supported");
+            }
 
-        job_asset = &job->assets[index];
-        if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), cover_url->valuestring) ||
-            !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest cover field too long");
+            job_asset = &job->assets[index];
+            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), cover_url->valuestring) ||
+                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest cover field too long");
+            }
+            ++index;
         }
-        ++index;
     }
     job->asset_count = index;
 
-    if (SDCard_Is_Playing()) {
-        ESP_LOGI(TAG, "Stop MP3 playback before MQTT manifest download");
+    if (job->start_queued_app && SDCard_Is_Playing()) {
+        ESP_LOGI(TAG, "Stop MP3 playback before PRELOAD for queued AI task");
         SDCard_Stop_Play();
     }
 
@@ -1868,7 +2155,7 @@
     return BoardMqtt_ResultOk("manifest accepted");
 }
 
-static board_mqtt_action_result_t BoardMqtt_HandleStartAppTask(cJSON *payload)
+static board_mqtt_action_result_t BoardMqtt_HandleStartAppTask(const char *req_id, cJSON *payload)
 {
     if (!payload) {
         return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload");
@@ -1877,24 +2164,77 @@
     cJSON *task_id = cJSON_GetObjectItem(payload, "task_id");
     cJSON *source = cJSON_GetObjectItem(payload, "source");
     cJSON *params = cJSON_GetObjectItem(payload, "params");
+    cJSON *package = cJSON_GetObjectItem(payload, "package");
     if (!cJSON_IsString(app_id) || !app_id->valuestring || app_id->valuestring[0] == '\0') {
         return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing app_id");
     }
     const char *tid = (cJSON_IsString(task_id) && task_id->valuestring) ? task_id->valuestring : "";
     const char *src = (cJSON_IsString(source) && source->valuestring) ? source->valuestring : "mqtt_immediate";
     char *params_json = params ? cJSON_PrintUnformatted(params) : NULL;
-    esp_err_t err = Board_StartAppTask(app_id->valuestring, tid, params_json, src);
+    const bool lesson = BoardMqtt_IsLessonAppTask(app_id->valuestring, tid);
+
+    if (!lesson || !cJSON_IsObject(package)) {
+        if (lesson && !Board_LessonFilesReady(tid, params_json)) {
+            ESP_LOGW(TAG, "lesson files missing on SD, start after sync %s/%s",
+                     app_id->valuestring, tid);
+            Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾闊抽锛岃绋嶇瓑", "cloud_arrow_down");
+        }
+        BoardMqtt_ClearQueuedAppTask();
+        esp_err_t err = Board_StartAppTask(app_id->valuestring, tid, params_json, src);
+        free(params_json);
+        if (err == ESP_ERR_NOT_FOUND) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "unknown app or task");
+        }
+        if (err == ESP_ERR_INVALID_ARG) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid app or task id");
+        }
+        if (err == ESP_ERR_INVALID_STATE) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "preempted by higher priority task");
+        }
+        if (err != ESP_OK) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+        }
+        return BoardMqtt_ResultOk("app task started");
+    }
+
+    cJSON *files = cJSON_GetObjectItem(package, "files");
+    if (BoardMqtt_PackageCached(files)) {
+        ESP_LOGI(TAG, "package cached, READY %s/%s", app_id->valuestring, tid);
+        BoardMqtt_ClearQueuedAppTask();
+        esp_err_t err = Board_StartAppTask(app_id->valuestring, tid, params_json, src);
+        free(params_json);
+        if (err == ESP_ERR_NOT_FOUND) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "unknown app or task");
+        }
+        if (err == ESP_ERR_INVALID_ARG) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid app or task id");
+        }
+        if (err == ESP_ERR_INVALID_STATE) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "preempted by higher priority task");
+        }
+        if (err != ESP_OK) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+        }
+        return BoardMqtt_ResultOk("app task started");
+    }
+
+    if (!BoardMqtt_QueueAppTask(app_id->valuestring, tid, src, params_json)) {
+        free(params_json);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to queue app task");
+    }
     free(params_json);
-    if (err == ESP_ERR_NOT_FOUND) {
-        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "unknown app or task");
+    ESP_LOGI(TAG, "PRELOAD package then run %s/%s", app_id->valuestring, tid);
+    Board_HoldIdleForServerPrompt();
+    Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾璇炬枃锛岃绋嶅��", "cloud_arrow_down");
+    s_bind_manifest_to_queue = true;
+    board_mqtt_action_result_t preload = BoardMqtt_HandleUpdateManifest(req_id, package);
+    s_bind_manifest_to_queue = false;
+    if (!preload.success) {
+        Board_ReleaseHoldIdle();
+        BoardMqtt_ClearQueuedAppTask();
+        Board_Show_Alert("鍚屾澶辫触", preload.info ? preload.info : "璇炬枃涓嬭浇澶辫触", "sad");
     }
-    if (err == ESP_ERR_INVALID_STATE) {
-        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "preempted by higher priority task");
-    }
-    if (err != ESP_OK) {
-        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
-    }
-    return BoardMqtt_ResultOk("app task started");
+    return preload.success ? BoardMqtt_ResultOk("preload queued") : preload;
 }
 
 static board_mqtt_action_result_t BoardMqtt_HandleEnsureAppDirs(cJSON *payload)
@@ -1929,6 +2269,29 @@
     return BoardMqtt_ResultOk("app dirs ensured");
 }
 
+static board_mqtt_action_result_t BoardMqtt_HandleSyncSchedule(cJSON *payload)
+{
+    if (!payload) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload");
+    }
+    char *json = cJSON_PrintUnformatted(payload);
+    if (!json) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "schedule json alloc failed");
+    }
+    esp_err_t err = Board_Schedule_SyncJson(json);
+    free(json);
+    if (err == ESP_ERR_INVALID_ARG) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid schedule");
+    }
+    if (err == ESP_ERR_NO_MEM) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "too many scheduled tasks");
+    }
+    if (err != ESP_OK) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+    }
+    return BoardMqtt_ResultOk("schedule synced");
+}
+
 static board_mqtt_action_result_t BoardMqtt_DispatchAction(const char *req_id, const char *action, cJSON *payload)
 {
     if (strcmp(action, "SET_VOLUME") == 0) return BoardMqtt_HandleSetVolume(payload);
@@ -1943,17 +2306,20 @@
     if (strcmp(action, "UPDATE_MANIFEST") == 0 || strcmp(action, "SYNC_COURSE_TASK") == 0) return BoardMqtt_HandleUpdateManifest(req_id, payload);
     if (strcmp(action, "DOWNLOAD") == 0) return BoardMqtt_HandleDownload(req_id, payload);
     if (strcmp(action, "OTA_UPDATE") == 0) return BoardMqtt_HandleOtaUpdate(req_id, payload);
-    if (strcmp(action, "START_APP_TASK") == 0) return BoardMqtt_HandleStartAppTask(payload);
+    if (strcmp(action, "START_APP_TASK") == 0) return BoardMqtt_HandleStartAppTask(req_id, payload);
+    if (strcmp(action, "SYNC_SCHEDULE") == 0) return BoardMqtt_HandleSyncSchedule(payload);
     if (strcmp(action, "ENSURE_APP_DIRS") == 0) return BoardMqtt_HandleEnsureAppDirs(payload);
     return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "unsupported action");
 }
 
 static board_mqtt_topic_type_t BoardMqtt_GetTopicType(const char *topic)
 {
-    if (strcmp(topic, s_cmd_ctl_topic) == 0) {
+    if (strcmp(topic, s_cmd_ctl_topic) == 0 ||
+        (s_cmd_ctl_mac_topic[0] && strcmp(topic, s_cmd_ctl_mac_topic) == 0)) {
         return BOARD_MQTT_TOPIC_CTL;
     }
-    if (strcmp(topic, s_cmd_task_topic) == 0) {
+    if (strcmp(topic, s_cmd_task_topic) == 0 ||
+        (s_cmd_task_mac_topic[0] && strcmp(topic, s_cmd_task_mac_topic) == 0)) {
         return BOARD_MQTT_TOPIC_TASK;
     }
     return BOARD_MQTT_TOPIC_UNKNOWN;
@@ -2074,7 +2440,9 @@
         result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_TOPIC_MISMATCH, "invalid command topic");
     } else if (!cJSON_IsString(version) || strcmp(version->valuestring, "1.0") != 0) {
         result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.version");
-    } else if (!cJSON_IsString(device_id) || strcmp(device_id->valuestring, s_device_id) != 0) {
+    } else if (!cJSON_IsString(device_id) ||
+               (strcmp(device_id->valuestring, s_device_id) != 0 &&
+                (s_mac_id[0] == '\0' || strcasecmp(device_id->valuestring, s_mac_id) != 0))) {
         result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.device_id");
     } else if (!cJSON_IsNumber(timestamp)) {
         result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.timestamp");
@@ -2095,49 +2463,45 @@
     free(payload_text);
 }
 
-static void BoardMqtt_ReconnectTask(void *arg)
+static void BoardMqtt_TryReconnect(void)
 {
-    (void)arg;
-    while (s_mqtt_client && !s_mqtt_connected) {
-        if (Board_Is_Voice_Session_Active()) {
-            ESP_LOGI(TAG, "Delay local MQTT reconnect while voice session is active");
-            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_DELAY_MS));
-            continue;
-        }
-        ESP_LOGI(TAG, "Reconnecting local MQTT client");
-        esp_err_t err = esp_mqtt_client_reconnect(s_mqtt_client);
-        if (err != ESP_OK) {
-            ESP_LOGW(TAG, "Local MQTT reconnect failed: %s", esp_err_to_name(err));
-            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_DELAY_MS));
-            continue;
-        }
-        break;
+    if (!s_mqtt_client || s_mqtt_connected) {
+        return;
     }
-    s_reconnect_task_handle = NULL;
-    vTaskDelete(NULL);
+    size_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
+    if (internal_free < BOARD_MQTT_RECONNECT_MIN_INTERNAL_HEAP) {
+        ESP_LOGW(TAG, "Skip MQTT reconnect, internal heap=%u", (unsigned)internal_free);
+        return;
+    }
+    ESP_LOGI(TAG, "Reconnecting local MQTT client");
+    esp_err_t err = esp_mqtt_client_reconnect(s_mqtt_client);
+    if (err != ESP_OK) {
+        ESP_LOGW(TAG, "Local MQTT reconnect failed: %s", esp_err_to_name(err));
+    }
 }
 
 static void BoardMqtt_ScheduleReconnect(void)
 {
-    if (!s_mqtt_client || s_reconnect_task_handle) {
-        return;
-    }
-    BaseType_t ret = xTaskCreate(BoardMqtt_ReconnectTask, "mqtt_reconnect", 4096, NULL, BOARD_MQTT_RECONNECT_TASK_PRIORITY, &s_reconnect_task_handle);
-    if (ret != pdPASS) {
-        ESP_LOGE(TAG, "Failed to create local MQTT reconnect task");
-        s_reconnect_task_handle = NULL;
-    }
+    // 鏂繛鍥炶皟閲屼笉瑕佸啀寮� 4KB 浠诲姟锛氬啀瑙佸悗鍔犺浇鍞ら啋璇嶆椂鍐呴儴 SRAM 寰堢揣锛�
+    // xTaskCreate 澶辫触鍚� WiFi MODEM sleep 杩樹細鍦� phy_track_pll_init 閲� abort銆�
+    ESP_LOGW(TAG, "MQTT disconnected, retry from state task");
 }
 
 static void BoardMqtt_StateTask(void *arg)
 {
     (void)arg;
     while (s_mqtt_client) {
-        vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
-        if (Board_Is_Audio_Busy()) {
+        if (s_mqtt_connected) {
+            if (!BoardMqtt_IsDownloadTask(BoardMqtt_GetTaskType())) {
+                BoardMqtt_PublishState();
+            }
+            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
             continue;
         }
-        BoardMqtt_PublishState();
+        vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_RETRY_MS));
+        if (!s_mqtt_connected) {
+            BoardMqtt_TryReconnect();
+        }
     }
     s_state_task_handle = NULL;
     vTaskDelete(NULL);
@@ -2175,9 +2539,14 @@
     case MQTT_EVENT_CONNECTED:
         s_mqtt_connected = true;
         ESP_LOGI(TAG, "MQTT business server connected, host=%s, port=%d, username=%s", BOARD_MQTT_BROKER_HOST, BOARD_MQTT_BROKER_PORT, BOARD_MQTT_USERNAME);
-        ESP_LOGI(TAG, "MQTT command topics ready, ctl=%s, task=%s", s_cmd_ctl_topic, s_cmd_task_topic);
+        ESP_LOGI(TAG, "MQTT command topics ready, ctl=%s, task=%s, mac_ctl=%s",
+                 s_cmd_ctl_topic, s_cmd_task_topic, s_cmd_ctl_mac_topic);
         BoardMqtt_Subscribe(s_cmd_ctl_topic);
         BoardMqtt_Subscribe(s_cmd_task_topic);
+        if (s_cmd_ctl_mac_topic[0] && strcmp(s_cmd_ctl_mac_topic, s_cmd_ctl_topic) != 0) {
+            BoardMqtt_Subscribe(s_cmd_ctl_mac_topic);
+            BoardMqtt_Subscribe(s_cmd_task_mac_topic);
+        }
         BoardMqtt_PublishLog("MQTT connected");
         BoardMqtt_PublishState();
         break;
@@ -2226,6 +2595,23 @@
         return ESP_ERR_INVALID_SIZE;
     }
 
+    uint8_t mac[6] = {0};
+    if (esp_read_mac(mac, ESP_MAC_WIFI_STA) == ESP_OK) {
+        snprintf(s_mac_id, sizeof(s_mac_id), "%02x:%02x:%02x:%02x:%02x:%02x",
+                 mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
+        BoardMqtt_FormatTopic(s_cmd_ctl_mac_topic, sizeof(s_cmd_ctl_mac_topic),
+                              BOARD_MQTT_CMD_CTL_TOPIC_FORMAT, s_mac_id);
+        BoardMqtt_FormatTopic(s_cmd_task_mac_topic, sizeof(s_cmd_task_mac_topic),
+                              BOARD_MQTT_CMD_TASK_TOPIC_FORMAT, s_mac_id);
+        BoardMqtt_FormatTopic(s_rpt_state_mac_topic, sizeof(s_rpt_state_mac_topic),
+                              BOARD_MQTT_RPT_STATE_TOPIC_FORMAT, s_mac_id);
+    } else {
+        s_mac_id[0] = '\0';
+        s_cmd_ctl_mac_topic[0] = '\0';
+        s_cmd_task_mac_topic[0] = '\0';
+        s_rpt_state_mac_topic[0] = '\0';
+    }
+
     esp_err_t ret = BoardMqtt_FormatTopic(s_cmd_ctl_topic, sizeof(s_cmd_ctl_topic), BOARD_MQTT_CMD_CTL_TOPIC_FORMAT, client_id);
     if (ret != ESP_OK) {
         return ret;
@@ -2269,6 +2655,11 @@
         mqtt_config.credentials.authentication.password = BOARD_MQTT_PASSWORD;
     }
     mqtt_config.session.keepalive = BOARD_MQTT_KEEPALIVE_SEC;
+    mqtt_config.session.last_will.topic = s_rpt_state_mac_topic[0] ? s_rpt_state_mac_topic : s_rpt_state_topic;
+    mqtt_config.session.last_will.msg = BOARD_MQTT_LWT_PAYLOAD;
+    mqtt_config.session.last_will.msg_len = sizeof(BOARD_MQTT_LWT_PAYLOAD) - 1;
+    mqtt_config.session.last_will.qos = 1;
+    mqtt_config.session.last_will.retain = true;
     mqtt_config.network.disable_auto_reconnect = true;
     mqtt_config.network.timeout_ms = BOARD_MQTT_NETWORK_TIMEOUT_MS;
 

--
Gitblit v1.9.1