| | |
| | | #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 |
| | |
| | | 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, |
| | |
| | | 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); |
| | |
| | | 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]; |
| | |
| | | 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); |
| | | |
| | |
| | | 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; |
| | | |
| | |
| | | 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) |
| | |
| | | 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) |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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 { |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | 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) { |
| | |
| | | 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; |
| | |
| | | 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 || |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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"); |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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; |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | |
| | | 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}; |
| | |
| | | |
| | | 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)); |
| | |
| | | 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) { |
| | |
| | | .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; |
| | |
| | | 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); |
| | |
| | | |
| | | 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}; |
| | |
| | | } |
| | | |
| | | s_download_voice_yield_requested = false; |
| | | Board_SetLessonDownloadBoost(false); |
| | | BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD); |
| | | Board_Release_Sleep_Inhibit(); |
| | | free(job); |
| | |
| | | } |
| | | 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)) { |
| | |
| | | { |
| | | 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(); |
| | |
| | | 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"); |
| | | } |
| | | |
| | |
| | | } |
| | | 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); \ |
| | |
| | | } |
| | | |
| | | 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(); |
| | | } |
| | | |
| | |
| | | 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"); |
| | |
| | | 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) |
| | |
| | | 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); |
| | |
| | | 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; |
| | |
| | | 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"); |
| | |
| | | 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); |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |