wangzhibo
2026-09-11 659c470eba542383987aad227b7f918100f131fa
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
@@ -1,6 +1,7 @@
#include "app_task.h"
#include "application.h"
#include "board.h"
#include "mqtt.h"
#include "system.h"
@@ -15,6 +16,7 @@
#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
@@ -23,6 +25,8 @@
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;
@@ -34,7 +38,9 @@
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)
@@ -82,10 +88,292 @@
           (!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,
@@ -195,6 +483,7 @@
                           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;
    }
@@ -214,7 +503,7 @@
                    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;
@@ -227,19 +516,29 @@
                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;
    }
@@ -256,18 +555,31 @@
        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();
@@ -288,15 +600,107 @@
    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)
@@ -313,9 +717,12 @@
        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;
    }
@@ -328,6 +735,10 @@
        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);
}
@@ -336,36 +747,66 @@
    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);
}