wangzhibo
10 天以前 220e4fab27b7f419b3e89e2f2d28ae789aac09d7
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), "正在同步课文,请稍候");
    } else {
        snprintf(msg, sizeof(msg), "正在下载 %u/%u,已完成 %d%%",
                 (unsigned)(asset_index + 1), (unsigned)asset_count, percent);
    }
    Board_Show_Alert("正在同步", 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("正在同步", "正在同步课文,请稍候", "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("正在同步", "正在同步音频,请稍等", "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("正在同步", "正在同步课文,请稍候", "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;