#include "mqtt.h" #include #include #include #include #include #include #include #include "esp_heap_caps.h" #include "esp_log.h" #include "esp_timer.h" #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "mqtt_client.h" #include "system.h" #include "app_task.h" #define BOARD_MQTT_KEEPALIVE_SEC 120 #define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 5000 #define BOARD_MQTT_RECONNECT_DELAY_MS 30000 #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_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 #define BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN 128 #define BOARD_MQTT_PLAYLIST_POLL_MS 200 #define BOARD_MQTT_PLAYLIST_TASK_STACK_SIZE 6144 #define BOARD_MQTT_PLAYLIST_TASK_PRIORITY 5 #define BOARD_MQTT_PLAYLIST_PATH MOUNT_POINT "/.mqtt_play_media_playlist.json" #define BOARD_MQTT_QOS 0 #define BOARD_MQTT_ERR_INVALID_PARAM 4001 #define BOARD_MQTT_ERR_UNSUPPORTED 4002 #define BOARD_MQTT_ERR_EXEC_FAILED 4003 #define BOARD_MQTT_ERR_TOPIC_MISMATCH 4004 #define BOARD_MQTT_ERR_NOT_FOUND 4005 #define BOARD_MQTT_ERR_BUSY 4006 static const char *TAG = "board_mqtt"; 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_cmd_ctl_topic[BOARD_MQTT_TOPIC_MAX_LEN]; static char s_cmd_task_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_event_topic[BOARD_MQTT_TOPIC_MAX_LEN]; typedef enum { BOARD_MQTT_TASK_NONE = 0, BOARD_MQTT_TASK_DOWNLOAD, BOARD_MQTT_TASK_MANIFEST, BOARD_MQTT_TASK_OTA, BOARD_MQTT_TASK_PLAYLIST, } board_mqtt_task_type_t; static bool s_task_busy = false; static bool s_manifest_pending = false; static board_mqtt_task_type_t s_task_type = BOARD_MQTT_TASK_NONE; 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; 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 bool BoardMqtt_IsMd5Hex(const char *value); static void BoardMqtt_WaitConnectedForFinalReport(void); static bool BoardMqtt_BeginTask(board_mqtt_task_type_t type); static void BoardMqtt_FinishTask(board_mqtt_task_type_t type); static bool BoardMqtt_PlaybackSpeedIsSupported(float speed); static void BoardMqtt_StopPlaylistTask(void); static esp_err_t BoardMqtt_StartPlaylistTask(void); typedef struct { char topic[BOARD_MQTT_TOPIC_MAX_LEN]; int topic_len; char *payload; int total_len; int received_len; } board_mqtt_payload_assembly_t; static board_mqtt_payload_assembly_t s_payload_assembly = {0}; typedef enum { BOARD_MQTT_TOPIC_UNKNOWN = 0, 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); typedef struct { char req_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN]; char url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN]; char target_path[SDCARD_PATH_MAX_LEN]; char md5[33]; } board_mqtt_download_job_t; typedef struct { char url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN]; char target_path[SDCARD_PATH_MAX_LEN]; char md5[33]; } board_mqtt_manifest_asset_t; typedef struct { char req_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN]; char course_id[ALARM_ID_MAX_LEN]; char *manifest_json; size_t asset_count; bool replace_existing_download; board_mqtt_manifest_asset_t assets[]; } board_mqtt_manifest_job_t; typedef struct { char req_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN]; char url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN]; char version[64]; int64_t last_publish_us; int last_progress; } board_mqtt_ota_job_t; typedef struct { char task_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN]; char path[SDCARD_PATH_MAX_LEN]; char sd_path[SDCARD_PATH_MAX_LEN]; int volume; bool has_volume; float speed; int index; int count; } board_mqtt_playlist_item_t; typedef struct { const char *req_id; int64_t last_publish_us; int last_progress; } board_mqtt_download_progress_ctx_t; typedef struct { const char *req_id; size_t asset_index; size_t asset_count; int64_t last_publish_us; int last_progress; } board_mqtt_manifest_progress_ctx_t; static bool BoardMqtt_IsDownloadTask(board_mqtt_task_type_t type) { return type == BOARD_MQTT_TASK_DOWNLOAD || type == BOARD_MQTT_TASK_MANIFEST; } static board_mqtt_task_type_t BoardMqtt_GetTaskType(void) { board_mqtt_task_type_t type; portENTER_CRITICAL(&s_task_lock); type = s_task_type; portEXIT_CRITICAL(&s_task_lock); return type; } static bool BoardMqtt_BeginTask(board_mqtt_task_type_t type) { bool started = false; portENTER_CRITICAL(&s_task_lock); if (!s_task_busy) { s_task_busy = true; s_task_type = type; s_download_cancel_requested = false; started = true; } portEXIT_CRITICAL(&s_task_lock); return started; } static bool BoardMqtt_BeginManifestReplacement(void) { bool started = false; portENTER_CRITICAL(&s_task_lock); if (s_task_busy && BoardMqtt_IsDownloadTask(s_task_type) && !s_manifest_pending) { s_manifest_pending = true; s_download_cancel_requested = true; started = true; } portEXIT_CRITICAL(&s_task_lock); return started; } static void BoardMqtt_FinishTask(board_mqtt_task_type_t type) { portENTER_CRITICAL(&s_task_lock); if (s_task_type == type) { s_task_busy = false; s_task_type = BOARD_MQTT_TASK_NONE; s_download_cancel_requested = false; } portEXIT_CRITICAL(&s_task_lock); } static bool BoardMqtt_HasPendingManifest(void) { bool pending; portENTER_CRITICAL(&s_task_lock); pending = s_manifest_pending; portEXIT_CRITICAL(&s_task_lock); return pending; } static bool BoardMqtt_ActivatePendingManifest(void) { bool activated = false; portENTER_CRITICAL(&s_task_lock); if (!s_task_busy && s_manifest_pending) { s_task_busy = true; s_task_type = BOARD_MQTT_TASK_MANIFEST; s_manifest_pending = false; s_download_cancel_requested = false; activated = true; } portEXIT_CRITICAL(&s_task_lock); return activated; } static void BoardMqtt_ClearPendingManifest(void) { portENTER_CRITICAL(&s_task_lock); s_manifest_pending = false; portEXIT_CRITICAL(&s_task_lock); } static void BoardMqtt_RequestDownloadCancel(void) { portENTER_CRITICAL(&s_task_lock); if (s_task_busy && BoardMqtt_IsDownloadTask(s_task_type)) { s_download_cancel_requested = true; } portEXIT_CRITICAL(&s_task_lock); } static bool BoardMqtt_WaitDownloadCanceled(int timeout_ms) { int waited_ms = 0; while (true) { board_mqtt_task_type_t type = BoardMqtt_GetTaskType(); if (!BoardMqtt_IsDownloadTask(type)) { return true; } if (waited_ms >= timeout_ms) { return false; } vTaskDelay(pdMS_TO_TICKS(100)); waited_ms += 100; } } bool BoardMqtt_CancelDownload(void) { board_mqtt_task_type_t type = BoardMqtt_GetTaskType(); if (!BoardMqtt_IsDownloadTask(type)) { return false; } BoardMqtt_RequestDownloadCancel(); return true; } static esp_err_t BoardMqtt_FormatTopic(char *buffer, size_t buffer_size, const char *format, const char *device_id) { int topic_len = snprintf(buffer, buffer_size, format, device_id); if (topic_len < 0 || topic_len >= (int)buffer_size) { ESP_LOGE(TAG, "MQTT topic is too long: %s", format); return ESP_ERR_INVALID_SIZE; } return ESP_OK; } static long BoardMqtt_GetTimestamp(void) { time_t now = time(NULL); if (now < 0) { return 0; } return (long)now; } static void BoardMqtt_PublishJson(const char *topic, cJSON *json, int qos) { if (!s_mqtt_client || !s_mqtt_connected || !topic || !json) { return; } char *payload = cJSON_PrintUnformatted(json); if (!payload) { ESP_LOGE(TAG, "Failed to format MQTT JSON payload"); return; } int msg_id = esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, qos, 0); if (msg_id >= 0) { ESP_LOGI(TAG, "Published MQTT message, topic=%s, msg_id=%d", topic, msg_id); } else { ESP_LOGE(TAG, "Failed to publish MQTT message to %s", topic); } cJSON_free(payload); } static void BoardMqtt_WaitConnectedForFinalReport(void) { 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); vTaskDelay(pdMS_TO_TICKS(500)); waited_ms += 500; } } static void BoardMqtt_PublishState(void) { cJSON *root = cJSON_CreateObject(); cJSON *header = cJSON_CreateObject(); cJSON *payload = cJSON_CreateObject(); if (!root || !header || !payload) { cJSON_Delete(root); cJSON_Delete(header); cJSON_Delete(payload); return; } wifi_ap_record_t ap_info = {0}; bool wifi_connected = esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK; size_t free_heap = heap_caps_get_free_size(MALLOC_CAP_8BIT); size_t total_heap = heap_caps_get_total_size(MALLOC_CAP_8BIT); int mem_percent = total_heap > 0 ? (int)(((total_heap - free_heap) * 100) / total_heap) : 0; cJSON_AddStringToObject(header, "device_id", s_device_id); cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp()); cJSON_AddItemToObject(root, "header", header); cJSON_AddStringToObject(root, "msg_type", "state"); ++s_state_sequence; board_mqtt_task_type_t task_type = BoardMqtt_GetTaskType(); cJSON_AddNumberToObject(payload, "cpu", Board_Get_CPU_Usage()); cJSON_AddNumberToObject(payload, "mem", mem_percent); cJSON_AddNumberToObject(payload, "battery", Battery_Get_Value()); 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")); cJSON_AddItemToObject(root, "payload", payload); BoardMqtt_PublishJson(s_rpt_state_topic, root, BOARD_MQTT_QOS); cJSON_Delete(root); } static void BoardMqtt_PublishLog(const char *message) { cJSON *root = cJSON_CreateObject(); cJSON *header = cJSON_CreateObject(); cJSON *payload = cJSON_CreateObject(); if (!root || !header || !payload) { cJSON_Delete(root); cJSON_Delete(header); cJSON_Delete(payload); return; } cJSON_AddStringToObject(header, "device_id", s_device_id); cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp()); cJSON_AddItemToObject(root, "header", header); cJSON_AddStringToObject(root, "msg_type", "log"); cJSON_AddStringToObject(payload, "module", "mqtt"); cJSON_AddStringToObject(payload, "message", message); cJSON_AddItemToObject(root, "payload", payload); BoardMqtt_PublishJson(s_rpt_log_topic, root, BOARD_MQTT_QOS); cJSON_Delete(root); } void BoardMqtt_PublishSttEvent(const char *text) { if (!text || text[0] == '\0') { return; } cJSON *root = cJSON_CreateObject(); cJSON *header = cJSON_CreateObject(); cJSON *payload = cJSON_CreateObject(); if (!root || !header || !payload) { cJSON_Delete(root); cJSON_Delete(header); cJSON_Delete(payload); return; } cJSON_AddStringToObject(header, "device_id", s_device_id); cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp()); cJSON_AddItemToObject(root, "header", header); cJSON_AddStringToObject(root, "msg_type", "event"); cJSON_AddStringToObject(payload, "event", "asr_result"); cJSON_AddStringToObject(payload, "text", text); cJSON_AddItemToObject(root, "payload", payload); BoardMqtt_PublishJson(s_rpt_event_topic, root, BOARD_MQTT_QOS); cJSON_Delete(root); } static void BoardMqtt_PublishAck(const char *req_id, bool success, int error_code, const char *info) { if (!req_id || req_id[0] == '\0') { return; } cJSON *root = cJSON_CreateObject(); cJSON *header = cJSON_CreateObject(); cJSON *payload = cJSON_CreateObject(); if (!root || !header || !payload) { cJSON_Delete(root); cJSON_Delete(header); cJSON_Delete(payload); return; } cJSON_AddStringToObject(header, "device_id", s_device_id); cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp()); cJSON_AddItemToObject(root, "header", header); cJSON_AddStringToObject(root, "msg_type", "ack"); cJSON_AddStringToObject(payload, "req_id", req_id); cJSON_AddStringToObject(payload, "status", success ? "success" : "failed"); cJSON_AddNumberToObject(payload, "error_code", success ? 0 : error_code); cJSON_AddStringToObject(payload, "info", info ? info : ""); cJSON_AddItemToObject(root, "payload", payload); BoardMqtt_PublishJson(s_rpt_ack_topic, root, BOARD_MQTT_QOS); cJSON_Delete(root); } static void BoardMqtt_PublishProgress(const char *task_id, int progress, const char *info) { if (!task_id || task_id[0] == '\0') { return; } if (progress < 0) { progress = 0; } else if (progress > 100) { progress = 100; } cJSON *root = cJSON_CreateObject(); cJSON *header = cJSON_CreateObject(); cJSON *payload = cJSON_CreateObject(); if (!root || !header || !payload) { cJSON_Delete(root); cJSON_Delete(header); cJSON_Delete(payload); return; } cJSON_AddStringToObject(header, "device_id", s_device_id); cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp()); cJSON_AddItemToObject(root, "header", header); cJSON_AddStringToObject(root, "msg_type", "progress"); cJSON_AddStringToObject(payload, "task_id", task_id); cJSON_AddStringToObject(payload, "status", "processing"); cJSON_AddNumberToObject(payload, "progress", progress); cJSON_AddStringToObject(payload, "info", info ? info : ""); cJSON_AddItemToObject(root, "payload", payload); ESP_LOGI(TAG, "MQTT progress: task_id=%s progress=%d info=%s", task_id, progress, info ? info : ""); BoardMqtt_PublishJson(s_rpt_progress_topic, root, BOARD_MQTT_QOS); cJSON_Delete(root); } static char *BoardMqtt_CopySegment(const char *data, int data_len) { if (!data || data_len <= 0) { return NULL; } char *copy = (char *)calloc(1, data_len + 1); if (!copy) { ESP_LOGE(TAG, "Failed to allocate MQTT segment buffer"); return NULL; } memcpy(copy, data, data_len); copy[data_len] = '\0'; return copy; } static bool BoardMqtt_NumberToInt(cJSON *item, int *value) { if (!cJSON_IsNumber(item) || !value) { return false; } double number = item->valuedouble; int int_value = item->valueint; if ((double)int_value != number) { return false; } *value = int_value; return true; } static void BoardMqtt_LogReceivedJson(const char *topic, cJSON *payload) { cJSON *root = cJSON_CreateObject(); if (!root) { ESP_LOGW(TAG, "Failed to format received MQTT JSON"); return; } cJSON_AddStringToObject(root, "event", "mqtt_command_received"); cJSON_AddStringToObject(root, "topic", topic ? topic : ""); #if BOARD_MQTT_EXECUTE_COMMANDS cJSON_AddBoolToObject(root, "execute_commands", true); #else cJSON_AddBoolToObject(root, "execute_commands", false); #endif cJSON_AddItemReferenceToObject(root, "payload", payload); char *formatted = cJSON_Print(root); if (!formatted) { ESP_LOGW(TAG, "Failed to format received MQTT JSON"); cJSON_Delete(root); return; } ESP_LOGI(TAG, "%s", formatted); cJSON_free(formatted); cJSON_Delete(root); } static void BoardMqtt_LogReceivedText(const char *topic, const char *payload) { cJSON *root = cJSON_CreateObject(); if (!root) { ESP_LOGW(TAG, "Failed to format received MQTT text"); return; } cJSON_AddStringToObject(root, "event", "mqtt_command_invalid_json"); cJSON_AddStringToObject(root, "topic", topic ? topic : ""); #if BOARD_MQTT_EXECUTE_COMMANDS cJSON_AddBoolToObject(root, "execute_commands", true); #else cJSON_AddBoolToObject(root, "execute_commands", false); #endif cJSON_AddStringToObject(root, "payload", payload ? payload : ""); char *formatted = cJSON_Print(root); if (!formatted) { ESP_LOGW(TAG, "Failed to format received MQTT text"); cJSON_Delete(root); return; } ESP_LOGW(TAG, "%s", formatted); cJSON_free(formatted); cJSON_Delete(root); } static bool BoardMqtt_CopyBusinessPath(char *dest, size_t dest_size, const char *path) { if (!dest || dest_size == 0 || !path || path[0] == '\0') { return false; } if (path[0] == '/') { return BoardMqtt_CopyString(dest, dest_size, path); } int written = snprintf(dest, dest_size, "/%s", path); 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 dot && (strcasecmp(dot, ".mp3") == 0 || strcasecmp(dot, ".jpg") == 0); } static bool BoardMqtt_IsPlaceholderMd5(const char *md5) { return !md5 || md5[0] == '\0' || strcmp(md5, "...") == 0; } static bool BoardMqtt_ValidateOptionalMd5(cJSON *md5) { if (!md5) { return true; } return cJSON_IsString(md5) && (BoardMqtt_IsPlaceholderMd5(md5->valuestring) || BoardMqtt_IsMd5Hex(md5->valuestring)); } static bool BoardMqtt_CopyOptionalMd5(char *dest, size_t dest_size, cJSON *md5) { if (!cJSON_IsString(md5) || BoardMqtt_IsPlaceholderMd5(md5->valuestring)) { return true; } return BoardMqtt_CopyString(dest, dest_size, md5->valuestring); } static bool BoardMqtt_ActionIsCtl(const char *action) { return strcmp(action, "SET_VOLUME") == 0 || strcmp(action, "SET_POWER") == 0 || strcmp(action, "SET_CLOCK") == 0 || strcmp(action, "PLAY_MEDIA") == 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; } static bool BoardMqtt_ActionIsTask(const char *action) { return strcmp(action, "DOWNLOAD") == 0 || strcmp(action, "UPDATE_MANIFEST") == 0 || strcmp(action, "SYNC_COURSE_TASK") == 0 || strcmp(action, "OTA_UPDATE") == 0; } static board_mqtt_action_result_t BoardMqtt_ResultOk(const char *info) { return (board_mqtt_action_result_t){ .success = true, .error_code = 0, .info = info }; } static board_mqtt_action_result_t BoardMqtt_ResultFail(int error_code, const char *info) { return (board_mqtt_action_result_t){ .success = false, .error_code = error_code, .info = info }; } static board_mqtt_action_result_t BoardMqtt_HandleSetVolume(cJSON *payload) { int level = 0; if (!BoardMqtt_NumberToInt(cJSON_GetObjectItem(payload, "level"), &level) || level < 0 || level > 100) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid volume level"); } esp_err_t err = Board_Set_Output_Volume(level); return err == ESP_OK ? BoardMqtt_ResultOk("volume updated") : BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); } static board_mqtt_action_result_t BoardMqtt_HandleSetPower(cJSON *payload) { cJSON *state = cJSON_GetObjectItem(payload, "state"); if (!cJSON_IsString(state)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing power state"); } esp_err_t err = ESP_OK; if (strcmp(state->valuestring, "sleep") == 0) { err = Board_Enter_Standby(); } else if (strcmp(state->valuestring, "wake") == 0) { err = Board_Request_Wake(30000); } else { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid power state"); } return err == ESP_OK ? BoardMqtt_ResultOk("power state updated") : BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); } static board_mqtt_action_result_t BoardMqtt_HandleSetClock(cJSON *payload) { cJSON *ts = cJSON_GetObjectItem(payload, "ts"); if (!cJSON_IsNumber(ts) || ts->valuedouble <= 0) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid timestamp"); } esp_err_t err = DS2312_Set_Unix_Time_Saved((time_t)ts->valuedouble); return err == ESP_OK ? BoardMqtt_ResultOk("clock updated") : BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); } static bool BoardMqtt_PlaybackSpeedIsSupported(float speed) { const float supported_speeds[] = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f}; for (size_t i = 0; i < sizeof(supported_speeds) / sizeof(supported_speeds[0]); ++i) { float diff = speed - supported_speeds[i]; if (diff < 0) { diff = -diff; } if (diff < 0.001f) { return true; } } return false; } static char *BoardMqtt_ReadPlaylistFile(void) { FILE *fp = fopen(BOARD_MQTT_PLAYLIST_PATH, "rb"); if (!fp) { return NULL; } if (fseek(fp, 0, SEEK_END) != 0) { fclose(fp); return NULL; } long file_size = ftell(fp); if (file_size <= 0 || file_size > BOARD_MQTT_PAYLOAD_MAX_LEN || fseek(fp, 0, SEEK_SET) != 0) { fclose(fp); return NULL; } char *buffer = (char *)calloc(1, (size_t)file_size + 1); if (!buffer) { fclose(fp); return NULL; } size_t read_size = fread(buffer, 1, (size_t)file_size, fp); fclose(fp); if (read_size != (size_t)file_size) { free(buffer); return NULL; } return buffer; } static esp_err_t BoardMqtt_WritePlaylistRoot(cJSON *root) { char *json = cJSON_PrintUnformatted(root); if (!json) { return ESP_ERR_NO_MEM; } FILE *fp = fopen(BOARD_MQTT_PLAYLIST_PATH, "wb"); if (!fp) { cJSON_free(json); return ESP_FAIL; } size_t len = strlen(json); size_t written = fwrite(json, 1, len, fp); int close_ret = fclose(fp); cJSON_free(json); return written == len && close_ret == 0 ? ESP_OK : ESP_FAIL; } static cJSON *BoardMqtt_LoadPlaylistRoot(void) { char *json = BoardMqtt_ReadPlaylistFile(); if (!json) { return NULL; } cJSON *root = cJSON_Parse(json); free(json); return root; } static cJSON *BoardMqtt_GetPlaylistItems(cJSON *root) { cJSON *items = cJSON_IsObject(root) ? cJSON_GetObjectItem(root, "items") : NULL; return cJSON_IsArray(items) ? items : NULL; } static bool BoardMqtt_PlaylistHasUnfinished(cJSON *root) { cJSON *items = BoardMqtt_GetPlaylistItems(root); if (!items) { return false; } cJSON *item = NULL; cJSON_ArrayForEach(item, items) { cJSON *completed = cJSON_IsObject(item) ? cJSON_GetObjectItem(item, "completed") : NULL; if (!cJSON_IsTrue(completed)) { return true; } } return false; } static esp_err_t BoardMqtt_SavePlaylist(const char *req_id, cJSON *items) { cJSON *root = cJSON_CreateObject(); cJSON *playlist_items = cJSON_CreateArray(); if (!root || !playlist_items) { cJSON_Delete(root); cJSON_Delete(playlist_items); return ESP_ERR_NO_MEM; } cJSON_AddNumberToObject(root, "version", 1); cJSON_AddStringToObject(root, "task_id", req_id ? req_id : "PLAY_MEDIA"); cJSON_AddItemToObject(root, "items", playlist_items); if (cJSON_GetArraySize(items) <= 0) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; } cJSON *item = NULL; cJSON_ArrayForEach(item, items) { if (!cJSON_IsObject(item)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; } cJSON *path = cJSON_GetObjectItem(item, "path"); if (!cJSON_IsString(path)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; } char business_path[SDCARD_PATH_MAX_LEN]; if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), path->valuestring)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; } char sd_path[SDCARD_PATH_MAX_LEN]; esp_err_t err = SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)); if (err != ESP_OK) { cJSON_Delete(root); return err; } const char *dot = strrchr(sd_path, '.'); if (!dot || strcasecmp(dot, ".mp3") != 0) { cJSON_Delete(root); return ESP_ERR_NOT_SUPPORTED; } if (access(sd_path, F_OK) != 0) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; } int volume_value = 0; bool has_volume = false; cJSON *volume = cJSON_GetObjectItem(item, "volume"); if (volume) { if (!BoardMqtt_NumberToInt(volume, &volume_value) || volume_value < 0 || volume_value > 100) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; } has_volume = true; } float speed = 1.0f; cJSON *speed_json = cJSON_GetObjectItem(item, "speed"); if (speed_json) { if (!cJSON_IsNumber(speed_json)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; } speed = (float)speed_json->valuedouble; } if (!BoardMqtt_PlaybackSpeedIsSupported(speed)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; } cJSON *stored_item = cJSON_CreateObject(); if (!stored_item) { cJSON_Delete(root); return ESP_ERR_NO_MEM; } cJSON_AddStringToObject(stored_item, "path", business_path); cJSON_AddStringToObject(stored_item, "sd_path", sd_path); if (has_volume) { cJSON_AddNumberToObject(stored_item, "volume", volume_value); } cJSON_AddNumberToObject(stored_item, "speed", speed); cJSON_AddBoolToObject(stored_item, "completed", false); cJSON_AddItemToArray(playlist_items, stored_item); } esp_err_t err = BoardMqtt_WritePlaylistRoot(root); cJSON_Delete(root); return err; } static bool BoardMqtt_LoadNextPlaylistItem(board_mqtt_playlist_item_t *next_item) { if (!next_item) { return false; } cJSON *root = BoardMqtt_LoadPlaylistRoot(); cJSON *items = BoardMqtt_GetPlaylistItems(root); if (!items) { cJSON_Delete(root); return false; } cJSON *task_id = cJSON_GetObjectItem(root, "task_id"); int index = 0; int count = cJSON_GetArraySize(items); cJSON *item = NULL; cJSON_ArrayForEach(item, items) { cJSON *completed = cJSON_IsObject(item) ? cJSON_GetObjectItem(item, "completed") : NULL; if (!cJSON_IsTrue(completed)) { cJSON *path = cJSON_GetObjectItem(item, "path"); cJSON *sd_path = cJSON_GetObjectItem(item, "sd_path"); cJSON *volume = cJSON_GetObjectItem(item, "volume"); cJSON *speed_json = cJSON_GetObjectItem(item, "speed"); if (!cJSON_IsString(path) || !cJSON_IsString(sd_path)) { cJSON_Delete(root); return false; } memset(next_item, 0, sizeof(*next_item)); if (cJSON_IsString(task_id)) { BoardMqtt_CopyString(next_item->task_id, sizeof(next_item->task_id), task_id->valuestring); } if (next_item->task_id[0] == '\0') { BoardMqtt_CopyString(next_item->task_id, sizeof(next_item->task_id), "PLAY_MEDIA"); } if (!BoardMqtt_CopyString(next_item->path, sizeof(next_item->path), path->valuestring) || !BoardMqtt_CopyString(next_item->sd_path, sizeof(next_item->sd_path), sd_path->valuestring)) { cJSON_Delete(root); return false; } if (volume) { if (!BoardMqtt_NumberToInt(volume, &next_item->volume) || next_item->volume < 0 || next_item->volume > 100) { cJSON_Delete(root); return false; } next_item->has_volume = true; } next_item->speed = cJSON_IsNumber(speed_json) ? (float)speed_json->valuedouble : 1.0f; if (!BoardMqtt_PlaybackSpeedIsSupported(next_item->speed)) { cJSON_Delete(root); return false; } next_item->index = index; next_item->count = count; cJSON_Delete(root); return true; } ++index; } cJSON_Delete(root); return false; } static void BoardMqtt_ShowPlaylistAlert(const char *message, const char *emotion) { Board_Show_Alert("播放清单", message, emotion ? emotion : "music"); } static esp_err_t BoardMqtt_MarkPlaylistItemCompleted(int target_index) { cJSON *root = BoardMqtt_LoadPlaylistRoot(); cJSON *items = BoardMqtt_GetPlaylistItems(root); if (!items) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; } cJSON *item = cJSON_GetArrayItem(items, target_index); if (!cJSON_IsObject(item)) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; } cJSON_DeleteItemFromObject(item, "completed"); cJSON_AddBoolToObject(item, "completed", true); esp_err_t err = BoardMqtt_WritePlaylistRoot(root); cJSON_Delete(root); return err; } static void BoardMqtt_PlaylistTask(void *arg) { (void)arg; Board_Acquire_Sleep_Inhibit(); while (!s_playlist_stop_requested) { board_mqtt_playlist_item_t item; if (!BoardMqtt_LoadNextPlaylistItem(&item)) { if (!s_playlist_stop_requested) { BoardMqtt_ShowPlaylistAlert("没有未完成的播放清单", "circle_info"); } break; } if (item.has_volume) { Board_Set_Output_Volume(item.volume); } char alert_message[96]; snprintf(alert_message, sizeof(alert_message), "正在播放 %d/%d", item.index + 1, item.count); BoardMqtt_ShowPlaylistAlert(alert_message, "music"); SDCard_Show_Matching_JPG_For_MP3_Path(item.sd_path); ESP_LOGI(TAG, "playlist play item %d/%d: %s speed=%.2fx", item.index + 1, item.count, item.sd_path, item.speed); esp_err_t err = SDCard_Play_MP3_File_With_Speed(item.sd_path, item.speed); if (err != ESP_OK) { ESP_LOGE(TAG, "playlist item playback failed: %s", esp_err_to_name(err)); snprintf(alert_message, sizeof(alert_message), "播放失败 %d/%d: %s", item.index + 1, item.count, esp_err_to_name(err)); BoardMqtt_ShowPlaylistAlert(alert_message, "circle_xmark"); break; } while (!s_playlist_stop_requested && SDCard_Is_Playing()) { vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_PLAYLIST_POLL_MS)); } if (s_playlist_stop_requested) { break; } err = BoardMqtt_MarkPlaylistItemCompleted(item.index); if (err != ESP_OK) { ESP_LOGE(TAG, "mark playlist item completed failed: %s", esp_err_to_name(err)); snprintf(alert_message, sizeof(alert_message), "保存播放进度失败: %s", esp_err_to_name(err)); BoardMqtt_ShowPlaylistAlert(alert_message, "circle_xmark"); break; } int progress = item.count > 0 ? ((item.index + 1) * 100) / item.count : 100; BoardMqtt_PublishProgress(item.task_id, progress, "playlist item completed"); if (progress >= 100) { BoardMqtt_ShowPlaylistAlert("播放清单已完成", "check"); } } BoardMqtt_FinishTask(BOARD_MQTT_TASK_PLAYLIST); Board_Release_Sleep_Inhibit(); s_playlist_stop_requested = false; s_playlist_task_handle = NULL; vTaskDelete(NULL); } static void BoardMqtt_StopPlaylistTask(void) { if (!s_playlist_task_handle) { s_playlist_stop_requested = false; return; } s_playlist_stop_requested = true; if (SDCard_Is_Playing()) { SDCard_Stop_Play(); } int waited_ms = 0; while (s_playlist_task_handle && waited_ms < BOARD_MQTT_NETWORK_TIMEOUT_MS) { vTaskDelay(pdMS_TO_TICKS(50)); waited_ms += 50; } } void BoardMqtt_StopPlaylistPlayback(void) { BoardMqtt_StopPlaylistTask(); Board_Show_Black_Screen(); } static esp_err_t BoardMqtt_StartPlaylistTask(void) { BoardMqtt_StopPlaylistTask(); if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_PLAYLIST)) { return ESP_ERR_INVALID_STATE; } s_playlist_stop_requested = false; BaseType_t ret = xTaskCreate(BoardMqtt_PlaylistTask, "mqtt_playlist", BOARD_MQTT_PLAYLIST_TASK_STACK_SIZE, NULL, BOARD_MQTT_PLAYLIST_TASK_PRIORITY, &s_playlist_task_handle); if (ret != pdPASS) { s_playlist_task_handle = NULL; BoardMqtt_FinishTask(BOARD_MQTT_TASK_PLAYLIST); return ESP_FAIL; } return ESP_OK; } void BoardMqtt_ResumePlaylistIfUnfinished(void) { ESP_LOGI(TAG, "checking unfinished MQTT playlist for offline resume"); char *json = BoardMqtt_ReadPlaylistFile(); if (!json) { ESP_LOGW(TAG, "no local MQTT playlist file for offline resume: %s", BOARD_MQTT_PLAYLIST_PATH); BoardMqtt_ShowPlaylistAlert("没有本地播放清单", "circle_info"); return; } cJSON *root = cJSON_Parse(json); free(json); if (!root) { ESP_LOGW(TAG, "local MQTT playlist file is not valid JSON"); BoardMqtt_ShowPlaylistAlert("本地播放清单格式错误", "circle_xmark"); return; } bool has_unfinished = BoardMqtt_PlaylistHasUnfinished(root); cJSON_Delete(root); if (!has_unfinished) { ESP_LOGW(TAG, "local MQTT playlist has no unfinished items"); BoardMqtt_ShowPlaylistAlert("播放清单已完成", "check"); return; } BoardMqtt_ShowPlaylistAlert("Wi-Fi 连接失败,继续播放本地清单", "music"); esp_err_t err = BoardMqtt_StartPlaylistTask(); if (err == ESP_OK) { ESP_LOGI(TAG, "resumed unfinished MQTT playlist"); } else { ESP_LOGW(TAG, "failed to resume MQTT playlist: %s", esp_err_to_name(err)); BoardMqtt_ShowPlaylistAlert("本地清单续播失败", "circle_xmark"); } } static board_mqtt_action_result_t BoardMqtt_HandlePlayMedia(const char *req_id, cJSON *payload) { cJSON *items = cJSON_GetObjectItem(payload, "items"); if (!cJSON_IsArray(items) || cJSON_GetArraySize(items) <= 0) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing media playlist items"); } BoardMqtt_StopPlaylistTask(); esp_err_t err = BoardMqtt_SavePlaylist(req_id, items); if (err == ESP_ERR_INVALID_ARG) { BoardMqtt_ShowPlaylistAlert("播放清单参数无效", "circle_xmark"); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid media playlist item"); } if (err == ESP_ERR_NOT_SUPPORTED) { BoardMqtt_ShowPlaylistAlert("播放清单只支持 MP3", "circle_xmark"); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 playback is supported"); } if (err == ESP_ERR_NOT_FOUND) { BoardMqtt_ShowPlaylistAlert("播放清单文件不存在", "circle_xmark"); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "media file not found"); } if (err != ESP_OK) { BoardMqtt_ShowPlaylistAlert("保存播放清单失败", "circle_xmark"); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); } err = BoardMqtt_StartPlaylistTask(); if (err != ESP_OK) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); } BoardMqtt_ShowPlaylistAlert("已接收播放清单", "music"); BoardMqtt_PublishProgress(req_id, 0, "media playlist accepted"); return BoardMqtt_ResultOk("media playlist started"); } static board_mqtt_action_result_t BoardMqtt_HandleStopMedia(cJSON *payload) { (void)payload; BoardMqtt_StopPlaylistPlayback(); return BoardMqtt_ResultOk("media playback stopped"); } static board_mqtt_action_result_t BoardMqtt_HandleDeleteFile(cJSON *payload) { cJSON *path = cJSON_GetObjectItem(payload, "path"); if (!cJSON_IsString(path)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing file path"); } esp_err_t err = SDCard_Delete_File_By_Business_Path(path->valuestring); if (err == ESP_OK) { return BoardMqtt_ResultOk("file deleted"); } return BoardMqtt_ResultFail(err == ESP_ERR_NOT_FOUND ? BOARD_MQTT_ERR_NOT_FOUND : BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); } static bool BoardMqtt_ParseTime(const char *value, int *hour, int *minute, int *second) { char tail = 0; if (!value || !hour || !minute || !second) { return false; } if (sscanf(value, "%2d:%2d:%2d%c", hour, minute, second, &tail) == 3) { return *hour >= 0 && *hour <= 23 && *minute >= 0 && *minute <= 59 && *second >= 0 && *second <= 59; } *second = 0; return sscanf(value, "%2d:%2d%c", hour, minute, &tail) == 2 && *hour >= 0 && *hour <= 23 && *minute >= 0 && *minute <= 59; } static board_mqtt_action_result_t BoardMqtt_HandleSetAlarm(cJSON *payload) { cJSON *id = cJSON_GetObjectItem(payload, "id"); if (!cJSON_IsString(id)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing alarm id"); } cJSON *command = cJSON_GetObjectItem(payload, "action"); if (cJSON_IsString(command) && strcmp(command->valuestring, "delete") == 0) { return BoardMqtt_HandleUnsetAlarm(payload); } cJSON *time_value = cJSON_GetObjectItem(payload, "time"); cJSON *repeat = cJSON_GetObjectItem(payload, "repeat"); if (!cJSON_IsString(time_value) || !cJSON_IsArray(repeat) || cJSON_GetArraySize(repeat) == 0) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid alarm payload"); } int hour = 0, minute = 0, second = 0; if (!BoardMqtt_ParseTime(time_value->valuestring, &hour, &minute, &second)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid alarm time"); } uint8_t repeat_mask = 0; cJSON *day = NULL; cJSON_ArrayForEach(day, repeat) { int value = 0; if (!BoardMqtt_NumberToInt(day, &value) || value < 1 || value > 7) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid alarm repeat"); } switch (value) { case 1: repeat_mask |= ALARM_REPEAT_MON; break; case 2: repeat_mask |= ALARM_REPEAT_TUE; break; case 3: repeat_mask |= ALARM_REPEAT_WED; break; case 4: repeat_mask |= ALARM_REPEAT_THU; break; case 5: repeat_mask |= ALARM_REPEAT_FRI; break; case 6: repeat_mask |= ALARM_REPEAT_SAT; break; case 7: repeat_mask |= ALARM_REPEAT_SUN; break; default: break; } } esp_err_t err = DS2312_Set_Alarm_Id_Saved(id->valuestring, hour, minute, second, repeat_mask); 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"); } static board_mqtt_action_result_t BoardMqtt_HandleUnsetAlarm(cJSON *payload) { cJSON *id = cJSON_GetObjectItem(payload, "id"); if (!cJSON_IsString(id)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing alarm id"); } esp_err_t err = DS2312_Delete_Alarm_Id_Saved(id->valuestring); if (err == ESP_OK) { return BoardMqtt_ResultOk("alarm deleted"); } return BoardMqtt_ResultFail(err == ESP_ERR_NOT_FOUND ? BOARD_MQTT_ERR_NOT_FOUND : BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); } static board_mqtt_action_result_t BoardMqtt_HandleReboot(cJSON *payload) { int delay = 0; cJSON *delay_item = cJSON_GetObjectItem(payload, "delay"); if (delay_item && (!BoardMqtt_NumberToInt(delay_item, &delay) || delay < 0 || delay > 3600)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid reboot delay"); } esp_err_t err = Board_Reboot_Delayed(delay); return err == ESP_OK ? BoardMqtt_ResultOk("reboot scheduled") : BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); } static bool BoardMqtt_HasPrefix(const char *value, const char *prefix) { return value && prefix && strncmp(value, prefix, strlen(prefix)) == 0; } static bool BoardMqtt_IsMd5Hex(const char *value) { if (!value || strlen(value) != 32) { return false; } for (const char *p = value; *p; ++p) { if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f') || (*p >= 'A' && *p <= 'F'))) { return false; } } return true; } static bool BoardMqtt_IsSafeCourseId(const char *course_id) { if (!course_id || course_id[0] == '\0' || strlen(course_id) >= ALARM_ID_MAX_LEN) { return false; } for (const char *p = course_id; *p; ++p) { if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') || *p == '_' || *p == '-')) { return false; } } return true; } static bool BoardMqtt_CopyString(char *dest, size_t dest_size, const char *src) { if (!dest || dest_size == 0 || !src) { return false; } int written = snprintf(dest, dest_size, "%s", src); return written >= 0 && (size_t)written < dest_size; } static bool BoardMqtt_DownloadProgressCallback(int progress, const char *info, void *ctx) { board_mqtt_download_progress_ctx_t *progress_ctx = (board_mqtt_download_progress_ctx_t *)ctx; if (!progress_ctx || !progress_ctx->req_id) { return true; } if (s_download_cancel_requested) { ESP_LOGI(TAG, "Cancel download by request"); return false; } if (Board_Is_Voice_Session_Active()) { s_download_voice_yield_requested = true; ESP_LOGI(TAG, "Cancel download for voice session"); return false; } int64_t now = esp_timer_get_time(); bool force = progress >= 100 || progress_ctx->last_progress < 0; if (!force && progress == progress_ctx->last_progress) { return true; } if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) { return true; } progress_ctx->last_publish_us = now; progress_ctx->last_progress = progress; BoardMqtt_PublishProgress(progress_ctx->req_id, progress, info); return true; } static bool BoardMqtt_ManifestProgressCallback(int progress, const char *info, void *ctx) { board_mqtt_manifest_progress_ctx_t *progress_ctx = (board_mqtt_manifest_progress_ctx_t *)ctx; if (!progress_ctx || !progress_ctx->req_id || progress_ctx->asset_count == 0) { return true; } if (s_download_cancel_requested) { ESP_LOGI(TAG, "Cancel manifest download by request"); return false; } if (Board_Is_Voice_Session_Active()) { s_download_voice_yield_requested = true; ESP_LOGI(TAG, "Cancel manifest download for voice session"); return false; } if (progress < 0) { progress = 0; } else if (progress > 100) { progress = 100; } 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; 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) { return true; } char progress_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN]; snprintf(progress_info, sizeof(progress_info), "asset %u/%u %s", (unsigned)(progress_ctx->asset_index + 1), (unsigned)progress_ctx->asset_count, info ? info : "downloading"); progress_ctx->last_publish_us = now; progress_ctx->last_progress = aggregate_progress; BoardMqtt_PublishProgress(progress_ctx->req_id, aggregate_progress, progress_info); return true; } static void BoardMqtt_OtaProgressCallback(int progress, size_t speed, void *ctx) { board_mqtt_ota_job_t *job = (board_mqtt_ota_job_t *)ctx; if (!job) { return; } int64_t now = esp_timer_get_time(); bool force = progress >= 100 || job->last_progress < 0; if (!force && progress == job->last_progress) { return; } if (!force && job->last_publish_us > 0 && now - job->last_publish_us < 2000000) { return; } job->last_publish_us = now; job->last_progress = progress; char info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN]; snprintf(info, sizeof(info), "upgrading firmware %uKB/s", (unsigned)(speed / 1024)); BoardMqtt_PublishProgress(job->req_id, progress, info); } static void BoardMqtt_OtaCompleteCallback(bool success, void *ctx) { board_mqtt_ota_job_t *job = (board_mqtt_ota_job_t *)ctx; if (!job) { BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA); return; } BoardMqtt_WaitConnectedForFinalReport(); if (success) { BoardMqtt_PublishProgress(job->req_id, 100, "ota completed"); BoardMqtt_PublishAck(job->req_id, true, 0, "ota completed, rebooting"); } else { BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED, "ota failed"); } BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA); free(job); } static void BoardMqtt_OtaTask(void *arg) { board_mqtt_ota_job_t *job = (board_mqtt_ota_job_t *)arg; if (!job) { vTaskDelete(NULL); return; } BoardMqtt_PublishProgress(job->req_id, 0, "starting ota"); esp_err_t err = Board_Start_Firmware_Upgrade(job->url, job->version, BoardMqtt_OtaProgressCallback, BoardMqtt_OtaCompleteCallback, job); if (err != ESP_OK) { BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA); free(job); } vTaskDelete(NULL); } static void BoardMqtt_ManifestTask(void *arg) { board_mqtt_manifest_job_t *job = (board_mqtt_manifest_job_t *)arg; if (!job) { vTaskDelete(NULL); return; } if (job->replace_existing_download) { if (!BoardMqtt_WaitDownloadCanceled(30000) || !BoardMqtt_ActivatePendingManifest()) { BoardMqtt_ClearPendingManifest(); BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_BUSY, "previous download stopping"); cJSON_free(job->manifest_json); free(job); vTaskDelete(NULL); return; } } Board_Acquire_Sleep_Inhibit(); s_download_voice_yield_requested = false; char final_path[SDCARD_PATH_MAX_LEN] = {0}; char part_path[SDCARD_PATH_MAX_LEN] = {0}; char error_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN] = {0}; esp_err_t err = ESP_OK; vTaskDelay(pdMS_TO_TICKS(100)); BoardMqtt_PublishProgress(job->req_id, 0, "saving manifest"); 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)); } for (size_t i = 0; err == ESP_OK && i < job->asset_count; ++i) { board_mqtt_manifest_asset_t *asset = &job->assets[i]; memset(final_path, 0, sizeof(final_path)); memset(part_path, 0, sizeof(part_path)); memset(error_info, 0, sizeof(error_info)); err = SDCard_Prepare_Business_File(asset->target_path, final_path, sizeof(final_path), part_path, sizeof(part_path)); if (err != ESP_OK) { snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err)); break; } board_mqtt_manifest_progress_ctx_t progress_ctx = { .req_id = job->req_id, .asset_index = i, .asset_count = job->asset_count, .last_publish_us = 0, .last_progress = -1, }; 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) { 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; const char *cancel_info = s_download_voice_yield_requested ? "download canceled for voice session" : "download canceled"; s_download_voice_yield_requested = false; BoardMqtt_PublishProgress(job->req_id, canceled_progress, cancel_info); snprintf(error_info, sizeof(error_info), "%s", cancel_info); } break; } err = SDCard_Commit_Part_File(part_path, final_path); if (err != ESP_OK) { snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err)); SDCard_Remove_File_If_Exists(part_path); break; } int completed_progress = (int)(((i + 1) * 100) / job->asset_count); char progress_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN]; snprintf(progress_info, sizeof(progress_info), "asset %u/%u completed", (unsigned)(i + 1), (unsigned)job->asset_count); BoardMqtt_PublishProgress(job->req_id, completed_progress, progress_info); } bool replaced_by_new_manifest = err != ESP_OK && s_download_cancel_requested && BoardMqtt_HasPendingManifest(); if (!replaced_by_new_manifest) { BoardMqtt_WaitConnectedForFinalReport(); 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"); } else { BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED, error_info[0] ? error_info : esp_err_to_name(err)); } } s_download_voice_yield_requested = false; Board_Release_Sleep_Inhibit(); BoardMqtt_FinishTask(BOARD_MQTT_TASK_MANIFEST); cJSON_free(job->manifest_json); free(job); vTaskDelete(NULL); } static void BoardMqtt_DownloadTask(void *arg) { board_mqtt_download_job_t *job = (board_mqtt_download_job_t *)arg; if (!job) { vTaskDelete(NULL); return; } Board_Acquire_Sleep_Inhibit(); s_download_voice_yield_requested = false; char final_path[SDCARD_PATH_MAX_LEN] = {0}; char part_path[SDCARD_PATH_MAX_LEN] = {0}; char error_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN] = {0}; board_mqtt_download_progress_ctx_t progress_ctx = { .req_id = job->req_id, .last_publish_us = 0, .last_progress = -1, }; BoardMqtt_PublishProgress(job->req_id, 0, "starting download"); esp_err_t err = SDCard_Prepare_Business_File(job->target_path, final_path, sizeof(final_path), part_path, sizeof(part_path)); if (err == ESP_OK) { err = Board_Download_File_To_Path(job->url, part_path, job->md5, BoardMqtt_DownloadProgressCallback, &progress_ctx, error_info, sizeof(error_info)); } else { snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err)); } if (err != ESP_OK) { 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; const char *cancel_info = s_download_voice_yield_requested ? "download canceled for voice session" : "download canceled"; s_download_voice_yield_requested = false; BoardMqtt_PublishProgress(job->req_id, canceled_progress, cancel_info); snprintf(error_info, sizeof(error_info), "%s", cancel_info); } } else { err = SDCard_Commit_Part_File(part_path, final_path); if (err != ESP_OK) { snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err)); SDCard_Remove_File_If_Exists(part_path); } } bool replaced_by_new_manifest = err != ESP_OK && s_download_cancel_requested && BoardMqtt_HasPendingManifest(); if (!replaced_by_new_manifest) { BoardMqtt_WaitConnectedForFinalReport(); if (err == ESP_OK) { BoardMqtt_PublishProgress(job->req_id, 100, "download completed"); BoardMqtt_PublishAck(job->req_id, true, 0, "download completed"); } else { BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED, error_info[0] ? error_info : esp_err_to_name(err)); } } s_download_voice_yield_requested = false; BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD); Board_Release_Sleep_Inhibit(); free(job); vTaskDelete(NULL); } static board_mqtt_action_result_t BoardMqtt_HandleDownload(const char *req_id, cJSON *payload) { cJSON *url = cJSON_GetObjectItem(payload, "url"); cJSON *target_path = cJSON_GetObjectItem(payload, "target_path"); cJSON *md5 = cJSON_GetObjectItem(payload, "md5"); if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid download payload"); } if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid download url"); } if (!BoardMqtt_ValidateOptionalMd5(md5)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid md5"); } char business_path[SDCARD_PATH_MAX_LEN]; if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid target path"); } char sd_path[SDCARD_PATH_MAX_LEN]; esp_err_t err = SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)); if (err != ESP_OK) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid target path"); } if (!BoardMqtt_IsSupportedResourcePath(sd_path)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg download is supported"); } BoardMqtt_StopPlaylistTask(); if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_DOWNLOAD)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "download busy"); } board_mqtt_download_job_t *job = (board_mqtt_download_job_t *)calloc(1, sizeof(board_mqtt_download_job_t)); if (!job) { BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "no memory for download job"); } if (!BoardMqtt_CopyString(job->req_id, sizeof(job->req_id), req_id) || !BoardMqtt_CopyString(job->url, sizeof(job->url), url->valuestring) || !BoardMqtt_CopyString(job->target_path, sizeof(job->target_path), business_path) || !BoardMqtt_CopyOptionalMd5(job->md5, sizeof(job->md5), md5)) { BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD); free(job); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "download field too long"); } if (SDCard_Is_Playing()) { ESP_LOGI(TAG, "Stop MP3 playback before MQTT download"); SDCard_Stop_Play(); } BaseType_t ret = xTaskCreate(BoardMqtt_DownloadTask, "mqtt_download", 6144, job, 5, NULL); if (ret != pdPASS) { BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD); free(job); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to create download task"); } return BoardMqtt_ResultOk("download accepted"); } static board_mqtt_action_result_t BoardMqtt_HandleOtaUpdate(const char *req_id, cJSON *payload) { cJSON *url = cJSON_GetObjectItem(payload, "url"); cJSON *version = cJSON_GetObjectItem(payload, "version"); if (!cJSON_IsString(url)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing ota url"); } if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid ota url"); } if (version && !cJSON_IsString(version)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid ota version"); } if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_OTA)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "task busy"); } board_mqtt_ota_job_t *job = (board_mqtt_ota_job_t *)calloc(1, sizeof(board_mqtt_ota_job_t)); if (!job) { BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "no memory for ota job"); } job->last_progress = -1; if (!BoardMqtt_CopyString(job->req_id, sizeof(job->req_id), req_id) || !BoardMqtt_CopyString(job->url, sizeof(job->url), url->valuestring)) { BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA); free(job); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "ota field too long"); } if (cJSON_IsString(version) && version->valuestring[0] != '\0' && !BoardMqtt_CopyString(job->version, sizeof(job->version), version->valuestring)) { BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA); free(job); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "ota version too long"); } BaseType_t ret = xTaskCreate(BoardMqtt_OtaTask, "mqtt_ota", 4096, job, 5, NULL); if (ret != pdPASS) { BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA); free(job); return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to create ota task"); } return BoardMqtt_ResultOk("ota accepted"); } static board_mqtt_action_result_t BoardMqtt_HandleUpdateManifest(const char *req_id, cJSON *payload) { cJSON *course_id = cJSON_GetObjectItem(payload, "course_id"); cJSON *assets = cJSON_GetObjectItem(payload, "assets"); if (!cJSON_IsString(course_id) || !BoardMqtt_IsSafeCourseId(course_id->valuestring) || !cJSON_IsArray(assets) || cJSON_GetArraySize(assets) <= 0) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest payload"); } BoardMqtt_StopPlaylistTask(); bool replace_existing_download = false; board_mqtt_task_type_t current_type = BoardMqtt_GetTaskType(); if (BoardMqtt_IsDownloadTask(current_type)) { ESP_LOGI(TAG, "Cancel current download before starting new manifest task"); if (!BoardMqtt_BeginManifestReplacement()) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "task busy"); } replace_existing_download = true; } else if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_MANIFEST)) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "task busy"); } #define BOARD_MQTT_RETURN_MANIFEST_FAIL(code, message) do { \ if (replace_existing_download) { \ BoardMqtt_ClearPendingManifest(); \ } else { \ BoardMqtt_FinishTask(BOARD_MQTT_TASK_MANIFEST); \ } \ return BoardMqtt_ResultFail((code), (message)); \ } while (0) int asset_count_int = cJSON_GetArraySize(assets); size_t source_asset_count = (size_t)asset_count_int; if (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)) { BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large"); } char *manifest_json = cJSON_PrintUnformatted((cJSON *)payload); if (!manifest_json) { BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "failed to format manifest"); } size_t job_size = sizeof(board_mqtt_manifest_job_t) + max_asset_count * sizeof(board_mqtt_manifest_asset_t); board_mqtt_manifest_job_t *job = (board_mqtt_manifest_job_t *)calloc(1, job_size); if (!job) { cJSON_free(manifest_json); BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "no memory for manifest job"); } job->manifest_json = manifest_json; job->replace_existing_download = replace_existing_download; #define BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(code, message) do { \ cJSON_free(job->manifest_json); \ free(job); \ BOARD_MQTT_RETURN_MANIFEST_FAIL((code), (message)); \ } while (0) if (!BoardMqtt_CopyString(job->req_id, sizeof(job->req_id), req_id) || !BoardMqtt_CopyString(job->course_id, sizeof(job->course_id), course_id->valuestring)) { BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest field too long"); } 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 (!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 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; 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"); } ++index; } job->asset_count = index; if (SDCard_Is_Playing()) { ESP_LOGI(TAG, "Stop MP3 playback before MQTT manifest download"); SDCard_Stop_Play(); } BaseType_t ret = xTaskCreate(BoardMqtt_ManifestTask, "mqtt_manifest", 6144, job, 5, NULL); if (ret != pdPASS) { cJSON_free(job->manifest_json); free(job); BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "failed to create manifest task"); } #undef BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL #undef BOARD_MQTT_RETURN_MANIFEST_FAIL return BoardMqtt_ResultOk("manifest accepted"); } static board_mqtt_action_result_t BoardMqtt_HandleStartAppTask(cJSON *payload) { if (!payload) { return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload"); } cJSON *app_id = cJSON_GetObjectItem(payload, "app_id"); cJSON *task_id = cJSON_GetObjectItem(payload, "task_id"); cJSON *source = cJSON_GetObjectItem(payload, "source"); cJSON *params = cJSON_GetObjectItem(payload, "params"); 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); 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_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"); } 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, "SET_POWER") == 0) return BoardMqtt_HandleSetPower(payload); if (strcmp(action, "SET_CLOCK") == 0) return BoardMqtt_HandleSetClock(payload); if (strcmp(action, "PLAY_MEDIA") == 0) return BoardMqtt_HandlePlayMedia(req_id, payload); if (strcmp(action, "STOP_MEDIA") == 0) return BoardMqtt_HandleStopMedia(payload); if (strcmp(action, "DELETE_FILE") == 0) return BoardMqtt_HandleDeleteFile(payload); if (strcmp(action, "SET_ALARM") == 0) return BoardMqtt_HandleSetAlarm(payload); if (strcmp(action, "UNSET_ALARM") == 0) return BoardMqtt_HandleUnsetAlarm(payload); if (strcmp(action, "REBOOT") == 0) return BoardMqtt_HandleReboot(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); 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) { return BOARD_MQTT_TOPIC_CTL; } if (strcmp(topic, s_cmd_task_topic) == 0) { return BOARD_MQTT_TOPIC_TASK; } return BOARD_MQTT_TOPIC_UNKNOWN; } static void BoardMqtt_ResetPayloadAssembly(void) { free(s_payload_assembly.payload); memset(&s_payload_assembly, 0, sizeof(s_payload_assembly)); } static void BoardMqtt_HandleDataEvent(esp_mqtt_event_t *event) { if (!event || event->data_len <= 0 || !event->data || event->total_data_len <= 0 || event->current_data_offset < 0 || event->current_data_offset > event->total_data_len || event->data_len > event->total_data_len - event->current_data_offset) { ESP_LOGW(TAG, "Invalid MQTT data event"); BoardMqtt_ResetPayloadAssembly(); return; } if (event->total_data_len > BOARD_MQTT_PAYLOAD_MAX_LEN) { ESP_LOGW(TAG, "MQTT payload too large: %d", event->total_data_len); BoardMqtt_ResetPayloadAssembly(); return; } if (event->current_data_offset == 0) { BoardMqtt_ResetPayloadAssembly(); if (!event->topic || event->topic_len <= 0 || event->topic_len >= BOARD_MQTT_TOPIC_MAX_LEN) { ESP_LOGW(TAG, "Invalid MQTT command topic"); return; } if (event->data_len == event->total_data_len) { BoardMqtt_HandleCommand(event->topic, event->topic_len, event->data, event->data_len); return; } s_payload_assembly.payload = (char *)calloc(1, event->total_data_len + 1); if (!s_payload_assembly.payload) { ESP_LOGE(TAG, "Failed to allocate MQTT payload assembly buffer"); BoardMqtt_ResetPayloadAssembly(); return; } memcpy(s_payload_assembly.topic, event->topic, event->topic_len); s_payload_assembly.topic[event->topic_len] = '\0'; s_payload_assembly.topic_len = event->topic_len; s_payload_assembly.total_len = event->total_data_len; s_payload_assembly.received_len = 0; } else if (!s_payload_assembly.payload || s_payload_assembly.total_len != event->total_data_len || event->current_data_offset != s_payload_assembly.received_len) { ESP_LOGW(TAG, "Invalid MQTT payload fragment sequence"); BoardMqtt_ResetPayloadAssembly(); return; } memcpy(s_payload_assembly.payload + event->current_data_offset, event->data, event->data_len); s_payload_assembly.received_len += event->data_len; if (s_payload_assembly.received_len == s_payload_assembly.total_len) { BoardMqtt_HandleCommand(s_payload_assembly.topic, s_payload_assembly.topic_len, s_payload_assembly.payload, s_payload_assembly.total_len); BoardMqtt_ResetPayloadAssembly(); } else if (s_payload_assembly.received_len > s_payload_assembly.total_len) { ESP_LOGW(TAG, "Invalid MQTT payload assembly length"); BoardMqtt_ResetPayloadAssembly(); } } static void BoardMqtt_HandleCommand(const char *topic, int topic_len, const char *data, int data_len) { char *topic_text = BoardMqtt_CopySegment(topic, topic_len); char *payload_text = BoardMqtt_CopySegment(data, data_len); if (!topic_text || !payload_text) { free(topic_text); free(payload_text); return; } cJSON *root = cJSON_Parse(payload_text); if (!root) { BoardMqtt_LogReceivedText(topic_text, payload_text); free(topic_text); free(payload_text); return; } BoardMqtt_LogReceivedJson(topic_text, root); #if !BOARD_MQTT_EXECUTE_COMMANDS cJSON_Delete(root); free(topic_text); free(payload_text); return; #endif const char *req_id_text = NULL; cJSON *header = cJSON_GetObjectItem(root, "header"); cJSON *req_id = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "req_id") : NULL; if (cJSON_IsString(req_id) && req_id->valuestring && req_id->valuestring[0] != '\0') { req_id_text = req_id->valuestring; } else { ESP_LOGW(TAG, "MQTT command has no header.req_id, skip ACK"); cJSON_Delete(root); free(topic_text); free(payload_text); return; } board_mqtt_topic_type_t topic_type = BoardMqtt_GetTopicType(topic_text); cJSON *version = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "version") : NULL; cJSON *device_id = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "device_id") : NULL; cJSON *timestamp = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "timestamp") : NULL; cJSON *action = cJSON_GetObjectItem(root, "action"); cJSON *payload = cJSON_GetObjectItem(root, "payload"); board_mqtt_action_result_t result = BoardMqtt_ResultOk("ok"); if (topic_type == 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) { 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"); } else if (!cJSON_IsString(action) || action->valuestring[0] == '\0') { result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid action"); } else if (!cJSON_IsObject(payload)) { result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid payload"); } else if ((topic_type == BOARD_MQTT_TOPIC_CTL && !BoardMqtt_ActionIsCtl(action->valuestring)) || (topic_type == BOARD_MQTT_TOPIC_TASK && !BoardMqtt_ActionIsTask(action->valuestring))) { result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_TOPIC_MISMATCH, "action not allowed on this topic"); } else { result = BoardMqtt_DispatchAction(req_id_text, action->valuestring, payload); } BoardMqtt_PublishAck(req_id_text, result.success, result.error_code, result.info); cJSON_Delete(root); free(topic_text); free(payload_text); } static void BoardMqtt_ReconnectTask(void *arg) { (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; } s_reconnect_task_handle = NULL; vTaskDelete(NULL); } 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; } } static void BoardMqtt_StateTask(void *arg) { (void)arg; while (s_mqtt_client) { vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS)); BoardMqtt_PublishState(); } s_state_task_handle = NULL; vTaskDelete(NULL); } static void BoardMqtt_StartStateTask(void) { if (s_state_task_handle) { return; } BaseType_t ret = xTaskCreate(BoardMqtt_StateTask, "mqtt_state", 4096, NULL, 2, &s_state_task_handle); if (ret != pdPASS) { ESP_LOGE(TAG, "Failed to create MQTT state report task"); s_state_task_handle = NULL; } } static void BoardMqtt_Subscribe(const char *topic) { int msg_id = esp_mqtt_client_subscribe_single(s_mqtt_client, topic, BOARD_MQTT_QOS); if (msg_id >= 0) { ESP_LOGI(TAG, "Subscribing to %s, qos=%d, msg_id=%d", topic, BOARD_MQTT_QOS, msg_id); } else { ESP_LOGE(TAG, "Failed to subscribe to %s", topic); } } static void BoardMqtt_EventHandler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) { (void)handler_args; (void)base; esp_mqtt_event_t *event = (esp_mqtt_event_t *)event_data; switch (event_id) { 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); BoardMqtt_Subscribe(s_cmd_ctl_topic); BoardMqtt_Subscribe(s_cmd_task_topic); BoardMqtt_PublishLog("MQTT connected"); BoardMqtt_PublishState(); break; case MQTT_EVENT_DISCONNECTED: s_mqtt_connected = false; ESP_LOGW(TAG, "Disconnected from broker%s", BoardMqtt_GetTaskType() != BOARD_MQTT_TASK_NONE ? ", current task will continue locally" : ""); BoardMqtt_ScheduleReconnect(); break; case MQTT_EVENT_SUBSCRIBED: ESP_LOGI(TAG, "Subscribed, msg_id=%d", event->msg_id); break; case MQTT_EVENT_DATA: BoardMqtt_HandleDataEvent(event); break; case MQTT_EVENT_ERROR: s_mqtt_connected = false; if (event->error_handle) { ESP_LOGE(TAG, "MQTT error, esp_tls=%s, transport_sock_errno=%d", esp_err_to_name(event->error_handle->esp_tls_last_esp_err), event->error_handle->esp_transport_sock_errno); } else { ESP_LOGE(TAG, "MQTT error"); } BoardMqtt_ScheduleReconnect(); break; default: break; } } esp_err_t BoardMqtt_Start(const char *client_id) { if (s_mqtt_client) { ESP_LOGI(TAG, "MQTT client already started"); return ESP_OK; } if (!client_id || strlen(client_id) == 0) { ESP_LOGE(TAG, "Invalid MQTT client id"); return ESP_ERR_INVALID_ARG; } int id_len = snprintf(s_device_id, sizeof(s_device_id), "%s", client_id); if (id_len < 0 || id_len >= (int)sizeof(s_device_id)) { ESP_LOGE(TAG, "MQTT client id is too long"); return ESP_ERR_INVALID_SIZE; } 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; } ret = BoardMqtt_FormatTopic(s_cmd_task_topic, sizeof(s_cmd_task_topic), BOARD_MQTT_CMD_TASK_TOPIC_FORMAT, client_id); if (ret != ESP_OK) { return ret; } ret = BoardMqtt_FormatTopic(s_rpt_log_topic, sizeof(s_rpt_log_topic), BOARD_MQTT_RPT_LOG_TOPIC_FORMAT, client_id); if (ret != ESP_OK) { return ret; } ret = BoardMqtt_FormatTopic(s_rpt_ack_topic, sizeof(s_rpt_ack_topic), BOARD_MQTT_RPT_ACK_TOPIC_FORMAT, client_id); if (ret != ESP_OK) { return ret; } ret = BoardMqtt_FormatTopic(s_rpt_progress_topic, sizeof(s_rpt_progress_topic), BOARD_MQTT_RPT_PROGRESS_TOPIC_FORMAT, client_id); if (ret != ESP_OK) { return ret; } ret = BoardMqtt_FormatTopic(s_rpt_state_topic, sizeof(s_rpt_state_topic), BOARD_MQTT_RPT_STATE_TOPIC_FORMAT, client_id); if (ret != ESP_OK) { return ret; } ret = BoardMqtt_FormatTopic(s_rpt_event_topic, sizeof(s_rpt_event_topic), BOARD_MQTT_RPT_EVENT_TOPIC_FORMAT, client_id); if (ret != ESP_OK) { return ret; } esp_mqtt_client_config_t mqtt_config = {0}; mqtt_config.task.priority = BOARD_MQTT_TASK_PRIORITY; mqtt_config.task.stack_size = 6144; mqtt_config.buffer.size = 8192; mqtt_config.buffer.out_size = 8192; mqtt_config.broker.address.hostname = BOARD_MQTT_BROKER_HOST; mqtt_config.broker.address.port = BOARD_MQTT_BROKER_PORT; mqtt_config.broker.address.transport = MQTT_TRANSPORT_OVER_TCP; mqtt_config.credentials.client_id = client_id; if (strlen(BOARD_MQTT_USERNAME) > 0) { mqtt_config.credentials.username = BOARD_MQTT_USERNAME; mqtt_config.credentials.authentication.password = BOARD_MQTT_PASSWORD; } mqtt_config.session.keepalive = BOARD_MQTT_KEEPALIVE_SEC; mqtt_config.network.disable_auto_reconnect = true; mqtt_config.network.timeout_ms = BOARD_MQTT_NETWORK_TIMEOUT_MS; s_mqtt_client = esp_mqtt_client_init(&mqtt_config); if (!s_mqtt_client) { ESP_LOGE(TAG, "Failed to initialize MQTT client"); return ESP_FAIL; } ret = esp_mqtt_client_register_event(s_mqtt_client, MQTT_EVENT_ANY, BoardMqtt_EventHandler, NULL); if (ret != ESP_OK) { ESP_LOGE(TAG, "Failed to register MQTT event handler: %s", esp_err_to_name(ret)); esp_mqtt_client_destroy(s_mqtt_client); s_mqtt_client = NULL; return ret; } ret = esp_mqtt_client_start(s_mqtt_client); if (ret != ESP_OK) { ESP_LOGE(TAG, "Failed to start MQTT client: %s", esp_err_to_name(ret)); esp_mqtt_client_destroy(s_mqtt_client); s_mqtt_client = NULL; return ret; } BoardMqtt_StartStateTask(); ESP_LOGI(TAG, "Starting MQTT client, host=%s, port=%d, ctl_topic=%s, task_topic=%s, state_topic=%s", BOARD_MQTT_BROKER_HOST, BOARD_MQTT_BROKER_PORT, s_cmd_ctl_topic, s_cmd_task_topic, s_rpt_state_topic); return ESP_OK; } void BoardMqtt_Stop(void) { if (!s_mqtt_client) { s_mqtt_connected = false; return; } ESP_LOGI(TAG, "Stopping MQTT client"); esp_mqtt_client_stop(s_mqtt_client); if (s_state_task_handle) { TaskHandle_t state_task = s_state_task_handle; s_state_task_handle = NULL; vTaskDelete(state_task); } esp_mqtt_client_destroy(s_mqtt_client); s_mqtt_client = NULL; s_mqtt_connected = false; } bool BoardMqtt_IsConnected(void) { return s_mqtt_connected; }