#include "mqtt.h"
|
|
#include <stdio.h>
|
#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 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_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_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,
|
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_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 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 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];
|
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;
|
|
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;
|
bool start_queued_app;
|
uint32_t queued_gen;
|
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 repeat;
|
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;
|
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)
|
{
|
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;
|
}
|
|
bool Board_IsLessonSyncing(void)
|
{
|
return BoardMqtt_IsDownloadTask(BoardMqtt_GetTaskType());
|
}
|
|
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_PublishJsonTo(const char *topic, cJSON *json, int qos, int retain)
|
{
|
if (!s_mqtt_client || !s_mqtt_connected || !topic || !topic[0] || !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, retain);
|
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_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);
|
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_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_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);
|
}
|
|
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 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 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_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 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_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, "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 || strcmp(action, "START_APP_TASK") == 0 ||
|
strcmp(action, "SYNC_SCHEDULE") == 0 ||
|
strcmp(action, "ENSURE_APP_DIRS") == 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 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 = SDCard_Clamp_MP3_Speed((float)speed_json->valuedouble);
|
}
|
int repeat = 1;
|
cJSON *repeat_json = cJSON_GetObjectItem(item, "repeat");
|
if (!repeat_json) {
|
repeat_json = cJSON_GetObjectItem(item, "count");
|
}
|
if (!repeat_json) {
|
repeat_json = cJSON_GetObjectItem(item, "play_count");
|
}
|
if (repeat_json) {
|
if (!BoardMqtt_NumberToInt(repeat_json, &repeat) || repeat < 1 || repeat > 99) {
|
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_AddNumberToObject(stored_item, "repeat", repeat);
|
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");
|
cJSON *repeat_json = cJSON_GetObjectItem(item, "repeat");
|
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 = SDCard_Clamp_MP3_Speed(
|
cJSON_IsNumber(speed_json) ? (float)speed_json->valuedouble : 1.0f);
|
next_item->repeat = 1;
|
if (repeat_json) {
|
int repeat = 1;
|
if (BoardMqtt_NumberToInt(repeat_json, &repeat) && repeat >= 1) {
|
next_item->repeat = repeat > 99 ? 99 : repeat;
|
}
|
}
|
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 repeat=%d",
|
item.index + 1, item.count, item.sd_path, item.speed, item.repeat);
|
esp_err_t err = SDCard_Play_MP3_File_With_Speed_Repeat(
|
item.sd_path, item.speed, item.repeat > 0 ? item.repeat : 1, false);
|
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));
|
}
|
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 < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
|
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 (!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;
|
}
|
|
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 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 < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
|
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 < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
|
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;
|
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};
|
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");
|
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));
|
}
|
|
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));
|
|
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) {
|
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_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;
|
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");
|
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);
|
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;
|
Board_SetLessonDownloadBoost(true);
|
vTaskDelay(pdMS_TO_TICKS(80));
|
|
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;
|
Board_SetLessonDownloadBoost(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");
|
}
|
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)) {
|
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");
|
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) ||
|
(!has_assets && !has_files)) {
|
return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest payload");
|
}
|
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();
|
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 = has_assets ? cJSON_GetArraySize(assets) : cJSON_GetArraySize(files);
|
size_t source_asset_count = (size_t)asset_count_int;
|
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 = 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");
|
}
|
|
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;
|
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); \
|
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;
|
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;
|
}
|
} 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 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 (job->start_queued_app && SDCard_Is_Playing()) {
|
ESP_LOGI(TAG, "Stop MP3 playback before PRELOAD for queued AI task");
|
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(const char *req_id, 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");
|
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;
|
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);
|
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");
|
}
|
return preload.success ? BoardMqtt_ResultOk("preload queued") : preload;
|
}
|
|
static board_mqtt_action_result_t BoardMqtt_HandleEnsureAppDirs(cJSON *payload)
|
{
|
if (!payload) {
|
return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload");
|
}
|
cJSON *app_id = cJSON_GetObjectItem(payload, "app_id");
|
if (!cJSON_IsString(app_id) || !app_id->valuestring || app_id->valuestring[0] == '\0') {
|
return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing app_id");
|
}
|
esp_err_t err = Board_EnsureAppLayout(app_id->valuestring, NULL);
|
if (err == ESP_ERR_INVALID_ARG || err == ESP_ERR_INVALID_SIZE) {
|
return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid app_id");
|
}
|
if (err != ESP_OK) {
|
return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
|
}
|
cJSON *tasks = cJSON_GetObjectItem(payload, "tasks");
|
if (cJSON_IsArray(tasks)) {
|
cJSON *task = NULL;
|
cJSON_ArrayForEach(task, tasks) {
|
if (!cJSON_IsString(task) || !task->valuestring) {
|
return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid tasks");
|
}
|
err = Board_EnsureAppLayout(app_id->valuestring, task->valuestring);
|
if (err != ESP_OK) {
|
return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
|
}
|
}
|
}
|
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, "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(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 ||
|
(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 ||
|
(s_cmd_task_mac_topic[0] && strcmp(topic, s_cmd_task_mac_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 &&
|
(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");
|
} 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_TryReconnect(void)
|
{
|
if (!s_mqtt_client || s_mqtt_connected) {
|
return;
|
}
|
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)
|
{
|
// 断连回调里不要再开 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) {
|
if (s_mqtt_connected) {
|
if (!BoardMqtt_IsDownloadTask(BoardMqtt_GetTaskType())) {
|
BoardMqtt_PublishState();
|
}
|
vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
|
continue;
|
}
|
vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_RETRY_MS));
|
if (!s_mqtt_connected) {
|
BoardMqtt_TryReconnect();
|
}
|
}
|
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, 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;
|
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;
|
}
|
|
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;
|
}
|
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.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;
|
|
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;
|
}
|