| | |
| | | #include "app_task.h" |
| | | |
| | | #include "application.h" |
| | | #include "board.h" |
| | | #include "mqtt.h" |
| | | #include "system.h" |
| | | |
| | |
| | | #define TAG "app_task" |
| | | #define APP_TASK_NVS_NS "app_task" |
| | | #define APP_TASK_NVS_KEY "pending" |
| | | #define APP_TASK_NVS_LAST_REP "last_rep" |
| | | #define APP_TASK_JSON_MAX 768 |
| | | #define APP_TASK_ID_MAX 32 |
| | | #define APP_TASK_SOURCE_MAX 24 |
| | |
| | | static char s_current_task[APP_TASK_ID_MAX] = "conversation"; |
| | | static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat"; |
| | | static int s_current_priority = 0; |
| | | static bool s_finish_task_after_mp3 = true; |
| | | static float s_shadowing_listen_ratio = 1.2f; |
| | | |
| | | typedef struct { |
| | | const char *app_id; |
| | |
| | | static const app_task_skill_map_t k_task_skills[] = { |
| | | {"chat", "conversation", {NULL, NULL, NULL, NULL}}, |
| | | {"english_learning", "repeater", {"self.mp3.play", NULL, NULL, NULL}}, |
| | | {"english_learning", "shadowing", {NULL, NULL, NULL, NULL}}, |
| | | {"english_translation", "live_translate", {"translate", NULL, NULL, NULL}}, |
| | | {"english_conversation", "bilingual_chat", {NULL, NULL, NULL, NULL}}, |
| | | }; |
| | | |
| | | int Board_AppTask_SourcePriority(const char *source) |
| | |
| | | (!task_id || task_id[0] == '\0' || strcmp(task_id, "conversation") == 0); |
| | | } |
| | | |
| | | bool Board_IsShadowingTask(void) |
| | | { |
| | | return strcmp(s_current_task, "shadowing") == 0; |
| | | } |
| | | |
| | | bool Board_IsRepeaterTask(void) |
| | | { |
| | | return strcmp(s_current_task, "repeater") == 0; |
| | | } |
| | | |
| | | void Board_SetShadowingListenRatio(float ratio) |
| | | { |
| | | if (ratio < 1.2f) { |
| | | ratio = 1.2f; |
| | | } |
| | | if (ratio > 1.5f) { |
| | | ratio = 1.5f; |
| | | } |
| | | s_shadowing_listen_ratio = ratio; |
| | | } |
| | | |
| | | unsigned Board_ShadowingListenWindowMs(unsigned duration_ms) |
| | | { |
| | | if (duration_ms == 0) { |
| | | return 800; |
| | | } |
| | | unsigned wait_ms = static_cast<unsigned>(static_cast<float>(duration_ms) * s_shadowing_listen_ratio); |
| | | if (wait_ms < 800) { |
| | | wait_ms = 800; |
| | | } |
| | | return wait_ms; |
| | | } |
| | | |
| | | static void copy_id(char *dest, size_t dest_size, const char *src, const char *fallback) |
| | | { |
| | | const char *value = (src && src[0]) ? src : fallback; |
| | | strlcpy(dest, value, dest_size); |
| | | } |
| | | |
| | | void Board_SetActiveTask(const char *app_id, const char *task_id) |
| | | { |
| | | if (!app_id || app_id[0] == '\0' || !task_id || task_id[0] == '\0') { |
| | | return; |
| | | } |
| | | if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, task_id) == 0) { |
| | | return; |
| | | } |
| | | copy_id(s_current_app, sizeof(s_current_app), app_id, "chat"); |
| | | copy_id(s_current_task, sizeof(s_current_task), task_id, "conversation"); |
| | | ESP_LOGI(TAG, "set active task %s/%s", s_current_app, s_current_task); |
| | | } |
| | | |
| | | static void normalize_media_path(char *path) |
| | | { |
| | | if (!path) { |
| | | return; |
| | | } |
| | | for (char *p = path; *p; ++p) { |
| | | if (*p == '\\') { |
| | | *p = '/'; |
| | | } |
| | | } |
| | | } |
| | | |
| | | static const char *path_without_leading_slash(const char *path) |
| | | { |
| | | if (!path) { |
| | | return ""; |
| | | } |
| | | return path[0] == '/' ? path + 1 : path; |
| | | } |
| | | |
| | | static bool path_has_folder_prefix(const char *path, const char *prefix) |
| | | { |
| | | if (!path || !prefix || prefix[0] == '\0') { |
| | | return false; |
| | | } |
| | | char normalized[SDCARD_PATH_MAX_LEN]; |
| | | char needle[96]; |
| | | strlcpy(normalized, path, sizeof(normalized)); |
| | | strlcpy(needle, prefix, sizeof(needle)); |
| | | normalize_media_path(normalized); |
| | | normalize_media_path(needle); |
| | | const char *hay = path_without_leading_slash(normalized); |
| | | const char *pre = path_without_leading_slash(needle); |
| | | size_t n = strlen(pre); |
| | | if (n == 0 || strncmp(hay, pre, n) != 0) { |
| | | return false; |
| | | } |
| | | return hay[n] == '\0' || hay[n] == '/'; |
| | | } |
| | | |
| | | static bool path_belongs_to_current_task(const char *path) |
| | | { |
| | | if (is_chat_task(s_current_app, s_current_task)) { |
| | | return true; |
| | | } |
| | | char prefix[96]; |
| | | snprintf(prefix, sizeof(prefix), "/%s/%s/", s_current_app, s_current_task); |
| | | return path_has_folder_prefix(path, prefix); |
| | | } |
| | | |
| | | static bool basename_is_full_mp3(const char *name) |
| | | { |
| | | if (!name || name[0] == '\0') { |
| | | return false; |
| | | } |
| | | const char *base = strrchr(name, '/'); |
| | | base = base ? base + 1 : name; |
| | | if (strcasecmp(base, "full.mp3") == 0) { |
| | | return true; |
| | | } |
| | | size_t n = strlen(base); |
| | | return n > 9 && strcasecmp(base + n - 9, "_full.mp3") == 0; |
| | | } |
| | | |
| | | void Board_BindTaskFromMediaPath(const char *path) |
| | | { |
| | | // 课文文件可能都在 /english_learning/shadowing/ 下。任务只由 MCP app_id/task_id 决定。 |
| | | (void)path; |
| | | } |
| | | |
| | | static bool task_owns_play_audio(const char *app_id, const char *task_id) |
| | | { |
| | | const app_task_skill_map_t *spec = find_task_spec(app_id, task_id); |
| | | if (!spec) { |
| | | return false; |
| | | } |
| | | for (int i = 0; i < 4 && spec->skills[i]; ++i) { |
| | | if (strcmp(spec->skills[i], "self.mp3.play") == 0) { |
| | | return true; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | static bool first_sd_mp3_name(char *name, size_t name_size, const char *prefix, bool prefer_full) |
| | | { |
| | | if (!name || name_size == 0) { |
| | | return false; |
| | | } |
| | | name[0] = '\0'; |
| | | const char *list = SDCard_Get_MP3_List(); |
| | | if (!list || list[0] == '\0') { |
| | | return false; |
| | | } |
| | | cJSON *root = cJSON_Parse(list); |
| | | if (!root) { |
| | | return false; |
| | | } |
| | | const cJSON *files = cJSON_GetObjectItem(root, "files"); |
| | | if (!cJSON_IsArray(files)) { |
| | | cJSON_Delete(root); |
| | | return false; |
| | | } |
| | | const cJSON *best = NULL; |
| | | bool best_full = false; |
| | | const cJSON *file = NULL; |
| | | cJSON_ArrayForEach(file, files) { |
| | | const cJSON *item = cJSON_GetObjectItem(file, "name"); |
| | | if (!cJSON_IsString(item) || !item->valuestring || !item->valuestring[0]) { |
| | | continue; |
| | | } |
| | | if (prefix && prefix[0] && !path_has_folder_prefix(item->valuestring, prefix)) { |
| | | continue; |
| | | } |
| | | bool is_full = basename_is_full_mp3(item->valuestring); |
| | | if (!best) { |
| | | best = item; |
| | | best_full = is_full; |
| | | continue; |
| | | } |
| | | if (prefer_full && is_full && !best_full) { |
| | | best = item; |
| | | best_full = true; |
| | | continue; |
| | | } |
| | | if ((!prefer_full || is_full == best_full) && |
| | | strcmp(item->valuestring, best->valuestring) < 0) { |
| | | best = item; |
| | | best_full = is_full; |
| | | } |
| | | } |
| | | bool ok = best != NULL; |
| | | if (ok) { |
| | | strlcpy(name, best->valuestring, name_size); |
| | | } |
| | | cJSON_Delete(root); |
| | | return ok; |
| | | } |
| | | |
| | | void Board_SetFinishTaskAfterMp3(bool finish) |
| | | { |
| | | s_finish_task_after_mp3 = finish; |
| | | } |
| | | |
| | | void Board_RememberRepeaterPlayback(const char *name, float speed, int repeat, bool power_off_after) |
| | | { |
| | | if (!name || name[0] == '\0') { |
| | | return; |
| | | } |
| | | if (strcmp(s_current_task, "repeater") != 0) { |
| | | return; |
| | | } |
| | | cJSON *root = cJSON_CreateObject(); |
| | | if (!root) { |
| | | return; |
| | | } |
| | | cJSON_AddStringToObject(root, "name", name); |
| | | cJSON_AddNumberToObject(root, "speed", speed); |
| | | cJSON_AddNumberToObject(root, "repeat", repeat < 1 ? 1 : repeat); |
| | | cJSON_AddBoolToObject(root, "power_off_after", power_off_after); |
| | | char *printed = cJSON_PrintUnformatted(root); |
| | | cJSON_Delete(root); |
| | | if (!printed) { |
| | | return; |
| | | } |
| | | nvs_handle_t handle; |
| | | if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) == ESP_OK) { |
| | | if (nvs_set_str(handle, APP_TASK_NVS_LAST_REP, printed) == ESP_OK) { |
| | | nvs_commit(handle); |
| | | } |
| | | nvs_close(handle); |
| | | } |
| | | free(printed); |
| | | } |
| | | |
| | | bool Board_ResolveRepeaterPlayback(char *name, size_t name_size, float *speed, int *repeat, bool *power_off_after) |
| | | { |
| | | if (!name || name_size == 0) { |
| | | return false; |
| | | } |
| | | if (name[0] != '\0') { |
| | | if (speed) { |
| | | *speed = SDCard_Clamp_MP3_Speed(*speed); |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | nvs_handle_t handle; |
| | | if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) == ESP_OK) { |
| | | char buf[APP_TASK_JSON_MAX]; |
| | | size_t length = sizeof(buf); |
| | | if (nvs_get_str(handle, APP_TASK_NVS_LAST_REP, buf, &length) == ESP_OK) { |
| | | cJSON *root = cJSON_Parse(buf); |
| | | if (root) { |
| | | const cJSON *name_item = cJSON_GetObjectItem(root, "name"); |
| | | const cJSON *repeat_item = cJSON_GetObjectItem(root, "repeat"); |
| | | const cJSON *power_item = cJSON_GetObjectItem(root, "power_off_after"); |
| | | if (cJSON_IsString(name_item) && name_item->valuestring && name_item->valuestring[0]) { |
| | | strlcpy(name, name_item->valuestring, name_size); |
| | | if (repeat && cJSON_IsNumber(repeat_item)) { |
| | | *repeat = repeat_item->valueint; |
| | | } |
| | | if (power_off_after) { |
| | | if (cJSON_IsBool(power_item)) { |
| | | *power_off_after = cJSON_IsTrue(power_item); |
| | | } else if (cJSON_IsNumber(power_item)) { |
| | | *power_off_after = power_item->valueint != 0; |
| | | } |
| | | } |
| | | } |
| | | cJSON_Delete(root); |
| | | } |
| | | } |
| | | nvs_close(handle); |
| | | } |
| | | if (name[0] != '\0' && !path_belongs_to_current_task(name)) { |
| | | ESP_LOGW(TAG, "discard last_rep from other task: %s", name); |
| | | name[0] = '\0'; |
| | | } |
| | | |
| | | if (name[0] == '\0') { |
| | | char prefix[96]; |
| | | snprintf(prefix, sizeof(prefix), "/%s/%s/lessons", s_current_app, s_current_task); |
| | | bool prefer_full = strcmp(s_current_task, "repeater") == 0; |
| | | if (!first_sd_mp3_name(name, name_size, prefix, prefer_full)) { |
| | | if (is_chat_task(s_current_app, s_current_task)) { |
| | | first_sd_mp3_name(name, name_size, NULL, false); |
| | | } |
| | | } |
| | | } |
| | | if (speed) { |
| | | *speed = SDCard_Clamp_MP3_Speed(*speed); |
| | | } |
| | | return name[0] != '\0'; |
| | | } |
| | | |
| | | esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json, |
| | |
| | | const char *params_json, const char *source) |
| | | { |
| | | if (!skill || skill[0] == '\0') { |
| | | // 上报 [app_task]。SendListenDetect 会开通道但保持 idle,不开 listening/AFE。 |
| | | notify_server(app_id, task_id, source, true); |
| | | return; |
| | | } |
| | |
| | | strlcpy(name, name_item->valuestring, sizeof(name)); |
| | | } |
| | | if (cJSON_IsNumber(speed_item)) { |
| | | speed = static_cast<float>(speed_item->valuedouble); |
| | | speed = SDCard_Clamp_MP3_Speed(static_cast<float>(speed_item->valuedouble)); |
| | | } |
| | | if (cJSON_IsNumber(repeat_item)) { |
| | | repeat = repeat_item->valueint; |
| | |
| | | cJSON_Delete(params); |
| | | } |
| | | } |
| | | if (name[0] == '\0') { |
| | | ESP_LOGW(TAG, "self.mp3.play missing name"); |
| | | if (!Board_ResolveRepeaterPlayback(name, sizeof(name), &speed, &repeat, &power_off_after)) { |
| | | ESP_LOGW(TAG, "self.mp3.play has no last lesson and no SD MP3"); |
| | | notify_server(app_id, task_id, source, true); |
| | | return; |
| | | } |
| | | if (repeat < 1) { |
| | | repeat = 1; |
| | | } |
| | | speed = SDCard_Clamp_MP3_Speed(speed); |
| | | Board_SetFinishTaskAfterMp3(true); |
| | | // 有网的语音入口:先让 Server 播报进场词,音频由 MCP 在 TTS 结束后再播。 |
| | | if (source && strcmp(source, "voice") == 0 && Board::GetInstance().IsNetworkReady()) { |
| | | Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after); |
| | | notify_server(app_id, task_id, source, true); |
| | | return; |
| | | } |
| | | esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name, speed, repeat, power_off_after); |
| | | if (err != ESP_OK) { |
| | | ESP_LOGW(TAG, "self.mp3.play failed: %s", esp_err_to_name(err)); |
| | | Board_OnSkillPlaybackFinished(true, false); |
| | | Board_OnSkillPlaybackFinished(true, false, 0); |
| | | return; |
| | | } |
| | | Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after); |
| | | notify_server(app_id, task_id, source, false); |
| | | return; |
| | | } |
| | |
| | | return ESP_ERR_INVALID_ARG; |
| | | } |
| | | const char *src = (source && source[0]) ? source : "mqtt_immediate"; |
| | | const char *tid = (task_id && task_id[0]) ? task_id : "conversation"; |
| | | const char *tid = (task_id && task_id[0]) ? task_id : NULL; |
| | | const app_task_skill_map_t *spec = find_task_spec(app_id, tid); |
| | | if (!spec) { |
| | | ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid); |
| | | ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid ? tid : ""); |
| | | return ESP_ERR_NOT_FOUND; |
| | | } |
| | | tid = spec->task_id; |
| | | |
| | | int new_priority = Board_AppTask_SourcePriority(src); |
| | | if (new_priority < s_current_priority) { |
| | | ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d", |
| | | app_id, tid, src, new_priority, s_current_priority); |
| | | return ESP_ERR_INVALID_STATE; |
| | | } |
| | | |
| | | if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 && |
| | | strcmp(app_id, "chat") == 0 && strcmp(src, "voice") == 0) { |
| | | if (SDCard_Is_Playing()) { |
| | | ESP_LOGI(TAG, "stop leftover MP3 while already in chat"); |
| | | preempt_current_skills(); |
| | | } |
| | | // 跟读由 MCP 起播时本地仍可能是 chat。「退出跟读」必须通知 Server 取消听窗。 |
| | | // 不要在这里 ResumeListening:会 ResetDecoder、提前开 AFE,欢迎词/结束播报会被清掉。 |
| | | notify_server("chat", "conversation", "voice", true); |
| | | return ESP_OK; |
| | | } |
| | | |
| | | preempt_current_skills(); |
| | |
| | | return ESP_OK; |
| | | } |
| | | |
| | | void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested) |
| | | void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested, unsigned duration_ms) |
| | | { |
| | | (void)power_off_requested; |
| | | ESP_LOGI(TAG, "skill playback %s", stopped ? "stopped" : "finished"); |
| | | copy_id(s_current_app, sizeof(s_current_app), "chat", "chat"); |
| | | copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation"); |
| | | copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat"); |
| | | s_current_priority = 0; |
| | | Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL); |
| | | // MP3 任务栈在 PSRAM,不能在这里 nvs_set_str / 改状态机。 |
| | | Application::GetInstance().Schedule([stopped, duration_ms]() { |
| | | ESP_LOGI(TAG, "skill playback %s duration_ms=%u task=%s/%s", |
| | | stopped ? "stopped" : "finished", duration_ms, s_current_app, s_current_task); |
| | | |
| | | char app_id[APP_TASK_ID_MAX]; |
| | | char task_id[APP_TASK_ID_MAX]; |
| | | copy_id(app_id, sizeof(app_id), s_current_app, "chat"); |
| | | copy_id(task_id, sizeof(task_id), s_current_task, "conversation"); |
| | | const bool shadowing = strcmp(task_id, "shadowing") == 0; |
| | | const bool repeater = strcmp(task_id, "repeater") == 0; |
| | | // 复读清单是否结束由 Server 决定。设备不要在此写 NVS 切回 chat。 |
| | | const bool end_task = !shadowing && !repeater && |
| | | task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3; |
| | | |
| | | if (end_task) { |
| | | copy_id(s_current_app, sizeof(s_current_app), "chat", "chat"); |
| | | copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation"); |
| | | copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat"); |
| | | s_current_priority = 0; |
| | | Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL); |
| | | } |
| | | |
| | | char detect[208]; |
| | | snprintf(detect, sizeof(detect), "[skill_event]audio.%s|%s|%s|%u", |
| | | stopped ? "stopped" : "completed", app_id, task_id, duration_ms); |
| | | const bool open_mic = !stopped && shadowing; |
| | | // 听窗从课文一停就开始。不要等开麦/AFE 初始化后再上报 completed。 |
| | | if (!Application::GetInstance().SendListenDetectNow(detect)) { |
| | | Application::GetInstance().SendListenDetect(detect, false); |
| | | } |
| | | if (open_mic) { |
| | | Application::GetInstance().GetAudioService().ResetDecoder(); |
| | | Application::GetInstance().ResumeListeningAfterMp3Clip(); |
| | | } else if (repeater && !stopped) { |
| | | // 清单已播完,马上要播「复读结束」:保持功放,不要再 ResetDecoder。 |
| | | Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback(); |
| | | Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true); |
| | | } else if (!repeater) { |
| | | Application::GetInstance().GetAudioService().ResetDecoder(); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | void Board_OnShadowingClipFinished(unsigned duration_ms) |
| | | { |
| | | Application::GetInstance().Schedule([duration_ms]() { |
| | | ESP_LOGI(TAG, "shadowing clip finished duration_ms=%u task=%s/%s", |
| | | duration_ms, s_current_app, s_current_task); |
| | | |
| | | char app_id[APP_TASK_ID_MAX]; |
| | | char task_id[APP_TASK_ID_MAX]; |
| | | copy_id(app_id, sizeof(app_id), s_current_app, "chat"); |
| | | copy_id(task_id, sizeof(task_id), s_current_task, "shadowing"); |
| | | |
| | | char detect[208]; |
| | | snprintf(detect, sizeof(detect), "[skill_event]audio.completed|%s|%s|%u", |
| | | app_id, task_id, duration_ms); |
| | | if (!Application::GetInstance().SendListenDetectNow(detect)) { |
| | | Application::GetInstance().SendListenDetect(detect, false); |
| | | } |
| | | Application::GetInstance().ResumeListeningAfterMp3Clip(); |
| | | }); |
| | | } |
| | | |
| | | void Board_OnShadowingPlaylistFinished(void) |
| | | { |
| | | Application::GetInstance().Schedule([]() { |
| | | ESP_LOGI(TAG, "shadowing playlist finished task=%s/%s", s_current_app, s_current_task); |
| | | |
| | | char app_id[APP_TASK_ID_MAX]; |
| | | char task_id[APP_TASK_ID_MAX]; |
| | | copy_id(app_id, sizeof(app_id), s_current_app, "chat"); |
| | | copy_id(task_id, sizeof(task_id), s_current_task, "shadowing"); |
| | | |
| | | char detect[208]; |
| | | snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s", |
| | | app_id, task_id); |
| | | if (!Application::GetInstance().SendListenDetectNow(detect)) { |
| | | ESP_LOGW(TAG, "playlist_completed not sent, return to chat locally"); |
| | | copy_id(s_current_app, sizeof(s_current_app), "chat", "chat"); |
| | | copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation"); |
| | | copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat"); |
| | | s_current_priority = 0; |
| | | Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL); |
| | | auto &app = Application::GetInstance(); |
| | | app.GetAudioService().SetAudioPowerInhibit(false); |
| | | app.GetAudioService().EnableVoiceProcessing(false); |
| | | app.GetAudioService().EnableWakeWordDetection(true); |
| | | if (app.GetDeviceState() == kDeviceStateListening || |
| | | app.GetDeviceState() == kDeviceStateConnecting) { |
| | | app.SetDeviceState(kDeviceStateIdle); |
| | | } |
| | | return; |
| | | } |
| | | Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback(); |
| | | Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true); |
| | | }); |
| | | } |
| | | |
| | | void Board_ResolveBootOrRestore(bool from_alarm_wake) |
| | |
| | | return; |
| | | } |
| | | |
| | | if (from_alarm_wake && strcmp(source, "schedule") == 0 && !is_chat_task(app_id, task_id)) { |
| | | ESP_LOGI(TAG, "alarm restore runs pending %s/%s", app_id, task_id); |
| | | Board_StartAppTask(app_id, task_id, params, "schedule"); |
| | | const bool scheduled = strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0; |
| | | const bool offline = !Board::GetInstance().IsNetworkReady(); |
| | | if (!is_chat_task(app_id, task_id) && (from_alarm_wake || scheduled || offline)) { |
| | | ESP_LOGI(TAG, "restore pending %s/%s source=%s offline=%d", |
| | | app_id, task_id, source, offline ? 1 : 0); |
| | | Board_StartAppTask(app_id, task_id, params, source); |
| | | return; |
| | | } |
| | | |
| | |
| | | ESP_LOGI(TAG, "ignore voice_entry, current priority=%d", s_current_priority); |
| | | return; |
| | | } |
| | | if (!Board::GetInstance().IsNetworkReady()) { |
| | | ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 复读机 to play locally"); |
| | | return; |
| | | } |
| | | Application::GetInstance().SendListenDetect("[voice_entry]", true); |
| | | } |
| | | |
| | |
| | | if (!command || command[0] == '\0') { |
| | | return false; |
| | | } |
| | | if (strcmp(command, "切换模式") == 0 || strcmp(command, "qie huan mo shi") == 0) { |
| | | const bool speaking = Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking; |
| | | if (Board_Is_Mode_Switch_Command(command)) { |
| | | Board_EnterVoiceEntry(); |
| | | return true; |
| | | } |
| | | if (strstr(command, "我们来聊天吧") || strstr(command, "聊会天吧")) { |
| | | if (s_current_priority >= 2) { |
| | | ESP_LOGI(TAG, "ignore voice chat switch, priority=%d", s_current_priority); |
| | | return true; |
| | | } |
| | | if (Board_Is_Chat_Return_Command(command)) { |
| | | s_current_priority = 0; |
| | | Board_StartAppTask("chat", "conversation", "{}", "voice"); |
| | | return true; |
| | | } |
| | | if (strstr(command, "打开翻译") || strstr(command, "开始翻译")) { |
| | | if (speaking) { |
| | | if (Board_Is_App_Launch_Command(command)) { |
| | | ESP_LOGI(TAG, "ignore app launch while TTS is speaking: %s", command); |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 || |
| | | strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 || |
| | | strstr(command, "开始复读") || strstr(command, "复读机")) { |
| | | if (s_current_priority >= 2) { |
| | | return true; |
| | | } |
| | | Board_StartAppTask("english_translation", "live_translate", "{}", "voice"); |
| | | return true; |
| | | } |
| | | if (strstr(command, "开始复读") || strstr(command, "打开复读")) { |
| | | if (s_current_priority >= 2) { |
| | | return true; |
| | | } |
| | | Board_EnterVoiceEntry(); |
| | | return true; |
| | | } |
| | | if (strstr(command, "数字背诵") || strstr(command, "成语接龙") || strstr(command, "口算")) { |
| | | ESP_LOGI(TAG, "reserved voice slot: %s", command); |
| | | Board_EnterVoiceEntry(); |
| | | Board_StartAppTask("english_learning", "repeater", "{}", "voice"); |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | static bool layout_id_is_safe(const char *id) |
| | | { |
| | | if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) { |
| | | return false; |
| | | } |
| | | for (const char *p = id; *p; ++p) { |
| | | if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') || |
| | | *p == '_' || *p == '-')) { |
| | | return false; |
| | | } |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | esp_err_t Board_EnsureAppLayout(const char *app_id, const char *task_id) |
| | | { |
| | | if (!layout_id_is_safe(app_id)) { |
| | | return ESP_ERR_INVALID_ARG; |
| | | } |
| | | char path[SDCARD_PATH_MAX_LEN]; |
| | | int written; |
| | | if (task_id && task_id[0]) { |
| | | if (!layout_id_is_safe(task_id)) { |
| | | return ESP_ERR_INVALID_ARG; |
| | | } |
| | | written = snprintf(path, sizeof(path), "/%s/%s/lessons", app_id, task_id); |
| | | } else { |
| | | written = snprintf(path, sizeof(path), "/%s", app_id); |
| | | } |
| | | if (written <= 0 || (size_t)written >= sizeof(path)) { |
| | | return ESP_ERR_INVALID_SIZE; |
| | | } |
| | | return SDCard_Ensure_Business_Dir(path); |
| | | } |