wangzhibo
10 天以前 220e4fab27b7f419b3e89e2f2d28ae789aac09d7
5种场景模式家长端ok
80个文件已修改
49个文件已添加
14677 ■■■■■ 已修改文件
.cursor/rules/xiaozhi-locked-modes.mdc 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-1.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-4-eval-and-roadmap.md 337 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
phase-7a-plan.md 372 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
roadmap.md 209 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/application.cc 359 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/application.h 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/audio_processor.h 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/audio_service.cc 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/audio_service.h 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.cc 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.h 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/wake_word.h 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc 977 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc 203 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c 665 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.cc 901 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.h 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/display/lvgl_display/jpg/image_to_jpeg.h 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/pom.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDeviceStatusDTO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/IdiomLessonCatalog.java 327 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/LessonClipIndex.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentCatalog.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentSceneCatalog.java 341 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/controller/ParentTaskController.java 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dao/ParentTaskDao.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentDeviceStatusDTO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryDetailDTO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryItemDTO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryListDTO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryTurnDTO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentImmediateCloseDTO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskDTO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskListDTO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskSaveDTO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/entity/ParentTaskEntity.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineEvent.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineListener.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDevicePresence.java 288 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentMqttPublisher.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentHistoryService.java 247 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentTaskService.java 764 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/LessonSyncController.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/LessonSyncDTO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181100.sql 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181400.sql 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191800.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191930.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/device/controller/DeviceControllerTest.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/catalog/ParentSceneCatalogTest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/mqtt/ParentDevicePresenceTest.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/env/.env 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/env/.env.development 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/manifest.config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/api/agent/types.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/api/device/types.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/api/parent/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/api/parent/parent.ts 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/api/parent/types.ts 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/i18n/de.ts 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/i18n/en.ts 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/i18n/pt_BR.ts 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/i18n/vi.ts 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_CN.ts 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_TW.ts 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/manifest.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages.json 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/edit.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/index.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/device/index.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/index/index.vue 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/login/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/edit.vue 907 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history-detail.vue 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history.vue 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/historyText.ts 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/index.vue 488 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/scheduleText.ts 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/src/utils/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/manager-mobile/vite.config.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/config.yaml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/api/lesson_audio_handler.py 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom-lessons.json 792 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom_catalog.py 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/lesson_audio.py 210 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py 288 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py 394 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/http_server.py 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py 547 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_lesson_audio.py 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.cursor/rules/xiaozhi-locked-modes.mdc
@@ -1,11 +1,11 @@
---
description: 已验收模式不可回退;只改复读/听读清单与播放逻辑
description: 已验收模式不可回退;只改泛听/跟读清单与播放逻辑
alwaysApply: true
---
# 已锁定:不要改这些已正确路径
开机欢迎词、对话模式、翻译模式、中英对话已经验收通过。后续只许改复读机 / 听读(跟读)。不要为修复读/听读去改 chat、translation、bilingual、hello/TTS 欢迎词时序。
开机欢迎词、对话模式、翻译模式、中英对话已经验收通过。后续只许改泛听(内部 repeater)/ 跟读(shadowing)。不要为修泛听/跟读去改 chat、translation、bilingual、hello/TTS 欢迎词时序。
不要动:
@@ -13,20 +13,21 @@
- `interaction_mode == CHAT | TRANSLATION | BILINGUAL` 的 ASR→LLM/翻译 路径。
- 欢迎词 TTS:先有 opus 再发 start/字幕。
# 复读机 PLAY_AUDIO
# 泛听 PLAY_AUDIO(内部 repeater)
JSON task 下发播放列表,每项 `{url|name, repeat, speed}`,speed 默认 1.0。测试清单:
JSON task 下发播放列表,每项 `{url|name, repeat, speed}`,speed 默认 1.0。测试清单(一级开天辟地 `l1.001`):
- `/english_learning/shadowing/lessons/unit01/01.mp3` × 10
- `/english_learning/shadowing/lessons/unit01/02.mp3` × 5
- `/english_learning/repeater/lessons/l1/001/cn/01.mp3` × 1,1.0 倍速
- `/english_learning/repeater/lessons/l1/001/en/01.mp3` × 1,0.8 倍速
- `/english_learning/repeater/lessons/l1/001/en/01.mp3` × 1,0.9 倍速
- `/english_learning/repeater/lessons/l1/001/en/01.mp3` × 3,1.0 倍速
播完自动回对话,播「复读结束」。播放中忽略闲聊,只响应离线口令:退出复读、我们来聊天吧。
播完自动回对话,播「泛听结束」。播放中忽略闲聊,只响应离线口令:退出泛听、我们来聊天吧。
# 听读 SHADOWING
# 跟读 SHADOWING
JSON task 一次性下发整份列表,每项 `{url|name, repeat, speed}`,speed 默认 1.0。之后在设备上排序执行,不要每播一句再 MCP 一次。测试清单:
- `/english_learning/shadowing/lessons/unit01/01.mp3` × 5(每遍后录音)
- `/english_learning/shadowing/lessons/unit01/02.mp3` × 10
- `/english_learning/shadowing/lessons/l1/001/en/01.mp3` 到 `58.mp3`,各 × 1,0.8 倍速(每遍后录音)
设备本地循环:播一句 → 听窗录音(墙钟播放时长 × 1.2)→ 录音上传 → 再播下一句,直到清单结束。ASR 只暂存/评估,不进 chat/LLM。清单结束播「跟读结束」再回对话。
phase-1.md
@@ -392,7 +392,7 @@
         若设备软关机 / 硬件关机后由 RTC 唤醒 → 联网后立刻执行 pending,不进 chat
```
这是 **小智硬件重启 / 闹钟唤醒的主要产品目标**:不是为了回到待机聊天,而是到点自动听读、跟读等。
这是 **小智硬件重启 / 闹钟唤醒的主要产品目标**:不是为了回到待机聊天,而是到点自动泛听、跟读等。
现有代码已经具备半截能力,**尚未接到 App/Task**:
phase-4-eval-and-roadmap.md
New file
@@ -0,0 +1,337 @@
# Phase 4 之后:评估报告与更新后的 Roadmap
> Date: 2026-09-18
> 对照:当前仓库代码 · `roadmap.md`(2026-09-02)· `phase-1-review.md` · `phase-2-review.md` · `phase-3-review.md` · `phase-4-plan.md`
> 产品决策:家长端(小程序)提前;小智硬件代际 / 多版本(原 Phase V)放到最后。
> 粒度:架构与项目管理。不是 Coding Plan。
> 下一编码阶段:`phase-7a-plan.md`——家长任务下发(场景+立即/计划;RTC 只写最近一次)+ 执行记录(后,无计量 UI)。
原则不变:每完成一个 Phase → Completion Review → Architecture 更新 → Roadmap Review → 下一份 Phase Plan。
**本文取代**旧路线图里「下一编码 = Phase 4 / Phase V 可与 4 并行」的排序。
---
## 0. 一页结论
跟读/复读已经能在**这一款**色宁设备上完整跑完:清单一次下发、设备本地播→听→下一句、后评估定量、本地 wav + 同名 txt、Instance 可入库。缺的不是再做一个英语 App,而是**家长能设一天的计划,设备自主开/关/切模式**;历史记录第二刀,分数先不给家长看。
| 判断 | 说明 |
|------|------|
| Phase 1–4 产品骨架 | **可用。** 对话 / 翻译 / 中英 / 复读 / 跟读已在设备上验收通过(锁定路径见 `.cursor/rules`) |
| Phase 4 相对原施工单 | **目标达成,执行器换了地方。** 步进权威从「Server 时钟 + 每句 MCP」改成「设备播放列表循环」;后评估仍在 Server,不指挥喇叭 |
| 演示闭环缺口 | 无云端计划表;闹钟 pending 只有一份;已开机到点不切模式;课文写死;`manager-mobile` 无计划页;Instance GET 仅 server secret |
| 下一刀 | **Phase 7a 家长任务下发**。全表同步、RTC 只写下一时刻;立即优先于计划;History 后做;metrics UI 不做 |
| 原 Phase V | **最后做。** 现网只有一款硬件;hello 代际字段不增加演示能力 |
不要在 7a 之前开倒背/口算/接龙、不要做摄像头兼容矩阵、不要把课文下载平台化当成选课前提(SD 手工拷卡 + catalog 与卡上路径一致即可)。不要先做分数页。
---
## 1. 总进度(对照旧 Roadmap)
| 编号 | 旧 Roadmap 一句话 | 代码事实(2026-09-18) | 状态 |
|------|-------------------|------------------------|------|
| Phase 1 | 薄 App Runtime | `apps.json` + `start_app_task`;CHAT / PLAY_AUDIO / TRANSLATION / BILINGUAL / SHADOWING;MQTT `START_APP_TASK`;离线口令挂钩 | **完成**(有债,见 §2.1) |
| Freeze | Capability Spec v1.0 | 文档冻结 | **完成** |
| Phase 2 | 事件 + 采集 | `[skill_event]` + `duration_ms`;TaskSession JSON;SD `/{app}/{task}/lessons` | **完成**(跟读已不是 TTS 范读) |
| Phase 3 | TaskInstance 持久化 | 三表 + PUT/GET;report 可回写 `audio_id` | **完成**(GET 鉴权不对家长;见 §2.3) |
| Phase 4 | 跟读时钟 + 后评估 | 设备播放列表 + `listen_ratio` 听窗;`expected_text` / `metrics`;本地 `data/recordings` + 同名 `.txt` | **完成**(与原文「固件零 diff」不符,见 §3) |
| Phase V | 产品代际 + 端云矩阵 | 无 generation/revision/cap_set | **未做 → 调到最后** |
| Phase 4b | 倒背 / 口算 / 接龙 inline | 仅 `apps.json` 意图槽预留 | **未做 → 演示闭环之后** |
| Phase 5 | Server Scheduler | 设备 RTC + NVS pending;**无**云端日历表 | **未做 → 权威表并入 7a-B;5 剩到点补推** |
| Phase 6 | 资源 DOWNLOAD + 在线 Locale | 只有 mkdir;词表仍硬编码中文 | **未做** |
| Phase 6b | 离线 Locale OTA | 仅现网中文 MultiNet | **未做** |
| Phase 7 | 家长任务管理 | `manager-mobile` 无计划/Instance UI | **未做 → 7a 下发优先、历史后置** |
| Phase 8 / 9 | 开发者 / 订阅 | — | **后置** |
```text
已落地(设备可用)
  chat.conversation · 翻译 · 中英对话 · 复读清单 · 跟读清单
  TaskSession JSON · Instance 三表 · 后评估 metrics · 本地 wav/txt
未落地(挡住「真实使用」)
  家长:按场景创建任务(泛听/跟读/双语…);立即执行或周期计划
  固件:本地计划全表;RTC 只编程最近一次;已开机到点执行
  云端 ai_parent_task + SYNC_SCHEDULE(不要 10 槽 SET_ALARM 一对一)
  历史:oauth2 读 Instance / 聊天(7a-D,无 metrics)
```
---
## 2. 已完成 Phase 的缺失(不要假装没有)
只列**仍影响产品或后续施工**的缺口。已在对应 `*-review.md` 写过、且不再挡演示的过程债(例如「Phase 1 无 pytest」)从略——Phase 2 之后已有 pytest。
### 2.1 Phase 1 仍缺
| 缺口 | 现状 | 是否挡 7a / 演示 |
|------|------|------------------|
| 家长小程序 UI | `manager-mobile` = 智能体列表 + 配网 + 聊天记录,不是练习控制台 | **挡。** 7a 本体 |
| Server Schedule 权威表 | 只有设备 `SET_ALARM`(软件最多 10 条)+ **唯一** NVS pending | **挡。** 7a:云端任务表 + 设备全表;DS3231 只写下一时刻。不要把 `ALARM_MAX_NUM` 当家长上限 |
| 冷场多次主动开口 | 进入对话有欢迎词;可配循环催促未做 | 不挡 |
| 非 Sening 板复读 | Task Gateway 仅 `sening-xiaozhi-s3` | 不挡(就这一款) |
| 双技能表 | 固件 `k_task_skills[]` + Server `apps.json` | 不挡;禁止再为新 App 扩固件表 |
| 离线/在线状态分叉 | 没网复读结束后 Server 仍可能以为在 CHAT | 演示保持在线 |
### 2.2 Phase 2 仍缺
| 缺口 | 现状 | 是否挡 7a / 演示 |
|------|------|------------------|
| D8 v1.1 本地文件上传 | 跟读 wav 在 xiaozhi-server 磁盘;chat report 另走 BLOB | 家长播录音依赖现有 chat_audio **或** 只展示文本。完整听原声可后做 |
| D11 `wait` Skill | 听窗在设备墙上时钟 | 不挡跟读;4b 间隔出题再用 |
| D9 长按退出 Task | 口令退出已有 | 不挡 |
| D10 `indicate` | 无 | 不挡 |
| 跟读句子表曾在 Server | **已改:** 整份 playlist JSON 一次下发,设备排序执行 | 已是现网;文档须承认 |
### 2.3 Phase 3 仍缺
| 缺口 | 现状 | 是否挡 7a / 演示 |
|------|------|------------------|
| GET 鉴权 | `ShiroConfig`:`/task/instance/**` **全部** `server`。计划写 oauth2,实施因「不能按方法拆过滤器」改成 server | **挡 7a-D。** Dispatch 不依赖读 Instance。History 再拆 oauth2 新路径;PUT 仍 server |
| `prompt_text` 仍是 SD 路径 | 原文在 `expected_text` | UI 必须显示 `expected_text`,不要把路径当课文 |
| upsert 失败后 `_skip_upsert` | 本会话不再上报。`audio_id` 已改为 VARCHAR(255) 消掉截断 | 部署 Liquibase 后新会话正常;旧 skip 会话需重新跟读 |
| 查询副本非权威 | 权威 = 连接内存 + `data/task_sessions/*.json` | 家长读库即可演示;不要把库当步进引擎 |
### 2.4 Phase 4 仍缺 / 与计划漂移
见下一节。对家长 **History**:**asr_text + expected_text + 可选 chat_audio 已够**。metrics 已入库,7a **不展示**。不要等音素、及格线、流利度。
---
## 3. Phase 4:原计划 vs 代码
### 3.1 原施工单(`phase-4-plan.md`)钉死的模型
```text
设备(当时要求零 diff)
  播一句 → completed(duration_ms) → Server 开听窗 → 时钟到了 MCP 播下一句
分析全部后置、定量、允许失败;无 passed;无 JUDGE。
```
### 3.2 实际实现(必须当成 V5 架构,不要回退)
SRAM / 58 句清单证明:每句再 MCP 一次会把设备卡死或截断列表。现网是:
```text
Server  一次 JSON playlist(url|name, repeat, speed;speed 默认 1.0)
Device  PSRAM 清单,本地循环:
          播一句 → 关解码器 → 听窗 = 墙钟播放时长 × listen_ratio(1.2)
          → 录音上行 → 下一句
        清单结束 → audio.playlist_completed → Server 播「跟读结束」回对话
Server  ASR 只暂存/评估,不进 chat/LLM
        后评估 text_similarity_v0 → step.metrics
        data/recordings/{mac}/{mode}/{date}/{time}_{asr|tts}_*.wav
        同 stem .txt(UTF-8)
复读    同样一次下发清单;播完「复读结束」;播放中忽略闲聊
        离线口令:退出复读 / 我们来聊天吧
```
锁定(已验收,后续禁止为修跟读去改):开机欢迎词时序、CHAT / TRANSLATION / BILINGUAL 的 ASR→LLM 路径。
| 原计划条目 | 代码 | 结论 |
|------------|------|------|
| 固件零 diff | `app_task.cc` 播放列表、听窗、`listen_ratio`;解码器/唤醒词在听窗前关掉 | **有意偏离。** 设备成为步骤循环执行器,Server 不再逐句 MCP |
| 下一句只由 Server 时钟触发 | 设备墙上时钟;Server 旁路评估 | 语义仍是「分析不挡喇叭」,触发器换端 |
| `expected_text` 来自 txt 契约 | `resolve_expected_text`;禁止用路径做相似度 | **符合** |
| `metrics[]` 无 passed | `text_similarity_v0` 0–1 | **符合** |
| 说错/不说仍下一句 | 听窗到点就进下一句;静音也落 wav | **符合** |
| 退出立刻回 Chat | 口令 `is_exit_to_chat` | **符合** |
| Liquibase 新列 | `expected_text` / `metrics` / `assessed_at`;后又 `audio_id` VARCHAR(255) | **符合**(255 是 9-18 为路径加的) |
宣称 Phase 4 **Done** 的产品标准(原 §2.3):热路径流畅 + 后评估定量 —— **达到**。
宣称「固件冻结面未移动」—— **不要再写。** 冻结的是 Skill 种类,不是「跟读循环必须在云端」。
### 3.3 Phase 4 未做(原 Out of Scope,保持)
当场纠错再播、及格线、音素、租户、4b 三个 Task、热路径读卡上 txt、manager-web 打卡页、Locale。
---
## 4. Current Architecture V5(基于代码)
### 4.1 设备(仅 sening-xiaozhi-s3)
```text
Application 热路径(hello/listen/tts/stt/Opus 语义锁定)
Task Gateway
  START_APP_TASK / pending / 离线口令
  SET_ALARM 软件表最多 10 时刻;DS3231 只编程下一次 Alarm1
  **7a-A:家长计划改走本地全表,不要 10 槽一对一;pending = 下一笔计划**
  playlist:repeat + speed + listen_ratio
  skill_event:audio.completed|stopped|playlist_completed + duration_ms
  SD mkdir /{app}/{task}/lessons
```
### 4.2 Server
```text
xiaozhi-server
  apps.json 权威定义
  start_app_task / mode 分流
  跟读/复读:一次下发 playlist,不逐句 MCP
  TaskSession → JSON + PUT Instance
  assessment.py 后评估(不 await 再 play)
  audio_archive:recordings + sidecar txt
manager-api
  PUT/GET /task/instance   目前仅 server secret
  ai_task_instance / step / event
  不执行 Skill、不判对错结论
```
### 4.3 家长端现状
```text
manager-mobile(uni-app,可出微信小程序)
  登录 / 智能体 / 配网 / 聊天记录 / 声纹
  无:练习会话、metrics、START_APP_TASK、Schedule
manager-web
  智控台(人设、模型、OTA)
  无跟读页
```
色宁 MQTT `START_APP_TASK` **设备侧已通**。缺的是小程序发出去,以及用用户身份读 Instance。
### 4.4 数据(跟读一句)
```text
设备录音 Opus
  → Server ASR(不进 LLM)
  → recordings/..._asr_....wav + .txt
  → turn.asr_text / expected_text / metrics
  → PUT Instance(audio_id = 相对路径,≤255)
  → 可选 chat-history report → 32 位 blob,回写同一列则覆盖路径
```
家长 7a-D:**只展示文本**(跟读 expected/asr;对话类走 chat_history)。相似度不渲染。播放原声若要用现成 API,走 chat_audio id,不要假设 manager-api 能读 xiaozhi-server 磁盘路径。
---
## 5. 更新后的 Roadmap
### 5.1 新依赖图
```text
Phase 1–4                         ✅ 设备侧英语学习可演示
    ↓
Phase 7a  家长任务下发            ← 下一编码
    │     A 本地计划全表;RTC 只写最近一次;开机到点也执行
    │     B ai_parent_task + 场景 catalog + SYNC_SCHEDULE / 立即 START
    │     C 小程序:场景参数 + 立即 / 周期
    │     D 历史(文本+录音;无分数)
    │
    ├───────────────┐
    ↓               ↓
Phase 5 到点补推    Phase 6 课文 DOWNLOAD + 在线 Locale
(7a 日历表已建)   (换课不靠拷卡时再做;不挡选课)
    │
    ↓
Phase 4b  倒背 / 口算 / 接龙(inline 评估)
    │     固件仍禁止 if correct;用已有 speak/listen
    ↓
Phase 6b  离线 Locale(仅 zh/en 有模型才做)
Phase 7b  家长完整:语言对、PAUSE、长按退出、听原声完善
Phase 8   开发者 App 平台
Phase 9   订阅
Phase V   产品代际 / 端云兼容     ← 最后(就这一款硬件)
```
**必须串行:** 7a-A 固件(全表+下一闹钟)→ 7a-B 云端 → 7a-C 小程序。未完成 A 不要宣称周期任务可用。7a-D 不挡 Dispatch Done。
**7a 不依赖:** 4b、6 下载、6b、V。SD 已有 unit01 即可选课。语言对可存 params。
**5 不挡 Dispatch:** 权威表在 7a-B;Phase 5 只补「已开机漏响」的云端再推。
### 5.2 Phase 7a — 家长任务下发(Roadmap 级)
1. **Objective**
   家长先选场景再填参数:泛听/跟读(课文、遍数、结束后关机或回对话);双语(中英/中法/中日…,可选时长)。可 **立即执行**(临时,最高优先)或 **存为周期计划**。设备保存计划全表;DS3231 **只写入下一次最近时刻**。History 第二刀看文本/录音,**不展示分数**。
2. **Why Now**
   10 槽闹钟一对一撑不住「每小时整点聊 10 分钟」。芯片本来就只编程下一次。现网 pending 会被覆盖;已开机到点不执行;课文写死;家长文案「复读/复读机」和跟读分不清。
3. **Dependencies**
   `START_APP_TASK` / 软关机 / Alarm1;Phase 3 Instance(7a-D);登录与绑设备。不把 `SET_ALARM` 当任务总线。
4. **Scope**
   - **7a-A** 本地 `schedule.json` 全表;`recompute_next_alarm()` 写唯一 pending + Alarm1;已开机也执行;软关机前重算。
   - **7a-B** `ai_parent_task` + 场景/课文/语言对 catalog;计划 `SYNC_SCHEDULE`;立即 `START_APP_TASK`。
   - **7a-C** 小程序:场景动态表单 + 立即 / 每天 / 每小时。对外写 **泛听**,不写复读机。
   - **7a-D**(后)oauth2 读 Instance + 聊天;不渲染 metrics。
5. **Out of Scope**
   分数、课文 DOWNLOAD、倒背/口算、OTA 代际、改锁定热路径、用 `ALARM_MAX_NUM` 限制条数、删旧口令「复读」。
6. **Client(设备)**
   允许补计划文件、只写下一闹钟、duration 墙钟、on_complete。禁止新 DeviceState、业务 if 树、把 58 句清单塞进 RTC。可新增一条 MQTT `SYNC_SCHEDULE`(不是新 WS type)。
7. **Acceptance(Dispatch)**
   立即跟读可打断计划;≥15 条计划含 hourly 全量在设备、芯片只有最近一次;软关机唤醒进正确场景;结束后重算下一闹钟;计划不打断临时。Chat/翻译/中英口令回归不坏。History 不挡 Dispatch Done。
施工单:`phase-7a-plan.md`。未完成 7a-A 不要宣称周期任务可用。
### 5.3 后续 Phase(目标级,不锁类名)
| 编号 | 一句话 | 相对旧图的变化 |
|------|--------|----------------|
| Phase 5 | 设备已开机漏响时云端再推 START | 任务表已并入 7a-B;本阶段是补救扫描 |
| Phase 6 | 清单 / DOWNLOAD / `resource_id`;在线 phrase pack | mkdir 已有。7a 已能存 language_pair;pack 不挡选课 |
| Phase 4b | 倒背/口算/接龙 = `assessment.timing=inline` + 及时 speak | **挪到家长能看见跟读之后** |
| Phase 6b | 离线 zh/en 词表 OTA | 不挡 7a/7b |
| Phase 7b | PAUSE/RESUME、长按退出、录音播放完善 | 语言对选择已在 7a 双语场景;7b 补控制台与离线词 |
| Phase 8 / 9 | 开发者沙箱 / 订阅 | 不变,更后 |
| Phase V | `product.generation/revision` + hello/OTA 矩阵 | **最后。** 摄像头 SKU 或第二款硬件立项时再打开 |
评估时机表(deferred vs inline)仍有效,见旧 `roadmap.md`;4b 不得把跟读改回流内纠错。
### 5.4 明确后置
| 事项 | 最早 |
|------|------|
| 小智 AI 1 / 1-1 / 2 代际字段 | Phase V(最后) |
| 摄像头 / 新传感器 Skill | Phase V 之后 |
| D8 v1.1 本地文件当家长主播放通道 | 7b 或独立;7a 用文本 + 可选 chat_audio |
| D11 wait 一等 Skill | 4b 需要「播完停 800ms」且不想云端睡时 |
| D14 TASK_PAUSE/RESUME | 7b |
| 新 WS `type=command` | 仅当 detect 前缀不够;不加新语义 |
| 德语离线唤醒 | 可能永久做不到(无官方 MultiNet) |
---
## 6. 场景(7a Dispatch Done 时)
```text
家长创建:
  立即     跟读 unit01 ×1,结束后回对话     → 马上执行(可打断当前计划)
  计划 A   每天 07:30 跟读 unit01
  计划 B   每天 10:00 泛听 unit01,结束后软关机
  计划 C   每小时整点 双语(中英) 10 分钟,结束后软关机
  (计划可以很多条,不是 10)
设备:
  本地保存全部计划
  软关机前只把「下一次最近」写入 Alarm1
  10:00 泛听关机后,闹钟变成 11:00 双语,而不是再占 10 个槽
History(7a-D,不挡 Dispatch):
  跟读:每句原文 / ASR / 可选录音
  泛听:完成态
  双语/翻译/对话:该时段聊天
  不展示相似度
```
---
## 7. 刻意不做进 7a 的事项
- 为倒背/口算改 C++ 或加 MultiNet 业务口令
- 把 `branch(if correct)` 放进固件
- 在 7a 做兼容矩阵 / hello 代际字段
- 把 `audio_id` 路径当成 manager-api 可直接 fopen 的文件
- 把及格、等级写入 `ai_task_step`;家长页渲染 metrics
- 改 CHAT / 翻译 / 中英 / 开机欢迎词去「顺便修」家长页
- 重做 SD 目录或逐句 MCP 跟读
- 用 10 个闹钟 id 一对一绑定家长任务
- 先做历史页、后做「全表 + 下一闹钟」(顺序必须 A→B→C,再 D)
**下一编码阶段:** 只打开 `phase-7a-plan.md`。先补本地计划表与只写下一时刻的 RTC,再云端与小程序。对外文案用 **泛听 / 跟读**。
phase-7a-plan.md
New file
@@ -0,0 +1,372 @@
# Phase 7a Coding Plan — 家长:任务下发(先)+ 执行记录(后)
> File: `phase-7a-plan.md`
> Date: 2026-09-18(修订:场景参数 / 泛听命名 / 临时 vs 计划 / **闹钟只写下一次**)
> 产品:① 创建并下发任务 ② 看历史;**计量评估不做**。
> 约束:Capability Spec v1.0;锁定 chat / 翻译 / 中英热路径与欢迎词时序。
> 给 Coding Agent:先读本文 §0–§5,再读 `system.c` `program_alarm1`(DS3231 本来就只编程下一次)、`app_task.cc` 唯一 pending、`runtime.py` `start_app_task`。
实施顺序:**先 Dispatch(A/B/C),再 History(D)。禁止先做 metrics UI。**
---
## 0. 家长怎么创建任务
先选 **场景**,再填该场景的参数,最后选 **怎么跑**:
| 怎么跑 | 含义 | 优先级 |
|--------|------|--------|
| **临时任务** | 创建后 **立即下发并执行** | **最高**(`source=mqtt_immediate`) |
| **计划任务** | 周期到点执行(每天某时刻、每小时整点、工作日…) | 其次(`source=schedule`) |
同一条任务定义(场景+参数)可以「现在就练」也可以「存成每天 7:30」。不要做成两套互不相干的对象:云端一行任务,`run_mode=immediate|scheduled`。
### 0.1 场景与参数(家长 UI)
内部 `app_id/task_id/Mode` **不要改名**(避免 SD 路径与口令断裂)。对外文案如下。
| 家长场景 | 内部 | 家长要填的参数 |
|----------|------|----------------|
| **泛听** | `english_learning` / `repeater` / `PLAY_AUDIO` | 课文、遍数、结束后:**软关机** 或 **回默认对话** |
| **跟读** | `english_learning` / `shadowing` / `SHADOWING` | 同上(课文、遍数、结束后关机或回对话) |
| **双语对话** | `english_conversation` / `bilingual_chat` / `BILINGUAL` | 语言对:中英 / 中法 / 中日…;可选时长(如聊 10 分钟);结束后关机或保持/回默认对话 |
| **翻译** | 现网 translation app | 可选语言对;可选时长与结束动作 |
| **对话** | `chat` / `conversation` | 可选时长(如每小时整点聊 10 分钟);结束后软关机或保持 |
课文:catalog 选 `lesson_id`(unit01),不要勾 58 个文件。遍数落到 playlist 每项 `repeat`(泛听/跟读默认 1,家长可改)。
双语语言对:写入 `params.language_pair`(如 `zh-en` / `zh-fr` / `zh-ja`)。**不改** ASR→LLM 锁定路由;只把 pair 传进已有 bilingual/翻译 prompt。现网口令仍叫「中英对话」的,当作 zh-en 别名保留。
### 0.2 命名:不要再对家长说「复读 / 复读机」
「复读」和「跟读」在口语里都像「跟着读」,家长分不清。对家长用 **泛听 / 跟读**(只听 vs 跟着读),不要用听读、不要用复读机。
| 对家长 | 含义 | 内部(冻结) |
|--------|------|----------------|
| **泛听** | 只听课文,不录音、不跟说 | `repeater` / 路径仍 `/english_learning/repeater/` |
| **跟读** | 播一句 → 听窗录音 → 下一句 | `shadowing` |
进场 TTS / 小程序 / 历史页 / 离线口令一律用 **泛听**。不再保留「开始复读 / 退出复读 / 复读机」。
不要新造第三种 Mode。
### 0.3 验收场景(Dispatch)
```text
临时:家长点「立即开始 · 跟读 · unit01」→ 设备马上跟读(可打断当前计划任务)
计划 A:每天 07:30 跟读 unit01 ×1,结束后回对话
计划 B:每天 10:00 泛听 unit01 ×1,提示后播,结束后软关机
计划 C:每小时整点 双语(中英) 10 分钟,结束后软关机
计划 D:每天 19:00 双语(中日) ,结束后保持对话直到下一条
软关机后:设备从本地任务表挑 **下一次最近** 的计划,只把这一次写入 DS3231 Alarm1
  (例如刚播完 10:00 泛听关机 → 闹钟写成 11:00 双语,而不是再占 10 个闹钟槽)
```
计划数量 **不是 10**。每小时整点是 **一条** 周期任务,不是 24 条闹钟。
---
## 1. 闹钟模型(必须改掉上一版)
上一版「每个计划一个 alarm id、最多 10 条」是错的。
**硬件事实:** DS3231 只有 Alarm1;`program_alarm1()` **已经**只写入下一次触发时刻。`ALARM_MAX_NUM=10` 是软件里的闹钟条目表,不是 RTC 能力,**不能**当家长任务上限。
**正确模型:**
```text
云端权威:任务表(临时一次 / 计划多条,数量远大于 10)
    ↓ 全量同步(替换,不是逐条 SET_ALARM)
设备本地:schedule.json(完整计划列表 + 每条 params)
    ↓ 每次需要闹钟时(同步后、一次任务结束、即将软关机)
挑 next = 所有计划里墙钟上最近的一次发生
    ↓
只 program DS3231 Alarm1 = next.time
同时把 next 的 {app,task,params} 写成「下一次唤醒载荷」(一份 pending 即可)
```
| 不要 | 要 |
|------|----|
| 家长 30 条计划 → 30 个 SET_ALARM | 30 条进文件;RTC 永远 1 个「下一次」 |
| 每小时整点 = 24 个 id | 一条 `hourly`,每次关机后算下一个 :00 |
| 用满 10 槽就 400 | 软上限(建议云端 100、设备 JSON 同样),与闹钟芯片无关 |
| 每个闹钟 id 绑一份 playlist | 计划里只存 `lesson_id`;真正 playlist 执行时再展开(MQTT 载荷上限 8KB) |
临时任务 **不写** 周期表、**不写** RTC。只发 `START_APP_TASK` `mqtt_immediate`。跑完后设备再 `recompute_next_alarm()`,以免把「下一计划」丢掉。
---
## 2. 现网缺口
| 能力 | 现网 | 缺口 |
|------|------|------|
| RTC | 只编程下一次 Alarm1 | **对。** 7a 应继续只用这一个槽 |
| 闹钟条目表 | 最多 10 条时刻 | **不要**当任务库。改成本地计划文件 |
| pending | `SET_ALARM` 覆盖唯一 NVS | 唤醒载荷 = **当前算出来的下一次计划**,不是 10 份 map |
| 已开机到点 | 未在 power-off 则不 StartAppTask | 已开机也要执行当前这笔载荷 |
| 课文/遍数/结束 | TEMP 清单;结束动作不完整 | 场景 params:lesson、repeat、on_complete |
| 语言对 | 双语写死中英文案 | params.language_pair;不改锁定路由 |
| 时长结束 | 无 | 对话/双语/翻译可 `duration_minutes` 到点结束 |
| 云端表 | 无 | `ai_parent_task`(名称可定,禁止改旧 changeset) |
| 历史 | GET 仅 server secret | 7a-D;无 metrics |
---
## 3. 目标与非目标
### 3.1 Dispatch 必须 Done
1. 家长选场景 → 填参数 → 选「立即执行」或「存为计划」。
2. 立即:设备马上进入该场景;优先级高于计划。
3. 计划:全量下发到设备;RTC **只存最近一次**;软关机后本地重算下一个。
4. 泛听/跟读:课文、遍数、结束后关机或回默认 chat;进场有提示。
5. 双语:可选语言对;可选时长。
6. 计划条数不被 10 限制;每小时整点是一条周期规则。
7. 已开机到点同样执行;不可打断正在跑的临时任务。
### 3.2 Out of Scope
- metrics UI
- 课文云下载(SD 已有 unit01)
- 倒背/口算;新 Mode;新 WS type
- 改 hello / CHAT / 翻译 / 双语 **热路径**(只允许 params 进 prompt / playlist)
- 农历;把 58 个 mp3 塞进 SYNC 包
### 3.3 Acceptance(Dispatch)
1. 立即跟读:点完设备进跟读,课文对。
2. ≥15 条计划(含一条「每小时整点聊 10 分钟后关机」)保存成功,设备 JSON 有全量;DS3231 **只有**最近一次。
3. 软关机 → 到点唤醒进的是那条最近计划,不是列表里第一条。
4. 该条结束后(或再关机)闹钟变成再下一条时刻。
5. 临时任务执行中,计划到点:**不抢占**;临时结束后再执行或重算(见 §5.3)。
6. 计划执行中来了临时任务:**打断计划**,跑临时;结束后重算 RTC。
7. 泛听结束关机 vs 跟读结束回 chat,按各自 `on_complete`。
8. Chat / 翻译 / 中英口令回归不坏。
---
## 4. 数据模型
### 4.1 云端 `ai_parent_task`
| 列 | 说明 |
|----|------|
| `id` | 任务 id(UUID),**不是** 闹钟 id |
| `user_id` / `mac_address` / `agent_id` | 绑定校验 |
| `run_mode` | `immediate` 一次性;`scheduled` 周期 |
| `scene` | `extensive` / `shadowing` / `bilingual` / `translation` / `chat` (extensive = 泛听) |
| `app_id` / `task_id` | 由 scene 映射,勿让家长填 |
| `params` JSON | 见下 |
| `schedule` JSON | 仅 scheduled;见下 |
| `enabled` | 计划可停用 |
| `status` | immediate:queued/running/done/canceled |
| `updated_at` | |
`params`:
```json
{
  "lesson_id": "unit01",
  "repeat": 1,
  "language_pair": "zh-en",
  "duration_minutes": 10,
  "on_complete": "power_off",
  "announce": true
}
```
`on_complete`:`power_off` | `chat`(回默认对话)。未填:泛听/跟读默认 `chat`;带时长的对话类默认 `power_off` 若家长选了关机,否则保持该场景直到被抢占。
`schedule`:
```json
{ "type": "daily", "time": "07:30", "weekdays": [1,2,3,4,5,6,7] }
{ "type": "hourly", "minute": 0 }
{ "type": "interval", "every_minutes": 60 }
```
`weekdays`:1=周一 … 7=周日。`hourly` = 每小时的第 `minute` 分。
immediate 行:无 schedule;下发即 `START_APP_TASK`,不进设备周期表(或进表但 `enabled` 且一次性、跑完 disable)。推荐:**周期表只含 scheduled**,immediate 不落 RTC。
软上限 100 条/设备(400 超出)。**禁止**按 10 拒绝。
### 4.2 设备本地周期表
文件例如 SD 或 NVS 旁路:`schedule.json` = 云端 scheduled 的紧凑拷贝(id、scene、app、task、params、schedule)。
**禁止**把展开后的 58 句 playlist 写入该文件。
`pending`(一份):仅「下一次 Alarm1 对应的那条任务」的 app/task/params。同步或跑完后重算覆盖。这是唯一 pending,语义变了:不是「最后一次 SET_ALARM」,而是「最近一次将发生的计划」。
`ALARM_MAX_NUM=10` 的时钟条目表:**不要**给家长计划用。旧 `SET_ALARM` 可留作普通闹钟,7a 同步路径不走它。
### 4.3 Catalog
```text
GET /user/scenes              场景列表 + 每个场景的表单 schema(课文? 遍数? 语言对? 时长? 结束动作?)
GET /user/lessons?scene=      extensive / shadowing 的课文
GET /user/language-pairs      zh-en, zh-fr, zh-ja…(展示名:中英/中法/中日)
```
---
## 5. 执行与优先级
```text
优先级:临时 mqtt_immediate  >  计划 schedule  >  语音/默认 chat
```
### 5.1 立即
```text
小程序「立即开始」
  → POST task run_mode=immediate
  → MQTT START_APP_TASK {app,task,params, source:mqtt_immediate}
  → 设备 Stop 当前计划 Skill(若有)再进入场景
```
不修改周期表。不 program 闹钟。结束后 `recompute_next_alarm()`。
### 5.2 计划同步与 RTC
```text
家长保存/删除/停用计划
  → 云端权威更新
  → MQTT SYNC_SCHEDULE { tasks: [ ...紧凑列表 ] }   // 全量替换
  → 设备写 schedule.json
  → recompute_next_alarm()
       next = 墙钟上最近一次 occurrence
       pending = next 的载荷
       program Alarm1 = next 的时刻
```
`SYNC_SCHEDULE` 是给「整表替换」用的一条 MQTT action(现网没有「下发 100 条计划」的命令)。不要用 10 次 `SET_ALARM` 冒充。不新增 Xiaozhi WS `type`。若 8KB 不够:先推文件再 `SYNC_SCHEDULE` 只带版本号——施工时选一种,不要两种并行。
软关机前必须 `recompute_next_alarm()`,这样没网也能被下一笔计划唤醒。
### 5.3 到点
```text
Alarm1 触发
  → 读 pending(这一笔计划)
  → 若正在跑临时任务:不抢占;临时结束后 recompute(若仍到期则补跑或跳过本 occurrence,建议:仍在宽限窗口内则补跑,否则等下一周期)
  → 若软关机:Restore + StartAppTask(schedule)
  → 若已开机且非临时:Stop 当前 + StartAppTask(schedule)
  → 本笔开始后即可 recompute「再下一次」写入 Alarm1(时长 10 分钟的任务不要把闹钟设在这 10 分钟内的下一个整点,除非产品就要重叠——默认:进行中的这条占用设备,下一个 Alarm 至少在本笔 on_complete 之后)
```
**进行中占用:** 一条任务没结束(清单没完 / duration 没到)时,不要用下一整点把它切掉,除非来了临时任务。每小时整点「聊 10 分钟」:10:00 开始,10:10 关机;11:00 再开。不要 10:00 开了又被 11:00 的计算误写进当前这 10 分钟里。
### 5.4 结束
只读本次 params,禁止 `if (shadowing) power_off`。
| on_complete | 行为 |
|-------------|------|
| `chat` | 回默认对话(现网「跟读结束」「泛听结束」提示可保留,文案泛听不要说复读机) |
| `power_off` | 自然结束后软关机;口令退出则 **不关** |
| duration 到点 | 等同自然结束,再走 on_complete |
时长定时:设备墙钟(关机/没网也要准)。不要只靠云端 sleep。
### 5.5 语言对
`start_app_task` 把 `language_pair` 放进 bilingual/翻译已有 prompt 拼装。未实现的语种:仍进入该 Mode,TTS 说明「先用中英」,不要崩。完整 phrase pack 仍是 Phase 6,不挡 7a 存 pair。
---
## 6. 改动清单
### 6.1 manager-api
| ID | 任务 |
|----|------|
| J1 | Liquibase `ai_parent_task` |
| J2 | oauth2 CRUD;scene 白名单;计划上限 100 不是 10 |
| J3 | scenes / lessons / language-pairs catalog |
| J4 | 计划变更 → `SYNC_SCHEDULE` 全量 |
| J5 | 立即 → `START_APP_TASK` mqtt_immediate |
| J6 | (后)oauth2 读 Instance / 聊天;不渲染 metrics |
### 6.2 xiaozhi-server
| ID | 任务 |
|----|------|
| S1 | lesson_id + repeat → playlist(替换写死 TEMP 为缺省) |
| S2 | on_complete / duration / language_pair 传入执行 |
| S3 | 进场 announce;家长文案「泛听」 |
| S4 | pytest:展开课文;immediate 不写周期表 |
不改 ASR→LLM 锁定路径。
### 6.3 固件
| ID | 任务 |
|----|------|
| C1 | 本地 `schedule.json` 全量;**禁止**用 10 槽闹钟表当任务库 |
| C2 | `recompute_next_alarm()`:最近一次 → 唯一 pending + Alarm1 |
| C3 | 触发时已开机也执行;软关机唤醒走这笔 pending |
| C4 | 临时不抢?否:临时可打断计划;计划不可打断临时 |
| C5 | `duration_minutes` 墙钟到点结束;`on_complete` |
| C6 | 软关机前重算下一次 Alarm1 |
| C7 | 禁止新 DeviceState / 业务 if 树;课文不进固件 |
C1/C2/C6 是 P0。没有「本地全表 + 只写下一次」,云端 100 条计划在真机上仍是错的。
### 6.4 manager-mobile
| ID | 任务 |
|----|------|
| M1 | 创建:场景 → 动态表单 → 「立即开始」或「每天/每小时…」 |
| M2 | 场景名:**泛听 / 跟读 / 双语对话 / 翻译 / 对话**。禁止写复读机 |
| M3 | 泛听/跟读:课文、遍数、结束动作。双语:语言对、可选时长 |
| M4 | 计划列表(可很多条);立即任务显示「正在执行」 |
| M5 | (后)历史:跟读句文本+录音;泛听完成态;对话类聊天记录 |
---
## 7. 实施顺序
```text
7a-A  固件:schedule.json + 只编程下一次 Alarm1 + 已开机执行 + 软关机前重算
7a-B  Java 任务表 + SYNC_SCHEDULE / 立即 START
      Python:lesson/repeat/on_complete/language_pair
7a-C  小程序:场景表单 + 立即 + 计划
7a-D  历史(无分数)
```
---
## 8. 测试
- 15+ 条计划含 hourly:设备文件全在,芯片里只有一个最近时刻
- 泛听关机后再开,进的是下一笔最近计划,不是列表[0]
- 立即跟读打断泛听计划;计划到点不打断立即
- 中英/中日 pair 写入 params;热路径不回归失败
- 未知课文:提示,不误关机
- 口令「退出泛听」退出泛听
锁定回归:欢迎词、CHAT、翻译、中英。
---
## 9. History(7a-D)
| 场景 | 家长看见 |
|------|----------|
| 跟读 | 每句 expected / ASR / 可选录音;无分数 |
| 泛听 | 完成/时长;通常无 turns |
| 双语/翻译/对话 | 该时段聊天文本 + 现有音频 API |
---
## 10. 编码约束
1. 不改 hello/listen/tts 语义。
2. 不把课文和 58 句清单写入 RTC/闹钟结构体。
3. 不用 `ALARM_MAX_NUM` 限制家长任务。
4. 临时 vs 计划只通过 `source` 优先级,不新造 Mode。
5. 设备离线:云端仍保存计划;上线再全量 SYNC;没网软关机只靠本地表 + Alarm1。
6. 每个 PR 先 Chat 回归。
roadmap.md
@@ -1,14 +1,17 @@
# Roadmap(Current Architecture V4 + Capability Spec v1.0)
# Roadmap(Current Architecture V5 + Capability Spec v1.0)
> Date: 2026-09-02
> 输入:`phase-1-review.md` · `phase-2-review.md` · `phase-3-review.md` · `Xiaozhi Device Capability Specification v1.0.md` · 设备默认语言 / 语言对评估 · **产品代际 / 端云版本匹配**
> Date: 2026-09-18
> **评估与排序修订:** `phase-4-eval-and-roadmap.md`(对照 Phase 1–4 代码缺口 + 家长端提前)。
> 输入(历史):`phase-1-review.md` · `phase-2-review.md` · `phase-3-review.md` · Capability Spec v1.0
> 粒度:架构与项目管理。不是 Coding Plan。  
> Phase 3 已实施:`phase-3-review.md`。下一编码阶段:`phase-4-plan.md`(验收 `phase-4-test.md`)。
> 语言:见本文 **Phase 6 / 6b / 7**;**不要**塞进 Phase 4。
> 版本与兼容:见本文 **Phase V**;摄像头 SKU(小智 AI 2)之前必须落地握手字段与兼容矩阵,**不要**等 Phase 8。
> Phase 4 已实施(设备播放列表 + 后评估)。**下一编码阶段:Phase 7a 家长任务下发**(施工单 `phase-7a-plan.md`)。先 Dispatch,再 History;**不做计量 UI**。
> 语言包 / 离线词表仍见 **Phase 6 / 6b / 7b**。7a 双语场景只存并传递 `language_pair`,不换 MultiNet。
> **产品代际 / 多硬件版本(原 Phase V)改到最后**:现网只有一款色宁设备,不挡演示。
原则:每完成一个 Phase → Completion Review → Architecture 更新 → Roadmap Review → 下一份 Phase Plan。  
**禁止**把本文当成锁死到 Phase 9 的施工图。后面 Phase 只保证目标、边界与 **对应的 Spec 能力 ID**。
---
---
@@ -46,16 +49,17 @@
| Phase 1 | 薄 App Runtime | 已完成:D15 设备 loop、D4 Mode 策略雏形、翻译走 Server |
| Freeze | Capability Spec v1.0 | 文档冻结;不编码 |
| Phase 2 | 用复读+跟读钉死事件与采集 | **已完成(见 phase-2-review.md)**:D1、D6、D7 v1、D8 v1 文本;另提前 D13 目录 `/{app}/{task}/lessons` |
| Phase 3 | TaskInstance 持久化 | **已完成(见 phase-3-review.md)**:三表 + PUT/GET;固件零 diff |
| Phase 4 | 跟读:时钟步进 + **后评估** | 听窗结束即下一句;metrics 不指挥喇叭 |
| Phase V | 产品代际 + 端云兼容 | 小智 AI 1 / 1-1 / 2;hello 报代际与 cap_set;Server 按矩阵降级或 OTA |
| Phase 4b | 倒背 / 口算 / 接龙 | **流内评估 + 及时 speak 反馈**;反证固件冻结 |
| Phase 5 | Server Scheduler | D14 TASK_START 的云端权威 |
| Phase 3 | TaskInstance 持久化 | **已完成(见 phase-3-review.md)**:三表 + PUT/GET;GET 现为 server secret |
| Phase 4 | 跟读:清单循环 + **后评估** | **已完成(见 phase-4-eval-and-roadmap.md)**:设备本地播→听→下一句;metrics 不指挥喇叭 |
| Phase 7a | 家长任务下发 + 执行记录 | **下一编码**:场景+参数;立即 vs 计划;本地全表、RTC 只写下一次。泛听≠跟读。History 后做、无 metrics |
| Phase 5 | Server Scheduler 补救扫描 | 权威表已并入 7a-B;本阶段剩「设备已开机、云端到点再推一把」 |
| Phase 6 | App / 资源 + **在线 Locale** | D13 fetch;phrase pack;翻译/双语的 language_pair |
| Phase 4b | 倒背 / 口算 / 接龙 | **流内评估 + 及时 speak**;排在家长能看见跟读之后 |
| Phase 6b | 离线 Locale(可选 OTA) | MultiNet/唤醒词表按 locale;**仅 ESP-SR 已有模型的语种(先 zh/en)** |
| Phase 7 | 家长任务管理 | 含:设备默认语言、语言对选择;只消费已有 Command |
| Phase 7b | 家长完整能力 | 语言对、PAUSE、长按退出、听原声完善 |
| Phase 8 | 开发者 App 平台 | Manifest 只能调已冻结 Skill |
| Phase 9 | Subscription / Marketplace | 账号,不改音频 |
| Phase V | 产品代际 + 端云兼容 | **最后做。** 小智 AI 1 / 1-1 / 2;就这一款硬件时不开工 |
---
@@ -66,51 +70,56 @@
    ↓
Capability Spec v1.0                      ✅ 文档
    ↓
Phase 2 钉死 D1 事件 + D6/D7/D8v1 采集    ✅ 见 phase-2-review.md
Phase 2 钉死 D1 事件 + D6/D7/D8v1 采集    ✅
    ↓
Phase 3 TaskInstance 持久化               ✅ 见 phase-3-review.md
    ├────────────────┬────────────────┐
    ↓                ↓                ↓
Phase 4 跟读后评估    Phase 5 调度     Phase V 代际/兼容
下一编码             可后做            可与 4 并行;摄像头 SKU 前必须完成
Phase 3 TaskInstance 持久化               ✅
    ↓
Phase 4 跟读清单循环 + 后评估             ✅  设备执行列表;Server 只评估
    ↓
Phase 7a 家长任务下发                     ← 下一编码
    │  7a-A 固件:计划全表 + RTC 只写最近一次;开机到点也执行
    │  7a-B 云端任务表 + 场景 catalog + SYNC_SCHEDULE / 立即 START
    │  7a-C 小程序:场景参数 + 立即 / 周期
    │  7a-D 历史(文本+可选录音;无 metrics)
    ├─────────────┐
    ↓             ↓
Phase 5 到点补推  Phase 6 资源 + 在线 Locale
    │
    ↓
Phase 4b 倒背/口算/接龙(流内及时反馈;可与 5 后期重叠)
    └────────┬───────┘
             ↓
        Phase 6 资源 + 在线 Locale(可与 4b/5 后期重叠)
             │
             ├─ Phase 6b 离线 Locale(zh/en 词表 OTA,不挡 7)
             ↓
        Phase 7 家长 UI(设 device.locale / 语言对)
             ↓
        Phase 8 开发者平台
             ↓
        Phase 9 订阅
Phase 4b 倒背/口算/接龙(inline;禁止改跟读后评估模型)
    ↓
Phase 6b 离线 Locale(zh/en)
Phase 7b 家长完整(语言对 / PAUSE / 听原声)
    ↓
Phase 8 开发者平台
    ↓
Phase 9 订阅
    ↓
Phase V 代际/兼容                         最后;第二款硬件或摄像头 SKU 前才必须
```
**必须串行:** 1 → Freeze → 2 → 3 → 4。跟读后评估挂 Instance。Phase 4b **另外**要流内评估:听窗结束后先 Server 算完再 speak,不能套跟读的「分析永不挡喇叭」。
**Phase V 必须在摄像头 SKU / 破坏性 API 之前完成**;不挡 Phase 4 跟读施工。
**必须串行:** 1 → Freeze → 2 → 3 → 4 → **7a-A 固件(全表+下一次闹钟)** → 7a-B 云端 → 7a-C 小程序。7a-D 历史不挡宣称 Dispatch Done。
**7a 不依赖** 4b / 6 / V。SD 已有 unit01 即可选课。家长任务数量 **不是** 10;`ALARM_MAX_NUM` 不准当上限。语言对可存 params,phrase pack 仍 Phase 6。
**Phase 4b** 依赖 4 的听窗/Instance,也依赖家长能看见结果(否则新 App 同样「只有喇叭」)。禁止为此改 C++。评估时机见下文「评估时机」表。
**Phase V 不挡 7a / 4b / 5。** 现网一款硬件;摄像头立项前再做握手字段。
**可并行:** Phase 5、Phase V 与 Phase 4 无数据依赖。产品顺序仍先做 4(跟读时钟)。
**Phase 4b** 依赖 Phase 4 的听窗/Instance 原语,不依赖 Phase 6。禁止为此改 C++。评估时机见下文「评估时机」表。
**Phase 6:** 目录树与 `ensure_app` 已在 Phase 2 提前落地。本阶段做清单 / DOWNLOAD / `resource_id`,并做 **在线 Locale**。不要重做 mkdir。
**Phase 6:** 目录树与 `ensure_app` 已在 Phase 2 提前落地。本阶段做清单 / DOWNLOAD / `resource_id`,并做 **在线 Locale**(phrase pack + 语言对)。不要重做 mkdir,也不要把路径再搬一次。
**Phase 6b:** 仅当需要「没网也用德语/英语喊切换」时才 OTA 离线词表。德语等 **没有** 官方 MultiNet 的语种,离线入口保持 zh 或 en,在线菜单仍可用该 locale。
**Phase 6b:** 仅当需要「没网也用德语/英语喊切换」时才 OTA 离线词表。德语等 **没有** 官方 MultiNet 的语种,离线入口保持 zh 或 en。
**明确后置、不挡冻结宣称:**
| Spec 能力 | 最早 Phase |
|-----------|------------|
| D11 `wait` 一等 Skill | 4b 前可仍用 Server 延时;需要「播完停 800ms 再听」且不想云端睡时再下固件 |
| D14 TASK_PAUSE/RESUME | 5 或 7 家长控制需要时 |
| D8 v1.1 本地文件上传 | 会议纪要 / 律师询问产品化时 |
| D9 长按退出 Task | 可与 2 的按钮现状并存;正式绑定放 7 前 |
| D14 TASK_PAUSE/RESUME | 5 或 7b 家长控制需要时 |
| D8 v1.1 本地文件上传 | 会议纪要 / 律师询问产品化时;7a 不依赖 |
| D9 长按退出 Task | 可与口令退出并存;正式绑定放 7b |
| D10 `indicate` 逻辑态 | 有空再做;禁止塞进 DeviceState 迁移表 |
| 新 WS `type=command` | 仅当 detect 前缀不够表达步进;**不加新语义** |
| 在线 Locale / 语言对 | Phase 6(phrase pack + pair);控制台 Phase 7 |
| 在线 Locale / 语言对 | Phase 6(phrase pack + pair);控制台 Phase 7b |
| 摄像头 / 新传感器 Skill | **Phase V 之后**;新 SKU = 小智 AI 2,禁止混进 1 的固件包 |
| 产品代际 hello 字段 | **Phase V,最后做**;现网一款硬件不挡 7a |
---
@@ -200,11 +209,11 @@
---
### Phase 4 — 跟读:时钟步进 + 后评估(定量)
### Phase 4 — 跟读:清单循环 + 后评估(定量)  ✅
1. **Objective**  
   **仅跟读这一类**:预定 MP3 序列 + 定时录音。分析全部后置、定量、允许失败。设备不宣读对错。  
   **不要**把倒背、接龙套进本阶段的「分析永不挡喇叭」。
   **完成事实:** 见 `phase-4-eval-and-roadmap.md`。步进在**设备播放列表**(一次 JSON 下发),不是逐句 MCP;后评估仍在 Server。
2. **Why Now**  
   跟读/打卡录音的价值在归档与事后看指标。若把云端比对绑在句间,喇叭会卡。
@@ -212,30 +221,56 @@
3. **Dependencies**  
   Phase 3 Instance;Phase 2 `duration_ms` 听窗。原文来自 txt 契约,**不能**用 `prompt_text` 路径。
4. **Scope**
   ASR 与 play next 解耦;`listen_ratio` 1.2–1.5;后台 `metrics[{method,value}]`。无 `passed`、无 retry、无当场 TTS 分数。
4. **Scope(已落地)**
   设备:`listen_ratio` × 墙钟播放时长;清单结束 `playlist_completed`。
   Server:ASR 不进 LLM;`metrics[{method,value}]`;本地 wav + 同名 txt。无 `passed`、无当场 TTS 分数。
5. **Out of Scope**  
   倒背/接龙的及时反馈(4b);Spec S8 再播同一句;及格线产品化;音素;租户;口令进固件。
   倒背/接龙的及时反馈(4b);家长 UI(**7a**);S8 再播同一句;音素;租户。
6. **Client**  
   零改动。
8. **Protocol**
   下一句只由时钟触发。不加 `JUDGE`。
   有 diff(播放列表 / 听窗)。禁止再改回「每句 MCP」。
13. **Acceptance**  
   说错/不说/API 挂了仍播下一句。见 `phase-4-test.md`。
   说错/不说/API 挂了仍播下一句。语音回归已按锁定路径验收。
详细施工:`phase-4-plan.md`。
详细施工(历史):`phase-4-plan.md`(其中「固件零 diff / Server 时钟」已被代码修正,以评估报告为准)。
---
### Phase V — 产品代际 + 端云版本匹配
### Phase 7a — 家长任务下发 + 执行记录  ← 下一编码
> 现状结论(2026-09-02 核对代码):**现在没有「小智 AI 1 / 1-1 / 2」这套产品代际,也没有客户端与 Server 的兼容矩阵。**
> 已有的只是「能不能升 bin」和「握手里开没开 mcp/aec」。摄像头 SKU 或破坏性 API 之前必须补齐。
> 不挡 Phase 4 跟读。施工单以后写 `phase-v-plan.md`。
> 产品:选场景填参数;可立即执行(临时,最高优先)或存为周期计划。设备存全表,RTC 只写最近一次。先下发,后看记录;无计量 UI。对外 **泛听**(旧称复读机)/ **跟读**。
> 详细见 `phase-4-eval-and-roadmap.md` §5.2。施工单:`phase-7a-plan.md`。
1. **Objective**
   家长按场景创建任务(泛听/跟读:课文、遍数、结束后关机或回对话;双语:中英/中法/中日…;对话可带时长)。立即下发马上跑;计划任务到点跑。软关机后从本地表挑下一最近时刻写入唯一硬件闹钟。History:跟读句文本+录音;对话类聊天。不展示相似度。
2. **Why Now**
   把计划 1:1 塞进 `ALARM_MAX_NUM=10` 撑不住「每小时整点聊 10 分钟」这类周期。DS3231 本来就只编程下一次。现网唯一 pending 会被覆盖;已开机到点不执行;课文写死。
3. **Dependencies**
   Phase 1 `START_APP_TASK` / 软关机 / Alarm1;Phase 3 Instance;Phase 4 播放列表。不依赖 4b/6/V。
4. **Scope**
   7a-A 本地 schedule 全表 + `recompute_next_alarm`;7a-B `ai_parent_task` + `SYNC_SCHEDULE` + 立即 START;7a-C 场景表单;7a-D 历史。
5. **Out of Scope**
   metrics;课文 DOWNLOAD;倒背口算;新 WS type;改锁定热路径;用 10 限制条数。
6. **Device**
   允许 OTA:计划文件、只写 Alarm1、duration 墙钟、on_complete。禁止新 DeviceState。课文不进固件。
13. **Acceptance(Dispatch)**
   立即跟读可打断计划;≥15 条计划含 hourly 全量在设备、芯片只有最近一次;软关机唤醒进正确场景;结束后重算下一闹钟。History 不挡 Dispatch Done。
---
### Phase V — 产品代际 + 端云版本匹配  (最后做)
> **2026-09-18:** 现网只有一款硬件。本阶段 **不挡** 7a / 5 / 4b。摄像头 SKU 或第二款板子立项前再开工。
> 现状结论(2026-09-02 核对代码,仍成立):**没有「小智 AI 1 / 1-1 / 2」代际,也没有兼容矩阵。**
> 施工单以后写 `phase-v-plan.md`。
1. **Objective**  
   把三套独立的编号钉死,并在连接时对上:  
@@ -382,22 +417,22 @@
施工单:以后写 `phase-4b-plan.md`(本文件只钉时机差异)。
### Phase 5 — Server Scheduler
### Phase 5 — Server Scheduler(补救扫描)
1. **Objective**  
   家长「每天 07:00 复读」权威在 Server。设备 RTC + pending 是 D14 执行器,不是日历真相。
   权威任务表已在 **7a-B `ai_parent_task`**。本阶段补:设备已开机但本地闹钟漏触发时,云端按分钟再推 `START_APP_TASK`。RTC 仍只负责「下一次」唤醒。
2. **Why Now**  
   设备闹钟能 Demo,不能对账 Instance。
   7a 完成后「每天准时」仍可能两边漏一刀;云扫描是对账,不是日历真相的第二份拷贝。
3. **Dependencies**  
   Phase 3;Phase 1 `SET_ALARM` / `START_APP_TASK`。
   7a-B plan 表;Phase 1 `START_APP_TASK`。
4. **Scope**  
   Schedule 表;到点 `TASK_START`(MQTT);跳过 mqtt_immediate。
   到期扫描;同一 plan+同一分钟幂等一次;跳过正在跑的 `mqtt_immediate`。
5. **Out of Scope**  
   完整家长 UI;Device 上的复杂 Plan DSL。
   再做一张平行日历;Device 上的复杂 Plan DSL。若 7a J5 已落地,本阶段可标部分完成。
8. **Protocol**  
   复用已有 MQTT 命令,不新增种类。PAUSE/RESUME 若做,必须是 Spec D14,不是第五套协议。
@@ -445,19 +480,19 @@
---
### Phase 7 — Parent Task Management
### Phase 7b — Parent Task Management(完整)
1. **Objective**  
   小程序调用已有 Command:即时 START、看 Instance/定量结果、改 Schedule;**设置设备默认语言与翻译/双语语言对**。
   小程序在 7a 之上:改 Schedule;**设置设备默认语言与翻译/双语语言对**;PAUSE;听原声完善。
2. **Dependencies**  
   Phase 5 + Phase 6;Phase 4 metrics 可选。6b 不是前提:控制台改 locale 后,在线立即生效。
   Phase 7a + Phase 5 + Phase 6。6b 不是前提:控制台改 locale 后,在线立即生效。
4. **Scope**  
   UI。D9 长按退出若未做可一并(仍是已列能力)。语言对只能选 App Manifest 声明的 supported_pairs。
   UI。D9 长按退出若未做可一并。语言对只能选 App Manifest 声明的 supported_pairs。
5. **Out of Scope**  
   开发者平台、支付、在小程序里训练唤醒词。
   开发者平台、支付、在小程序里训练唤醒词、硬件代际管理。
---
@@ -538,15 +573,20 @@
  Server  TaskSession JSON         ──HTTP upsert──►  manager-api Instance
  无新 Device 能力;设备不持有 instance_id
Phase 4
  Server  仅时钟到点 PLAY_AUDIO 下一句 ──► Device
Phase 4  ✅
  Server  一次 JSON playlist           ──MCP──► Device
  Device  本地播→听窗→下一句
  Device  听流(旁路 ASR/report)     ──► Server 后评估 metrics
  Device  playlist_completed          ──detect──► Server 回 CHAT
  跟读:分析失败不挡下一句;无 JUDGE
Phase V
  Device  hello/OTA 带 generation+revision+cap_set  ──► Server 矩阵
  Server  交集业务 / 引导 OTA / 拒未知 SKU          ──► Device
  旧固件缺字段 = 小智 AI 1 / cap.v1;不加新 WS type
Phase 7a
  小程序  场景任务(立即/计划)         ──HTTP──► manager-api
  立即    START_APP_TASK mqtt_immediate ──MQTT──► Device
  计划    SYNC_SCHEDULE 全表           ──MQTT──► Device schedule.json
  Device  挑最近一次                    ──Alarm1──► 唤醒/到点 StartAppTask
  7a-D    GET instance / chat          ──oauth2──► 文本+可选录音
  无新 WS type;无 metrics UI
Phase 4b
  Device  听流                       ──► Server ASR + parser(inline,短超时)
@@ -560,6 +600,11 @@
Phase 6
  Server  FETCH                    ──DOWNLOAD──► Device SD
  Device  PLAY_AUDIO(resource)     ──event──► Server
Phase V(最后)
  Device  hello/OTA 带 generation+revision+cap_set  ──► Server 矩阵
  Server  交集业务 / 引导 OTA / 拒未知 SKU          ──► Device
  旧固件缺字段 = 当前这一款 SKU;不加新 WS type
```
---
@@ -569,13 +614,14 @@
| 长期目标 | Roadmap |
|----------|---------|
| Device 能力冻结 | Freeze 文档 + Phase 2 补事件/采集 |
| Task Runtime 在 Server | P2 Session → P3 Instance ✅ → P4 跟读后评估 → P4b 流内评估 |
| Task Runtime 在 Server | P2 Session → P3 Instance ✅ → P4 后评估 ✅ → P4b 流内评估 |
| 新 App 不改 C++ | P4b 三个代表 Task 验收 |
| 跟读语音对象 | P2 D8 v1 → P3 挂 step → P6 标准音 |
| Scheduler | P5 / P7 |
| 跟读语音对象 | P2 D8 v1 → P3 挂 step → P4 本地 wav/txt → P6 标准音下载 |
| Scheduler | **P7a 任务全表 + 设备只写下一闹钟**;临时优先于计划;P5 到点补推可选 |
| App ≠ Firmware | 贯穿;P6 资源、P8 沙箱 |
| 设备默认语言 / 语言对 | P6 在线 pack + pair;P6b 离线 zh/en;P7 控制台;P4b 继承 locale |
| 产品代际 / 端云匹配 | **Phase V**;摄像头 SKU 之前;现网缺字段视为 小智 AI 1 |
| 设备默认语言 / 语言对 | P6 在线 pack + pair;P6b 离线 zh/en;P7b 控制台;P4b 继承 locale |
| 产品代际 / 端云匹配 | **Phase V,最后**;现网一款硬件视为当前 SKU |
| 家长可见练习 | **P7a 场景任务下发 + 7a-D 历史(无分数)**;语言对已作为双语参数;PAUSE P7b |
| 心理陪伴 / 主持人 | 无新硬件;复用 listen/speak + Server Memory |
| Subscription | P9 |
@@ -593,11 +639,12 @@
- Phase 2 拆微服务 / 对象存储 / 情绪分析 / 临床诊断  
- 一次性写死 Phase 5–9 的类名与 REST 路径  
- 把业务 App 切换词写入固件(系统级仅 Chat + 离线复读 + 「切换模式」总入口)  
- 在 Phase 4 引入租户 / `tenant_id`(最早缝在 Phase 6,产品化 Phase 7)
- 在 Phase 4 引入租户 / `tenant_id`(最早缝在 Phase 6,产品化 Phase 7b)
- 在 Phase 4 做多语言 / 把德语唤醒写进固件  
- 为每种翻译语言对新增一个 `app_id` 或新 WS type  
- 用一个数字同时表示产品代际、固件 semver 和协议版本  
- 把摄像头固件包按 semver 推给无摄像头的小智 AI 1  
- 在 Phase 4 做兼容矩阵 / 改 hello 字段(放到 Phase V)
- 在 Phase 4 / 7a 做兼容矩阵 / 改 hello 字段(放到 **Phase V,最后**)
- 在 7a 之前做倒背/口算/接龙或课文 DOWNLOAD 平台
**下一编码阶段:** 只打开 `phase-4-plan.md`,验收对照 `phase-4-test.md`。固件冻结。跟读按 MP3 序列定时录音;后评估只定量、不指挥喇叭。Locale 等到 Phase 6。端云版本匹配等到 **Phase V**(可与 4 并行设计,摄像头 SKU 前必须落地)。
**下一编码阶段:** 只打开 `phase-7a-plan.md`。跟读/泛听热路径与欢迎词/翻译/中英锁定。先 7a-A「全表 + 只写下一次闹钟」,不要再做 10 槽一对一。History 与 metrics 后置。
xiaozhi-esp32-2.2.6/main/application.cc
@@ -16,6 +16,7 @@
#include <cstring>
#include <memory>
#include <esp_log.h>
#include <esp_heap_caps.h>
#include <cJSON.h>
#include <driver/gpio.h>
#include <arpa/inet.h>
@@ -25,7 +26,6 @@
extern "C" void SDCard_Pause_Play(void) __attribute__((weak));
extern "C" void SDCard_Resume_Play(void) __attribute__((weak));
extern "C" void SDCard_Stop_Play(void) __attribute__((weak));
extern "C" bool SDCard_Is_Playing(void) __attribute__((weak));
extern "C" bool SDCard_Is_Paused(void) __attribute__((weak));
@@ -37,6 +37,16 @@
    return SDCard_Is_Paused && SDCard_Is_Paused();
}
static bool ShouldHoldIdleForLessonWork() {
    return Board_IsLessonSyncing() || Board_IsRepeaterTask() || Board_IsShadowingTask() ||
           Board_IsWaitingStartPhrase();
}
static bool IsLessonDetectText(const std::string& text) {
    return text.rfind("[lesson_sync]", 0) == 0 ||
           text.rfind("[app_task]english_learning|", 0) == 0;
}
static void PauseSdCardMp3() {
    if (SDCard_Pause_Play) {
        SDCard_Pause_Play();
@@ -46,12 +56,6 @@
static void ResumeSdCardMp3() {
    if (SDCard_Resume_Play) {
        SDCard_Resume_Play();
    }
}
static void StopSdCardMp3() {
    if (SDCard_Stop_Play) {
        SDCard_Stop_Play();
    }
}
@@ -240,6 +244,7 @@
}
void Application::Run() {
    main_loop_task_ = xTaskGetCurrentTaskHandle();
    // Set the priority of the main task to 10
    vTaskPrioritySet(nullptr, 10);
@@ -331,6 +336,7 @@
            display->UpdateStatusBar();
            auto state = GetDeviceState();
            if (state == kDeviceStateConnecting || state == kDeviceStateSpeaking ||
                state == kDeviceStateActivating ||
                (state == kDeviceStateIdle && suppress_wake_until_listen_)) {
                audio_service_.KeepCodecReadyForPlayback();
            }
@@ -341,10 +347,16 @@
                    if (greeting_listen_wait_ticks_ > 0) {
                        greeting_listen_wait_ticks_--;
                        if (greeting_listen_wait_ticks_ == 0) {
                            ESP_LOGW(TAG, "greeting TTS missing, start listening");
                            SetListeningMode(GetDefaultListeningMode());
                            if (ShouldHoldIdleForLessonWork()) {
                                ESP_LOGI(TAG, "skip greeting listen while lesson task");
                            } else {
                                ESP_LOGW(TAG, "greeting TTS missing, start listening");
                                SetListeningMode(GetDefaultListeningMode());
                            }
                        }
                    }
                } else if (Board_IsLessonSyncing()) {
                    boot_wake_restore_ticks_ = 0;
                } else if (++boot_wake_restore_ticks_ >= 20) {
                    ESP_LOGW(TAG, "boot websocket not opened, restore wake word");
                    suppress_wake_until_listen_ = false;
@@ -358,7 +370,7 @@
                speaking_started_us_ > 0) {
                const int64_t wait_ms = (esp_timer_get_time() - speaking_started_us_) / 1000;
                if (wait_ms >= 1000 && (wait_ms / 1000) <= 8) {
                    ESP_LOGW(TAG, "TTS audio still missing after %lldms", (long long)wait_ms);
                    ESP_LOGW(TAG, "TTS audio still missing after %dms", (int)wait_ms);
                }
            }
            if (protocol_ && IsSdCardMp3Playing() && protocol_->IsAudioChannelConnected()) {
@@ -379,7 +391,33 @@
                    pending_detect_text_.clear();
                }
            }
            if (hold_idle_for_prompt_ && !pending_detect_text_.empty() &&
                GetDeviceState() == kDeviceStateIdle &&
                protocol_ && protocol_->IsAudioChannelConnected()) {
                if (pending_detect_retry_ticks_ > 0) {
                    pending_detect_retry_ticks_--;
                } else if (pending_detect_retries_left_ > 0) {
                    pending_detect_retries_left_--;
                    pending_detect_retry_ticks_ = 2;
                    ESP_LOGW(TAG, "retry app_task detect: %s left=%d",
                             pending_detect_text_.c_str(), pending_detect_retries_left_);
                    SendListenDetectNow(pending_detect_text_);
                } else if (IsLessonDetectText(pending_detect_text_) || ShouldHoldIdleForLessonWork()) {
                    ESP_LOGW(TAG, "lesson detect unanswered, keep idle: %s",
                             pending_detect_text_.c_str());
                    pending_detect_text_.clear();
                    pending_detect_retries_left_ = 0;
                    pending_detect_retry_ticks_ = 0;
                } else {
                    ESP_LOGW(TAG, "app_task detect unanswered, start listening");
                    hold_idle_for_prompt_ = false;
                    suppress_wake_until_listen_ = false;
                    pending_detect_text_.clear();
                    SetListeningMode(GetDefaultListeningMode());
                }
            }
            Board_PollPendingLessonPlayback();
            // Print debug info every 10 seconds
            if (clock_ticks_ % 10 == 0) {
                SystemInfo::PrintHeapStats();
@@ -621,6 +659,10 @@
            if (GetDeviceState() == kDeviceStateIdle) {
                break;
            }
            // 激活请求失败时再播一遍 6 位码,避免只在开机瞬间播一次被听漏。
            if (ota_->HasActivationCode() && i < 9) {
                ShowActivationCode(ota_->GetActivationCode(), ota_->GetActivationMessage());
            }
        }
    }
}
@@ -666,10 +708,13 @@
            }
            if (state == kDeviceStateListening || state == kDeviceStateIdle) {
                tts_audio_expected_ = true;
                if (!ShouldHoldIdleForLessonWork()) {
                    hold_idle_for_prompt_ = false;
                }
            }
            tts_response_started_ = true;
            ESP_LOGI(TAG, "TTS audio packet bytes=%u expected=%d ready=%d state=%d",
            ESP_LOGD(TAG, "TTS audio packet bytes=%u expected=%d ready=%d state=%d",
                     (unsigned)shared_packet->payload.size(),
                     tts_audio_expected_ ? 1 : 0,
                     tts_speaking_state_ready_ ? 1 : 0,
@@ -695,12 +740,10 @@
            ESP_LOGI(TAG, "Skip channel-closed teardown while opening/reusing websocket");
            return;
        }
        // 跟读/复读还在播时不要切 LOW_POWER:后面补发 playlist_completed 会 handshake timeout。
        if ((Board_IsShadowingTask() || Board_IsRepeaterTask()) && IsSdCardMp3Playing()) {
            board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
        } else {
            board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
        }
        // 通道关掉后会立刻切 idle、加载唤醒词。这时再进 MAX_MODEM,
        // MQTT 重连任务和 phy_track_pll 会把内部 SRAM 打穿,ESP_ERROR_CHECK 直接重启。
        // 保持 PERFORMANCE,等 PowerSaveTimer 超时后再真正断网待命。
        board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
        Schedule([this]() {
            auto display = Board::GetInstance().GetDisplay();
            opening_audio_channel_ = false;
@@ -716,6 +759,7 @@
            tts_stop_pending_ = false;
            tts_stop_waiting_ = false;
            tts_idle_waiting_ = false;
            tts_session_end_ = false;
            tts_speaking_state_ready_ = false;
            tts_activity_generation_++;
            audio_service_.ResetDecoder();
@@ -748,11 +792,18 @@
            if (strcmp(state->valuestring, "start") == 0) {
                Schedule([this]() {
                    aborted_ = false;
                    if (!ShouldHoldIdleForLessonWork()) {
                        hold_idle_for_prompt_ = false;
                    }
                    pending_detect_text_.clear();
                    pending_detect_retries_left_ = 0;
                    pending_detect_retry_ticks_ = 0;
                    tts_audio_expected_ = true;
                    tts_response_started_ = false;
                    tts_audio_received_ = false;
                    tts_stop_pending_ = false;
                    tts_stop_waiting_ = false;
                    tts_session_end_ = false;
                    tts_playback_empty_ticks_ = 0;
                    tts_stop_wait_ticks_ = 0;
                    tts_activity_generation_++;
@@ -766,9 +817,15 @@
                             Board::GetInstance().GetAudioCodec()->output_enabled() ? 1 : 0);
                });
            } else if (strcmp(state->valuestring, "stop") == 0) {
                Schedule([this]() {
                    ESP_LOGI(TAG, "TTS stop started=%d received=%d",
                             tts_response_started_, tts_audio_received_);
                auto session_end_item = cJSON_GetObjectItem(root, "session_end");
                const bool session_end = cJSON_IsTrue(session_end_item) ||
                    (cJSON_IsNumber(session_end_item) && session_end_item->valuedouble != 0);
                Schedule([this, session_end]() {
                    ESP_LOGI(TAG, "TTS stop started=%d received=%d session_end=%d",
                             tts_response_started_, tts_audio_received_, session_end ? 1 : 0);
                    if (session_end) {
                        tts_session_end_ = true;
                    }
                    tts_stop_pending_ = true;
                    FinishTtsResponseAfterPlayback();
                });
@@ -785,13 +842,22 @@
                        return;
                    }
                    Schedule([this, display, message = std::string(text->valuestring)]() {
                        if (!ShouldHoldIdleForLessonWork()) {
                            hold_idle_for_prompt_ = false;
                        }
                        pending_detect_text_.clear();
                        pending_detect_retries_left_ = 0;
                        pending_detect_retry_ticks_ = 0;
                        tts_audio_expected_ = true;
                        tts_response_started_ = true;
                        tts_activity_generation_++;
                        tts_playback_empty_ticks_ = 0;
                        audio_service_.SetAudioPowerInhibit(true);
                        audio_service_.KeepCodecReadyForPlayback();
                        if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                        if (Board_OnStartPhraseSentence(message.c_str())) {
                            resume_sdcard_mp3_after_tts_ = false;
                            resume_sdcard_mp3_after_voice_ = false;
                        } else if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                            PauseSdCardMp3();
                            resume_sdcard_mp3_after_tts_ = true;
                        }
@@ -890,6 +956,18 @@
    protocol_->Start();
}
static void WaitUntilActivationPlaybackIdle(AudioService& audio) {
    audio.KeepCodecReadyForPlayback();
    for (int i = 0; i < 150; ++i) {
        if (audio.IsPlaybackQueueEmpty()) {
            vTaskDelay(pdMS_TO_TICKS(150));
            return;
        }
        audio.KeepCodecReadyForPlayback();
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}
void Application::ShowActivationCode(const std::string& code, const std::string& message) {
    struct digit_sound {
        char digit;
@@ -908,14 +986,24 @@
        digit_sound{'9', Lang::Sounds::OGG_9}
    }};
    ESP_LOGI(TAG, "Speaking activation code: %s", code.c_str());
    audio_service_.KeepCodecReadyForPlayback();
    // 功放刚打开时前几百毫秒常是静音,先稳住再播,避免只听到提示音听不到数字。
    vTaskDelay(pdMS_TO_TICKS(400));
    // This sentence uses 9KB of SRAM, so we need to wait for it to finish
    Alert(Lang::Strings::ACTIVATION, message.c_str(), "link", Lang::Sounds::OGG_ACTIVATION);
    WaitUntilActivationPlaybackIdle(audio_service_);
    for (const auto& digit : code) {
        auto it = std::find_if(digit_sounds.begin(), digit_sounds.end(),
            [digit](const digit_sound& ds) { return ds.digit == digit; });
        if (it != digit_sounds.end()) {
            ESP_LOGI(TAG, "Playing activation digit: %c", digit);
            audio_service_.KeepCodecReadyForPlayback();
            audio_service_.PlaySound(it->sound);
            WaitUntilActivationPlaybackIdle(audio_service_);
            vTaskDelay(pdMS_TO_TICKS(250));
        }
    }
}
@@ -966,6 +1054,87 @@
    return protocol_->SendWakeWordDetected(text);
}
void Application::HoldIdleForServerPrompt() {
    hold_idle_for_prompt_ = true;
    suppress_wake_until_listen_ = true;
    auto state = GetDeviceState();
    if (state == kDeviceStateListening) {
        SetDeviceState(kDeviceStateIdle);
    }
}
void Application::ReleaseHoldIdle() {
    hold_idle_for_prompt_ = false;
    suppress_wake_until_listen_ = false;
    boot_wake_restore_ticks_ = 0;
}
void Application::StartListeningAfterPrompt() {
    SetListeningMode(GetDefaultListeningMode());
}
void Application::ArmPendingDetectRetry(const std::string& text, int retries) {
    if (text.empty() || retries <= 0) {
        return;
    }
    pending_detect_text_ = text;
    pending_detect_retries_left_ = retries;
    pending_detect_retry_ticks_ = 2;
}
bool Application::IsAudioChannelConnected() const {
    return protocol_ && protocol_->IsAudioChannelConnected();
}
void Application::AnnounceReturnedToChat() {
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    pending_detect_retry_ticks_ = 0;
    suppress_wake_until_listen_ = false;
    audio_service_.KeepCodecReadyForPlayback();
    auto display = Board::GetInstance().GetDisplay();
    display->SetStatus("对话模式");
    display->SetEmotion("happy");
    display->SetChatMessage("assistant", "我们来聊天吧。");
    audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS);
    ESP_LOGI(TAG, "announced local return to chat");
}
void Application::ReconnectAfterLesson(const std::string& detect) {
    // 课文结束 WS 已断:先释放 AFE,再握手补发 skill_event。
    // Server 会播「泛听/跟读结束。我们来聊天吧。」说完再 listening。
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    pending_detect_retry_ticks_ = 0;
    suppress_wake_until_listen_ = true;
    audio_service_.SetAudioPowerInhibit(false);
    audio_service_.KeepCodecReadyForPlayback();
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(false);
    audio_service_.ReleaseAfeRuntime();
    auto state = GetDeviceState();
    if (state == kDeviceStateListening || state == kDeviceStateSpeaking) {
        SetDeviceState(kDeviceStateIdle);
    }
    if (detect.empty() || !protocol_) {
        ESP_LOGW(TAG, "lesson reconnect skipped, stay local chat");
        AnnounceReturnedToChat();
        audio_service_.EnableWakeWordDetection(true);
        return;
    }
    // 泛听时长会把 WiFi 待机关掉(跟读有听窗所以不会)。不要用 IsNetworkReady()
    // 直接放弃:后面 OpenAudioChannel 会 EnsureNetworkReadyForWake,连上再播结束语。
    ESP_LOGI(TAG, "lesson reconnect for %s", detect.c_str());
    Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
    SetDeviceState(kDeviceStateConnecting);
    Schedule([this, detect]() {
        ContinueOpenAudioChannelForDetect(detect);
    });
}
void Application::SendListenDetect(const std::string& text, bool open_if_idle) {
    Schedule([this, text, open_if_idle]() {
        if (!protocol_ || text.empty()) {
@@ -977,6 +1146,7 @@
            Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
            protocol_->MarkIncomingActivity();
            if (protocol_->SendWakeWordDetected(text)) {
                Board_NoteLessonStartDetectSent();
                return;
            }
            ESP_LOGW(TAG, "detect send failed, reopen audio channel");
@@ -1089,6 +1259,18 @@
    // AFE 起来后内部堆只剩几 KB,xTaskCreate(8KB) 会直接失败。
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(false);
    const size_t dma_before = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
    ESP_LOGI(TAG, "OpenAudioChannel DMA largest=%u free=%u",
             (unsigned)dma_before,
             (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
    if (dma_before < 8192) {
        ESP_LOGW(TAG, "low DMA, release AFE before websocket");
        audio_service_.ClearWakeWordBuffers();
        audio_service_.ReleaseAfeRuntime();
        vTaskDelay(pdMS_TO_TICKS(50));
        ESP_LOGI(TAG, "OpenAudioChannel DMA after AFE release=%u",
                 (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA));
    }
    Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
    audio_service_.SetAudioPowerInhibit(true);
@@ -1136,25 +1318,45 @@
    listening_mode_ = GetDefaultListeningMode();
    suppress_wake_until_listen_ = true;
    if (!OpenAudioChannelWithRetry(2)) {
        pending_detect_text_ = detect_text;
        if (pending_detect_retries_left_ <= 0) {
            pending_detect_retries_left_ = 3;
        // 课文结束后通道已死时不要反复 handshake:会卡在「连接中」并关掉唤醒词。
        const bool skip_retry = detect_text.rfind("[skill_event]", 0) == 0;
        if (skip_retry) {
            pending_detect_text_.clear();
            pending_detect_retries_left_ = 0;
            pending_detect_retry_ticks_ = 0;
        } else {
            pending_detect_text_ = detect_text;
            if (pending_detect_retries_left_ <= 0) {
                pending_detect_retries_left_ = 3;
            }
            pending_detect_retry_ticks_ = 3;
        }
        pending_detect_retry_ticks_ = 3;
        suppress_wake_until_listen_ = false;
        audio_service_.EnableWakeWordDetection(true);
        SetDeviceState(kDeviceStateIdle);
        ESP_LOGW(TAG, "boot websocket failed, will retry detect in 3s");
        if (skip_retry) {
            ESP_LOGW(TAG, "websocket failed after lesson, keep idle chat without retry");
            AnnounceReturnedToChat();
        } else {
            ESP_LOGW(TAG, "boot websocket failed, will retry detect in 3s");
        }
        if (IsLessonDetectText(detect_text) || ShouldHoldIdleForLessonWork()) {
            suppress_wake_until_listen_ = true;
            audio_service_.EnableWakeWordDetection(false);
        } else {
            audio_service_.EnableWakeWordDetection(true);
        }
        return;
    }
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    pending_detect_retry_ticks_ = 0;
    greeting_listen_wait_ticks_ = 8;
    greeting_listen_wait_ticks_ = (IsLessonDetectText(detect_text) || ShouldHoldIdleForLessonWork()) ? 0 : 8;
    if (!detect_text.empty()) {
        protocol_->MarkIncomingActivity();
        protocol_->SendWakeWordDetected(detect_text);
        if (protocol_->SendWakeWordDetected(detect_text)) {
            Board_NoteLessonStartDetectSent();
        }
    }
    // 保持待命且不开 AFE:欢迎词从 idle 进 speaking。说完再 listening 开麦。
    SetDeviceState(kDeviceStateIdle);
@@ -1185,9 +1387,20 @@
    if (!IsSdCardMp3Playing() || IsSdCardMp3Paused()) {
        return;
    }
    // 音频在播:不上行录音/闲聊,只留离线口令(退出复读/跟读、我们聊天吧)。
    // 音频在播:不上行录音/闲聊,只留离线口令(退出泛听/跟读、我们聊天吧)。
    // 课文还在读 SD 时禁止拉起 AFE:会把 SDMMC DMA 需要的连续内部 SRAM 吃光,fopen abort。
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
    const size_t largest_internal = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL);
    if (!SDCard_Mp3OfflineWakeAllowed() || largest_internal < 32768) {
        audio_service_.EnableWakeWordDetection(false);
        if (!SDCard_Mp3OfflineWakeAllowed()) {
            ESP_LOGD(TAG, "offline wake deferred until MP3 cached");
        } else {
            ESP_LOGW(TAG, "skip offline wake, internal largest=%u", (unsigned)largest_internal);
        }
    } else {
        audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
    }
    if (GetDeviceState() != kDeviceStateListening) {
        return;
    }
@@ -1241,7 +1454,9 @@
void Application::CloseShadowingListenWindow()
{
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
    // 下一句还要 fopen/解码,「跟读结束」TTS 还要 mbedtls AES。这里不要立刻拉起
    // AFE 唤醒词,否则内部 SRAM 只剩 1KB 连续块,SD DMA / SSL 都会失败。
    audio_service_.EnableWakeWordDetection(false);
    if (protocol_ && protocol_->IsAudioChannelOpened()) {
        while (auto packet = audio_service_.PopPacketFromSendQueue()) {
            if (!protocol_->SendAudio(std::move(packet))) {
@@ -1326,7 +1541,21 @@
    if (state == kDeviceStateIdle) {
        PauseSdCardMp3ForVoice();
        audio_service_.EncodeWakeWord();
        size_t dma_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
        // 再见后唤醒词 AFE 还占着内部堆,DMA 往往不够编码 opus。
        // 旧逻辑会跳过编码却继续阻塞等 opus,主循环卡死,15s 后 codec 掉电,像软关机。
        if (dma_largest < 12288) {
            ESP_LOGW(TAG, "low DMA before wake handshake, release AFE largest=%u",
                     (unsigned)dma_largest);
            audio_service_.ReleaseAfeRuntime();
            dma_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
        }
        if (dma_largest >= 8192) {
            audio_service_.EncodeWakeWord();
        } else {
            ESP_LOGW(TAG, "skip wake opus encode, DMA largest=%u", (unsigned)dma_largest);
            audio_service_.AbortWakeWordEncode();
        }
        auto wake_word = audio_service_.GetLastWakeWord();
        if (!protocol_->IsAudioChannelOpened()) {
@@ -1429,6 +1658,7 @@
                tts_stop_pending_ = false;
                tts_stop_waiting_ = false;
                tts_idle_waiting_ = false;
                tts_session_end_ = false;
                tts_speaking_state_ready_ = false;
                tts_activity_generation_++;
            }
@@ -1437,16 +1667,18 @@
            display->ClearChatMessages();  // Clear messages first
            display->SetEmotion("neutral"); // Then set emotion (wechat mode checks child count)
            audio_service_.EnableVoiceProcessing(false);
            if (suppress_wake_until_listen_) {
            // 再见断线会 speaking→listening→idle:processor AFE 没释放就把唤醒词 AFE 拉起来,
            // audio_detection 建不起来,feed 刷屏直到内部 SRAM 打穿。
            if (!suppress_wake_until_listen_) {
                audio_service_.ReleaseVoiceProcessorRuntime();
            }
            if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                ApplyMp3CommandOnlyMic();
            } else if (suppress_wake_until_listen_ || Board_IsLessonSyncing() ||
                       Board_IsRepeaterTask() || Board_IsShadowingTask()) {
                audio_service_.EnableWakeWordDetection(false);
                audio_service_.SetAudioPowerInhibit(true);
                audio_service_.KeepCodecReadyForPlayback();
            } else if (Board_IsRepeaterTask()) {
                audio_service_.EnableVoiceProcessing(false);
                audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
                audio_service_.SetAudioPowerInhibit(false);
            } else if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                ApplyMp3CommandOnlyMic();
            } else {
                audio_service_.EnableWakeWordDetection(true);
                audio_service_.SetAudioPowerInhibit(false);
@@ -1522,6 +1754,10 @@
            // Do nothing
            break;
    }
}
bool Application::IsOnMainLoop() const {
    return main_loop_task_ != nullptr && xTaskGetCurrentTaskHandle() == main_loop_task_;
}
void Application::Schedule(std::function<void()>&& callback) {
@@ -1710,6 +1946,7 @@
        tts_stop_pending_ = false;
        tts_stop_waiting_ = false;
        tts_idle_waiting_ = false;
        tts_session_end_ = false;
        tts_speaking_state_ready_ = false;
        tts_activity_generation_++;
@@ -1770,20 +2007,28 @@
}
void Application::FinishTtsResponse() {
    const bool end_session = tts_session_end_;
    const bool hold_idle = hold_idle_for_prompt_;
    tts_audio_expected_ = false;
    tts_response_started_ = false;
    tts_audio_received_ = false;
    tts_stop_pending_ = false;
    tts_stop_waiting_ = false;
    tts_idle_waiting_ = false;
    tts_session_end_ = false;
    tts_speaking_state_ready_ = false;
    tts_playback_empty_ticks_ = 0;
    tts_stop_wait_ticks_ = 0;
    speaking_started_us_ = 0;
    audio_service_.SetAudioPowerInhibit(false);
    suppress_wake_until_listen_ = false;
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    if (hold_idle || ShouldHoldIdleForLessonWork()) {
        suppress_wake_until_listen_ = true;
    } else {
        suppress_wake_until_listen_ = false;
        pending_detect_text_.clear();
        pending_detect_retries_left_ = 0;
        pending_detect_retry_ticks_ = 0;
    }
    ResumeSdCardMp3AfterVoice();
    if (resume_sdcard_mp3_after_tts_ && !sdcard_mp3_stop_requested_) {
        resume_sdcard_mp3_after_tts_ = false;
@@ -1792,8 +2037,12 @@
        resume_sdcard_mp3_after_tts_ = false;
    }
    if (GetDeviceState() == kDeviceStateSpeaking) {
        if (listening_mode_ == kListeningModeManualStop ||
        if (end_session || hold_idle || ShouldHoldIdleForLessonWork() ||
            listening_mode_ == kListeningModeManualStop ||
            (IsSdCardMp3Playing() && !IsSdCardMp3Paused())) {
            ESP_LOGI(TAG, "TTS done, return to idle session_end=%d hold=%d lesson=%d",
                     end_session ? 1 : 0, hold_idle ? 1 : 0,
                     ShouldHoldIdleForLessonWork() ? 1 : 0);
            SetDeviceState(kDeviceStateIdle);
        } else {
            listening_mode_ = GetDefaultListeningMode();
@@ -1803,6 +2052,7 @@
    if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
        ApplyMp3CommandOnlyMic();
    }
    Board_FlushPendingLessonPlayback();
    if (power_off_requested_) {
        power_off_requested_ = false;
        Board_Enter_Power_Off();
@@ -1866,8 +2116,9 @@
        tts_stop_wait_ticks_++;
        const int empty_needed = tts_audio_received_ ? 1 : 2;
        if (playback_empty && tts_playback_empty_ticks_ >= empty_needed) {
            ESP_LOGI(TAG, "TTS done, return to listen empty=%d wait=%d received=%d",
                     tts_playback_empty_ticks_, tts_stop_wait_ticks_, tts_audio_received_);
            ESP_LOGI(TAG, "TTS done empty=%d wait=%d received=%d session_end=%d",
                     tts_playback_empty_ticks_, tts_stop_wait_ticks_, tts_audio_received_,
                     tts_session_end_ ? 1 : 0);
            FinishTtsResponse();
        } else if (!playback_empty && tts_stop_wait_ticks_ >= 30) {
            ESP_LOGW(TAG, "TTS playback stuck after stop, return to listen");
@@ -1878,13 +2129,13 @@
    // 欢迎词/长回答:stop 没到时,只在「一直没收到 opus」或「播完后长时间空队列」才回听。
    // 不要用说话墙钟 15s/队列空 2s:中英对话长句会在句间空隙或 15 秒处被掐断。
    if (!tts_audio_received_ && elapsed_ms >= 5000) {
        ESP_LOGW(TAG, "TTS stop missing, return to listen elapsed=%lldms received=0 empty=%d",
                 (long long)elapsed_ms, tts_playback_empty_ticks_);
    if (!tts_audio_received_ && elapsed_ms >= 10000) {
        ESP_LOGW(TAG, "TTS stop missing, return to listen elapsed=%dms received=0 empty=%d",
                 (int)elapsed_ms, tts_playback_empty_ticks_);
        FinishTtsResponse();
    } else if (playback_empty && tts_audio_received_ && tts_playback_empty_ticks_ >= 8) {
        ESP_LOGW(TAG, "TTS stop missing after idle playback elapsed=%lldms empty=%d",
                 (long long)elapsed_ms, tts_playback_empty_ticks_);
        ESP_LOGW(TAG, "TTS stop missing after idle playback elapsed=%dms empty=%d",
                 (int)elapsed_ms, tts_playback_empty_ticks_);
        FinishTtsResponse();
    }
}
@@ -1901,6 +2152,12 @@
        return false;
    }
    // 泛听是 idle 播 SD MP3,AudioService 队列是空的,会被当成可休眠。
    // 跟读每句有听窗所以不会进 WiFi 待机;泛听会把网关掉,结束语就发不出去。
    if (IsSdCardMp3Playing() || Board_IsRepeaterTask() || Board_IsShadowingTask()) {
        return false;
    }
    if (!audio_service_.IsIdle()) {
        return false;
    }
xiaozhi-esp32-2.2.6/main/application.h
@@ -106,6 +106,13 @@
    void StopListening();
    void SendListenDetect(const std::string& text, bool open_if_idle = true);
    bool SendListenDetectNow(const std::string& text);
    void HoldIdleForServerPrompt();
    void ReleaseHoldIdle();
    void StartListeningAfterPrompt();
    void ArmPendingDetectRetry(const std::string& text, int retries);
    bool IsAudioChannelConnected() const;
    void AnnounceReturnedToChat();
    void ReconnectAfterLesson(const std::string& detect);
    void Reboot();
    void WakeWordInvoke(const std::string& wake_word);
@@ -127,6 +134,7 @@
    void PlaySound(const std::string_view& sound);
    AudioService& GetAudioService() { return audio_service_; }
    void ApplyMp3CommandOnlyMic();
    bool IsOnMainLoop() const;
    void ResumeListeningAfterMp3Clip();
    void CloseShadowingListenWindow();
    
@@ -164,6 +172,7 @@
    bool sdcard_mp3_stop_requested_ = false;
    bool opening_audio_channel_ = false;
    bool suppress_wake_until_listen_ = false;
    bool hold_idle_for_prompt_ = false;
    bool network_error_suppressed_ = false;
    std::string pending_detect_text_;
    int pending_detect_retry_ticks_ = 0;
@@ -176,6 +185,7 @@
    bool tts_stop_pending_ = false;
    bool tts_stop_waiting_ = false;
    bool tts_idle_waiting_ = false;
    bool tts_session_end_ = false;
    bool tts_speaking_state_ready_ = false;
    uint32_t tts_activity_generation_ = 0;
    int tts_playback_empty_ticks_ = 0;
@@ -187,6 +197,7 @@
    int sleep_inhibit_count_ = 0;
    int clock_ticks_ = 0;
    TaskHandle_t activation_task_handle_ = nullptr;
    TaskHandle_t main_loop_task_ = nullptr;
    // Event handlers
xiaozhi-esp32-2.2.6/main/audio/audio_processor.h
@@ -16,6 +16,8 @@
    virtual void Feed(std::vector<int16_t>&& data) = 0;
    virtual void Start() = 0;
    virtual void Stop() = 0;
    virtual void ReleaseRuntime() {}
    virtual bool EnsureRuntime() { return true; }
    virtual bool IsRunning() = 0;
    virtual void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) = 0;
    virtual void OnVadStateChange(std::function<void(bool speaking)> callback) = 0;
xiaozhi-esp32-2.2.6/main/audio/audio_service.cc
@@ -1,4 +1,5 @@
#include "audio_service.h"
#include "processors/audio_debugger.h"
#include <esp_log.h>
#include <cstring>
#include <chrono>
@@ -240,6 +241,9 @@
        if (audio_input_need_warmup_) {
            audio_input_need_warmup_ = false;
            vTaskDelay(pdMS_TO_TICKS(120));
            if (xEventGroupGetBits(event_group_) & AS_EVENT_AUDIO_PROCESSOR_RUNNING) {
                audio_processor_->Start();
            }
            continue;
        }
@@ -538,6 +542,12 @@
    }
}
void AudioService::AbortWakeWordEncode() {
    if (wake_word_) {
        wake_word_->AbortWakeWordEncode();
    }
}
const std::string& AudioService::GetLastWakeWord() const {
    return wake_word_->GetLastDetectedWakeWord();
}
@@ -568,6 +578,9 @@
                return;
            }
            wake_word_initialized_ = true;
        } else if (!wake_word_->EnsureRuntime()) {
            ESP_LOGE(TAG, "Failed to restore wake word runtime");
            return;
        }
        // Reset input resampler to clear cached data from previous mode (e.g. AudioProcessor)
        // This prevents buffer overflow when switching between different feed sizes
@@ -585,13 +598,39 @@
    }
}
void AudioService::ClearWakeWordBuffers() {
    if (wake_word_) {
        wake_word_->ClearWakeBuffers();
    }
}
void AudioService::ReleaseAfeRuntime() {
    EnableVoiceProcessing(false);
    EnableWakeWordDetection(false);
    if (wake_word_) {
        wake_word_->ReleaseRuntime();
    }
    ReleaseVoiceProcessorRuntime();
}
void AudioService::ReleaseVoiceProcessorRuntime() {
    EnableVoiceProcessing(false);
    if (audio_processor_initialized_ && audio_processor_) {
        audio_processor_->ReleaseRuntime();
    }
}
void AudioService::EnsureVoiceProcessorReady() {
    if (audio_processor_initialized_ || audio_processor_ == nullptr) {
    if (audio_processor_ == nullptr) {
        return;
    }
    ESP_LOGI(TAG, "Preload voice processor");
    audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
    audio_processor_initialized_ = true;
    if (!audio_processor_initialized_) {
        ESP_LOGI(TAG, "Preload voice processor");
        audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
        audio_processor_initialized_ = true;
    } else if (!audio_processor_->EnsureRuntime()) {
        ESP_LOGE(TAG, "Failed to restore voice processor runtime");
    }
}
void AudioService::EnableVoiceProcessing(bool enable) {
@@ -600,6 +639,9 @@
        if (!audio_processor_initialized_) {
            audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
            audio_processor_initialized_ = true;
        } else if (!audio_processor_->EnsureRuntime()) {
            ESP_LOGE(TAG, "Failed to restore voice processor runtime");
            return;
        }
        /* We should make sure no audio is playing */
@@ -613,8 +655,8 @@
                esp_ae_rate_cvt_reset(input_resampler_);
            }
        }
        audio_processor_->Start();
        xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING);
        // fetch 等 input warmup 后再 Start,避免刚进聆听就 AFE empty / Error -1
    } else {
        audio_processor_->Stop();
        xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING);
xiaozhi-esp32-2.2.6/main/audio/audio_service.h
@@ -3,6 +3,7 @@
#include <memory>
#include <deque>
#include <vector>
#include <condition_variable>
#include <chrono>
#include <mutex>
@@ -20,7 +21,6 @@
#include "audio_codec.h"
#include "audio_processor.h"
#include "processors/audio_debugger.h"
#include "wake_word.h"
#include "protocol.h"
#include "ogg_demuxer.h"
@@ -103,6 +103,8 @@
    uint32_t playback_count = 0;
};
class AudioDebugger;
class AudioService {
public:
    AudioService();
@@ -112,6 +114,7 @@
    void Start();
    void Stop();
    void EncodeWakeWord();
    void AbortWakeWordEncode();
    std::unique_ptr<AudioStreamPacket> PopWakeWordPacket();
    const std::string& GetLastWakeWord() const;
    bool IsVoiceDetected() const { return voice_detected_; }
@@ -124,6 +127,9 @@
    void EnableWakeWordDetection(bool enable);
    void EnableVoiceProcessing(bool enable);
    void ReleaseAfeRuntime();
    void ReleaseVoiceProcessorRuntime();
    void ClearWakeWordBuffers();
    void EnsureVoiceProcessorReady();
    void EnableAudioTesting(bool enable);
    void EnableDeviceAec(bool enable);
xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.cc
@@ -12,13 +12,37 @@
void AfeAudioProcessor::Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) {
    codec_ = codec;
    models_list_ = models_list;
    frame_duration_ms_ = frame_duration_ms;
    frame_samples_ = frame_duration_ms * 16000 / 1000;
    // Pre-allocate output buffer capacity
    output_buffer_.reserve(frame_samples_);
    int ref_num = codec_->input_reference() ? 1 : 0;
    if (!CreateAfe()) {
        ESP_LOGE(TAG, "Failed to create AFE runtime");
        return;
    }
    if (!processor_task_started_) {
        xTaskCreate([](void* arg) {
            auto this_ = (AfeAudioProcessor*)arg;
            this_->AudioProcessorTask();
            vTaskDelete(NULL);
        }, "audio_communication", 4096, this, 3, NULL);
        processor_task_started_ = true;
    }
}
bool AfeAudioProcessor::CreateAfe() {
    if (afe_data_ != nullptr) {
        return true;
    }
    if (codec_ == nullptr) {
        return false;
    }
    int ref_num = codec_->input_reference() ? 1 : 0;
    std::string input_format;
    for (int i = 0; i < codec_->input_channels() - ref_num; i++) {
        input_format.push_back('M');
@@ -28,15 +52,15 @@
    }
    srmodel_list_t *models;
    if (models_list == nullptr) {
    if (models_list_ == nullptr) {
        models = esp_srmodel_init("model");
    } else {
        models = models_list;
        models = models_list_;
    }
    char* ns_model_name = esp_srmodel_filter(models, ESP_NSNET_PREFIX, NULL);
    char* vad_model_name = esp_srmodel_filter(models, ESP_VADN_PREFIX, NULL);
    afe_config_t* afe_config = afe_config_init(input_format.c_str(), NULL, AFE_TYPE_VC, AFE_MODE_HIGH_PERF);
    afe_config->aec_mode = AEC_MODE_VOIP_HIGH_PERF;
    afe_config->vad_mode = VAD_MODE_0;
@@ -66,12 +90,31 @@
    afe_iface_ = esp_afe_handle_from_config(afe_config);
    afe_data_ = afe_iface_->create_from_config(afe_config);
    xTaskCreate([](void* arg) {
        auto this_ = (AfeAudioProcessor*)arg;
        this_->AudioProcessorTask();
        vTaskDelete(NULL);
    }, "audio_communication", 4096, this, 3, NULL);
    if (afe_data_ == nullptr) {
        ESP_LOGE(TAG, "create_from_config failed");
        return false;
    }
    ESP_LOGI(TAG, "AFE runtime created");
    return true;
}
void AfeAudioProcessor::ReleaseRuntime() {
    Stop();
    if (afe_data_ == nullptr) {
        return;
    }
    vTaskDelay(pdMS_TO_TICKS(120));
    std::lock_guard<std::mutex> lock(input_buffer_mutex_);
    if (afe_data_ != nullptr && afe_iface_ != nullptr) {
        afe_iface_->destroy(afe_data_);
        afe_data_ = nullptr;
    }
    input_buffer_.clear();
    ESP_LOGI(TAG, "AFE runtime released");
}
bool AfeAudioProcessor::EnsureRuntime() {
    return CreateAfe();
}
AfeAudioProcessor::~AfeAudioProcessor() {
@@ -133,21 +176,30 @@
}
void AfeAudioProcessor::AudioProcessorTask() {
    auto fetch_size = afe_iface_->get_fetch_chunksize(afe_data_);
    auto feed_size = afe_iface_->get_feed_chunksize(afe_data_);
    ESP_LOGI(TAG, "Audio communication task started, feed size: %d fetch size: %d",
        feed_size, fetch_size);
    ESP_LOGI(TAG, "Audio communication task started");
    while (true) {
        xEventGroupWaitBits(event_group_, PROCESSOR_RUNNING, pdFALSE, pdTRUE, portMAX_DELAY);
        auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
        esp_afe_sr_data_t* afe_data = nullptr;
        const esp_afe_sr_iface_t* afe_iface = nullptr;
        {
            std::lock_guard<std::mutex> lock(input_buffer_mutex_);
            afe_data = afe_data_;
            afe_iface = afe_iface_;
        }
        if (afe_data == nullptr || afe_iface == nullptr) {
            vTaskDelay(pdMS_TO_TICKS(20));
            continue;
        }
        auto res = afe_iface->fetch_with_delay(afe_data, pdMS_TO_TICKS(80));
        if ((xEventGroupGetBits(event_group_) & PROCESSOR_RUNNING) == 0) {
            continue;
        }
        if (res == nullptr || res->ret_value == ESP_FAIL) {
            if (res != nullptr) {
                ESP_LOGI(TAG, "Error code: %d", res->ret_value);
                ESP_LOGD(TAG, "Error code: %d", res->ret_value);
            }
            continue;
        }
@@ -187,6 +239,9 @@
}
void AfeAudioProcessor::EnableDeviceAec(bool enable) {
    if (afe_data_ == nullptr || afe_iface_ == nullptr) {
        return;
    }
    if (enable) {
#if CONFIG_USE_DEVICE_AEC
        afe_iface_->disable_vad(afe_data_);
xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.h
@@ -23,6 +23,8 @@
    void Feed(std::vector<int16_t>&& data) override;
    void Start() override;
    void Stop() override;
    void ReleaseRuntime() override;
    bool EnsureRuntime() override;
    bool IsRunning() override;
    void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) override;
    void OnVadStateChange(std::function<void(bool speaking)> callback) override;
@@ -36,12 +38,16 @@
    std::function<void(std::vector<int16_t>&& data)> output_callback_;
    std::function<void(bool speaking)> vad_state_change_callback_;
    AudioCodec* codec_ = nullptr;
    srmodel_list_t* models_list_ = nullptr;
    int frame_duration_ms_ = 0;
    int frame_samples_ = 0;
    bool processor_task_started_ = false;
    bool is_speaking_ = false;
    std::vector<int16_t> input_buffer_;
    std::mutex input_buffer_mutex_;
    std::vector<int16_t> output_buffer_;
    bool CreateAfe();
    void AudioProcessorTask();
};
xiaozhi-esp32-2.2.6/main/audio/wake_word.h
@@ -18,6 +18,10 @@
    virtual void OnOfflineCommandDetected(std::function<void(const std::string& command)> callback) = 0;
    virtual void Start() = 0;
    virtual void Stop() = 0;
    virtual void ReleaseRuntime() {}
    virtual bool EnsureRuntime() { return true; }
    virtual void ClearWakeBuffers() {}
    virtual void AbortWakeWordEncode() {}
    virtual size_t GetFeedSize() = 0;
    virtual void EncodeWakeWordData() = 0;
    virtual bool GetWakeWordOpus(std::vector<uint8_t>& opus) = 0;
xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc
@@ -1,9 +1,11 @@
#include "afe_wake_word.h"
#include "audio_service.h"
#include "boards/sening-xiaozhi-s3/system.h"
#include <esp_heap_caps.h>
#include <esp_log.h>
#include <esp_mn_models.h>
#include <esp_mn_speech_commands.h>
#include <chrono>
#include <sstream>
#define DETECTION_RUNNING_EVENT 1
@@ -43,7 +45,6 @@
bool AfeWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list) {
    codec_ = codec;
    int ref_num = codec_->input_reference() ? 1 : 0;
    if (models_list == nullptr) {
        models_ = esp_srmodel_init("model");
@@ -73,6 +74,11 @@
    if (multinet_model_ == nullptr) {
        multinet_model_ = esp_srmodel_filter(models_, ESP_MN_PREFIX, NULL);
    }
    if (!EnsureDetectionTask()) {
        ESP_LOGE(TAG, "Failed to start audio detection task");
        return false;
    }
    if (multinet_model_ != nullptr) {
        multinet_ = esp_mn_handle_from_name(multinet_model_);
        multinet_data_ = multinet_->create(multinet_model_, 3000);
@@ -92,6 +98,57 @@
        ESP_LOGW(TAG, "MultiNet model not found, offline command word disabled");
    }
    if (!CreateAfe()) {
        ESP_LOGE(TAG, "Failed to create AFE runtime");
        return false;
    }
    return true;
}
bool AfeWakeWord::EnsureDetectionTask() {
    if (detection_task_started_ && detection_task_handle_ != nullptr) {
        return true;
    }
    const size_t stack_size = 8192;
    if (detection_task_stack_ == nullptr) {
        detection_task_stack_ = (StackType_t*)heap_caps_malloc(stack_size, MALLOC_CAP_SPIRAM);
        if (detection_task_stack_ == nullptr) {
            ESP_LOGE(TAG, "Failed to alloc audio detection stack");
            return false;
        }
    }
    if (detection_task_buffer_ == nullptr) {
        detection_task_buffer_ = (StaticTask_t*)heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_INTERNAL);
        if (detection_task_buffer_ == nullptr) {
            ESP_LOGE(TAG, "Failed to alloc audio detection TCB");
            return false;
        }
    }
    detection_task_handle_ = xTaskCreateStatic([](void* arg) {
        auto this_ = (AfeWakeWord*)arg;
        this_->AudioDetectionTask();
        vTaskDelete(NULL);
    }, "audio_detection", stack_size, this, 3, detection_task_stack_, detection_task_buffer_);
    if (detection_task_handle_ == nullptr) {
        ESP_LOGE(TAG, "Failed to create audio detection task");
        return false;
    }
    detection_task_started_ = true;
    return true;
}
bool AfeWakeWord::CreateAfe() {
    if (afe_data_ != nullptr) {
        return true;
    }
    if (codec_ == nullptr) {
        return false;
    }
    int ref_num = codec_->input_reference() ? 1 : 0;
    std::string input_format;
    for (int i = 0; i < codec_->input_channels() - ref_num; i++) {
        input_format.push_back('M');
@@ -105,17 +162,53 @@
    afe_config->afe_perferred_core = 1;
    afe_config->afe_perferred_priority = 1;
    afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
    afe_iface_ = esp_afe_handle_from_config(afe_config);
    afe_data_ = afe_iface_->create_from_config(afe_config);
    xTaskCreate([](void* arg) {
        auto this_ = (AfeWakeWord*)arg;
        this_->AudioDetectionTask();
        vTaskDelete(NULL);
    }, "audio_detection", 8192, this, 3, nullptr);
    if (afe_data_ == nullptr) {
        ESP_LOGE(TAG, "create_from_config failed");
        return false;
    }
    ESP_LOGI(TAG, "AFE runtime created");
    return true;
}
void AfeWakeWord::ReleaseRuntime() {
    Stop();
    if (afe_data_ == nullptr) {
        return;
    }
    // fetch 用短超时,停检测后再销毁,避免和 AudioDetectionTask 抢 afe_data_
    vTaskDelay(pdMS_TO_TICKS(120));
    {
        std::lock_guard<std::mutex> lock(input_buffer_mutex_);
        if (afe_data_ != nullptr && afe_iface_ != nullptr) {
            afe_iface_->destroy(afe_data_);
            afe_data_ = nullptr;
        }
        input_buffer_.clear();
        multinet_input_buffer_.clear();
    }
    ClearWakeBuffers();
    ESP_LOGI(TAG, "AFE runtime released");
}
bool AfeWakeWord::EnsureRuntime() {
    return CreateAfe();
}
void AfeWakeWord::ClearWakeBuffers() {
    std::lock_guard<std::mutex> lock(wake_word_mutex_);
    wake_word_pcm_.clear();
    wake_word_opus_.clear();
}
void AfeWakeWord::AbortWakeWordEncode() {
    std::lock_guard<std::mutex> lock(wake_word_mutex_);
    wake_word_pcm_.clear();
    wake_word_opus_.clear();
    wake_word_opus_.emplace_back();
    wake_word_cv_.notify_all();
}
void AfeWakeWord::OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) {
@@ -145,7 +238,7 @@
}
void AfeWakeWord::Feed(const std::vector<int16_t>& data) {
    if (afe_data_ == nullptr) {
    if (afe_data_ == nullptr || detection_task_handle_ == nullptr) {
        return;
    }
@@ -170,17 +263,29 @@
}
void AfeWakeWord::AudioDetectionTask() {
    auto fetch_size = afe_iface_->get_fetch_chunksize(afe_data_);
    auto feed_size = afe_iface_->get_feed_chunksize(afe_data_);
    ESP_LOGI(TAG, "Audio detection task started, feed size: %d fetch size: %d",
        feed_size, fetch_size);
    ESP_LOGI(TAG, "Audio detection task started");
    while (true) {
        xEventGroupWaitBits(event_group_, DETECTION_RUNNING_EVENT, pdFALSE, pdTRUE, portMAX_DELAY);
        auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
        esp_afe_sr_data_t* afe_data = nullptr;
        const esp_afe_sr_iface_t* afe_iface = nullptr;
        {
            std::lock_guard<std::mutex> lock(input_buffer_mutex_);
            afe_data = afe_data_;
            afe_iface = afe_iface_;
        }
        if (afe_data == nullptr || afe_iface == nullptr) {
            vTaskDelay(pdMS_TO_TICKS(20));
            continue;
        }
        auto res = afe_iface->fetch_with_delay(afe_data, pdMS_TO_TICKS(80));
        if (!(xEventGroupGetBits(event_group_) & DETECTION_RUNNING_EVENT)) {
            continue;
        }
        if (res == nullptr || res->ret_value == ESP_FAIL) {
            continue;;
            continue;
        }
        // Store the wake word data for voice recognition, like who is speaking
@@ -241,8 +346,11 @@
}
void AfeWakeWord::StoreWakeWordData(const int16_t* data, size_t samples) {
    // store audio data to wake_word_pcm_
    wake_word_pcm_.emplace_back(std::vector<int16_t>(data, data + samples));
    // 512 sample * 2B = 1KB,默认会进内部 SRAM,播完本地 MP3 后 DMA 最大块会被切成 ~1KB。
    std::vector<int16_t> chunk;
    chunk.reserve(2048);
    chunk.assign(data, data + samples);
    wake_word_pcm_.push_back(std::move(chunk));
    // keep about 2 seconds of data, detect duration is 30ms (sample_rate == 16000, chunksize == 512)
    while (wake_word_pcm_.size() > 2000 / 30) {
        wake_word_pcm_.pop_front();
@@ -330,13 +438,22 @@
        }
        vTaskDelete(NULL);
    }, "encode_wake_word", stack_size, this, 2, wake_word_encode_task_stack_, wake_word_encode_task_buffer_);
    if (wake_word_encode_task_ == nullptr) {
        ESP_LOGE(TAG, "Failed to create encode_wake_word task");
        std::lock_guard<std::mutex> lock(wake_word_mutex_);
        wake_word_opus_.push_back(std::vector<uint8_t>());
        wake_word_cv_.notify_all();
    }
}
bool AfeWakeWord::GetWakeWordOpus(std::vector<uint8_t>& opus) {
    std::unique_lock<std::mutex> lock(wake_word_mutex_);
    wake_word_cv_.wait(lock, [this]() {
    if (!wake_word_cv_.wait_for(lock, std::chrono::milliseconds(2000), [this]() {
        return !wake_word_opus_.empty();
    });
    })) {
        ESP_LOGW(TAG, "wake opus wait timeout");
        return false;
    }
    opus.swap(wake_word_opus_.front());
    wake_word_opus_.pop_front();
    return !opus.empty();
xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h
@@ -31,6 +31,10 @@
    void OnOfflineCommandDetected(std::function<void(const std::string& command)> callback);
    void Start();
    void Stop();
    void ReleaseRuntime() override;
    bool EnsureRuntime() override;
    void ClearWakeBuffers() override;
    void AbortWakeWordEncode() override;
    size_t GetFeedSize();
    void EncodeWakeWordData();
    bool GetWakeWordOpus(std::vector<uint8_t>& opus);
@@ -57,11 +61,18 @@
    TaskHandle_t wake_word_encode_task_ = nullptr;
    StaticTask_t* wake_word_encode_task_buffer_ = nullptr;
    StackType_t* wake_word_encode_task_stack_ = nullptr;
    TaskHandle_t detection_task_handle_ = nullptr;
    StaticTask_t* detection_task_buffer_ = nullptr;
    StackType_t* detection_task_stack_ = nullptr;
    std::deque<std::vector<int16_t>> wake_word_pcm_;
    std::deque<std::vector<uint8_t>> wake_word_opus_;
    std::mutex wake_word_mutex_;
    std::condition_variable wake_word_cv_;
    bool detection_task_started_ = false;
    bool CreateAfe();
    bool EnsureDetectionTask();
    void StoreWakeWordData(const int16_t* data, size_t size);
    void AudioDetectionTask();
};
xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc
@@ -9,6 +9,7 @@
#include <esp_mn_models.h>
#include <esp_mn_speech_commands.h>
#include <cJSON.h>
#include <chrono>
#define TAG "CustomWakeWord"
@@ -250,12 +251,28 @@
}
void CustomWakeWord::StoreWakeWordData(const std::vector<int16_t>& data) {
    // store audio data to wake_word_pcm_
    wake_word_pcm_.push_back(data);
    std::vector<int16_t> chunk;
    chunk.reserve(2048);
    chunk.assign(data.begin(), data.end());
    wake_word_pcm_.push_back(std::move(chunk));
    // keep about 2 seconds of data, detect duration is 30ms (sample_rate == 16000, chunksize == 512)
    while (wake_word_pcm_.size() > 2000 / 30) {
        wake_word_pcm_.pop_front();
    }
}
void CustomWakeWord::ClearWakeBuffers() {
    std::lock_guard<std::mutex> lock(wake_word_mutex_);
    wake_word_pcm_.clear();
    wake_word_opus_.clear();
}
void CustomWakeWord::AbortWakeWordEncode() {
    std::lock_guard<std::mutex> lock(wake_word_mutex_);
    wake_word_pcm_.clear();
    wake_word_opus_.clear();
    wake_word_opus_.emplace_back();
    wake_word_cv_.notify_all();
}
void CustomWakeWord::EncodeWakeWordData() {
@@ -333,13 +350,22 @@
        }
        vTaskDelete(NULL);
    }, "encode_wake_word", stack_size, this, 2, wake_word_encode_task_stack_, wake_word_encode_task_buffer_);
    if (wake_word_encode_task_ == nullptr) {
        ESP_LOGE(TAG, "Failed to create encode_wake_word task");
        std::lock_guard<std::mutex> lock(wake_word_mutex_);
        wake_word_opus_.push_back(std::vector<uint8_t>());
        wake_word_cv_.notify_all();
    }
}
bool CustomWakeWord::GetWakeWordOpus(std::vector<uint8_t>& opus) {
    std::unique_lock<std::mutex> lock(wake_word_mutex_);
    wake_word_cv_.wait(lock, [this]() {
    if (!wake_word_cv_.wait_for(lock, std::chrono::milliseconds(2000), [this]() {
        return !wake_word_opus_.empty();
    });
    })) {
        ESP_LOGW(TAG, "wake opus wait timeout");
        return false;
    }
    opus.swap(wake_word_opus_.front());
    wake_word_opus_.pop_front();
    return !opus.empty();
xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h
@@ -28,6 +28,8 @@
    void OnOfflineCommandDetected(std::function<void(const std::string& command)> callback);
    void Start();
    void Stop();
    void ClearWakeBuffers() override;
    void AbortWakeWordEncode() override;
    size_t GetFeedSize();
    void EncodeWakeWordData();
    bool GetWakeWordOpus(std::vector<uint8_t>& opus);
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
@@ -1,4 +1,5 @@
#include "app_task.h"
#include "parent_schedule.h"
#include "application.h"
#include "board.h"
@@ -12,21 +13,45 @@
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <dirent.h>
#include <ctype.h>
#include <string>
#include <esp_heap_caps.h>
#include <esp_timer.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_NVS_LAST_RPT "last_rpt"
#define APP_TASK_NVS_LAST_SHD "last_shd"
#define APP_TASK_JSON_MAX 768
#define APP_TASK_ID_MAX 32
#define APP_TASK_SOURCE_MAX 24
#define LESSON_CLIP_NAME_MAX 16
#define LESSON_CLIP_MAX 128
static char s_current_app[APP_TASK_ID_MAX] = "chat";
static char s_current_task[APP_TASK_ID_MAX] = "conversation";
static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat";
static char s_current_params[APP_TASK_JSON_MAX] = "{}";
static int s_current_priority = 0;
static bool s_finish_task_after_mp3 = true;
static float s_shadowing_listen_ratio = 1.2f;
static bool s_wait_start_phrase = false;
static bool s_start_phrase_tts_begun = false;
static bool s_start_detect_sent = false;
static bool s_listen_after_phrase = false;
static int64_t s_wait_start_us = 0;
static std::string s_start_detect_text;
void Board_TrySendLessonStartDetect(void);
bool Board_IsWaitingStartPhrase(void)
{
    return s_wait_start_phrase;
}
typedef struct {
    const char *app_id;
@@ -63,6 +88,20 @@
int Board_AppTask_CurrentPriority(void)
{
    return s_current_priority;
}
static bool is_safe_task_id(const char *id)
{
    if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
        return false;
    }
    for (const char *p = id; *p; ++p) {
        unsigned char ch = static_cast<unsigned char>(*p);
        if (!isalnum(ch) && ch != '_' && ch != '-') {
            return false;
        }
    }
    return true;
}
static const app_task_skill_map_t *find_task_spec(const char *app_id, const char *task_id)
@@ -376,6 +415,328 @@
    return name[0] != '\0';
}
static const char *last_lesson_nvs_key(const char *task_id)
{
    if (task_id && strcmp(task_id, "shadowing") == 0) {
        return APP_TASK_NVS_LAST_SHD;
    }
    if (task_id && strcmp(task_id, "repeater") == 0) {
        return APP_TASK_NVS_LAST_RPT;
    }
    return NULL;
}
static const char *const k_last_lesson_keys[] = {
    "lesson_id",
    "lesson",
    "lessonId",
    "en_passes",
    "enPasses",
    "cn_repeat",
    "cn_speed",
    "language",
    "lang",
    "speed",
    "on_complete",
    "listen_ratio",
    "sentence_count",
    "org_id",
    "orgId",
    NULL,
};
static bool json_has_lesson_id(cJSON *root)
{
    if (!root) {
        return false;
    }
    const cJSON *id = cJSON_GetObjectItem(root, "lesson_id");
    if (!id) {
        id = cJSON_GetObjectItem(root, "lessonId");
    }
    if (!id) {
        id = cJSON_GetObjectItem(root, "lesson");
    }
    return cJSON_IsString(id) && id->valuestring && id->valuestring[0];
}
static bool compact_last_lesson_json(const char *params_json, char *out, size_t out_size)
{
    if (!out || out_size == 0) {
        return false;
    }
    out[0] = '\0';
    if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
        return false;
    }
    cJSON *root = cJSON_Parse(params_json);
    if (!root) {
        return false;
    }
    cJSON *compact = cJSON_CreateObject();
    if (!compact) {
        cJSON_Delete(root);
        return false;
    }
    for (int i = 0; k_last_lesson_keys[i]; ++i) {
        cJSON *item = cJSON_GetObjectItem(root, k_last_lesson_keys[i]);
        if (item) {
            cJSON_AddItemToObject(compact, k_last_lesson_keys[i], cJSON_Duplicate(item, 1));
        }
    }
    bool ok = json_has_lesson_id(compact);
    if (ok) {
        const cJSON *id = cJSON_GetObjectItem(compact, "lesson_id");
        if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
            id = cJSON_GetObjectItem(compact, "lessonId");
            if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
                id = cJSON_GetObjectItem(compact, "lesson");
            }
            if (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) {
                cJSON_AddStringToObject(compact, "lesson_id", id->valuestring);
            }
        }
        char *printed = cJSON_PrintUnformatted(compact);
        if (printed) {
            strlcpy(out, printed, out_size);
            ok = out[0] != '\0';
            cJSON_free(printed);
        } else {
            ok = false;
        }
    }
    cJSON_Delete(compact);
    cJSON_Delete(root);
    return ok;
}
static bool params_has_lesson_id(const char *params_json)
{
    if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
        return false;
    }
    cJSON *root = cJSON_Parse(params_json);
    if (!root) {
        return false;
    }
    bool ok = json_has_lesson_id(root);
    cJSON_Delete(root);
    return ok;
}
static bool sd_business_file_ready(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 const char *json_clip_name(cJSON *obj)
{
    if (!cJSON_IsObject(obj)) {
        return NULL;
    }
    const char *keys[] = {"name", "path", "url"};
    for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) {
        cJSON *item = cJSON_GetObjectItem(obj, keys[i]);
        if (cJSON_IsString(item) && item->valuestring && item->valuestring[0] != '\0') {
            return item->valuestring;
        }
    }
    return NULL;
}
static int mp3_name_order(const void *a, const void *b)
{
    int left = atoi(static_cast<const char *>(a));
    int right = atoi(static_cast<const char *>(b));
    if (left != right) {
        return left - right;
    }
    return strcmp(static_cast<const char *>(a), static_cast<const char *>(b));
}
static int list_lesson_mp3s(const char *business_dir, char names[][LESSON_CLIP_NAME_MAX], int cap)
{
    char sd_path[SDCARD_PATH_MAX_LEN];
    if (!business_dir || business_dir[0] == '\0' || !names || cap < 1) {
        return 0;
    }
    if (SDCard_Map_Business_Path(business_dir, sd_path, sizeof(sd_path)) != ESP_OK) {
        return 0;
    }
    DIR *dir = opendir(sd_path);
    if (!dir) {
        return 0;
    }
    int count = 0;
    struct dirent *ent;
    while ((ent = readdir(dir)) != NULL && count < cap) {
        const char *name = ent->d_name;
        size_t len = name ? strlen(name) : 0;
        if (len < 5 || len >= LESSON_CLIP_NAME_MAX || strcasecmp(name + len - 4, ".mp3") != 0) {
            continue;
        }
        strlcpy(names[count], name, LESSON_CLIP_NAME_MAX);
        count++;
    }
    closedir(dir);
    if (count > 1) {
        qsort(names, static_cast<size_t>(count), LESSON_CLIP_NAME_MAX, mp3_name_order);
    }
    return count;
}
static const char *shadowing_lang_folder(const char *language)
{
    return (language && strcasecmp(language, "cn") == 0) ? "cn" : "en";
}
static bool probe_lesson_id_on_sd(const char *task_id, const char *lesson_id, const char *language)
{
    int level = 0;
    int story = 0;
    if (!lesson_id || sscanf(lesson_id, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
        return true;
    }
    char path[SDCARD_PATH_MAX_LEN];
    if (task_id && strcmp(task_id, "shadowing") == 0) {
        const char *lang = shadowing_lang_folder(language);
        snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s",
                 level, story, lang);
        char names[1][LESSON_CLIP_NAME_MAX];
        return list_lesson_mp3s(path, names, 1) >= 1;
    }
    snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
    char names[1][LESSON_CLIP_NAME_MAX];
    if (list_lesson_mp3s(path, names, 1) < 1) {
        return false;
    }
    snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
    return list_lesson_mp3s(path, names, 1) >= 1;
}
static bool lesson_files_ready(const char *task_id, const char *params_json)
{
    if (!params_json || params_json[0] == '\0') {
        return true;
    }
    cJSON *params = cJSON_Parse(params_json);
    if (!params) {
        return true;
    }
    cJSON *items = cJSON_GetObjectItem(params, "items");
    bool checked = false;
    if (cJSON_IsArray(items)) {
        cJSON *it = NULL;
        cJSON_ArrayForEach(it, items) {
            const char *name = json_clip_name(it);
            if (!name) {
                continue;
            }
            checked = true;
            if (!sd_business_file_ready(name)) {
                cJSON_Delete(params);
                return false;
            }
        }
        if (checked) {
            cJSON_Delete(params);
            return true;
        }
    }
    const char *single = json_clip_name(params);
    if (single) {
        bool ok = sd_business_file_ready(single);
        cJSON_Delete(params);
        return ok;
    }
    cJSON *lesson = cJSON_GetObjectItem(params, "lesson_id");
    if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
        lesson = cJSON_GetObjectItem(params, "lesson");
    }
    if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
        lesson = cJSON_GetObjectItem(params, "lessonId");
    }
    cJSON *lang = cJSON_GetObjectItem(params, "language");
    if (!cJSON_IsString(lang) || !lang->valuestring) {
        lang = cJSON_GetObjectItem(params, "lang");
    }
    bool ok = true;
    if (cJSON_IsString(lesson) && lesson->valuestring && lesson->valuestring[0] != '\0') {
        ok = probe_lesson_id_on_sd(task_id, lesson->valuestring,
                                  cJSON_IsString(lang) ? lang->valuestring : NULL);
    }
    cJSON_Delete(params);
    return ok;
}
static void show_lesson_sync_alert(void)
{
    Board_Show_Alert("正在同步", "正在同步课文,请稍候", "cloud_arrow_down");
}
bool Board_LessonFilesReady(const char *task_id, const char *params_json)
{
    return lesson_files_ready(task_id, params_json);
}
bool Board_IsLessonPlaybackActive(void)
{
    return s_wait_start_phrase || SDCard_Is_Playing();
}
void Board_RememberLastLessonTask(const char *task_id, const char *params_json)
{
    const char *key = last_lesson_nvs_key(task_id);
    char compact[APP_TASK_JSON_MAX];
    if (!key || !compact_last_lesson_json(params_json, compact, sizeof(compact))) {
        return;
    }
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) != ESP_OK) {
        return;
    }
    if (nvs_set_str(handle, key, compact) == ESP_OK) {
        nvs_commit(handle);
        ESP_LOGI(TAG, "remember last %s lesson", task_id);
    }
    nvs_close(handle);
}
bool Board_LoadLastLessonTask(const char *task_id, char *buf, size_t buf_size)
{
    const char *key = last_lesson_nvs_key(task_id);
    if (!key || !buf || buf_size == 0) {
        return false;
    }
    buf[0] = '\0';
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
        return false;
    }
    size_t length = buf_size;
    esp_err_t err = nvs_get_str(handle, key, buf, &length);
    nvs_close(handle);
    if (err != ESP_OK || buf[0] == '\0' || !params_has_lesson_id(buf)) {
        buf[0] = '\0';
        return false;
    }
    return true;
}
esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
                                const char *source, const char *alarm_id)
{
@@ -417,66 +778,391 @@
    return err;
}
static bool load_pending_fields(char *app_id, size_t app_size, char *task_id, size_t task_size,
                                char *source, size_t source_size, char *params_json, size_t params_size)
{
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
        return false;
    }
    char buf[APP_TASK_JSON_MAX];
    size_t length = sizeof(buf);
    esp_err_t err = nvs_get_str(handle, APP_TASK_NVS_KEY, buf, &length);
    nvs_close(handle);
    if (err != ESP_OK) {
        return false;
    }
    cJSON *root = cJSON_Parse(buf);
    if (!root) {
        return false;
    }
    const cJSON *app = cJSON_GetObjectItem(root, "app_id");
    const cJSON *task = cJSON_GetObjectItem(root, "task_id");
    const cJSON *src = cJSON_GetObjectItem(root, "source");
    const cJSON *params = cJSON_GetObjectItem(root, "params");
    copy_id(app_id, app_size, cJSON_IsString(app) ? app->valuestring : NULL, "chat");
    copy_id(task_id, task_size, cJSON_IsString(task) ? task->valuestring : NULL, "conversation");
    copy_id(source, source_size, cJSON_IsString(src) ? src->valuestring : NULL, "default_chat");
    if (params_json && params_size > 0) {
        params_json[0] = '\0';
        if (params) {
            char *printed = cJSON_PrintUnformatted(params);
            if (printed) {
                strlcpy(params_json, printed, params_size);
                free(printed);
            }
        }
    }
    cJSON_Delete(root);
    return app_id[0] != '\0';
}
static void preempt_current_skills(void)
{
    SDCard_Stop_Play();
    BoardMqtt_StopPlaylistPlayback();
    Application::GetInstance().Schedule([]() {
        auto state = Application::GetInstance().GetDeviceState();
        if (state == kDeviceStateSpeaking) {
            Application::GetInstance().AbortSpeaking(kAbortReasonNone);
}
static bool is_schedule_talk_task(const char *task_id)
{
    return task_id && (strcmp(task_id, "conversation") == 0 ||
                       strcmp(task_id, "live_translate") == 0 ||
                       strcmp(task_id, "bilingual_chat") == 0);
}
static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle,
                          bool expect_prompt = true, bool sd_ready = false, bool wait_phrase = false)
{
    std::string params = s_current_params[0] ? s_current_params : "{}";
    if (sd_ready && params.size() >= 2 && params.back() == '}') {
        params.pop_back();
        if (params.size() > 1) {
            params += ',';
        }
        params += "\"sd_ready\":true}";
    }
    std::string detect = "[app_task]";
    detect += (app_id && app_id[0]) ? app_id : "chat";
    detect += "|";
    detect += (task_id && task_id[0]) ? task_id : "conversation";
    detect += "|";
    detect += (source && source[0]) ? source : "default_chat";
    detect += "|";
    detect += params;
    const bool hold_for_phrase = sd_ready || wait_phrase;
    if (hold_for_phrase) {
        s_start_detect_text = detect;
        s_start_detect_sent = false;
    }
    Application::GetInstance().Schedule([detect, open_if_idle, expect_prompt, hold_for_phrase]() {
        auto& app = Application::GetInstance();
        app.HoldIdleForServerPrompt();
        if (hold_for_phrase) {
            // 关机拉起时通道可能还没打开。连上后再上报,等「开始xx任务」说完再进入任务。
            Board_TrySendLessonStartDetect();
            return;
        }
        if (expect_prompt && app.GetDeviceState() == kDeviceStateSpeaking) {
            app.AbortSpeaking(kAbortReasonNone);
        }
        if (app.SendListenDetectNow(detect)) {
            if (expect_prompt) {
                app.ArmPendingDetectRetry(detect, 5);
            }
            return;
        }
        app.SendListenDetect(detect, open_if_idle);
    });
}
static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle)
static const char *json_lesson_id(cJSON *params)
{
    char detect[160];
    snprintf(detect, sizeof(detect), "[app_task]%s|%s|%s",
             app_id && app_id[0] ? app_id : "chat",
             task_id && task_id[0] ? task_id : "conversation",
             source && source[0] ? source : "default_chat");
    Application::GetInstance().SendListenDetect(detect, open_if_idle);
    if (!params) {
        return NULL;
    }
    const cJSON *id = cJSON_GetObjectItem(params, "lesson_id");
    if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
        id = cJSON_GetObjectItem(params, "lessonId");
    }
    if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
        id = cJSON_GetObjectItem(params, "lesson");
    }
    return (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) ? id->valuestring : NULL;
}
static bool add_playlist_item(sdcard_mp3_playlist_item_t *items, int *n, int cap,
                              const char *business, float speed, int repeat)
{
    if (!items || !n || *n >= cap || !business || business[0] == '\0') {
        return false;
    }
    sdcard_mp3_playlist_item_t *item = &items[*n];
    if (!SDCard_Resolve_MP3_Path(business, item->path, sizeof(item->path))) {
        ESP_LOGW(TAG, "lesson clip missing on sd: %s", business);
        return false;
    }
    item->speed = SDCard_Clamp_MP3_Speed(speed);
    item->repeat = repeat < 1 ? 1 : repeat;
    if (item->repeat > 10) {
        item->repeat = 10;
    }
    item->power_off_after = false;
    (*n)++;
    return true;
}
static int build_repeater_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
{
    int level = 0;
    int story = 0;
    const char *lesson = json_lesson_id(params);
    if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
        return 0;
    }
    int cn_repeat = 1;
    float cn_speed = 1.0f;
    const cJSON *cn_r = cJSON_GetObjectItem(params, "cn_repeat");
    if (cJSON_IsNumber(cn_r)) {
        cn_repeat = cn_r->valueint;
    }
    const cJSON *cn_s = cJSON_GetObjectItem(params, "cn_speed");
    if (cJSON_IsNumber(cn_s)) {
        cn_speed = static_cast<float>(cn_s->valuedouble);
    }
    char dir[SDCARD_PATH_MAX_LEN];
    char path[SDCARD_PATH_MAX_LEN];
    static char cn_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
    static char en_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
    int n = 0;
    snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
    int cn_n = list_lesson_mp3s(dir, cn_names, LESSON_CLIP_MAX);
    snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
    int en_n = list_lesson_mp3s(dir, en_names, LESSON_CLIP_MAX);
    if (cn_n < 1 || en_n < 1) {
        return 0;
    }
    for (int i = 0; i < cn_n; ++i) {
        snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn/%.15s",
                 level, story, cn_names[i]);
        if (!add_playlist_item(items, &n, cap, path, cn_speed, cn_repeat)) {
            return n > 0 ? n : 0;
        }
    }
    const cJSON *passes = cJSON_GetObjectItem(params, "en_passes");
    int en_added = 0;
    if (cJSON_IsArray(passes)) {
        cJSON *row = NULL;
        cJSON_ArrayForEach(row, passes) {
            if (en_added >= 8) {
                break;
            }
            float speed = 1.0f;
            int repeat = 1;
            const cJSON *sp = cJSON_GetObjectItem(row, "speed");
            const cJSON *rp = cJSON_GetObjectItem(row, "repeat");
            if (cJSON_IsNumber(sp)) {
                speed = static_cast<float>(sp->valuedouble);
            }
            if (cJSON_IsNumber(rp)) {
                repeat = rp->valueint;
            }
            for (int i = 0; i < en_n; ++i) {
                snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s",
                         level, story, en_names[i]);
                if (!add_playlist_item(items, &n, cap, path, speed, repeat)) {
                    return n;
                }
            }
            en_added++;
        }
    }
    if (en_added == 0) {
        const float speeds[] = {0.8f, 0.9f, 1.0f};
        const int repeats[] = {1, 2, 5};
        for (int pass = 0; pass < 3; ++pass) {
            for (int i = 0; i < en_n; ++i) {
                snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s",
                         level, story, en_names[i]);
                if (!add_playlist_item(items, &n, cap, path, speeds[pass], repeats[pass])) {
                    return n;
                }
            }
        }
    }
    return n;
}
static int build_shadowing_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
{
    int level = 0;
    int story = 0;
    const char *lesson = json_lesson_id(params);
    if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
        return 0;
    }
    char lang[3] = "en";
    const cJSON *language = cJSON_GetObjectItem(params, "language");
    if (!cJSON_IsString(language) || !language->valuestring) {
        language = cJSON_GetObjectItem(params, "lang");
    }
    if (cJSON_IsString(language) && language->valuestring && strcasecmp(language->valuestring, "cn") == 0) {
        memcpy(lang, "cn", 3);
    }
    float speed = 0.8f;
    const cJSON *speed_item = cJSON_GetObjectItem(params, "speed");
    if (cJSON_IsNumber(speed_item)) {
        speed = static_cast<float>(speed_item->valuedouble);
    }
    char dir[SDCARD_PATH_MAX_LEN];
    static char names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
    snprintf(dir, sizeof(dir), "/english_learning/shadowing/lessons/l%d/%03d/%.2s", level, story, lang);
    int found = list_lesson_mp3s(dir, names, LESSON_CLIP_MAX);
    int n = 0;
    for (int i = 0; i < found && n < cap; ++i) {
        char clip[LESSON_CLIP_NAME_MAX];
        memcpy(clip, names[i], sizeof(clip));
        clip[sizeof(clip) - 1] = '\0';
        char path[SDCARD_PATH_MAX_LEN];
        snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s/%.15s",
                 level, story, lang, clip);
        if (!add_playlist_item(items, &n, cap, path, speed, 1)) {
            break;
        }
    }
    return n;
}
static bool start_lesson_playback_locally(const char *task_id, const char *params_json)
{
    cJSON *params = cJSON_Parse((params_json && params_json[0]) ? params_json : "{}");
    if (!params) {
        return false;
    }
    const bool shadowing = task_id && strcmp(task_id, "shadowing") == 0;
    const int cap = LESSON_CLIP_MAX;
    sdcard_mp3_playlist_item_t *items = static_cast<sdcard_mp3_playlist_item_t *>(
        heap_caps_calloc(static_cast<size_t>(cap), sizeof(*items), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    if (!items) {
        cJSON_Delete(params);
        return false;
    }
    int n = shadowing ? build_shadowing_playlist(params, items, cap)
                      : build_repeater_playlist(params, items, cap);
    cJSON_Delete(params);
    if (n < 1) {
        heap_caps_free(items);
        return false;
    }
    SDCard_PreparePlaybackAndWait();
    Board_SetFinishTaskAfterMp3(false);
    esp_err_t err = SDCard_Play_MP3_File_Playlist(items, n);
    if (err == ESP_OK) {
        ESP_LOGI(TAG, "local lesson playlist started clips=%d task=%s", n, task_id ? task_id : "");
        Board_RememberRepeaterPlayback(items[0].path, items[0].speed, items[0].repeat, false);
        SDCard_Show_Matching_JPG_For_MP3_Path(items[0].path);
    } else {
        ESP_LOGW(TAG, "local lesson playlist failed: %s", esp_err_to_name(err));
    }
    heap_caps_free(items);
    return err == ESP_OK;
}
static bool is_lesson_start_phrase(const char *text)
{
    if (!text) {
        return false;
    }
    return strstr(text, "开始泛听") != NULL || strstr(text, "开始跟读") != NULL ||
           strstr(text, "开始翻译") != NULL || strstr(text, "开始中英") != NULL ||
           strstr(text, "开始对话") != NULL;
}
bool Board_OnStartPhraseSentence(const char *text)
{
    if (!s_wait_start_phrase || !is_lesson_start_phrase(text)) {
        return false;
    }
    s_start_phrase_tts_begun = true;
    if (SDCard_Is_Playing()) {
        ESP_LOGW(TAG, "stop early lesson mp3 until start phrase finishes");
        SDCard_Stop_Play();
    }
    ESP_LOGI(TAG, "start phrase heard, wait until it finishes");
    return true;
}
static void begin_scheduled_talk_after_phrase(void)
{
    s_wait_start_phrase = false;
    s_start_phrase_tts_begun = false;
    s_listen_after_phrase = false;
    auto& app = Application::GetInstance();
    if (app.GetDeviceState() == kDeviceStateSpeaking) {
        s_wait_start_phrase = true;
        s_start_phrase_tts_begun = true;
        s_listen_after_phrase = true;
        return;
    }
    ESP_LOGI(TAG, "start phrase done, open listening");
    app.ReleaseHoldIdle();
    app.StartListeningAfterPrompt();
}
void Board_FlushPendingLessonPlayback(void)
{
    if (!s_wait_start_phrase || !s_start_phrase_tts_begun) {
        return;
    }
    if (s_listen_after_phrase) {
        begin_scheduled_talk_after_phrase();
        return;
    }
    s_wait_start_phrase = false;
    s_start_phrase_tts_begun = false;
    if (strcmp(s_current_task, "repeater") != 0 && strcmp(s_current_task, "shadowing") != 0) {
        return;
    }
    if (Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking) {
        s_wait_start_phrase = true;
        s_start_phrase_tts_begun = true;
        return;
    }
    ESP_LOGI(TAG, "start phrase done, play lesson");
    start_lesson_playback_locally(s_current_task, s_current_params);
}
void Board_NoteLessonStartDetectSent(void)
{
    if (!s_wait_start_phrase || s_start_detect_sent) {
        return;
    }
    s_start_detect_sent = true;
    s_wait_start_us = esp_timer_get_time();
    ESP_LOGI(TAG, "start detect sent, wait for phrase");
}
void Board_TrySendLessonStartDetect(void)
{
    if (!s_wait_start_phrase || s_start_detect_sent || s_start_detect_text.empty()) {
        return;
    }
    auto& app = Application::GetInstance();
    auto state = app.GetDeviceState();
    if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) {
        return;
    }
    if (app.IsAudioChannelConnected()) {
        if (app.SendListenDetectNow(s_start_detect_text)) {
            Board_NoteLessonStartDetectSent();
        }
        return;
    }
    if (state == kDeviceStateIdle && Board::GetInstance().IsNetworkReady()) {
        app.SendListenDetect(s_start_detect_text, true);
    }
}
void Board_PollPendingLessonPlayback(void)
{
    if (!s_wait_start_phrase) {
        return;
    }
    auto state = Application::GetInstance().GetDeviceState();
    if (!s_start_phrase_tts_begun) {
        if (!s_start_detect_sent) {
            Board_TrySendLessonStartDetect();
            return;
        }
        const int64_t elapsed_ms = (esp_timer_get_time() - s_wait_start_us) / 1000;
        if (elapsed_ms >= 15000 && state != kDeviceStateSpeaking && state != kDeviceStateConnecting) {
            if (s_listen_after_phrase) {
                ESP_LOGW(TAG, "start phrase timeout, open listening");
                s_start_phrase_tts_begun = true;
                begin_scheduled_talk_after_phrase();
            } else {
                ESP_LOGW(TAG, "start phrase timeout, play lesson locally");
                s_start_phrase_tts_begun = true;
                Board_FlushPendingLessonPlayback();
            }
        }
        return;
    }
    if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) {
        return;
    }
    Board_FlushPendingLessonPlayback();
}
static void return_to_chat_locally(const char *reason, const char *detect)
{
    ESP_LOGW(TAG, "%s, return to chat locally", reason ? reason : "skill event not sent");
    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);
    Application::GetInstance().ReconnectAfterLesson(detect ? detect : "");
}
static void dispatch_skill(const char *skill, const char *app_id, const char *task_id,
@@ -516,6 +1202,16 @@
                cJSON_Delete(params);
            }
        }
        const bool server_first = Board::GetInstance().IsNetworkReady() && source &&
            (strcmp(source, "voice") == 0 || strcmp(source, "mqtt_immediate") == 0 ||
             strcmp(source, "schedule") == 0);
        if (server_first) {
            if (name[0]) {
                Board_RememberRepeaterPlayback(name, speed, repeat < 1 ? 1 : repeat, power_off_after);
            }
            notify_server(app_id, task_id, source, true);
            return;
        }
        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);
@@ -526,12 +1222,6 @@
        }
        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));
@@ -555,19 +1245,40 @@
        return ESP_ERR_INVALID_ARG;
    }
    const char *src = (source && source[0]) ? source : "mqtt_immediate";
    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 ? tid : "");
        return ESP_ERR_NOT_FOUND;
    char resolved_tid[APP_TASK_ID_MAX];
    copy_id(resolved_tid, sizeof(resolved_tid), task_id, "conversation");
    const app_task_skill_map_t *spec = find_task_spec(app_id, resolved_tid);
    if (spec) {
        copy_id(resolved_tid, sizeof(resolved_tid), spec->task_id, resolved_tid);
    } else if (!is_safe_task_id(app_id) || !is_safe_task_id(resolved_tid)) {
        ESP_LOGW(TAG, "invalid app/task id: %s/%s", app_id, resolved_tid);
        return ESP_ERR_INVALID_ARG;
    } else {
        ESP_LOGI(TAG, "server-defined app_task %s/%s", app_id, resolved_tid);
    }
    tid = spec->task_id;
    const char *tid = resolved_tid;
    int new_priority = Board_AppTask_SourcePriority(src);
    if (new_priority < s_current_priority) {
    if (strcmp(src, "mqtt_immediate") != 0 && 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 (s_wait_start_phrase && strcmp(src, "schedule") == 0 &&
        strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0) {
        ESP_LOGI(TAG, "schedule %s/%s already waiting for start phrase", app_id, tid);
        return ESP_OK;
    }
    if (strcmp(src, "schedule") == 0 &&
        (strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0)) {
        const char *raw_params = (params_json && params_json[0]) ? params_json : "{}";
        if (!lesson_files_ready(tid, raw_params)) {
            ESP_LOGW(TAG, "schedule %s/%s files not on SD yet", app_id, tid);
            show_lesson_sync_alert();
            return ESP_ERR_NOT_FOUND;
        }
    }
    if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
@@ -586,16 +1297,62 @@
    copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
    copy_id(s_current_task, sizeof(s_current_task), tid, "conversation");
    copy_id(s_current_source, sizeof(s_current_source), src, "default_chat");
    char loaded_params[APP_TASK_JSON_MAX];
    loaded_params[0] = '\0';
    const char *raw = (params_json && params_json[0]) ? params_json : "{}";
    const bool lesson_task = strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0;
    if (strcmp(src, "voice") == 0 && lesson_task && !params_has_lesson_id(raw)) {
        if (Board_LoadLastLessonTask(tid, loaded_params, sizeof(loaded_params))) {
            raw = loaded_params;
            ESP_LOGI(TAG, "voice %s uses last dispatched lesson", tid);
        }
    }
    if (lesson_task && compact_last_lesson_json(raw, s_current_params, sizeof(s_current_params))) {
        Board_RememberLastLessonTask(tid, s_current_params);
    } else {
        strlcpy(s_current_params, raw, sizeof(s_current_params));
        if (lesson_task) {
            Board_RememberLastLessonTask(tid, s_current_params);
        }
    }
    const char *effective = s_current_params;
    s_current_priority = new_priority;
    Board_SavePendingTask(app_id, tid, params_json, src, NULL);
    s_wait_start_phrase = false;
    s_start_phrase_tts_begun = false;
    s_listen_after_phrase = false;
    Board_SavePendingTask(app_id, tid, effective, src, NULL);
    ESP_LOGI(TAG, "start app_task %s/%s source=%s", app_id, tid, src);
    if (!spec->skills[0]) {
        dispatch_skill(NULL, app_id, tid, params_json, src);
    if (strcmp(src, "schedule") == 0 && is_schedule_talk_task(tid)) {
        s_wait_start_phrase = true;
        s_start_phrase_tts_begun = false;
        s_start_detect_sent = false;
        s_listen_after_phrase = true;
        s_wait_start_us = esp_timer_get_time();
        Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
        notify_server(app_id, tid, src, true, true, false, true);
        return ESP_OK;
    }
    if (lesson_task && !lesson_files_ready(tid, effective)) {
        ESP_LOGW(TAG, "lesson files missing on SD, wait for sync %s/%s", app_id, tid);
        show_lesson_sync_alert();
    } else if (lesson_task && lesson_files_ready(tid, effective)) {
        s_wait_start_phrase = true;
        s_start_phrase_tts_begun = false;
        s_start_detect_sent = false;
        s_wait_start_us = esp_timer_get_time();
        Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
        notify_server(app_id, tid, src, false, true, true);
        return ESP_OK;
    }
    Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
    if (!spec || !spec->skills[0]) {
        dispatch_skill(NULL, app_id, tid, effective, src);
        return ESP_OK;
    }
    for (int i = 0; i < 4 && spec->skills[i]; ++i) {
        dispatch_skill(spec->skills[i], app_id, tid, params_json, src);
        dispatch_skill(spec->skills[i], app_id, tid, effective, src);
    }
    return ESP_OK;
}
@@ -614,7 +1371,7 @@
        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。
        // WS 在线时清单结束由 Server 切回 chat。WS 已断则下面本地兜底,避免一直停在泛听。
        const bool end_task = !shadowing && !repeater &&
            task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
@@ -632,15 +1389,26 @@
        const bool open_mic = !stopped && shadowing;
        // 听窗从课文一停就开始。不要等开麦/AFE 初始化后再上报 completed。
        if (!Application::GetInstance().SendListenDetectNow(detect)) {
            // WS 已断:释放 AFE 后重连,把 completed 补发给 Server 播结束语。
            if (repeater && !stopped) {
                return_to_chat_locally("repeater completed not sent", detect);
                Board_Schedule_OnNaturalEnd();
                return;
            }
            Application::GetInstance().SendListenDetect(detect, false);
        }
        if (open_mic) {
            Application::GetInstance().GetAudioService().ResetDecoder();
            Application::GetInstance().ResumeListeningAfterMp3Clip();
        } else if (repeater && !stopped) {
            // 清单已播完,马上要播「复读结束」:保持功放,不要再 ResetDecoder。
            // 与跟读清单结束相同:关 AFE/唤醒,等 Server 播「泛听结束」再回对话。
            Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
            Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
            Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
            Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
            Board_Schedule_OnNaturalEnd();
        } else if (repeater && stopped) {
            Board_Schedule_OnUserExit();
        } else if (!repeater) {
            Application::GetInstance().GetAudioService().ResetDecoder();
        }
@@ -682,48 +1450,36 @@
        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_to_chat_locally("playlist_completed not sent", detect);
            Board_Schedule_OnNaturalEnd();
            return;
        }
        Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
        Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
        Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
        Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
        Board_Schedule_OnNaturalEnd();
    });
}
void Board_ResolveBootOrRestore(bool from_alarm_wake)
{
    char app_id[APP_TASK_ID_MAX] = "chat";
    char task_id[APP_TASK_ID_MAX] = "conversation";
    char source[APP_TASK_SOURCE_MAX] = "default_chat";
    char params[APP_TASK_JSON_MAX] = "{}";
    bool has_pending = load_pending_fields(app_id, sizeof(app_id), task_id, sizeof(task_id),
                                           source, sizeof(source), params, sizeof(params));
    if (!has_pending) {
        Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
        Board_StartAppTask("chat", "conversation", "{}", "default_chat");
        return;
    if (!from_alarm_wake) {
        Board_Schedule_Reload();
        Board_Schedule_Recompute();
    }
    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;
    const int window = 180;
    if (Board_Schedule_IsDueNow(window)) {
        char app_id[APP_TASK_ID_MAX];
        char task_id[APP_TASK_ID_MAX];
        char params[APP_TASK_JSON_MAX] = "{}";
        if (Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id),
                                       params, sizeof(params))) {
            ESP_LOGI(TAG, "boot/restore due schedule %s/%s alarm=%d",
                     app_id, task_id, from_alarm_wake ? 1 : 0);
            Board_StartAppTask(app_id, task_id, params, "schedule");
            return;
        }
    }
    Board_StartAppTask("chat", "conversation", "{}", "default_chat");
@@ -736,7 +1492,7 @@
        return;
    }
    if (!Board::GetInstance().IsNetworkReady()) {
        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 复读机 to play locally");
        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 泛听 to play locally");
        return;
    }
    Application::GetInstance().SendListenDetect("[voice_entry]", true);
@@ -754,6 +1510,19 @@
    }
    if (Board_Is_Chat_Return_Command(command)) {
        s_current_priority = 0;
        auto &app = Application::GetInstance();
        if (is_chat_task(s_current_app, s_current_task) && !app.IsAudioChannelConnected()) {
            // 课文结束已本地回 chat。通道已断时再去 handshake 会卡「连接中」并关掉唤醒。
            app.Schedule([]() {
                auto &inner = Application::GetInstance();
                inner.AnnounceReturnedToChat();
                inner.GetAudioService().EnableWakeWordDetection(true);
                if (inner.GetDeviceState() == kDeviceStateConnecting) {
                    inner.SetDeviceState(kDeviceStateIdle);
                }
            });
            return true;
        }
        Board_StartAppTask("chat", "conversation", "{}", "voice");
        return true;
    }
@@ -766,7 +1535,7 @@
    }
    if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
        strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
        strstr(command, "开始复读") || strstr(command, "复读机")) {
        strstr(command, OFFLINE_START_REPEATER_WORD) || strstr(command, "打开泛听")) {
        if (s_current_priority >= 2) {
            return true;
        }
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h
@@ -11,6 +11,8 @@
int Board_AppTask_SourcePriority(const char *source);
esp_err_t Board_StartAppTask(const char *app_id, const char *task_id, const char *params_json, const char *source);
bool Board_LessonFilesReady(const char *task_id, const char *params_json);
bool Board_IsLessonPlaybackActive(void);
esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json, const char *source, const char *alarm_id);
void Board_ResolveBootOrRestore(bool from_alarm_wake);
void Board_EnterVoiceEntry(void);
@@ -22,12 +24,19 @@
unsigned Board_ShadowingListenWindowMs(unsigned duration_ms);
bool Board_IsShadowingTask(void);
bool Board_IsRepeaterTask(void);
bool Board_IsWaitingStartPhrase(void);
void Board_BindTaskFromMediaPath(const char *path);
void Board_SetActiveTask(const char *app_id, const char *task_id);
int Board_AppTask_CurrentPriority(void);
bool Board_ResolveRepeaterPlayback(char *name, size_t name_size, float *speed, int *repeat, bool *power_off_after);
void Board_RememberRepeaterPlayback(const char *name, float speed, int repeat, bool power_off_after);
void Board_RememberLastLessonTask(const char *task_id, const char *params_json);
bool Board_LoadLastLessonTask(const char *task_id, char *buf, size_t buf_size);
void Board_SetFinishTaskAfterMp3(bool finish);
void Board_FlushPendingLessonPlayback(void);
void Board_PollPendingLessonPlayback(void);
void Board_NoteLessonStartDetectSent(void);
bool Board_OnStartPhraseSentence(const char *text);
esp_err_t Board_EnsureAppLayout(const char *app_id, const char *task_id);
#ifdef __cplusplus
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc
@@ -30,7 +30,7 @@
typedef struct {
    int count;
    sdcard_mp3_playlist_item_t items[SDCARD_MP3_PLAYLIST_MAX];
    sdcard_mp3_playlist_item_t items[];
} mp3_player_task_arg_t;
static TaskHandle_t mp3_player_task_handle;
@@ -41,6 +41,7 @@
static volatile int mp3_repeat_left;
static TickType_t s_mp3_audible_started;
static SemaphoreHandle_t s_shadowing_listen_closed;
static volatile bool s_mp3_offline_wake_ok;
static int16_t *s_pcm_a = nullptr;
static int16_t *s_pcm_b = nullptr;
@@ -143,23 +144,110 @@
    return !mp3_stop_requested;
}
static void wait_for_sd_dma(void)
static bool wait_for_internal_sram(size_t need_bytes, int tries)
{
    for (int i = 0; i < 20; ++i) {
    for (int i = 0; i < tries; ++i) {
        size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
        if (largest >= 4096) {
            return;
        if (largest >= need_bytes) {
            return true;
        }
        ESP_LOGW(TAG, "wait SD DMA largest=%u try=%d", (unsigned)largest, i);
        ESP_LOGW(TAG, "wait SD DMA largest=%u need=%u try=%d",
                 (unsigned)largest, (unsigned)need_bytes, i);
        vTaskDelay(pdMS_TO_TICKS(40));
    }
    return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA) >= need_bytes;
}
static bool sd_internal_heap_ok_for_fopen(void)
{
    return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA) >= 4096 &&
           heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL) >= 2048;
}
static FILE *fopen_mp3_if_heap_ok(const char *path)
{
    wait_for_internal_sram(4096, 25);
    if (!sd_internal_heap_ok_for_fopen()) {
        ESP_LOGE(TAG, "refuse fopen, internal DMA heap too small: %s", path);
        return nullptr;
    }
    return fopen(path, "rb");
}
static void ensure_shadowing_listen_sem(void);
static void close_mp3_decode_resources(esp_audio_simple_dec_handle_t *decoder,
                                       esp_ae_rate_cvt_handle_t *rate_cvt,
                                       FILE **fp, uint8_t **file_buf)
{
    if (rate_cvt && *rate_cvt) {
        esp_ae_rate_cvt_close(*rate_cvt);
        *rate_cvt = nullptr;
    }
    if (decoder && *decoder) {
        esp_audio_simple_dec_close(*decoder);
        *decoder = nullptr;
    }
    if (fp && *fp) {
        fclose(*fp);
        *fp = nullptr;
    }
    if (file_buf && *file_buf) {
        heap_caps_free(*file_buf);
        *file_buf = nullptr;
    }
}
static void apply_stop_afe_for_sd(bool leave_listening)
{
    auto &app = Application::GetInstance();
    if (leave_listening) {
        app.HoldIdleForServerPrompt();
        if (app.GetDeviceState() == kDeviceStateSpeaking) {
            app.AbortSpeaking(kAbortReasonNone);
        }
        if (app.GetDeviceState() == kDeviceStateListening) {
            app.SetDeviceState(kDeviceStateIdle);
        }
    }
    auto &audio = app.GetAudioService();
    audio.EnableVoiceProcessing(false);
    audio.EnableWakeWordDetection(false);
    audio.ReleaseAfeRuntime();
}
static void stop_afe_for_sd_and_wait(bool leave_listening)
{
    s_mp3_offline_wake_ok = false;
    auto &app = Application::GetInstance();
    if (app.IsOnMainLoop()) {
        apply_stop_afe_for_sd(leave_listening);
        wait_for_internal_sram(4096, 40);
        return;
    }
    ensure_shadowing_listen_sem();
    xSemaphoreTake(s_shadowing_listen_closed, 0);
    app.Schedule([leave_listening]() {
        apply_stop_afe_for_sd(leave_listening);
        if (s_shadowing_listen_closed != nullptr) {
            xSemaphoreGive(s_shadowing_listen_closed);
        }
    });
    if (xSemaphoreTake(s_shadowing_listen_closed, pdMS_TO_TICKS(2500)) != pdTRUE) {
        ESP_LOGW(TAG, "pause wake for SD timeout");
    }
    wait_for_internal_sram(4096, 40);
}
static void pause_wake_for_sd(void)
{
    stop_afe_for_sd_and_wait(false);
}
static uint8_t *load_mp3_to_psram(const char *path, size_t *out_len)
{
    *out_len = 0;
    wait_for_sd_dma();
    FILE *fp = fopen(path, "rb");
    FILE *fp = fopen_mp3_if_heap_ok(path);
    if (!fp) {
        return nullptr;
    }
@@ -274,16 +362,6 @@
    return true;
}
static void pause_wake_for_sd(void)
{
    Application::GetInstance().Schedule([]() {
        auto &audio = Application::GetInstance().GetAudioService();
        audio.EnableVoiceProcessing(false);
        audio.EnableWakeWordDetection(false);
    });
    vTaskDelay(pdMS_TO_TICKS(40));
}
static void wait_shadowing_listen(unsigned duration_ms)
{
    unsigned wait_ms = Board_ShadowingListenWindowMs(duration_ms);
@@ -322,6 +400,7 @@
    if (xSemaphoreTake(s_shadowing_listen_closed, pdMS_TO_TICKS(2500)) != pdTRUE) {
        ESP_LOGW(TAG, "shadowing wait mic off timeout, force AFE stop");
        Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
        Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
    }
}
@@ -329,26 +408,26 @@
{
    mp3_player_task_handle = xTaskGetCurrentTaskHandle();
    mp3_player_task_arg_t *task_arg = static_cast<mp3_player_task_arg_t *>(arg);
    sdcard_mp3_playlist_item_t items[SDCARD_MP3_PLAYLIST_MAX];
    int count = task_arg->count;
    int count = task_arg ? task_arg->count : 0;
    if (count < 0) {
        count = 0;
    }
    if (count > SDCARD_MP3_PLAYLIST_MAX) {
        count = SDCARD_MP3_PLAYLIST_MAX;
    }
    if (count > 0) {
        memcpy(items, task_arg->items, sizeof(items[0]) * static_cast<size_t>(count));
    }
    bool power_off_after = count > 0 ? items[count - 1].power_off_after : false;
    heap_caps_free(task_arg);
    const sdcard_mp3_playlist_item_t *items = (task_arg && count > 0) ? task_arg->items : nullptr;
    bool power_off_after = (items != nullptr) ? items[count - 1].power_off_after : false;
    auto codec = Board::GetInstance().GetAudioCodec();
    if (!codec || count < 1) {
    if (!codec || count < 1 || items == nullptr) {
        ESP_LOGE(TAG, "audio codec unavailable or empty playlist");
        if (task_arg) {
            heap_caps_free(task_arg);
        }
        mp3_player_task_handle = nullptr;
        mp3_stop_requested = false;
        mp3_repeat_left = 0;
        s_mp3_offline_wake_ok = false;
        Board_OnSkillPlaybackFinished(true, false, 0);
        vTaskDelete(nullptr);
        return;
@@ -371,30 +450,38 @@
        ESP_LOGE(TAG, "no memory for MP3 playback");
    }
    s_mp3_offline_wake_ok = false;
    for (int item_i = 0; in_buf && out_buf && item_i < count && !mp3_stop_requested; ++item_i) {
    strlcpy(path, items[item_i].path, sizeof(path));
    float speed = SDCard_Clamp_MP3_Speed(items[item_i].speed);
    int repeat = items[item_i].repeat < 1 ? 1 : items[item_i].repeat;
    if (item_i > 0) {
        pause_wake_for_sd();
    }
    pause_wake_for_sd();
    size_t file_len = 0;
    uint8_t *file_buf = load_mp3_to_psram(path, &file_len);
    if (!file_buf) {
        pause_wake_for_sd();
        file_buf = load_mp3_to_psram(path, &file_len);
    }
    FILE *fp = nullptr;
    if (!file_buf) {
        wait_for_sd_dma();
        fp = fopen(path, "rb");
        fp = fopen_mp3_if_heap_ok(path);
    }
    if (!file_buf && !fp) {
        ESP_LOGE(TAG, "open MP3 failed: %s", path);
        continue;
    }
    if (!mp3_pause_requested) {
        Application::GetInstance().Schedule([]() {
            Application::GetInstance().ApplyMp3CommandOnlyMic();
        });
    // 先把文件读进 PSRAM,再开离线口令唤醒。反过来会把 SD DMA 需要的连续内部 SRAM 吃掉。
    if (file_buf) {
        s_mp3_offline_wake_ok = true;
        if (!mp3_pause_requested) {
            Application::GetInstance().Schedule([]() {
                Application::GetInstance().ApplyMp3CommandOnlyMic();
            });
        }
    } else {
        s_mp3_offline_wake_ok = false;
    }
    esp_audio_simple_dec_handle_t decoder = nullptr;
@@ -442,8 +529,7 @@
                        fp = nullptr;
                    }
                    vTaskDelay(pdMS_TO_TICKS(30));
                    wait_for_sd_dma();
                    fp = fopen(path, "rb");
                    fp = fopen_mp3_if_heap_ok(path);
                    if (!fp) {
                        ESP_LOGE(TAG, "reopen MP3 failed: %s", path);
                        break;
@@ -578,6 +664,11 @@
                unsigned duration_ms = s_mp3_audible_started
                    ? pdTICKS_TO_MS(xTaskGetTickCount() - s_mp3_audible_started)
                    : 0;
                // 听窗要开 AFE。先关掉 MP3 解码器,避免和录音抢内部 SRAM。
                close_mp3_decode_resources(&decoder, &rate_cvt_handle, &fp, &file_buf);
                audio_info_checked = false;
                mp3_sample_rate = 0;
                mp3_channels = 0;
                Board_OnShadowingClipFinished(duration_ms);
                wait_shadowing_listen(duration_ms);
                wait_shadowing_mic_off();
@@ -585,22 +676,7 @@
            }
        }
    if (rate_cvt_handle) {
        esp_ae_rate_cvt_close(rate_cvt_handle);
        rate_cvt_handle = nullptr;
    }
    if (decoder) {
        esp_audio_simple_dec_close(decoder);
        decoder = nullptr;
    }
    if (fp) {
        fclose(fp);
        fp = nullptr;
    }
    if (file_buf) {
        heap_caps_free(file_buf);
        file_buf = nullptr;
    }
    close_mp3_decode_resources(&decoder, &rate_cvt_handle, &fp, &file_buf);
    ESP_LOGI(TAG, "MP3 item %s: %s", mp3_stop_requested ? "stopped" : "finished", path);
    }
@@ -622,10 +698,15 @@
    unsigned duration_ms = s_mp3_audible_started
        ? pdTICKS_TO_MS(xTaskGetTickCount() - s_mp3_audible_started)
        : pdTICKS_TO_MS(xTaskGetTickCount() - play_started);
    if (task_arg) {
        heap_caps_free(task_arg);
        task_arg = nullptr;
    }
    mp3_player_task_handle = nullptr;
    mp3_stop_requested = false;
    mp3_pause_requested = false;
    mp3_repeat_left = 0;
    s_mp3_offline_wake_ok = false;
    if (should_power_off) {
        Application::GetInstance().RequestPowerOffStandby();
    }
@@ -666,6 +747,7 @@
        return ESP_ERR_INVALID_ARG;
    }
    if (count > SDCARD_MP3_PLAYLIST_MAX) {
        ESP_LOGW(TAG, "playlist truncated incoming=%d max=%d", count, SDCARD_MP3_PLAYLIST_MAX);
        count = SDCARD_MP3_PLAYLIST_MAX;
    }
@@ -679,7 +761,10 @@
    }
    mp3_player_task_arg_t *arg = static_cast<mp3_player_task_arg_t *>(
        heap_caps_calloc(1, sizeof(mp3_player_task_arg_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
        heap_caps_calloc(1,
                         sizeof(mp3_player_task_arg_t) +
                             sizeof(sdcard_mp3_playlist_item_t) * static_cast<size_t>(count),
                         MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    if (!arg) {
        return ESP_ERR_NO_MEM;
    }
@@ -748,6 +833,16 @@
    });
}
extern "C" void SDCard_PreparePlaybackAndWait(void)
{
    stop_afe_for_sd_and_wait(true);
}
extern "C" bool SDCard_Mp3OfflineWakeAllowed(void)
{
    return s_mp3_offline_wake_ok;
}
extern "C" bool SDCard_Is_Playing(void)
{
    return mp3_player_task_handle != nullptr;
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
@@ -4,27 +4,32 @@
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include <cJSON.h>
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_mac.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mqtt_client.h"
#include "system.h"
#include "app_task.h"
#include "parent_schedule.h"
#define BOARD_MQTT_KEEPALIVE_SEC 120
#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 30000
#define BOARD_MQTT_RECONNECT_DELAY_MS 30000
#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 60000
#define BOARD_MQTT_RECONNECT_RETRY_MS 5000
#define BOARD_MQTT_RECONNECT_MIN_INTERNAL_HEAP 49152
#define BOARD_MQTT_NETWORK_TIMEOUT_MS 15000
#define BOARD_MQTT_FINAL_REPORT_WAIT_MS 10000
#define BOARD_MQTT_VOICE_YIELD_WAIT_MS 120000
#define BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US 15000000
#define BOARD_MQTT_SYNC_DISPLAY_INTERVAL_US 2000000
#define BOARD_MQTT_TASK_PRIORITY 5
#define BOARD_MQTT_RECONNECT_TASK_PRIORITY 4
#define BOARD_MQTT_TOPIC_MAX_LEN 128
#define BOARD_MQTT_PAYLOAD_MAX_LEN 8192
#define BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN 256
@@ -47,13 +52,19 @@
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,
@@ -68,11 +79,28 @@
static volatile bool s_download_voice_yield_requested = false;
static volatile bool s_download_cancel_requested = false;
static portMUX_TYPE s_task_lock = portMUX_INITIALIZER_UNLOCKED;
static TaskHandle_t s_reconnect_task_handle = NULL;
static TaskHandle_t s_state_task_handle = NULL;
static TaskHandle_t s_playlist_task_handle = NULL;
static volatile bool s_playlist_stop_requested = false;
static uint32_t s_state_sequence = 0;
typedef struct {
    bool active;
    uint32_t gen;
    char app_id[32];
    char task_id[32];
    char source[24];
    char *params_json;
} board_mqtt_queued_app_task_t;
static board_mqtt_queued_app_task_t s_queued_app_task = {0};
static uint32_t s_queued_app_gen = 0;
typedef struct {
    bool success;
    int error_code;
    const char *info;
} board_mqtt_action_result_t;
static void BoardMqtt_HandleCommand(const char *topic, int topic_len, const char *data, int data_len);
static bool BoardMqtt_CopyString(char *dest, size_t dest_size, const char *src);
@@ -82,6 +110,10 @@
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];
@@ -98,12 +130,6 @@
    BOARD_MQTT_TOPIC_CTL,
    BOARD_MQTT_TOPIC_TASK,
} board_mqtt_topic_type_t;
typedef struct {
    bool success;
    int error_code;
    const char *info;
} board_mqtt_action_result_t;
static board_mqtt_action_result_t BoardMqtt_HandleUnsetAlarm(cJSON *payload);
@@ -126,6 +152,8 @@
    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;
@@ -160,7 +188,9 @@
    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)
@@ -175,6 +205,11 @@
    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)
@@ -300,9 +335,9 @@
    return (long)now;
}
static void BoardMqtt_PublishJson(const char *topic, cJSON *json, int qos)
static void BoardMqtt_PublishJsonTo(const char *topic, cJSON *json, int qos, int retain)
{
    if (!s_mqtt_client || !s_mqtt_connected || !topic || !json) {
    if (!s_mqtt_client || !s_mqtt_connected || !topic || !topic[0] || !json) {
        return;
    }
@@ -312,7 +347,7 @@
        return;
    }
    int msg_id = esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, qos, 0);
    int msg_id = esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, qos, retain);
    if (msg_id >= 0) {
        ESP_LOGI(TAG, "Published MQTT message, topic=%s, msg_id=%d", topic, msg_id);
    } else {
@@ -321,8 +356,17 @@
    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);
@@ -359,11 +403,18 @@
    cJSON_AddNumberToObject(payload, "cpu", Board_Get_CPU_Usage());
    cJSON_AddNumberToObject(payload, "mem", mem_percent);
    cJSON_AddNumberToObject(payload, "battery", Battery_Get_Value());
    cJSON_AddTrueToObject(payload, "online");
    cJSON_AddStringToObject(payload, "net", wifi_connected ? "wifi" : "offline");
    cJSON_AddStringToObject(payload, "mode", task_type != BOARD_MQTT_TASK_NONE ? "task" : (Board_Is_Voice_Session_Active() ? "voice" : "idle"));
    if (s_mac_id[0]) {
        cJSON_AddStringToObject(payload, "mac", s_mac_id);
    }
    cJSON_AddItemToObject(root, "payload", payload);
    BoardMqtt_PublishJson(s_rpt_state_topic, root, BOARD_MQTT_QOS);
    BoardMqtt_PublishJsonTo(s_rpt_state_topic, root, BOARD_MQTT_QOS, 1);
    if (s_rpt_state_mac_topic[0] && strcmp(s_rpt_state_mac_topic, s_rpt_state_topic) != 0) {
        BoardMqtt_PublishJsonTo(s_rpt_state_mac_topic, root, BOARD_MQTT_QOS, 1);
    }
    cJSON_Delete(root);
}
@@ -485,6 +536,23 @@
    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) {
@@ -583,6 +651,43 @@
    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;
@@ -610,6 +715,88 @@
    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 ||
@@ -617,6 +804,7 @@
        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;
}
@@ -1229,16 +1417,6 @@
    if (err != ESP_OK) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
    }
    cJSON *app_id = cJSON_GetObjectItem(payload, "app_id");
    cJSON *task_id = cJSON_GetObjectItem(payload, "task_id");
    if (cJSON_IsString(app_id) && app_id->valuestring && app_id->valuestring[0]) {
        const char *tid = (cJSON_IsString(task_id) && task_id->valuestring) ? task_id->valuestring : "";
        cJSON *params = cJSON_GetObjectItem(payload, "params");
        char *params_json = params ? cJSON_PrintUnformatted(params) : NULL;
        Board_SavePendingTask(app_id->valuestring, tid, params_json, "schedule", id->valuestring);
        free(params_json);
    }
    return BoardMqtt_ResultOk("alarm updated");
}
@@ -1331,7 +1509,8 @@
    if (!force && progress == progress_ctx->last_progress) {
        return true;
    }
    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
    if (!force && progress_ctx->last_publish_us > 0
        && now - progress_ctx->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
        return true;
    }
@@ -1352,7 +1531,7 @@
        ESP_LOGI(TAG, "Cancel manifest download by request");
        return false;
    }
    if (Board_Is_Voice_Session_Active()) {
    if (!progress_ctx->ignore_voice_yield && Board_Is_Voice_Session_Active()) {
        s_download_voice_yield_requested = true;
        ESP_LOGI(TAG, "Cancel manifest download for voice session");
        return false;
@@ -1366,11 +1545,21 @@
    int aggregate_progress = (int)(((progress_ctx->asset_index * 100) + (size_t)progress) / progress_ctx->asset_count);
    int64_t now = esp_timer_get_time();
    bool force = progress <= 0 || progress >= 100 || aggregate_progress >= 100;
    bool show = progress_ctx->last_display_us == 0
        || now - progress_ctx->last_display_us >= BOARD_MQTT_SYNC_DISPLAY_INTERVAL_US
        || aggregate_progress >= 100
        || progress <= 0;
    if (show) {
        progress_ctx->last_display_us = now;
        BoardMqtt_ShowLessonSyncProgress(aggregate_progress, progress_ctx->asset_index, progress_ctx->asset_count);
    }
    bool force = progress >= 100 || progress_ctx->last_progress < 0 || aggregate_progress >= 100;
    if (!force && aggregate_progress == progress_ctx->last_progress) {
        return true;
    }
    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
    if (!force && progress_ctx->last_publish_us > 0
        && now - progress_ctx->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
        return true;
    }
@@ -1395,7 +1584,8 @@
    if (!force && progress == job->last_progress) {
        return;
    }
    if (!force && job->last_publish_us > 0 && now - job->last_publish_us < 2000000) {
    if (!force && job->last_publish_us > 0
        && now - job->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
        return;
    }
@@ -1467,6 +1657,24 @@
    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};
@@ -1475,6 +1683,7 @@
    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));
@@ -1485,6 +1694,11 @@
        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) {
@@ -1497,11 +1711,17 @@
            .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;
@@ -1534,13 +1754,26 @@
        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);
@@ -1558,6 +1791,8 @@
    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};
@@ -1608,6 +1843,7 @@
    }
    s_download_voice_yield_requested = false;
    Board_SetLessonDownloadBoost(false);
    BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD);
    Board_Release_Sleep_Inhibit();
    free(job);
@@ -1641,6 +1877,10 @@
    }
    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)) {
@@ -1724,11 +1964,25 @@
{
    cJSON *course_id = cJSON_GetObjectItem(payload, "course_id");
    cJSON *assets = cJSON_GetObjectItem(payload, "assets");
    cJSON *files = cJSON_GetObjectItem(payload, "files");
    cJSON *base_url = cJSON_GetObjectItem(payload, "base_url");
    bool has_assets = cJSON_IsArray(assets) && cJSON_GetArraySize(assets) > 0;
    bool has_files = cJSON_IsString(base_url) && base_url->valuestring && base_url->valuestring[0] != '\0' &&
                     cJSON_IsArray(files) && cJSON_GetArraySize(files) > 0;
    if (!cJSON_IsString(course_id) || !BoardMqtt_IsSafeCourseId(course_id->valuestring) ||
        !cJSON_IsArray(assets) || cJSON_GetArraySize(assets) <= 0) {
        (!has_assets && !has_files)) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest payload");
    }
    BoardMqtt_StopPlaylistTask();
    if (has_files && !BoardMqtt_HasPrefix(base_url->valuestring, "http://") &&
        !BoardMqtt_HasPrefix(base_url->valuestring, "https://")) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest base_url");
    }
    if (has_files && !BoardMqtt_PackageCached(files)) {
        Board_Show_Alert("正在同步", "正在同步课文,请稍候", "cloud_arrow_down");
    }
    if (s_bind_manifest_to_queue) {
        BoardMqtt_StopPlaylistTask();
    }
    bool replace_existing_download = false;
    board_mqtt_task_type_t current_type = BoardMqtt_GetTaskType();
@@ -1751,13 +2005,14 @@
        return BoardMqtt_ResultFail((code), (message)); \
    } while (0)
    int asset_count_int = cJSON_GetArraySize(assets);
    int asset_count_int = has_assets ? cJSON_GetArraySize(assets) : cJSON_GetArraySize(files);
    size_t source_asset_count = (size_t)asset_count_int;
    if (source_asset_count > SIZE_MAX / 2) {
    if (has_assets && source_asset_count > SIZE_MAX / 2) {
        BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large");
    }
    size_t max_asset_count = source_asset_count * 2;
    if (max_asset_count > (SIZE_MAX - sizeof(board_mqtt_manifest_job_t)) / sizeof(board_mqtt_manifest_asset_t)) {
    size_t max_asset_count = has_assets ? source_asset_count * 2 : source_asset_count;
    if (max_asset_count == 0 ||
        max_asset_count > (SIZE_MAX - sizeof(board_mqtt_manifest_job_t)) / sizeof(board_mqtt_manifest_asset_t)) {
        BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large");
    }
@@ -1774,6 +2029,8 @@
    }
    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); \
@@ -1787,73 +2044,103 @@
    }
    size_t index = 0;
    cJSON *asset = NULL;
    cJSON_ArrayForEach(asset, assets) {
        cJSON *url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "url") : NULL;
        cJSON *target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "target_path") : NULL;
        cJSON *md5 = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "md5") : NULL;
        if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset");
    if (has_files && !has_assets) {
        cJSON *file = NULL;
        cJSON_ArrayForEach(file, files) {
            if (!cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest file path");
            }
            char business_path[SDCARD_PATH_MAX_LEN];
            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), file->valuestring)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
            }
            char sd_path[SDCARD_PATH_MAX_LEN];
            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
            }
            if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
            }
            char joined_url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
            if (!BoardMqtt_JoinDownloadUrl(joined_url, sizeof(joined_url), base_url->valuestring, business_path)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset url too long");
            }
            board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), joined_url) ||
                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
            }
            ++index;
        }
        if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset url");
        }
        if (!BoardMqtt_ValidateOptionalMd5(md5)) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset md5");
        }
    } else {
        cJSON *asset = NULL;
        cJSON_ArrayForEach(asset, assets) {
            cJSON *url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "url") : NULL;
            cJSON *target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "target_path") : NULL;
            cJSON *md5 = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "md5") : NULL;
            if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset");
            }
            if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset url");
            }
            if (!BoardMqtt_ValidateOptionalMd5(md5)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset md5");
            }
        char business_path[SDCARD_PATH_MAX_LEN];
        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
        }
            char business_path[SDCARD_PATH_MAX_LEN];
            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
            }
        char sd_path[SDCARD_PATH_MAX_LEN];
        if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
        }
        if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
        }
            char sd_path[SDCARD_PATH_MAX_LEN];
            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
            }
            if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
            }
        board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
        if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), url->valuestring) ||
            !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path) ||
            !BoardMqtt_CopyOptionalMd5(job_asset->md5, sizeof(job_asset->md5), md5)) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
        }
        ++index;
            board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), url->valuestring) ||
                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path) ||
                !BoardMqtt_CopyOptionalMd5(job_asset->md5, sizeof(job_asset->md5), md5)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
            }
            ++index;
        cJSON *cover_url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_url") : NULL;
        cJSON *cover_target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_target_path") : NULL;
        if (!cJSON_IsString(cover_url) || !cJSON_IsString(cover_target_path) ||
            cover_url->valuestring[0] == '\0' || cover_target_path->valuestring[0] == '\0') {
            continue;
        }
        if (!BoardMqtt_HasPrefix(cover_url->valuestring, "http://") && !BoardMqtt_HasPrefix(cover_url->valuestring, "https://")) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover url");
        }
        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), cover_target_path->valuestring)) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
        }
        if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
        }
        const char *cover_dot = strrchr(sd_path, '.');
        if (!cover_dot || strcasecmp(cover_dot, ".jpg") != 0) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only jpg cover assets are supported");
        }
            cJSON *cover_url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_url") : NULL;
            cJSON *cover_target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_target_path") : NULL;
            if (!cJSON_IsString(cover_url) || !cJSON_IsString(cover_target_path) ||
                cover_url->valuestring[0] == '\0' || cover_target_path->valuestring[0] == '\0') {
                continue;
            }
            if (!BoardMqtt_HasPrefix(cover_url->valuestring, "http://") && !BoardMqtt_HasPrefix(cover_url->valuestring, "https://")) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover url");
            }
            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), cover_target_path->valuestring)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
            }
            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
            }
            const char *cover_dot = strrchr(sd_path, '.');
            if (!cover_dot || strcasecmp(cover_dot, ".jpg") != 0) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only jpg cover assets are supported");
            }
        job_asset = &job->assets[index];
        if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), cover_url->valuestring) ||
            !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest cover field too long");
            job_asset = &job->assets[index];
            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), cover_url->valuestring) ||
                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest cover field too long");
            }
            ++index;
        }
        ++index;
    }
    job->asset_count = index;
    if (SDCard_Is_Playing()) {
        ESP_LOGI(TAG, "Stop MP3 playback before MQTT manifest download");
    if (job->start_queued_app && SDCard_Is_Playing()) {
        ESP_LOGI(TAG, "Stop MP3 playback before PRELOAD for queued AI task");
        SDCard_Stop_Play();
    }
@@ -1868,7 +2155,7 @@
    return BoardMqtt_ResultOk("manifest accepted");
}
static board_mqtt_action_result_t BoardMqtt_HandleStartAppTask(cJSON *payload)
static board_mqtt_action_result_t BoardMqtt_HandleStartAppTask(const char *req_id, cJSON *payload)
{
    if (!payload) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload");
@@ -1877,24 +2164,77 @@
    cJSON *task_id = cJSON_GetObjectItem(payload, "task_id");
    cJSON *source = cJSON_GetObjectItem(payload, "source");
    cJSON *params = cJSON_GetObjectItem(payload, "params");
    cJSON *package = cJSON_GetObjectItem(payload, "package");
    if (!cJSON_IsString(app_id) || !app_id->valuestring || app_id->valuestring[0] == '\0') {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing app_id");
    }
    const char *tid = (cJSON_IsString(task_id) && task_id->valuestring) ? task_id->valuestring : "";
    const char *src = (cJSON_IsString(source) && source->valuestring) ? source->valuestring : "mqtt_immediate";
    char *params_json = params ? cJSON_PrintUnformatted(params) : NULL;
    esp_err_t err = Board_StartAppTask(app_id->valuestring, tid, params_json, src);
    const bool lesson = BoardMqtt_IsLessonAppTask(app_id->valuestring, tid);
    if (!lesson || !cJSON_IsObject(package)) {
        if (lesson && !Board_LessonFilesReady(tid, params_json)) {
            ESP_LOGW(TAG, "lesson files missing on SD, start after sync %s/%s",
                     app_id->valuestring, tid);
            Board_Show_Alert("正在同步", "正在同步音频,请稍等", "cloud_arrow_down");
        }
        BoardMqtt_ClearQueuedAppTask();
        esp_err_t err = Board_StartAppTask(app_id->valuestring, tid, params_json, src);
        free(params_json);
        if (err == ESP_ERR_NOT_FOUND) {
            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "unknown app or task");
        }
        if (err == ESP_ERR_INVALID_ARG) {
            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid app or task id");
        }
        if (err == ESP_ERR_INVALID_STATE) {
            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "preempted by higher priority task");
        }
        if (err != ESP_OK) {
            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
        }
        return BoardMqtt_ResultOk("app task started");
    }
    cJSON *files = cJSON_GetObjectItem(package, "files");
    if (BoardMqtt_PackageCached(files)) {
        ESP_LOGI(TAG, "package cached, READY %s/%s", app_id->valuestring, tid);
        BoardMqtt_ClearQueuedAppTask();
        esp_err_t err = Board_StartAppTask(app_id->valuestring, tid, params_json, src);
        free(params_json);
        if (err == ESP_ERR_NOT_FOUND) {
            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "unknown app or task");
        }
        if (err == ESP_ERR_INVALID_ARG) {
            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid app or task id");
        }
        if (err == ESP_ERR_INVALID_STATE) {
            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "preempted by higher priority task");
        }
        if (err != ESP_OK) {
            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
        }
        return BoardMqtt_ResultOk("app task started");
    }
    if (!BoardMqtt_QueueAppTask(app_id->valuestring, tid, src, params_json)) {
        free(params_json);
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to queue app task");
    }
    free(params_json);
    if (err == ESP_ERR_NOT_FOUND) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "unknown app or task");
    ESP_LOGI(TAG, "PRELOAD package then run %s/%s", app_id->valuestring, tid);
    Board_HoldIdleForServerPrompt();
    Board_Show_Alert("正在同步", "正在同步课文,请稍候", "cloud_arrow_down");
    s_bind_manifest_to_queue = true;
    board_mqtt_action_result_t preload = BoardMqtt_HandleUpdateManifest(req_id, package);
    s_bind_manifest_to_queue = false;
    if (!preload.success) {
        Board_ReleaseHoldIdle();
        BoardMqtt_ClearQueuedAppTask();
        Board_Show_Alert("同步失败", preload.info ? preload.info : "课文下载失败", "sad");
    }
    if (err == ESP_ERR_INVALID_STATE) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "preempted by higher priority task");
    }
    if (err != ESP_OK) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
    }
    return BoardMqtt_ResultOk("app task started");
    return preload.success ? BoardMqtt_ResultOk("preload queued") : preload;
}
static board_mqtt_action_result_t BoardMqtt_HandleEnsureAppDirs(cJSON *payload)
@@ -1929,6 +2269,29 @@
    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);
@@ -1943,17 +2306,20 @@
    if (strcmp(action, "UPDATE_MANIFEST") == 0 || strcmp(action, "SYNC_COURSE_TASK") == 0) return BoardMqtt_HandleUpdateManifest(req_id, payload);
    if (strcmp(action, "DOWNLOAD") == 0) return BoardMqtt_HandleDownload(req_id, payload);
    if (strcmp(action, "OTA_UPDATE") == 0) return BoardMqtt_HandleOtaUpdate(req_id, payload);
    if (strcmp(action, "START_APP_TASK") == 0) return BoardMqtt_HandleStartAppTask(payload);
    if (strcmp(action, "START_APP_TASK") == 0) return BoardMqtt_HandleStartAppTask(req_id, payload);
    if (strcmp(action, "SYNC_SCHEDULE") == 0) return BoardMqtt_HandleSyncSchedule(payload);
    if (strcmp(action, "ENSURE_APP_DIRS") == 0) return BoardMqtt_HandleEnsureAppDirs(payload);
    return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "unsupported action");
}
static board_mqtt_topic_type_t BoardMqtt_GetTopicType(const char *topic)
{
    if (strcmp(topic, s_cmd_ctl_topic) == 0) {
    if (strcmp(topic, s_cmd_ctl_topic) == 0 ||
        (s_cmd_ctl_mac_topic[0] && strcmp(topic, s_cmd_ctl_mac_topic) == 0)) {
        return BOARD_MQTT_TOPIC_CTL;
    }
    if (strcmp(topic, s_cmd_task_topic) == 0) {
    if (strcmp(topic, s_cmd_task_topic) == 0 ||
        (s_cmd_task_mac_topic[0] && strcmp(topic, s_cmd_task_mac_topic) == 0)) {
        return BOARD_MQTT_TOPIC_TASK;
    }
    return BOARD_MQTT_TOPIC_UNKNOWN;
@@ -2074,7 +2440,9 @@
        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_TOPIC_MISMATCH, "invalid command topic");
    } else if (!cJSON_IsString(version) || strcmp(version->valuestring, "1.0") != 0) {
        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.version");
    } else if (!cJSON_IsString(device_id) || strcmp(device_id->valuestring, s_device_id) != 0) {
    } else if (!cJSON_IsString(device_id) ||
               (strcmp(device_id->valuestring, s_device_id) != 0 &&
                (s_mac_id[0] == '\0' || strcasecmp(device_id->valuestring, s_mac_id) != 0))) {
        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.device_id");
    } else if (!cJSON_IsNumber(timestamp)) {
        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.timestamp");
@@ -2095,49 +2463,45 @@
    free(payload_text);
}
static void BoardMqtt_ReconnectTask(void *arg)
static void BoardMqtt_TryReconnect(void)
{
    (void)arg;
    while (s_mqtt_client && !s_mqtt_connected) {
        if (Board_Is_Voice_Session_Active()) {
            ESP_LOGI(TAG, "Delay local MQTT reconnect while voice session is active");
            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_DELAY_MS));
            continue;
        }
        ESP_LOGI(TAG, "Reconnecting local MQTT client");
        esp_err_t err = esp_mqtt_client_reconnect(s_mqtt_client);
        if (err != ESP_OK) {
            ESP_LOGW(TAG, "Local MQTT reconnect failed: %s", esp_err_to_name(err));
            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_DELAY_MS));
            continue;
        }
        break;
    if (!s_mqtt_client || s_mqtt_connected) {
        return;
    }
    s_reconnect_task_handle = NULL;
    vTaskDelete(NULL);
    size_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
    if (internal_free < BOARD_MQTT_RECONNECT_MIN_INTERNAL_HEAP) {
        ESP_LOGW(TAG, "Skip MQTT reconnect, internal heap=%u", (unsigned)internal_free);
        return;
    }
    ESP_LOGI(TAG, "Reconnecting local MQTT client");
    esp_err_t err = esp_mqtt_client_reconnect(s_mqtt_client);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "Local MQTT reconnect failed: %s", esp_err_to_name(err));
    }
}
static void BoardMqtt_ScheduleReconnect(void)
{
    if (!s_mqtt_client || s_reconnect_task_handle) {
        return;
    }
    BaseType_t ret = xTaskCreate(BoardMqtt_ReconnectTask, "mqtt_reconnect", 4096, NULL, BOARD_MQTT_RECONNECT_TASK_PRIORITY, &s_reconnect_task_handle);
    if (ret != pdPASS) {
        ESP_LOGE(TAG, "Failed to create local MQTT reconnect task");
        s_reconnect_task_handle = NULL;
    }
    // 断连回调里不要再开 4KB 任务:再见后加载唤醒词时内部 SRAM 很紧,
    // xTaskCreate 失败后 WiFi MODEM sleep 还会在 phy_track_pll_init 里 abort。
    ESP_LOGW(TAG, "MQTT disconnected, retry from state task");
}
static void BoardMqtt_StateTask(void *arg)
{
    (void)arg;
    while (s_mqtt_client) {
        vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
        if (Board_Is_Audio_Busy()) {
        if (s_mqtt_connected) {
            if (!BoardMqtt_IsDownloadTask(BoardMqtt_GetTaskType())) {
                BoardMqtt_PublishState();
            }
            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
            continue;
        }
        BoardMqtt_PublishState();
        vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_RETRY_MS));
        if (!s_mqtt_connected) {
            BoardMqtt_TryReconnect();
        }
    }
    s_state_task_handle = NULL;
    vTaskDelete(NULL);
@@ -2175,9 +2539,14 @@
    case MQTT_EVENT_CONNECTED:
        s_mqtt_connected = true;
        ESP_LOGI(TAG, "MQTT business server connected, host=%s, port=%d, username=%s", BOARD_MQTT_BROKER_HOST, BOARD_MQTT_BROKER_PORT, BOARD_MQTT_USERNAME);
        ESP_LOGI(TAG, "MQTT command topics ready, ctl=%s, task=%s", s_cmd_ctl_topic, s_cmd_task_topic);
        ESP_LOGI(TAG, "MQTT command topics ready, ctl=%s, task=%s, mac_ctl=%s",
                 s_cmd_ctl_topic, s_cmd_task_topic, s_cmd_ctl_mac_topic);
        BoardMqtt_Subscribe(s_cmd_ctl_topic);
        BoardMqtt_Subscribe(s_cmd_task_topic);
        if (s_cmd_ctl_mac_topic[0] && strcmp(s_cmd_ctl_mac_topic, s_cmd_ctl_topic) != 0) {
            BoardMqtt_Subscribe(s_cmd_ctl_mac_topic);
            BoardMqtt_Subscribe(s_cmd_task_mac_topic);
        }
        BoardMqtt_PublishLog("MQTT connected");
        BoardMqtt_PublishState();
        break;
@@ -2226,6 +2595,23 @@
        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;
@@ -2269,6 +2655,11 @@
        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;
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h
@@ -11,6 +11,7 @@
esp_err_t BoardMqtt_Start(const char *client_id);
void BoardMqtt_Stop(void);
bool BoardMqtt_IsConnected(void);
bool Board_IsLessonSyncing(void);
bool BoardMqtt_CancelDownload(void);
void BoardMqtt_PublishSttEvent(const char *text);
void BoardMqtt_ResumePlaylistIfUnfinished(void);
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.cc
New file
@@ -0,0 +1,901 @@
#include "parent_schedule.h"
#include "app_task.h"
#include "application.h"
#include "system.h"
#include <cJSON.h>
#include <esp_log.h>
#include <esp_timer.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>
#define TAG "parent_sched"
#define SCHEDULE_PATH MOUNT_POINT "/.parent_schedule.json"
#define SCHEDULE_MAX_TASKS 100
#define SCHEDULE_ID_MAX 40
#define SCHEDULE_APP_MAX 32
#define SCHEDULE_PARAMS_MAX 384
#define SCHEDULE_TYPE_DAILY 0
#define SCHEDULE_TYPE_HOURLY 1
#define SCHEDULE_TYPE_INTERVAL 2
#define SCHEDULE_END_DEFAULT 0
#define SCHEDULE_END_CHAT 1
#define SCHEDULE_END_POWER_OFF 2
#define SCHEDULE_DUE_WINDOW_SEC 180
typedef struct {
    char id[SCHEDULE_ID_MAX];
    char app_id[SCHEDULE_APP_MAX];
    char task_id[SCHEDULE_APP_MAX];
    char params_json[SCHEDULE_PARAMS_MAX];
    uint8_t enabled;
    uint8_t type;
    uint8_t hour;
    uint8_t min;
    uint8_t sec;
    uint8_t weekdays;
    uint8_t hourly_minute;
    uint16_t every_minutes;
} sched_task_t;
static sched_task_t s_tasks[SCHEDULE_MAX_TASKS];
static int s_task_count = 0;
static bool s_loaded = false;
static char s_next_id[SCHEDULE_ID_MAX];
static char s_next_app[SCHEDULE_APP_MAX];
static char s_next_task[SCHEDULE_APP_MAX];
static char s_next_params[SCHEDULE_PARAMS_MAX];
static time_t s_next_unix = 0;
static bool s_next_valid = false;
static bool s_occupying = false;
static time_t s_busy_until = 0;
static int s_on_complete = SCHEDULE_END_DEFAULT;
static bool s_deferred_due = false;
static bool s_fire_queued = false;
static bool s_wait_lesson_files = false;
static bool s_files_poll_queued = false;
static time_t s_wait_lesson_files_since = 0;
#define CLAIMED_SLOT_MAX 16
typedef struct {
    char id[SCHEDULE_ID_MAX];
    time_t unix_time;
} claimed_slot_t;
static claimed_slot_t s_claimed_slots[CLAIMED_SLOT_MAX];
static int s_claimed_count = 0;
static time_t s_last_started_unix = 0;
static char s_pending_app[SCHEDULE_APP_MAX];
static char s_pending_task[SCHEDULE_APP_MAX];
static char s_pending_params[SCHEDULE_PARAMS_MAX];
static bool s_duration_fired = false;
static esp_timer_handle_t s_duration_timer = NULL;
static bool slot_already_claimed(const sched_task_t *task, time_t cand);
static uint8_t tm_wday_to_repeat_bit(int tm_wday)
{
    switch (tm_wday) {
    case 1: return ALARM_REPEAT_MON;
    case 2: return ALARM_REPEAT_TUE;
    case 3: return ALARM_REPEAT_WED;
    case 4: return ALARM_REPEAT_THU;
    case 5: return ALARM_REPEAT_FRI;
    case 6: return ALARM_REPEAT_SAT;
    default: return ALARM_REPEAT_SUN;
    }
}
static bool parse_hhmmss(const char *value, uint8_t *hour, uint8_t *min, uint8_t *sec)
{
    int h = 0, m = 0, s = 0;
    char tail = 0;
    if (!value) {
        return false;
    }
    if (sscanf(value, "%2d:%2d:%2d%c", &h, &m, &s, &tail) == 3 ||
        sscanf(value, "%2d:%2d%c", &h, &m, &tail) == 2) {
        if (h < 0 || h > 23 || m < 0 || m > 59 || s < 0 || s > 59) {
            return false;
        }
        *hour = (uint8_t)h;
        *min = (uint8_t)m;
        *sec = (uint8_t)s;
        return true;
    }
    return false;
}
static uint8_t parse_weekdays(const cJSON *repeat)
{
    uint8_t mask = 0;
    if (!cJSON_IsArray(repeat) || cJSON_GetArraySize(repeat) == 0) {
        return ALARM_REPEAT_ALL;
    }
    const cJSON *day = NULL;
    cJSON_ArrayForEach(day, repeat) {
        int value = cJSON_IsNumber(day) ? day->valueint : 0;
        switch (value) {
        case 1: mask |= ALARM_REPEAT_MON; break;
        case 2: mask |= ALARM_REPEAT_TUE; break;
        case 3: mask |= ALARM_REPEAT_WED; break;
        case 4: mask |= ALARM_REPEAT_THU; break;
        case 5: mask |= ALARM_REPEAT_FRI; break;
        case 6: mask |= ALARM_REPEAT_SAT; break;
        case 7: mask |= ALARM_REPEAT_SUN; break;
        default: break;
        }
    }
    return mask ? mask : ALARM_REPEAT_ALL;
}
static void copy_str(char *dest, size_t dest_size, const char *src)
{
    if (!dest || dest_size == 0) {
        return;
    }
    dest[0] = '\0';
    if (src && src[0]) {
        strlcpy(dest, src, dest_size);
    }
}
static time_t wall_now(struct tm *out)
{
    struct tm now = {};
    if (!Board_Get_Wall_Clock(&now)) {
        time_t t = 0;
        time(&t);
        localtime_r(&t, &now);
    }
    if (out) {
        *out = now;
    }
    now.tm_isdst = -1;
    return mktime(&now);
}
static int on_complete_from_params(const cJSON *params)
{
    if (!params) {
        return SCHEDULE_END_DEFAULT;
    }
    const cJSON *item = cJSON_GetObjectItem(params, "on_complete");
    if (cJSON_IsString(item) && item->valuestring) {
        if (strcmp(item->valuestring, "power_off") == 0) {
            return SCHEDULE_END_POWER_OFF;
        }
        if (strcmp(item->valuestring, "chat") == 0) {
            return SCHEDULE_END_CHAT;
        }
    }
    const cJSON *power = cJSON_GetObjectItem(params, "power_off_after");
    if (cJSON_IsTrue(power) || (cJSON_IsNumber(power) && power->valueint != 0)) {
        return SCHEDULE_END_POWER_OFF;
    }
    return SCHEDULE_END_DEFAULT;
}
static int duration_minutes_from_params(const cJSON *params)
{
    if (!params) {
        return 0;
    }
    const cJSON *item = cJSON_GetObjectItem(params, "duration_minutes");
    if (cJSON_IsNumber(item) && item->valuedouble > 0) {
        int value = (int)item->valuedouble;
        if (value > 24 * 60) {
            value = 24 * 60;
        }
        return value;
    }
    return 0;
}
static bool parse_one_task(const cJSON *obj, sched_task_t *out)
{
    if (!cJSON_IsObject(obj) || !out) {
        return false;
    }
    memset(out, 0, sizeof(*out));
    const cJSON *id = cJSON_GetObjectItem(obj, "id");
    const cJSON *app = cJSON_GetObjectItem(obj, "app_id");
    const cJSON *task = cJSON_GetObjectItem(obj, "task_id");
    const cJSON *enabled = cJSON_GetObjectItem(obj, "enabled");
    const cJSON *params = cJSON_GetObjectItem(obj, "params");
    const cJSON *schedule = cJSON_GetObjectItem(obj, "schedule");
    if (!cJSON_IsString(id) || !id->valuestring[0] || !cJSON_IsString(app) || !app->valuestring[0]) {
        return false;
    }
    copy_str(out->id, sizeof(out->id), id->valuestring);
    copy_str(out->app_id, sizeof(out->app_id), app->valuestring);
    copy_str(out->task_id, sizeof(out->task_id),
             (cJSON_IsString(task) && task->valuestring) ? task->valuestring : "");
    out->enabled = enabled ? !cJSON_IsFalse(enabled) : 1;
    if (params) {
        char *printed = cJSON_PrintUnformatted(params);
        if (printed) {
            copy_str(out->params_json, sizeof(out->params_json), printed);
            free(printed);
        }
    } else {
        copy_str(out->params_json, sizeof(out->params_json), "{}");
    }
    const cJSON *type = schedule ? cJSON_GetObjectItem(schedule, "type") : NULL;
    const char *type_str = (cJSON_IsString(type) && type->valuestring) ? type->valuestring : "daily";
    if (strcmp(type_str, "hourly") == 0) {
        out->type = SCHEDULE_TYPE_HOURLY;
        const cJSON *minute = cJSON_GetObjectItem(schedule, "minute");
        int value = cJSON_IsNumber(minute) ? minute->valueint : 0;
        if (value < 0 || value > 59) {
            value = 0;
        }
        out->hourly_minute = (uint8_t)value;
        return true;
    }
    if (strcmp(type_str, "interval") == 0) {
        out->type = SCHEDULE_TYPE_INTERVAL;
        const cJSON *every = cJSON_GetObjectItem(schedule, "every_minutes");
        int value = cJSON_IsNumber(every) ? every->valueint : 60;
        if (value < 1) {
            value = 1;
        }
        if (value > 24 * 60) {
            value = 24 * 60;
        }
        out->every_minutes = (uint16_t)value;
        return true;
    }
    out->type = SCHEDULE_TYPE_DAILY;
    const cJSON *time_value = schedule ? cJSON_GetObjectItem(schedule, "time") : NULL;
    if (!cJSON_IsString(time_value) ||
        !parse_hhmmss(time_value->valuestring, &out->hour, &out->min, &out->sec)) {
        return false;
    }
    out->weekdays = parse_weekdays(schedule ? cJSON_GetObjectItem(schedule, "weekdays") : NULL);
    ESP_LOGI(TAG, "task %s %s/%s daily %02u:%02u:%02u weekdays=0x%02x",
             out->id, out->app_id, out->task_id, out->hour, out->min, out->sec, out->weekdays);
    return true;
}
static bool load_from_file(void)
{
    s_task_count = 0;
    FILE *fp = fopen(SCHEDULE_PATH, "rb");
    if (!fp) {
        // SD 还没挂载时不要记成“已经加载且没有计划”,否则开机后不再重读。
        s_loaded = false;
        s_task_count = 0;
        return false;
    }
    if (fseek(fp, 0, SEEK_END) != 0) {
        fclose(fp);
        return false;
    }
    long size = ftell(fp);
    rewind(fp);
    if (size <= 0 || size > 49152) {
        fclose(fp);
        return false;
    }
    char *buf = (char *)malloc((size_t)size + 1);
    if (!buf) {
        fclose(fp);
        return false;
    }
    size_t n = fread(buf, 1, (size_t)size, fp);
    fclose(fp);
    buf[n] = '\0';
    cJSON *root = cJSON_Parse(buf);
    free(buf);
    if (!root) {
        return false;
    }
    const cJSON *tasks = cJSON_IsArray(root) ? root : cJSON_GetObjectItem(root, "tasks");
    if (cJSON_IsArray(tasks)) {
        const cJSON *item = NULL;
        cJSON_ArrayForEach(item, tasks) {
            if (s_task_count >= SCHEDULE_MAX_TASKS) {
                break;
            }
            if (parse_one_task(item, &s_tasks[s_task_count])) {
                s_task_count++;
            }
        }
    }
    cJSON_Delete(root);
    s_loaded = true;
    ESP_LOGI(TAG, "loaded %d scheduled tasks", s_task_count);
    return true;
}
static bool next_occurrence(const sched_task_t *task, time_t not_before, time_t *out)
{
    if (!task || !out || !task->enabled) {
        return false;
    }
    struct tm now_tm = {};
    time_t now = wall_now(&now_tm);
    if (not_before < now) {
        not_before = now;
    }
    if (task->type == SCHEDULE_TYPE_HOURLY) {
        time_t midnight = now - (now_tm.tm_hour * 3600 + now_tm.tm_min * 60 + now_tm.tm_sec);
        time_t missed = 0;
        for (int h = 0; h < 24 * 8; ++h) {
            time_t cand = midnight + h * 3600 + task->hourly_minute * 60;
            if (cand > not_before) {
                if (slot_already_claimed(task, cand)) {
                    continue;
                }
                if (missed != 0 && not_before <= now) {
                    *out = missed;
                    return true;
                }
                *out = cand;
                return true;
            }
            if (not_before <= now && cand <= now && now - cand <= SCHEDULE_DUE_WINDOW_SEC &&
                !slot_already_claimed(task, cand)) {
                missed = cand;
            }
        }
        if (missed != 0) {
            *out = missed;
            return true;
        }
        return false;
    }
    if (task->type == SCHEDULE_TYPE_INTERVAL) {
        int every = task->every_minutes > 0 ? task->every_minutes : 60;
        time_t step = (time_t)every * 60;
        if (step < 60) {
            step = 60;
        }
        if (not_before <= now && step > 0) {
            time_t slot = (now / step) * step;
            if (slot > 0 && now - slot <= SCHEDULE_DUE_WINDOW_SEC && !slot_already_claimed(task, slot)) {
                *out = slot;
                return true;
            }
        }
        time_t cand = ((not_before / step) + 1) * step;
        if (cand <= not_before) {
            cand += step;
        }
        *out = cand;
        return true;
    }
    uint8_t mask = task->weekdays ? task->weekdays : ALARM_REPEAT_ALL;
    int now_sec = now_tm.tm_hour * 3600 + now_tm.tm_min * 60 + now_tm.tm_sec;
    int alarm_sec = task->hour * 3600 + task->min * 60 + task->sec;
    int min_delta = (int)(not_before - now);
    if (min_delta < 0) {
        min_delta = 0;
    }
    int best_delta = 8 * 24 * 3600;
    bool found = false;
    for (int day_offset = 0; day_offset <= 8; ++day_offset) {
        int tm_wday = (now_tm.tm_wday + day_offset) % 7;
        if ((mask & tm_wday_to_repeat_bit(tm_wday)) == 0) {
            continue;
        }
        int delta = day_offset * 24 * 3600 + alarm_sec - now_sec;
        if (delta <= min_delta) {
            // 刚过点的几分钟内仍算本次(硬件开机、软关机唤醒),不要直接排到下一天。
            if (min_delta <= 0 && delta >= -SCHEDULE_DUE_WINDOW_SEC &&
                !slot_already_claimed(task, now + delta)) {
                best_delta = delta;
                found = true;
            }
            continue;
        }
        if (slot_already_claimed(task, now + (time_t)delta)) {
            continue;
        }
        if (found && best_delta <= 0) {
            break;
        }
        if (delta < best_delta) {
            best_delta = delta;
            found = true;
        }
    }
    if (!found) {
        return false;
    }
    *out = now + best_delta;
    return true;
}
static void clear_next(void)
{
    s_next_valid = false;
    s_next_unix = 0;
    s_next_id[0] = '\0';
    s_next_app[0] = '\0';
    s_next_task[0] = '\0';
    copy_str(s_next_params, sizeof(s_next_params), "{}");
    DS2312_Clear_Schedule_Wake();
}
static void cancel_duration_timer(void)
{
    if (s_duration_timer) {
        esp_timer_stop(s_duration_timer);
    }
}
static void duration_timer_cb(void *arg)
{
    (void)arg;
    Application::GetInstance().Schedule([]() {
        s_duration_fired = true;
        Board_Schedule_OnNaturalEnd();
    });
}
static void ensure_duration_timer(void)
{
    if (s_duration_timer) {
        return;
    }
    const esp_timer_create_args_t args = {
        .callback = duration_timer_cb,
        .arg = NULL,
        .dispatch_method = ESP_TIMER_TASK,
        .name = "sched_dur",
        .skip_unhandled_events = true,
    };
    if (esp_timer_create(&args, &s_duration_timer) != ESP_OK) {
        s_duration_timer = NULL;
    }
}
static bool is_playlist_task(const char *task_id)
{
    return task_id && (strcmp(task_id, "repeater") == 0 || strcmp(task_id, "shadowing") == 0);
}
static bool claim_slot_recorded(const char *id, time_t cand)
{
    if (!id || id[0] == '\0' || cand <= 0) {
        return false;
    }
    for (int i = 0; i < s_claimed_count; ++i) {
        if (s_claimed_slots[i].unix_time == cand && strcmp(s_claimed_slots[i].id, id) == 0) {
            return true;
        }
    }
    return false;
}
static void remember_claim_slot(const char *id, time_t unix_time)
{
    if (!id || id[0] == '\0' || unix_time <= 0 || claim_slot_recorded(id, unix_time)) {
        if (unix_time > s_last_started_unix) {
            s_last_started_unix = unix_time;
        }
        return;
    }
    if (s_claimed_count >= CLAIMED_SLOT_MAX) {
        memmove(&s_claimed_slots[0], &s_claimed_slots[1],
                sizeof(claimed_slot_t) * (CLAIMED_SLOT_MAX - 1));
        s_claimed_count = CLAIMED_SLOT_MAX - 1;
    }
    copy_str(s_claimed_slots[s_claimed_count].id, sizeof(s_claimed_slots[s_claimed_count].id), id);
    s_claimed_slots[s_claimed_count].unix_time = unix_time;
    s_claimed_count++;
    if (unix_time > s_last_started_unix) {
        s_last_started_unix = unix_time;
    }
}
static bool slot_already_claimed(const sched_task_t *task, time_t cand)
{
    if (!task) {
        return false;
    }
    // 时间已经走到更晚的计划,更早的那次不再回头启动。
    if (s_last_started_unix > 0 && cand < s_last_started_unix) {
        return true;
    }
    return claim_slot_recorded(task->id, cand);
}
static bool is_chat_task(const char *app_id, const char *task_id)
{
    return app_id && strcmp(app_id, "chat") == 0 &&
           (!task_id || task_id[0] == '\0' || strcmp(task_id, "conversation") == 0);
}
static void apply_on_complete(bool user_exit)
{
    cancel_duration_timer();
    s_occupying = false;
    s_busy_until = 0;
    int complete = s_on_complete;
    s_on_complete = SCHEDULE_END_DEFAULT;
    bool from_duration = s_duration_fired;
    s_duration_fired = false;
    Board_Schedule_Recompute();
    if (!user_exit && s_deferred_due && Board_Schedule_IsDueNow(180)) {
        s_deferred_due = false;
        Board_Schedule_OnAlarmFired();
        return;
    }
    s_deferred_due = false;
    if (!user_exit && complete == SCHEDULE_END_POWER_OFF) {
        Application::GetInstance().RequestPowerOffStandby();
        return;
    }
    if (!user_exit && from_duration) {
        Board_StartAppTask("chat", "conversation", "{}", "default_chat");
    }
}
esp_err_t Board_Schedule_SyncJson(const char *json)
{
    if (!json) {
        return ESP_ERR_INVALID_ARG;
    }
    cJSON *root = cJSON_Parse(json);
    if (!root) {
        return ESP_ERR_INVALID_ARG;
    }
    const cJSON *tasks = cJSON_IsArray(root) ? root : cJSON_GetObjectItem(root, "tasks");
    if (tasks && !cJSON_IsArray(tasks)) {
        cJSON_Delete(root);
        return ESP_ERR_INVALID_ARG;
    }
    if (tasks && cJSON_GetArraySize(tasks) > SCHEDULE_MAX_TASKS) {
        cJSON_Delete(root);
        return ESP_ERR_NO_MEM;
    }
    cJSON *store = cJSON_CreateObject();
    if (!store) {
        cJSON_Delete(root);
        return ESP_ERR_NO_MEM;
    }
    cJSON *copy = tasks ? cJSON_Duplicate(tasks, 1) : cJSON_CreateArray();
    if (!copy) {
        cJSON_Delete(store);
        cJSON_Delete(root);
        return ESP_ERR_NO_MEM;
    }
    cJSON_AddItemToObject(store, "tasks", copy);
    char *printed = cJSON_PrintUnformatted(store);
    cJSON_Delete(store);
    cJSON_Delete(root);
    if (!printed) {
        return ESP_ERR_NO_MEM;
    }
    FILE *fp = fopen(SCHEDULE_PATH, "wb");
    if (!fp) {
        free(printed);
        return ESP_FAIL;
    }
    size_t len = strlen(printed);
    size_t wrote = fwrite(printed, 1, len, fp);
    fclose(fp);
    free(printed);
    if (wrote != len) {
        return ESP_FAIL;
    }
    s_loaded = false;
    if (!load_from_file()) {
        return ESP_FAIL;
    }
    for (int i = 0; i < s_task_count; ++i) {
        if (!s_tasks[i].enabled) {
            continue;
        }
        Board_RememberLastLessonTask(s_tasks[i].task_id, s_tasks[i].params_json);
    }
    return Board_Schedule_Recompute();
}
void Board_Schedule_Reload(void)
{
    s_loaded = false;
    s_task_count = 0;
}
esp_err_t Board_Schedule_Recompute(void)
{
    if (!s_loaded && !load_from_file()) {
        ESP_LOGW(TAG, "schedule file not ready");
        return ESP_FAIL;
    }
    struct tm now_tm = {};
    time_t now = wall_now(&now_tm);
    time_t not_before = now;
    // 课文计划播完后如果没收到结束回调,不要继续占着下一次闹钟。
    if (s_occupying && s_busy_until <= now && !Board_IsLessonPlaybackActive()) {
        s_occupying = false;
        s_busy_until = 0;
    }
    // 只有设定了持续时长才往后推。正在泛听不能把跟读推迟 12 小时。
    if (s_occupying && s_busy_until > now) {
        not_before = s_busy_until;
    }
    int best_index = -1;
    time_t best_unix = 0;
    bool best_due = false;
    for (int i = 0; i < s_task_count; ++i) {
        time_t cand = 0;
        if (!next_occurrence(&s_tasks[i], not_before, &cand)) {
            continue;
        }
        bool due = cand <= now;
        bool take = false;
        if (best_index < 0) {
            take = true;
        } else if (due && !best_due) {
            take = true;
        } else if (due && best_due && cand > best_unix) {
            take = true;
        } else if (!due && !best_due && cand < best_unix) {
            take = true;
        }
        if (take) {
            best_index = i;
            best_unix = cand;
            best_due = due;
        }
    }
    if (best_index < 0) {
        ESP_LOGI(TAG, "no upcoming scheduled task");
        clear_next();
        return ESP_OK;
    }
    const sched_task_t *best = &s_tasks[best_index];
    copy_str(s_next_id, sizeof(s_next_id), best->id);
    copy_str(s_next_app, sizeof(s_next_app), best->app_id);
    copy_str(s_next_task, sizeof(s_next_task), best->task_id);
    copy_str(s_next_params, sizeof(s_next_params), best->params_json[0] ? best->params_json : "{}");
    s_next_unix = best_unix;
    s_next_valid = true;
    struct tm fire_tm = {};
    localtime_r(&best_unix, &fire_tm);
    uint8_t bit = tm_wday_to_repeat_bit(fire_tm.tm_wday);
    DS2312_Set_Schedule_Wake(fire_tm.tm_hour, fire_tm.tm_min, fire_tm.tm_sec, bit);
    ESP_LOGI(TAG, "next schedule id=%s %s/%s at %02d:%02d:%02d unix=%ld",
             s_next_id, s_next_app, s_next_task, fire_tm.tm_hour, fire_tm.tm_min, fire_tm.tm_sec,
             (long)best_unix);
    return ESP_OK;
}
bool Board_Schedule_IsDueNow(int window_sec)
{
    if (!s_next_valid) {
        if (!s_loaded) {
            load_from_file();
            Board_Schedule_Recompute();
        }
        if (!s_next_valid) {
            return false;
        }
    }
    time_t now = wall_now(NULL);
    if (window_sec < 1) {
        window_sec = SCHEDULE_DUE_WINDOW_SEC;
    }
    time_t late = now - s_next_unix;
    // 计划时间未到就不要启动,避免下发后提前打断正在进行的对话。
    if (late < 0) {
        return false;
    }
    return late <= window_sec;
}
bool Board_Schedule_LoadDueTask(char *app_id, size_t app_size, char *task_id, size_t task_size,
                                char *params_json, size_t params_size)
{
    if (!s_next_valid) {
        return false;
    }
    copy_str(app_id, app_size, s_next_app);
    copy_str(task_id, task_size, s_next_task);
    copy_str(params_json, params_size, s_next_params[0] ? s_next_params : "{}");
    return app_id && app_id[0];
}
bool Board_Schedule_ClaimDue(void)
{
    if (!Board_Schedule_IsDueNow(SCHEDULE_DUE_WINDOW_SEC)) {
        return false;
    }
    if (claim_slot_recorded(s_next_id, s_next_unix)) {
        return false;
    }
    remember_claim_slot(s_next_id, s_next_unix);
    return true;
}
static void remember_claimed_schedule(const char *task_id)
{
    if (!s_next_valid || !task_id || strcmp(task_id, s_next_task) != 0) {
        return;
    }
    if (!Board_Schedule_IsDueNow(SCHEDULE_DUE_WINDOW_SEC)) {
        return;
    }
    remember_claim_slot(s_next_id, s_next_unix);
}
static void arm_wait_lesson_files(const char *app_id, const char *task_id, const char *params)
{
    if (!s_wait_lesson_files) {
        ESP_LOGW(TAG, "schedule %s/%s files not ready, wait for sync", app_id, task_id);
        Board_Show_Alert("正在同步", "正在同步课文,请稍候", "cloud_arrow_down");
        s_wait_lesson_files_since = wall_now(NULL);
    }
    s_wait_lesson_files = true;
    copy_str(s_pending_app, sizeof(s_pending_app), app_id);
    copy_str(s_pending_task, sizeof(s_pending_task), task_id);
    copy_str(s_pending_params, sizeof(s_pending_params), (params && params[0]) ? params : "{}");
    Board_Schedule_Recompute();
}
void Board_Schedule_OnAlarmFired(void)
{
    if (s_fire_queued || s_deferred_due) {
        return;
    }
    s_fire_queued = true;
    Application::GetInstance().Schedule([]() {
        s_fire_queued = false;
        if (!s_next_valid && !Board_Schedule_IsDueNow(SCHEDULE_DUE_WINDOW_SEC)) {
            Board_Schedule_Recompute();
            return;
        }
        if (!Board_Schedule_IsDueNow(SCHEDULE_DUE_WINDOW_SEC)) {
            Board_Schedule_Recompute();
            return;
        }
        if (Board_AppTask_CurrentPriority() >= Board_AppTask_SourcePriority("mqtt_immediate")) {
            ESP_LOGI(TAG, "defer schedule, immediate task running");
            s_deferred_due = true;
            return;
        }
        char app_id[SCHEDULE_APP_MAX];
        char task_id[SCHEDULE_APP_MAX];
        char params[SCHEDULE_PARAMS_MAX];
        if (!Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id),
                                        params, sizeof(params))) {
            Board_Schedule_Recompute();
            return;
        }
        if (is_playlist_task(task_id) && !Board_LessonFilesReady(task_id, params)) {
            arm_wait_lesson_files(app_id, task_id, params);
            return;
        }
        ESP_LOGI(TAG, "alarm start schedule %s/%s", app_id, task_id);
        s_deferred_due = false;
        s_wait_lesson_files = false;
        s_wait_lesson_files_since = 0;
        Board_StartAppTask(app_id, task_id, params, "schedule");
    });
}
void Board_Schedule_OnLessonFilesUpdated(void)
{
    if (!s_wait_lesson_files || s_pending_task[0] == '\0' || s_files_poll_queued) {
        return;
    }
    time_t now = wall_now(NULL);
    if (s_wait_lesson_files_since > 0 && now > s_wait_lesson_files_since + 15 * 60) {
        ESP_LOGW(TAG, "lesson files still missing after 15 min, skip %s/%s",
                 s_pending_app, s_pending_task);
        s_wait_lesson_files = false;
        s_wait_lesson_files_since = 0;
        s_pending_task[0] = '\0';
        Application::GetInstance().Schedule([]() {
            Board_Schedule_Recompute();
        });
        return;
    }
    if (!Board_LessonFilesReady(s_pending_task, s_pending_params)) {
        return;
    }
    s_files_poll_queued = true;
    Application::GetInstance().Schedule([]() {
        s_files_poll_queued = false;
        if (!s_wait_lesson_files || s_pending_task[0] == '\0') {
            return;
        }
        char app_id[SCHEDULE_APP_MAX];
        char task_id[SCHEDULE_APP_MAX];
        char params[SCHEDULE_PARAMS_MAX];
        copy_str(app_id, sizeof(app_id), s_pending_app);
        copy_str(task_id, sizeof(task_id), s_pending_task);
        copy_str(params, sizeof(params), s_pending_params[0] ? s_pending_params : "{}");
        if (!Board_LessonFilesReady(task_id, params)) {
            return;
        }
        ESP_LOGI(TAG, "lesson files ready, start schedule %s/%s", app_id, task_id);
        s_wait_lesson_files = false;
        s_wait_lesson_files_since = 0;
        s_pending_task[0] = '\0';
        Board_StartAppTask(app_id, task_id, params, "schedule");
    });
}
void Board_Schedule_OnTaskStarted(const char *app_id, const char *task_id, const char *source,
                                  const char *params_json)
{
    if (source && strcmp(source, "schedule") == 0) {
        remember_claimed_schedule(task_id);
    }
    cancel_duration_timer();
    cJSON *params = params_json ? cJSON_Parse(params_json) : NULL;
    s_on_complete = on_complete_from_params(params);
    int duration = duration_minutes_from_params(params);
    if (params) {
        cJSON_Delete(params);
    }
    bool chat = is_chat_task(app_id, task_id);
    bool schedule_or_immediate =
        source && (strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0);
    bool parent_end_to_chat = chat && schedule_or_immediate && duration <= 0;
    if (chat && (!source || strcmp(source, "voice") == 0 || strcmp(source, "default_chat") == 0
            || parent_end_to_chat)) {
        if (source && (strcmp(source, "voice") == 0 || parent_end_to_chat)) {
            s_deferred_due = false;
        }
        s_occupying = false;
        s_busy_until = 0;
        s_on_complete = SCHEDULE_END_DEFAULT;
        Board_Schedule_Recompute();
        return;
    }
    s_occupying = schedule_or_immediate;
    time_t now = wall_now(NULL);
    if (schedule_or_immediate && !is_playlist_task(task_id) && duration > 0) {
        s_busy_until = now + (time_t)duration * 60;
        ensure_duration_timer();
        if (s_duration_timer) {
            esp_timer_stop(s_duration_timer);
            esp_timer_start_once(s_duration_timer, (uint64_t)duration * 60ULL * 1000000ULL);
        }
    } else {
        s_busy_until = 0;
    }
    if (source && strcmp(source, "mqtt_immediate") == 0) {
        return;
    }
    Board_Schedule_Recompute();
}
void Board_Schedule_OnNaturalEnd(void)
{
    apply_on_complete(false);
}
void Board_Schedule_OnUserExit(void)
{
    s_deferred_due = false;
    apply_on_complete(true);
}
void Board_Schedule_OnEnteringPowerOff(void)
{
    cancel_duration_timer();
    s_occupying = false;
    s_busy_until = 0;
    s_duration_fired = false;
    Board_Schedule_Recompute();
}
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.h
New file
@@ -0,0 +1,32 @@
#ifndef PARENT_SCHEDULE_H
#define PARENT_SCHEDULE_H
#include "esp_err.h"
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t Board_Schedule_SyncJson(const char *json);
void Board_Schedule_Reload(void);
esp_err_t Board_Schedule_Recompute(void);
bool Board_Schedule_IsDueNow(int window_sec);
bool Board_Schedule_LoadDueTask(char *app_id, size_t app_size, char *task_id, size_t task_size,
                                char *params_json, size_t params_size);
bool Board_Schedule_ClaimDue(void);
void Board_Schedule_OnAlarmFired(void);
void Board_Schedule_OnLessonFilesUpdated(void);
void Board_Schedule_OnTaskStarted(const char *app_id, const char *task_id, const char *source,
                                  const char *params_json);
void Board_Schedule_OnNaturalEnd(void);
void Board_Schedule_OnUserExit(void);
void Board_Schedule_OnEnteringPowerOff(void);
#ifdef __cplusplus
}
#endif
#endif
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc
@@ -25,6 +25,7 @@
#include <cmath>
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
#include <esp_lcd_panel_io.h>
@@ -41,6 +42,7 @@
#include <ds3231.h>
#include "system.h"
#include "mqtt.h"
#include "parent_schedule.h"
#define TAG "sening_xiaozhi_s3"
#define WIFI_RECONNECT_CONNECTED_BIT BIT0
@@ -119,6 +121,68 @@
{
    DeviceState state = Application::GetInstance().GetDeviceState();
    return state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking;
}
extern "C" void Board_HoldIdleForServerPrompt(void)
{
    Application::GetInstance().Schedule([]() {
        auto& app = Application::GetInstance();
        app.HoldIdleForServerPrompt();
        if (app.GetDeviceState() == kDeviceStateSpeaking) {
            app.AbortSpeaking(kAbortReasonNone);
        }
    });
}
extern "C" void Board_ReleaseHoldIdle(void)
{
    Application::GetInstance().Schedule([]() {
        Application::GetInstance().ReleaseHoldIdle();
    });
}
extern "C" void Board_SetLessonDownloadBoost(bool enable)
{
    Board::GetInstance().SetPowerSaveLevel(
        enable ? PowerSaveLevel::PERFORMANCE : PowerSaveLevel::LOW_POWER);
}
extern "C" void Board_AnnounceLessonSyncAndWait(void)
{
    Board_SetLessonDownloadBoost(true);
    Application::GetInstance().Schedule([]() {
        auto& app = Application::GetInstance();
        app.HoldIdleForServerPrompt();
        app.Alert("正在同步", "正在同步音频,请稍等", "cloud_arrow_down");
        if (!app.SendListenDetectNow("[lesson_sync]")) {
            app.SendListenDetect("[lesson_sync]", true);
        }
    });
    int waited_ms = 0;
    bool saw_speaking = false;
    while (waited_ms < 8000) {
        DeviceState state = Application::GetInstance().GetDeviceState();
        if (state == kDeviceStateSpeaking) {
            saw_speaking = true;
        } else if (saw_speaking && state == kDeviceStateIdle) {
            break;
        }
        vTaskDelay(pdMS_TO_TICKS(100));
        waited_ms += 100;
    }
    if (!saw_speaking) {
        vTaskDelay(pdMS_TO_TICKS(2500));
    }
    Application::GetInstance().Schedule([]() {
        auto& app = Application::GetInstance();
        app.HoldIdleForServerPrompt();
        app.GetAudioService().EnableVoiceProcessing(false);
        app.GetAudioService().EnableWakeWordDetection(false);
        if (app.GetDeviceState() == kDeviceStateListening) {
            app.SetDeviceState(kDeviceStateIdle);
        }
    });
}
extern "C" bool Board_Is_Audio_Busy(void)
@@ -246,11 +310,21 @@
        return ESP_FAIL;
    }
    constexpr size_t IO_BUF_SIZE = 16384;
    char *io_buf = (char *)heap_caps_malloc(IO_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    if (io_buf && setvbuf(fp, io_buf, _IOFBF, IO_BUF_SIZE) != 0) {
        heap_caps_free(io_buf);
        io_buf = nullptr;
    }
    auto network = Board::GetInstance().GetNetwork();
    auto http = network->CreateHttp(0);
    http->SetTimeout(30000);
    http->SetTimeout(120000);
    if (!http->Open("GET", url)) {
        fclose(fp);
        if (io_buf) {
            heap_caps_free(io_buf);
        }
        SetDownloadError(error_info, error_info_size, "open http failed");
        return ESP_FAIL;
    }
@@ -259,6 +333,9 @@
    if (status != 200) {
        fclose(fp);
        http->Close();
        if (io_buf) {
            heap_caps_free(io_buf);
        }
        char message[64];
        snprintf(message, sizeof(message), "http status %d", status);
        SetDownloadError(error_info, error_info_size, message);
@@ -266,11 +343,27 @@
    }
    size_t content_length = http->GetBodyLength();
    constexpr size_t BUFFER_SIZE = 1024;
    char *buffer = (char *)heap_caps_malloc(BUFFER_SIZE, MALLOC_CAP_INTERNAL);
    ESP_LOGI(TAG, "download start: %s content_length=%u", url, (unsigned)content_length);
    constexpr size_t BUFFER_SIZE = 16384;
    size_t buffer_size = BUFFER_SIZE;
    char *buffer = (char *)heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    if (!buffer) {
        buffer_size = 4096;
        buffer = (char *)heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    }
    if (!buffer) {
        buffer = (char *)heap_caps_malloc(buffer_size, MALLOC_CAP_INTERNAL);
    }
    if (!buffer) {
        buffer_size = 1024;
        buffer = (char *)heap_caps_malloc(buffer_size, MALLOC_CAP_INTERNAL);
    }
    if (!buffer) {
        fclose(fp);
        http->Close();
        if (io_buf) {
            heap_caps_free(io_buf);
        }
        SetDownloadError(error_info, error_info_size, "no memory for download buffer");
        return ESP_ERR_NO_MEM;
    }
@@ -283,12 +376,14 @@
    }
    size_t total_read = 0;
    size_t recent_read = 0;
    int last_progress = -1;
    int64_t last_report = 0;
    int64_t last_speed_us = esp_timer_get_time();
    bool ok = true;
    bool canceled = false;
    while (true) {
        int ret = http->Read(buffer, BUFFER_SIZE);
        int ret = http->Read(buffer, buffer_size);
        if (ret < 0) {
            ESP_LOGE(TAG, "read HTTP failed: %d", ret);
            SetDownloadError(error_info, error_info_size, "read http failed");
@@ -310,9 +405,16 @@
        }
        total_read += ret;
        recent_read += (size_t)ret;
        int64_t now = esp_timer_get_time();
        if (now - last_speed_us >= 1000000) {
            ESP_LOGI(TAG, "download %u/%u bytes %uB/s",
                     (unsigned)total_read, (unsigned)content_length, (unsigned)recent_read);
            last_speed_us = now;
            recent_read = 0;
        }
        if (progress_cb) {
            int progress = content_length > 0 ? (int)(total_read * 100 / content_length) : 0;
            int64_t now = esp_timer_get_time();
            bool report = content_length <= 0 || progress != last_progress || now - last_report >= 1000000 || progress >= 100;
            if (report) {
                last_progress = progress;
@@ -338,6 +440,9 @@
    fclose(fp);
    http->Close();
    heap_caps_free(buffer);
    if (io_buf) {
        heap_caps_free(io_buf);
    }
    if (!ok) {
        if (check_md5) {
@@ -776,8 +881,10 @@
                    task_id.c_str());
                Board_SetShadowingListenRatio(
                    static_cast<float>(properties["listen_ratio"].value<double>()));
                sdcard_mp3_playlist_item_t playlist[SDCARD_MP3_PLAYLIST_MAX];
                std::unique_ptr<sdcard_mp3_playlist_item_t, void(*)(void*)> playlist(
                    nullptr, heap_caps_free);
                int playlist_n = 0;
                int playlist_cap = 0;
                if (!items_json.empty()) {
                    cJSON *root = cJSON_Parse(items_json.c_str());
                    cJSON *arr = root;
@@ -785,9 +892,25 @@
                        arr = cJSON_GetObjectItem(root, "items");
                    }
                    if (cJSON_IsArray(arr)) {
                        const int incoming = cJSON_GetArraySize(arr);
                        playlist_cap = incoming;
                        if (playlist_cap > SDCARD_MP3_PLAYLIST_MAX) {
                            ESP_LOGW(TAG, "playlist truncated incoming=%d max=%d",
                                     incoming, SDCARD_MP3_PLAYLIST_MAX);
                            playlist_cap = SDCARD_MP3_PLAYLIST_MAX;
                        }
                        if (playlist_cap > 0) {
                            playlist.reset(static_cast<sdcard_mp3_playlist_item_t *>(
                                heap_caps_calloc(playlist_cap, sizeof(sdcard_mp3_playlist_item_t),
                                                 MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)));
                            if (!playlist) {
                                cJSON_Delete(root);
                                return std::string("{\"accepted\":false,\"error\":\"no_mem\"}");
                            }
                        }
                        cJSON *it = nullptr;
                        cJSON_ArrayForEach(it, arr) {
                            if (playlist_n >= SDCARD_MP3_PLAYLIST_MAX) {
                            if (playlist_n >= playlist_cap) {
                                break;
                            }
                            const cJSON *name_item = cJSON_GetObjectItem(it, "name");
@@ -800,22 +923,22 @@
                                continue;
                            }
                            if (!SDCard_Resolve_MP3_Path(name_item->valuestring,
                                                         playlist[playlist_n].path,
                                                         sizeof(playlist[playlist_n].path))) {
                                                         playlist.get()[playlist_n].path,
                                                         sizeof(playlist.get()[playlist_n].path))) {
                                ESP_LOGW(TAG, "playlist skip missing %s", name_item->valuestring);
                                continue;
                            }
                            const cJSON *rep = cJSON_GetObjectItem(it, "repeat");
                            playlist[playlist_n].repeat = cJSON_IsNumber(rep) ? rep->valueint : 1;
                            if (playlist[playlist_n].repeat < 1) {
                                playlist[playlist_n].repeat = 1;
                            playlist.get()[playlist_n].repeat = cJSON_IsNumber(rep) ? rep->valueint : 1;
                            if (playlist.get()[playlist_n].repeat < 1) {
                                playlist.get()[playlist_n].repeat = 1;
                            }
                            const cJSON *spd = cJSON_GetObjectItem(it, "speed");
                            playlist[playlist_n].speed = cJSON_IsNumber(spd)
                            playlist.get()[playlist_n].speed = cJSON_IsNumber(spd)
                                ? SDCard_Clamp_MP3_Speed(static_cast<float>(spd->valuedouble))
                                : 1.0f;
                            const cJSON *po = cJSON_GetObjectItem(it, "power_off_after");
                            playlist[playlist_n].power_off_after = cJSON_IsTrue(po);
                            playlist.get()[playlist_n].power_off_after = cJSON_IsTrue(po);
                            playlist_n++;
                        }
                    }
@@ -832,13 +955,13 @@
                        repeat = 1;
                    }
                } else {
                    name = playlist[0].path;
                    speed = playlist[0].speed;
                    repeat = playlist[0].repeat;
                    power_off_after = playlist[playlist_n - 1].power_off_after;
                    name = playlist.get()[0].path;
                    speed = playlist.get()[0].speed;
                    repeat = playlist.get()[0].repeat;
                    power_off_after = playlist.get()[playlist_n - 1].power_off_after;
                }
                int64_t now = esp_timer_get_time();
                // 跟读/复读会连续播同一文件;不要按路径去重。force 给旧固件重试用。
                // 跟读/泛听会连续播同一文件;不要按路径去重。force 给旧固件重试用。
                bool lesson = (task_id == "shadowing" || task_id == "repeater");
                if (!force && !lesson && playlist_n < 2 && name == last_mp3_play_name &&
                    std::fabs(speed - last_mp3_play_speed) < 0.001f &&
@@ -853,9 +976,9 @@
                audio.EnableWakeWordDetection(false);
                esp_err_t err = ESP_FAIL;
                if (playlist_n > 0) {
                    err = SDCard_Play_MP3_File_Playlist(playlist, playlist_n);
                    err = SDCard_Play_MP3_File_Playlist(playlist.get(), playlist_n);
                    if (err == ESP_OK) {
                        SDCard_Show_Matching_JPG_For_MP3_Path(playlist[0].path);
                        SDCard_Show_Matching_JPG_For_MP3_Path(playlist.get()[0].path);
                    }
                } else {
                    err = SDCard_Play_MP3_With_Speed_Repeat(name.data(), speed, repeat, power_off_after);
@@ -1108,6 +1231,7 @@
extern "C" esp_err_t Board_Enter_Power_Off(void)
{
    Board_Schedule_OnEnteringPowerOff();
    auto& board = static_cast<sening_xiaozhi_s3&>(Board::GetInstance());
    return board.EnterPowerOffStandby();
}
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c
@@ -1,6 +1,7 @@
#include "system.h"
#include "config.h"
#include "app_task.h"
#include "parent_schedule.h"
#include "display/lvgl_display/jpg/jpeg_to_image.h"
#include <esp_heap_caps.h>
@@ -71,6 +72,8 @@
static bool battery_charging_gpio_initialized;
static alarm_entry_t current_alarm1_entry;
static bool current_alarm1_valid;
static alarm_entry_t schedule_wake_entry;
static bool schedule_wake_valid;
static TaskHandle_t alarm_monitor_task_handle;
static TaskHandle_t alarm_power_off_restore_task_handle;
@@ -89,6 +92,8 @@
static bool get_current_time(i2c_dev_t *i2c_dev, struct tm *timeinfo);
static int select_next_alarm_index(const alarm_store_t *store, const struct tm *now);
static int alarm_delta_seconds(const alarm_entry_t *entry, const struct tm *now);
static const alarm_entry_t *select_soonest_alarm(const alarm_store_t *store, const struct tm *now);
static void program_soonest_alarm(i2c_dev_t *i2c_dev, const struct tm *now);
static int mqtt_weekday_to_tm_wday(int day);
static uint8_t tm_wday_to_repeat_bit(int tm_wday);
static bool alarm_entry_equals(const alarm_entry_t *left, const alarm_entry_t *right);
@@ -159,10 +164,13 @@
    if (!command || command[0] == '\0') {
        return false;
    }
    if (Board_Is_Chat_Return_Command(command)) {
        return false;
    }
    return strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
           strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
           strstr(command, OFFLINE_REPEATER_WORD) ||
           strstr(command, OFFLINE_START_REPEATER_WORD);
           strstr(command, OFFLINE_START_REPEATER_WORD) ||
           strstr(command, "打开泛听");
}
bool Board_Handle_Offline_Command(const char *command)
@@ -198,20 +206,52 @@
    esp_sntp_init();
}
void DS2312_Update_Alarm(i2c_dev_t *i2c_dev, struct tm *time)
static const alarm_entry_t *select_soonest_alarm(const alarm_store_t *store, const struct tm *now)
{
    const alarm_entry_t *best = NULL;
    int best_delta = SECONDS_PER_DAY * 8 + 1;
    if (store && store->count > 0) {
        int index = select_next_alarm_index(store, now);
        if (index >= 0) {
            best = &store->entries[index];
            best_delta = alarm_delta_seconds(best, now);
        }
    }
    if (schedule_wake_valid) {
        int delta = alarm_delta_seconds(&schedule_wake_entry, now);
        if (delta < best_delta) {
            best = &schedule_wake_entry;
        }
    }
    return best;
}
static void program_soonest_alarm(i2c_dev_t *i2c_dev, const struct tm *now)
{
    alarm_store_t store;
    if (load_alarm_store(&store) != ESP_OK) {
        disable_alarm1(i2c_dev);
        memset(&store, 0, sizeof(store));
    }
    const alarm_entry_t *best = select_soonest_alarm(&store, now);
    if (!best) {
        if (current_alarm1_valid && !Board_Is_Power_Off()) {
            disable_alarm1(i2c_dev);
        }
        return;
    }
    if (store.count == 0) {
        disable_alarm1(i2c_dev);
        ESP_LOGI(TAG, "alarm list is empty");
    if (!current_alarm1_valid) {
        program_alarm1(i2c_dev, best);
        return;
    }
    int next_delta = alarm_delta_seconds(best, now);
    int current_delta = alarm_delta_seconds(&current_alarm1_entry, now);
    if (next_delta < current_delta || !alarm_entry_equals(best, &current_alarm1_entry)) {
        program_alarm1(i2c_dev, best);
    }
}
void DS2312_Update_Alarm(i2c_dev_t *i2c_dev, struct tm *time)
{
    struct tm now;
    if (time) {
        now = *time;
@@ -219,14 +259,7 @@
        ESP_LOGW(TAG, "current time unavailable, skip alarm update");
        return;
    }
    int index = select_next_alarm_index(&store, &now);
    if (index < 0) {
        disable_alarm1(i2c_dev);
        return;
    }
    program_alarm1(i2c_dev, &store.entries[index]);
    program_soonest_alarm(i2c_dev, &now);
}
void DS2312_Start_Alarm_Monitor(i2c_dev_t *i2c_dev)
@@ -241,6 +274,43 @@
        alarm_monitor_task_handle = NULL;
        ESP_LOGE(TAG, "create alarm monitor task failed");
    }
}
bool Board_Get_Wall_Clock(struct tm *out)
{
    if (!out) {
        return false;
    }
    return get_current_time(ds2321_dev, out);
}
esp_err_t DS2312_Set_Schedule_Wake(int hour, int min, int sec, uint8_t weekday_bit)
{
    if (hour < 0 || hour > 23 || min < 0 || min > 59 || sec < 0 || sec > 59 ||
        (weekday_bit & ALARM_REPEAT_ALL) == 0) {
        return ESP_ERR_INVALID_ARG;
    }
    memset(&schedule_wake_entry, 0, sizeof(schedule_wake_entry));
    strlcpy(schedule_wake_entry.id, "_sched", sizeof(schedule_wake_entry.id));
    schedule_wake_entry.hour = (uint8_t)hour;
    schedule_wake_entry.min = (uint8_t)min;
    schedule_wake_entry.sec = (uint8_t)sec;
    schedule_wake_entry.flags = ALARM_ENTRY_ENABLED;
    schedule_wake_entry.repeat_mask = (uint8_t)(weekday_bit & ALARM_REPEAT_ALL);
    schedule_wake_valid = true;
    DS2312_Update_Alarm(ds2321_dev, NULL);
    ESP_LOGI(TAG, "schedule wake %02d:%02d:%02d mask=0x%02x", hour, min, sec, schedule_wake_entry.repeat_mask);
    return ESP_OK;
}
void DS2312_Clear_Schedule_Wake(void)
{
    if (!schedule_wake_valid) {
        return;
    }
    schedule_wake_valid = false;
    memset(&schedule_wake_entry, 0, sizeof(schedule_wake_entry));
    DS2312_Update_Alarm(ds2321_dev, NULL);
}
void DS2312_Add_Alarm_Time(i2c_dev_t *i2c_dev, int hour, int min, int sec)
@@ -1623,14 +1693,6 @@
    while (true) {
        vTaskDelay(pdMS_TO_TICKS(1000));
        alarm_store_t store;
        if (load_alarm_store(&store) != ESP_OK || store.count == 0) {
            if (current_alarm1_valid) {
                disable_alarm1(i2c_dev);
            }
            continue;
        }
        struct tm now;
        if (!get_current_time(i2c_dev, &now)) {
            continue;
@@ -1641,28 +1703,24 @@
            ESP_LOGI(TAG, "DS3231 alarm1 triggered");
            ds3231_clear_alarm_flags(i2c_dev, DS3231_ALARM_1);
            current_alarm1_valid = false;
            schedule_power_off_restore_from_alarm();
        }
        int next_index = select_next_alarm_index(&store, &now);
        if (next_index < 0) {
            if (current_alarm1_valid && !Board_Is_Power_Off()) {
                disable_alarm1(i2c_dev);
            if (Board_Schedule_ClaimDue()) {
                if (Board_Is_Power_Off()) {
                    schedule_power_off_restore_from_alarm();
                } else {
                    Board_Schedule_OnAlarmFired();
                }
            }
            continue;
        } else if (Board_Schedule_ClaimDue()) {
            ESP_LOGI(TAG, "schedule wall clock due");
            if (Board_Is_Power_Off()) {
                schedule_power_off_restore_from_alarm();
            } else {
                Board_Schedule_OnAlarmFired();
            }
        }
        alarm_entry_t *next_alarm = &store.entries[next_index];
        if (!current_alarm1_valid) {
            program_alarm1(i2c_dev, next_alarm);
            continue;
        }
        int next_delta = alarm_delta_seconds(next_alarm, &now);
        int current_delta = alarm_delta_seconds(&current_alarm1_entry, &now);
        if (next_delta < current_delta || !alarm_entry_equals(next_alarm, &current_alarm1_entry)) {
            program_alarm1(i2c_dev, next_alarm);
        }
        Board_Schedule_OnLessonFilesUpdated();
        program_soonest_alarm(i2c_dev, &now);
    }
}
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h
@@ -36,7 +36,8 @@
#define MOUNT_POINT "/sdcard"
#define SDCARD_PATH_MAX_LEN 256
#define SDCARD_MP3_PLAYLIST_MAX 16
/* 跟读/泛听没有「课文只能 N 句」的业务上限。256 只是一次 MCP 下发的安全顶。 */
#define SDCARD_MP3_PLAYLIST_MAX 256
typedef struct {
    char path[SDCARD_PATH_MAX_LEN];
@@ -84,19 +85,19 @@
/**
 * @brief 板级 MultiNet 离线命令词。只留没网也必须能用的入口:
 * 「切换模式」「模式切换」「复读机」「开始复读」「退出复读」「退出跟读」「我们聊天吧」「我们来聊天吧」。
 * 复读/跟读播音频时不上行 ASR,退出口令必须能离线识别。其它 App 的进入词仍只在 Server apps.json。
 * 「切换模式」「模式切换」「泛听」「开始泛听」「退出泛听」「退出跟读」「我们聊天吧」「我们来聊天吧」。
 * 泛听/跟读播音频时不上行 ASR,退出口令必须能离线识别。其它 App 的进入词仍只在 Server apps.json。
 */
#define OFFLINE_COMMAND_WORD "切换模式"
#define OFFLINE_COMMAND_WORD_PINYIN "qie huan mo shi"
#define OFFLINE_COMMAND_WORD_ALT "模式切换"
#define OFFLINE_COMMAND_WORD_ALT_PINYIN "mo shi qie huan"
#define OFFLINE_REPEATER_WORD "复读机"
#define OFFLINE_REPEATER_WORD_PINYIN "fu du ji"
#define OFFLINE_START_REPEATER_WORD "开始复读"
#define OFFLINE_START_REPEATER_WORD_PINYIN "kai shi fu du"
#define OFFLINE_EXIT_REPEATER_WORD "退出复读"
#define OFFLINE_EXIT_REPEATER_WORD_PINYIN "tui chu fu du"
#define OFFLINE_REPEATER_WORD "泛听"
#define OFFLINE_REPEATER_WORD_PINYIN "fan ting"
#define OFFLINE_START_REPEATER_WORD "开始泛听"
#define OFFLINE_START_REPEATER_WORD_PINYIN "kai shi fan ting"
#define OFFLINE_EXIT_REPEATER_WORD "退出泛听"
#define OFFLINE_EXIT_REPEATER_WORD_PINYIN "tui chu fan ting"
#define OFFLINE_EXIT_SHADOWING_WORD "退出跟读"
#define OFFLINE_EXIT_SHADOWING_WORD_PINYIN "tui chu gen du"
#define OFFLINE_CHAT_WORD "我们聊天吧"
@@ -274,6 +275,19 @@
bool SDCard_Is_Playing(void);
/**
 * @brief 课文开播前释放 AFE、退出聆听并等到内部 SRAM 够 SD DMA。
 *
 * 必须在创建 MP3 播放任务之前调用。AFE/MultiNet 和 SDMMC DMA 争用连续内部 SRAM
 * 时,fopen/sdmmc_read_blocks 会失败,严重时 newlib 锁初始化 abort 重启。
 */
void SDCard_PreparePlaybackAndWait(void);
/**
 * @brief 课文 MP3 已进入 PSRAM 后才允许离线口令 AFE。
 */
bool SDCard_Mp3OfflineWakeAllowed(void);
/**
 * @brief 查询 SD 卡 MP3 播放是否处于暂停状态。
 *
 * @return true 表示播放被暂停或后续新播放应从暂停状态开始;false 表示未暂停。
@@ -431,10 +445,17 @@
esp_err_t Board_Enter_Power_Off(void);
esp_err_t Board_Restore_From_Power_Off(int timeout_ms);
bool Board_Is_Power_Off(void);
bool Board_Get_Wall_Clock(struct tm *out);
esp_err_t DS2312_Set_Schedule_Wake(int hour, int min, int sec, uint8_t weekday_bit);
void DS2312_Clear_Schedule_Wake(void);
esp_err_t Board_Reboot_Delayed(int delay_seconds);
void Board_Acquire_Sleep_Inhibit(void);
void Board_Release_Sleep_Inhibit(void);
bool Board_Is_Voice_Session_Active(void);
void Board_HoldIdleForServerPrompt(void);
void Board_ReleaseHoldIdle(void);
void Board_AnnounceLessonSyncAndWait(void);
void Board_SetLessonDownloadBoost(bool enable);
bool Board_Is_Audio_Busy(void);
void Board_Show_JPG(const char *jpg_name);
void Board_Show_Black_Screen(void);
xiaozhi-esp32-2.2.6/main/display/lvgl_display/jpg/image_to_jpeg.h
@@ -7,11 +7,10 @@
#include <stdint.h>
#include <stddef.h>
#if defined(CONFIG_IDF_TARGET_ESP32P4) || defined(CONFIG_IDF_TARGET_ESP32S3)
// ESP32-P4 使用 esp_video 组件提供的 V4L2 头文件
#if defined(CONFIG_IDF_TARGET_ESP32P4)
// ESP32-P4 硬件 JPEG 走 V4L2;S3 只用像素格式常量,避免与 lwip sockets.h 的 _IO/_IOR/_IOW 重定义
#include <linux/videodev2.h>
#else
// ESP32-S3 等其他芯片:定义常用的 V4L2 像素格式
#define V4L2_PIX_FMT_RGB565 0x50424752  // 'RGBP'
#define V4L2_PIX_FMT_RGB565X 0x52474250 // 'PRGB'
#define V4L2_PIX_FMT_RGB24 0x33424752   // 'RGB3'
xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc
@@ -97,7 +97,7 @@
    }
    // MODEM 从 MAX_MODEM 切回 NONE 不是瞬时完成的。立刻握手会
    // WebSocket handshake timeout,开机欢迎词和复读结束后回 chat 都会失败。
    // WebSocket handshake timeout,开机欢迎词和泛听结束后回 chat 都会失败。
    vTaskDelay(pdMS_TO_TICKS(400));
    Settings settings("websocket", false);
xiaozhi-esp32-server/main/manager-api/pom.xml
@@ -207,6 +207,11 @@
            <artifactId>bcprov-jdk18on</artifactId>
            <version>1.78</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.paho</groupId>
            <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
            <version>1.2.5</version>
        </dependency>
        <!-- springdoc -->
        <dependency>
            <groupId>org.springdoc</groupId>
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java
@@ -195,4 +195,11 @@
        return "device:address_book:all";
    }
    /**
     * 家长任务:设备 MQTT 在线标记
     */
    public static String getParentDeviceOnlineKey(String macAddress) {
        return "parent:device:online:" + macAddress;
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java
@@ -48,6 +48,12 @@
    @Schema(description = "设备数量", example = "10")
    private Integer deviceCount;
    @Schema(description = "在线设备数量")
    private Integer onlineDeviceCount;
    @Schema(description = "绑定设备及在线状态")
    private List<AgentDeviceStatusDTO> devices;
    @Schema(description = "标签列表")
    private List<AgentTagDTO> tags;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDeviceStatusDTO.java
New file
@@ -0,0 +1,17 @@
package xiaozhi.modules.agent.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "智能体绑定设备的在线状态")
public class AgentDeviceStatusDTO {
    @Schema(description = "MAC 地址")
    private String macAddress;
    @Schema(description = "设备别名")
    private String alias;
    @Schema(description = "是否在线(家长 MQTT 状态)")
    private Boolean online;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
@@ -34,6 +34,7 @@
import xiaozhi.modules.agent.dao.AgentTagDao;
import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentDeviceStatusDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentTagDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
@@ -53,6 +54,7 @@
import xiaozhi.modules.correctword.service.CorrectWordFileService;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.parent.mqtt.ParentDevicePresence;
import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.dto.VoiceDTO;
import xiaozhi.modules.model.entity.ModelConfigEntity;
@@ -71,6 +73,7 @@
    private final ModelConfigService modelConfigService;
    private final RedisUtils redisUtils;
    private final DeviceService deviceService;
    private final ParentDevicePresence devicePresence;
    private final AgentPluginMappingService agentPluginMappingService;
    private final AgentChatHistoryService agentChatHistoryService;
    private final AgentTemplateService agentTemplateService;
@@ -286,8 +289,26 @@
        // 获取智能体最近的最后连接时长
        dto.setLastConnectedAt(deviceService.getLatestLastConnectionTime(agent.getId()));
        // 获取设备数量
        dto.setDeviceCount(getDeviceCountByAgentId(agent.getId()));
        List<DeviceEntity> bound = deviceService.getUserDevices(agent.getUserId(), agent.getId());
        if (bound == null) {
            bound = List.of();
        }
        List<AgentDeviceStatusDTO> devices = new ArrayList<>();
        int onlineCount = 0;
        for (DeviceEntity device : bound) {
            AgentDeviceStatusDTO row = new AgentDeviceStatusDTO();
            row.setMacAddress(device.getMacAddress());
            row.setAlias(device.getAlias());
            boolean online = devicePresence.isOnline(device.getMacAddress());
            row.setOnline(online);
            if (online) {
                onlineCount++;
            }
            devices.add(row);
        }
        dto.setDevices(devices);
        dto.setOnlineDeviceCount(onlineCount);
        dto.setDeviceCount(devices.size());
        // 获取标签列表
        List<AgentTagEntity> tags = agentTagDao.selectByAgentId(agent.getId());
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java
@@ -34,6 +34,9 @@
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
import xiaozhi.modules.parent.dto.ParentDeviceStatusDTO;
import xiaozhi.modules.parent.mqtt.ParentDevicePresence;
import xiaozhi.modules.parent.service.ParentTaskService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
@@ -45,13 +48,18 @@
    private final DeviceAddressBookService deviceAddressBookService;
    private final RedisUtils redisUtils;
    private final SysParamsService sysParamsService;
    private final ParentDevicePresence devicePresence;
    private final ParentTaskService parentTaskService;
    public DeviceController(DeviceService deviceService, DeviceAddressBookService deviceAddressBookService,
            RedisUtils redisUtils, SysParamsService sysParamsService) {
            RedisUtils redisUtils, SysParamsService sysParamsService, ParentDevicePresence devicePresence,
            ParentTaskService parentTaskService) {
        this.deviceService = deviceService;
        this.deviceAddressBookService = deviceAddressBookService;
        this.redisUtils = redisUtils;
        this.sysParamsService = sysParamsService;
        this.devicePresence = devicePresence;
        this.parentTaskService = parentTaskService;
    }
    @PostMapping("/bind/{agentId}/{deviceCode}")
@@ -89,9 +97,25 @@
    public Result<List<UserShowDeviceListVO>> getUserDevices(@PathVariable String agentId) {
        UserDetail user = SecurityUser.getUser();
        List<UserShowDeviceListVO> devices = deviceService.getUserDeviceList(user.getId(), agentId);
        for (UserShowDeviceListVO device : devices) {
            device.setOnline(devicePresence.isOnline(device.getMacAddress()));
        }
        return new Result<List<UserShowDeviceListVO>>().ok(devices);
    }
    @GetMapping("/presence")
    @Operation(summary = "查询设备是否在线(家长 MQTT 状态)")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentDeviceStatusDTO> presence(
            @RequestParam("macAddress") String macAddress,
            @RequestParam("agentId") String agentId) {
        DeviceEntity device = parentTaskService.requireUserDevice(SecurityUser.getUserId(), macAddress, agentId);
        ParentDeviceStatusDTO dto = new ParentDeviceStatusDTO();
        dto.setMacAddress(device.getMacAddress());
        dto.setOnline(devicePresence.isOnline(device.getMacAddress()));
        return new Result<ParentDeviceStatusDTO>().ok(dto);
    }
    @PostMapping("/bind/{agentId}")
    @Operation(summary = "设备在线接口")
    @RequiresPermissions("sys:role:normal")
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java
@@ -11,6 +11,9 @@
@Schema(description = "用户显示设备列表VO")
public class UserShowDeviceListVO {
    @Schema(description = "是否在线(家长 MQTT 状态)")
    private Boolean online;
    @Schema(description = "app版本")
    private String appVersion;
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/IdiomLessonCatalog.java
New file
@@ -0,0 +1,327 @@
package xiaozhi.modules.parent.catalog;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
 * 成语课文清单。增删只改 classpath:catalog/idiom-lessons.json。
 * 故事按 7 章收录一次,6 个 level 共用;lessonId = 级别.序号(如 l1.001)。
 * 分类 systemId 只给家长端筛选,不进文件路径。
 */
public final class IdiomLessonCatalog {
    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static final TypeReference<Map<String, Object>> MAP = new TypeReference<Map<String, Object>>() {
    };
    private static final Pattern LESSON = Pattern.compile("^(l[1-6])\\.(\\d{3})$", Pattern.CASE_INSENSITIVE);
    private static final Map<String, Object> DATA = load();
    private static final Map<String, Map<String, Object>> BY_ID = indexStories();
    private IdiomLessonCatalog() {
    }
    public static Map<String, Object> asApiMap() {
        Map<String, Object> out = new LinkedHashMap<>();
        out.put("version", DATA.getOrDefault("version", 2));
        out.put("defaults", DATA.get("defaults"));
        out.put("pathPatterns", DATA.get("pathPatterns"));
        out.put("levels", DATA.get("levels"));
        out.put("systems", DATA.get("systems"));
        List<Map<String, Object>> stories = new ArrayList<>();
        for (Map<String, Object> story : stories()) {
            if (enabled(story)) {
                stories.add(publicStory(story));
            }
        }
        out.put("stories", stories);
        return out;
    }
    public static Map<String, Object> findStory(String lessonId) {
        if (lessonId == null || lessonId.isBlank()) {
            return null;
        }
        String key = lessonId.trim().toLowerCase(Locale.ROOT);
        Map<String, Object> hit = BY_ID.get(key);
        if (hit != null) {
            return hit;
        }
        String[] parts = parseLesson(key);
        if (parts != null) {
            return BY_ID.get(parts[1]);
        }
        return null;
    }
    public static String canonicalLessonId(String lessonId) {
        Map<String, Object> story = findStory(lessonId);
        if (story == null) {
            return lessonId == null ? "" : lessonId.trim();
        }
        Map<String, Object> bound = bindStory(story, lessonId);
        return text(bound.get("levelId")) + "." + text(story.get("id"));
    }
    public static String storyName(String lessonId) {
        Map<String, Object> story = findStory(lessonId);
        return story == null ? "" : text(story.get("name"));
    }
    public static String storyNameEn(String lessonId) {
        Map<String, Object> story = findStory(lessonId);
        return story == null ? "" : text(story.get("nameEn"));
    }
    public static int sentenceCount(String lessonId) {
        Map<String, Object> story = findStory(lessonId);
        if (story == null) {
            return defaultInt("sentenceCount", 30);
        }
        return Math.max(1, Math.min(200, intVal(story.get("sentenceCount"), defaultInt("sentenceCount", 30))));
    }
    @SuppressWarnings("unchecked")
    public static List<Map<String, Object>> defaultEnPasses() {
        Map<String, Object> defaults = (Map<String, Object>) DATA.get("defaults");
        Object raw = defaults == null ? null : defaults.get("enPasses");
        if (!(raw instanceof List<?> list) || list.isEmpty()) {
            return List.of(
                    pass(0.8, 1),
                    pass(0.9, 2),
                    pass(1.0, 5));
        }
        List<Map<String, Object>> out = new ArrayList<>();
        for (Object item : list) {
            Map<String, Object> row = normalizePass(item);
            if (row != null) {
                out.add(row);
            }
        }
        return out.isEmpty() ? List.of(pass(0.8, 1), pass(0.9, 2), pass(1.0, 5)) : out;
    }
    public static List<String> relativePaths(String scene, String lessonId, String language) {
        Map<String, Object> story = findStory(lessonId);
        if (story == null) {
            return List.of();
        }
        Map<String, Object> bound = bindStory(story, lessonId);
        if ("extensive".equals(scene)) {
            return extensivePaths(bound, lessonId);
        }
        if ("shadowing".equals(scene)) {
            return shadowingPaths(bound, language, sentenceCount(lessonId), lessonId);
        }
        return List.of();
    }
    public static List<Map<String, Object>> lessons(String scene) {
        if (!"extensive".equals(scene) && !"shadowing".equals(scene)) {
            return List.of();
        }
        String kind = "extensive".equals(scene) ? "泛听" : "跟读";
        List<Map<String, Object>> out = new ArrayList<>();
        for (Map<String, Object> story : stories()) {
            if (!enabled(story)) {
                continue;
            }
            Map<String, Object> row = publicStory(story);
            row.put("lessonId", "l1." + text(story.get("id")));
            row.put("levelId", "l1");
            row.put("scene", scene);
            row.put("hint", kind + " · " + text(story.get("name")));
            out.add(row);
        }
        return out;
    }
    private static Map<String, Object> publicStory(Map<String, Object> story) {
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("id", text(story.get("id")));
        row.put("slug", text(story.get("slug")));
        row.put("systemId", text(story.get("systemId")));
        row.put("name", text(story.get("name")));
        row.put("nameEn", text(story.get("nameEn")));
        row.put("sentenceCount", intVal(story.get("sentenceCount"), defaultInt("sentenceCount", 30)));
        return row;
    }
    @SuppressWarnings("unchecked")
    private static List<String> extensivePaths(Map<String, Object> story, String lessonId) {
        Map<String, Object> patterns = (Map<String, Object>) DATA.get("pathPatterns");
        String unified = patterns == null ? "" : text(patterns.get("repeater"));
        if (!unified.isBlank()) {
            return List.of(fill(unified, story, "cn", "01"), fill(unified, story, "en", "01"));
        }
        String cnPat = patterns == null ? "" : text(patterns.get("repeaterCn"));
        String enPat = patterns == null ? "" : text(patterns.get("repeaterEn"));
        return List.of(fill(cnPat, story, "cn", "01"), fill(enPat, story, "en", "01"));
    }
    @SuppressWarnings("unchecked")
    private static List<String> shadowingPaths(Map<String, Object> story, String language, int count, String lessonId) {
        String lang = "cn".equalsIgnoreCase(language) ? "cn" : "en";
        int width = count > 99 ? 3 : 2;
        List<String> files = new ArrayList<>(count);
        Map<String, Object> patterns = (Map<String, Object>) DATA.get("pathPatterns");
        String pattern = patterns == null ? "" : text(patterns.get("shadowing"));
        for (int i = 1; i <= count; i++) {
            files.add(fill(pattern, story, lang, String.format("%0" + width + "d", i)));
        }
        return files;
    }
    private static Map<String, Object> bindStory(Map<String, Object> story, String lessonId) {
        Map<String, Object> bound = new LinkedHashMap<>(story);
        String[] parts = parseLesson(lessonId);
        bound.put("levelId", parts != null ? parts[0] : "l1");
        return bound;
    }
    private static String[] parseLesson(String lessonId) {
        if (lessonId == null || lessonId.isBlank()) {
            return null;
        }
        Matcher compact = LESSON.matcher(lessonId.trim());
        if (compact.matches()) {
            return new String[] {
                    compact.group(1).toLowerCase(Locale.ROOT),
                    compact.group(2)
            };
        }
        return null;
    }
    private static String fill(String pattern, Map<String, Object> story, String lang, String nn) {
        String value = pattern == null ? "" : pattern;
        return value
                .replace("{level}", text(story.get("levelId")))
                .replace("{story}", text(story.get("id")))
                .replace("{lang}", lang)
                .replace("{nn}", nn);
    }
    @SuppressWarnings("unchecked")
    private static List<Map<String, Object>> stories() {
        Object raw = DATA.get("stories");
        if (!(raw instanceof List<?> list)) {
            return List.of();
        }
        List<Map<String, Object>> out = new ArrayList<>();
        for (Object item : list) {
            if (item instanceof Map<?, ?> map) {
                out.add((Map<String, Object>) map);
            }
        }
        return out;
    }
    private static Map<String, Map<String, Object>> indexStories() {
        Map<String, Map<String, Object>> index = new LinkedHashMap<>();
        for (Map<String, Object> story : stories()) {
            putIndex(index, text(story.get("id")), story);
        }
        return index;
    }
    private static void putIndex(Map<String, Map<String, Object>> index, String key, Map<String, Object> story) {
        if (key == null || key.isBlank()) {
            return;
        }
        index.putIfAbsent(key.trim().toLowerCase(Locale.ROOT), story);
    }
    private static boolean enabled(Map<String, Object> story) {
        Object raw = story.get("enabled");
        if (raw instanceof Boolean bool) {
            return bool;
        }
        return true;
    }
    public static Map<String, Object> normalizePass(Object raw) {
        if (!(raw instanceof Map<?, ?> map)) {
            return null;
        }
        double speed = doubleVal(map.get("speed"), 1.0);
        if (speed < 0.5) {
            speed = 0.5;
        }
        if (speed > 2.0) {
            speed = 2.0;
        }
        int repeat = intVal(map.get("repeat"), 1);
        if (repeat < 1) {
            repeat = 1;
        }
        if (repeat > 10) {
            repeat = 10;
        }
        return pass(speed, repeat);
    }
    private static Map<String, Object> pass(double speed, int repeat) {
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("speed", speed);
        row.put("repeat", repeat);
        return row;
    }
    @SuppressWarnings("unchecked")
    private static int defaultInt(String key, int fallback) {
        Map<String, Object> defaults = (Map<String, Object>) DATA.get("defaults");
        return defaults == null ? fallback : intVal(defaults.get(key), fallback);
    }
    private static int intVal(Object value, int fallback) {
        if (value == null) {
            return fallback;
        }
        if (value instanceof Number number) {
            return number.intValue();
        }
        try {
            return Integer.parseInt(String.valueOf(value).trim());
        } catch (NumberFormatException ex) {
            return fallback;
        }
    }
    private static double doubleVal(Object value, double fallback) {
        if (value == null) {
            return fallback;
        }
        if (value instanceof Number number) {
            return number.doubleValue();
        }
        try {
            return Double.parseDouble(String.valueOf(value).trim());
        } catch (NumberFormatException ex) {
            return fallback;
        }
    }
    private static String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
    private static Map<String, Object> load() {
        try (InputStream in = IdiomLessonCatalog.class.getResourceAsStream("/catalog/idiom-lessons.json")) {
            if (in == null) {
                throw new IllegalStateException("missing classpath:catalog/idiom-lessons.json");
            }
            Map<String, Object> data = MAPPER.readValue(in, MAP);
            return data == null ? Map.of() : data;
        } catch (Exception ex) {
            throw new IllegalStateException("failed to load idiom-lessons.json", ex);
        }
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/LessonClipIndex.java
New file
@@ -0,0 +1,89 @@
package xiaozhi.modules.parent.catalog;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
/**
 * 课文 MP3 数量以 xiaozhi-server 磁盘目录为准。索引接口失败时返回 null,调用方再用清单兜底。
 */
@Slf4j
public final class LessonClipIndex {
    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static final Pattern LESSON = Pattern.compile("^(l[1-6])\\.(\\d{3})$", Pattern.CASE_INSENSITIVE);
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(2))
            .build();
    private LessonClipIndex() {
    }
    public static List<String> fetch(String baseUrl, String scene, String lessonId, String language) {
        String base = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
        Matcher lesson = LESSON.matcher(lessonId == null ? "" : lessonId.trim());
        if (base.isEmpty() || !lesson.matches()) {
            return null;
        }
        String level = lesson.group(1).toLowerCase();
        String story = lesson.group(2);
        try {
            if ("shadowing".equals(scene)) {
                String lang = "cn".equalsIgnoreCase(language) ? "cn" : "en";
                return oneFolder(base, "shadowing", level, story, lang);
            }
            if ("extensive".equals(scene)) {
                List<String> cn = oneFolder(base, "repeater", level, story, "cn");
                List<String> en = oneFolder(base, "repeater", level, story, "en");
                if (cn == null || en == null) {
                    return null;
                }
                List<String> both = new ArrayList<>(cn.size() + en.size());
                both.addAll(cn);
                both.addAll(en);
                return both;
            }
        } catch (Exception ex) {
            log.warn("lesson clip index failed: {}", ex.toString());
            return null;
        }
        return null;
    }
    private static List<String> oneFolder(String base, String kind, String level, String story, String lang)
            throws Exception {
        String url = base + "/index/" + kind + "/" + level + "/" + story + "/" + lang;
        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                .timeout(Duration.ofSeconds(3))
                .GET()
                .build();
        HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            return null;
        }
        JsonNode root = MAPPER.readTree(response.body());
        if (root == null || !root.path("present").asBoolean(false)) {
            return null;
        }
        List<String> files = new ArrayList<>();
        JsonNode raw = root.get("files");
        if (raw != null && raw.isArray()) {
            for (JsonNode item : raw) {
                if (item.isTextual()) {
                    files.add(item.asText());
                }
            }
        }
        return files;
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentCatalog.java
New file
@@ -0,0 +1,100 @@
package xiaozhi.modules.parent.catalog;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class ParentCatalog {
    private ParentCatalog() {
    }
    public static final int SCHEDULED_SOFT_LIMIT = 100;
    public static final String DEFAULT_ORG_ID = "default_org";
    public static String resolveOrgId(String orgId) {
        if (orgId == null) {
            return DEFAULT_ORG_ID;
        }
        String value = orgId.trim();
        if (value.isEmpty() || !value.matches("[A-Za-z0-9_-]{1,64}")) {
            return DEFAULT_ORG_ID;
        }
        return value;
    }
    public static String courseId(String scene, String lessonId) {
        String lesson = (lessonId == null || lessonId.isBlank()) ? "l1.001" : lessonId.trim();
        String kind = "shadowing".equals(scene) ? "shd" : "rpt";
        String id = (lesson + "-" + kind).replaceAll("[^A-Za-z0-9_-]", "-");
        return id.length() < 32 ? id : id.substring(0, 31);
    }
    public static List<String> lessonRelativePaths(String scene, String lessonId) {
        return lessonRelativePaths(scene, lessonId, "en");
    }
    public static List<String> lessonRelativePaths(String scene, String lessonId, String language) {
        String lesson = (lessonId == null || lessonId.isBlank()) ? "l1.001" : lessonId.trim();
        return IdiomLessonCatalog.relativePaths(scene, lesson, language);
    }
    public static List<String> uniqueLessonPaths(List<String> files) {
        Set<String> seen = new LinkedHashSet<>();
        List<String> out = new ArrayList<>();
        if (files == null) {
            return out;
        }
        for (String raw : files) {
            if (raw == null) {
                continue;
            }
            String path = raw.replace('\\', '/').trim();
            if (path.isEmpty()) {
                continue;
            }
            if (!path.startsWith("/")) {
                path = "/" + path;
            }
            if (path.contains("..") || !path.startsWith("/english_learning/") || !path.toLowerCase().endsWith(".mp3")) {
                continue;
            }
            if (seen.add(path)) {
                out.add(path);
            }
        }
        return out;
    }
    public static Map<String, String> sceneToAppTask(String scene) {
        return ParentSceneCatalog.sceneToAppTask(scene);
    }
    public static List<Map<String, Object>> scenes() {
        return ParentSceneCatalog.scenes();
    }
    public static List<Map<String, Object>> lessons(String scene) {
        return IdiomLessonCatalog.lessons(scene);
    }
    public static List<Map<String, Object>> languagePairs() {
        return List.of(
                Map.of("id", "zh-en", "name", "中英", "ready", true),
                Map.of("id", "zh-fr", "name", "中法", "ready", false),
                Map.of("id", "zh-ja", "name", "中日", "ready", false));
    }
    public static String sceneName(String scene) {
        return ParentSceneCatalog.sceneName(scene);
    }
    public static String sceneIdFromAppTask(String appId, String taskId) {
        return ParentSceneCatalog.sceneIdFromAppTask(appId, taskId);
    }
    public static String kindFromAppTask(String appId, String taskId) {
        return ParentSceneCatalog.kindFromAppTask(appId, taskId);
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentSceneCatalog.java
New file
@@ -0,0 +1,341 @@
package xiaozhi.modules.parent.catalog;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
/**
 * 家长场景目录。优先读 data/catalog/parent-scenes.json(改文件即生效),否则用 classpath。
 */
@Slf4j
public final class ParentSceneCatalog {
    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static final TypeReference<Map<String, Object>> MAP = new TypeReference<>() {
    };
    private static final Object LOCK = new Object();
    private static final long CLASSPATH_RECHECK_MS = 2000L;
    private static List<Map<String, Object>> cached = List.of();
    private static String cachedSource = "";
    private static long cachedMtime = Long.MIN_VALUE;
    private static long cachedAtMs = 0L;
    private ParentSceneCatalog() {
    }
    public static List<Map<String, Object>> scenes() {
        List<Map<String, Object>> out = new ArrayList<>();
        for (Map<String, Object> row : snapshot()) {
            if (bool(row.get("enabled"), true)) {
                out.add(publicScene(row));
            }
        }
        return out;
    }
    public static Map<String, Object> find(String sceneId) {
        String id = text(sceneId).toLowerCase(Locale.ROOT);
        if (id.isEmpty()) {
            return null;
        }
        for (Map<String, Object> row : snapshot()) {
            if (id.equalsIgnoreCase(text(row.get("id")))) {
                return row;
            }
        }
        return null;
    }
    public static Map<String, Object> require(String sceneId) {
        Map<String, Object> row = find(sceneId);
        if (row == null || !bool(row.get("enabled"), true)) {
            throw new IllegalArgumentException("不支持的场景");
        }
        return row;
    }
    public static Map<String, String> sceneToAppTask(String sceneId) {
        Map<String, Object> row = find(sceneId);
        if (row == null || !bool(row.get("enabled"), true)) {
            return null;
        }
        String appId = text(row.get("appId"));
        String taskId = text(row.get("taskId"));
        if (appId.isEmpty() || taskId.isEmpty()) {
            return null;
        }
        Map<String, String> mapped = new LinkedHashMap<>();
        mapped.put("appId", appId);
        mapped.put("taskId", taskId);
        mapped.put("name", text(row.get("name")));
        return mapped;
    }
    public static String sceneName(String sceneId) {
        Map<String, Object> row = find(sceneId);
        if (row == null) {
            return text(sceneId);
        }
        String name = text(row.get("name"));
        return name.isEmpty() ? text(sceneId) : name;
    }
    public static String sceneIdFromAppTask(String appId, String taskId) {
        String app = text(appId);
        String task = text(taskId);
        for (Map<String, Object> row : snapshot()) {
            if (app.equals(text(row.get("appId"))) && (task.isEmpty() || task.equals(text(row.get("taskId"))))) {
                return text(row.get("id"));
            }
        }
        return "";
    }
    public static String kindFromAppTask(String appId, String taskId) {
        Map<String, Object> row = find(sceneIdFromAppTask(appId, taskId));
        if (row == null) {
            return "chat";
        }
        String kind = text(row.get("kind"));
        return kind.isEmpty() ? text(row.get("id")) : kind;
    }
    public static boolean flag(String sceneId, String key) {
        Map<String, Object> row = find(sceneId);
        return row != null && bool(row.get(key), false);
    }
    public static boolean flag(Map<String, Object> scene, String key) {
        return scene != null && bool(scene.get(key), false);
    }
    static void resetForTests() {
        synchronized (LOCK) {
            cached = List.of();
            cachedSource = "";
            cachedMtime = Long.MIN_VALUE;
            cachedAtMs = 0L;
        }
    }
    private static List<Map<String, Object>> snapshot() {
        synchronized (LOCK) {
            reloadIfNeeded();
            return cached;
        }
    }
    @SuppressWarnings("unchecked")
    private static void reloadIfNeeded() {
        Path file = firstExisting(catalogCandidates());
        long now = System.currentTimeMillis();
        if (file != null) {
            long mtime = file.toFile().lastModified();
            if (!cached.isEmpty() && file.toAbsolutePath().toString().equals(cachedSource) && mtime == cachedMtime) {
                return;
            }
            try {
                Map<String, Object> data = MAPPER.readValue(Files.readAllBytes(file), MAP);
                cached = normalizeScenes(data);
                cachedSource = file.toAbsolutePath().toString();
                cachedMtime = mtime;
                cachedAtMs = now;
                log.info("loaded parent scenes {} count={}", cachedSource, cached.size());
                return;
            } catch (Exception ex) {
                log.warn("failed to read {}, keep previous catalog: {}", file, ex.getMessage());
                if (!cached.isEmpty()) {
                    return;
                }
            }
        }
        if (!cached.isEmpty() && "classpath".equals(cachedSource) && now - cachedAtMs < CLASSPATH_RECHECK_MS) {
            return;
        }
        try (InputStream in = ParentSceneCatalog.class.getResourceAsStream("/catalog/parent-scenes.json")) {
            if (in != null) {
                Map<String, Object> data = MAPPER.readValue(in, MAP);
                cached = normalizeScenes(data);
                cachedSource = "classpath";
                cachedMtime = Long.MIN_VALUE;
                cachedAtMs = now;
                return;
            }
        } catch (Exception ex) {
            log.warn("failed to read classpath parent-scenes.json: {}", ex.getMessage());
        }
        if (cached.isEmpty()) {
            cached = builtinScenes();
            cachedSource = "builtin";
            cachedMtime = Long.MIN_VALUE;
            cachedAtMs = now;
        }
    }
    private static Path firstExisting(List<Path> candidates) {
        for (Path path : candidates) {
            if (path != null && Files.isRegularFile(path)) {
                return path;
            }
        }
        return null;
    }
    private static List<Path> catalogCandidates() {
        List<Path> out = new ArrayList<>();
        String override = System.getProperty("xiaozhi.catalog.dir", System.getenv("XIAOZHI_CATALOG_DIR"));
        if (override != null && !override.isBlank()) {
            out.add(Paths.get(override.trim(), "parent-scenes.json"));
        }
        String userDir = System.getProperty("user.dir", ".");
        out.add(Paths.get(userDir, "data", "catalog", "parent-scenes.json"));
        out.add(Paths.get(userDir, "src", "main", "resources", "catalog", "parent-scenes.json"));
        return out;
    }
    @SuppressWarnings("unchecked")
    private static List<Map<String, Object>> normalizeScenes(Map<String, Object> data) {
        Object raw = data == null ? null : data.get("scenes");
        if (!(raw instanceof List<?> list)) {
            return builtinScenes();
        }
        List<Map<String, Object>> out = new ArrayList<>();
        for (Object item : list) {
            if (!(item instanceof Map<?, ?> map)) {
                continue;
            }
            Map<String, Object> row = sanitize((Map<String, Object>) map);
            if (row != null) {
                out.add(row);
            }
        }
        out.sort(Comparator.comparingInt(a -> intVal(a.get("sort"), 100)));
        return out.isEmpty() ? builtinScenes() : out;
    }
    private static Map<String, Object> sanitize(Map<String, Object> raw) {
        String id = text(raw.get("id")).toLowerCase(Locale.ROOT);
        String appId = text(raw.get("appId"));
        String taskId = text(raw.get("taskId"));
        if (!id.matches("[a-z0-9_]{1,32}") || !safeId(appId) || !safeId(taskId)) {
            return null;
        }
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("id", id);
        row.put("name", text(raw.get("name")).isEmpty() ? id : text(raw.get("name")));
        row.put("hint", text(raw.get("hint")));
        row.put("enabled", bool(raw.get("enabled"), true));
        row.put("sort", intVal(raw.get("sort"), 100));
        row.put("appId", appId);
        row.put("taskId", taskId);
        String kind = text(raw.get("kind"));
        row.put("kind", kind.isEmpty() ? id : kind);
        row.put("audioPackage", bool(raw.get("audioPackage"), false));
        row.put("needsLesson", bool(raw.get("needsLesson"), false));
        row.put("needsEnPasses", bool(raw.get("needsEnPasses"), false));
        row.put("needsShadowingSettings", bool(raw.get("needsShadowingSettings"), false));
        row.put("needsRepeat", bool(raw.get("needsRepeat"), false));
        row.put("needsLanguagePair", bool(raw.get("needsLanguagePair"), false));
        row.put("needsDuration", bool(raw.get("needsDuration"), false));
        row.put("needsOnComplete", bool(raw.get("needsOnComplete"), true));
        return row;
    }
    private static Map<String, Object> publicScene(Map<String, Object> row) {
        Map<String, Object> out = new LinkedHashMap<>();
        out.put("id", row.get("id"));
        out.put("name", row.get("name"));
        out.put("hint", row.get("hint"));
        out.put("appId", row.get("appId"));
        out.put("taskId", row.get("taskId"));
        out.put("kind", row.get("kind"));
        out.put("audioPackage", row.get("audioPackage"));
        out.put("needsLesson", row.get("needsLesson"));
        out.put("needsEnPasses", row.get("needsEnPasses"));
        out.put("needsShadowingSettings", row.get("needsShadowingSettings"));
        out.put("needsRepeat", row.get("needsRepeat"));
        out.put("needsLanguagePair", row.get("needsLanguagePair"));
        out.put("needsDuration", row.get("needsDuration"));
        out.put("needsOnComplete", row.get("needsOnComplete"));
        return out;
    }
    private static List<Map<String, Object>> builtinScenes() {
        List<Map<String, Object>> out = new ArrayList<>();
        out.add(sanitize(scene("extensive", "泛听", "english_learning", "repeater", "extensive", 10, true, true, false, false, false, true, true)));
        out.add(sanitize(scene("shadowing", "跟读", "english_learning", "shadowing", "shadowing", 20, true, false, true, false, false, true, true)));
        out.add(sanitize(scene("chat", "默认聊天", "chat", "conversation", "chat", 30, false, false, false, false, true, true, false)));
        out.add(sanitize(scene("translation", "翻译", "english_translation", "live_translate", "translation", 40, false, false, false, true, true, true, false)));
        out.add(sanitize(scene("bilingual", "中英对话", "english_conversation", "bilingual_chat", "bilingual", 50, false, false, false, true, true, true, false)));
        return out;
    }
    private static Map<String, Object> scene(String id, String name, String appId, String taskId, String kind, int sort,
            boolean lesson, boolean enPasses, boolean shadowing, boolean pair, boolean duration, boolean onComplete,
            boolean audio) {
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("id", id);
        row.put("name", name);
        row.put("appId", appId);
        row.put("taskId", taskId);
        row.put("kind", kind);
        row.put("sort", sort);
        row.put("needsLesson", lesson);
        row.put("needsEnPasses", enPasses);
        row.put("needsShadowingSettings", shadowing);
        row.put("needsLanguagePair", pair);
        row.put("needsDuration", duration);
        row.put("needsOnComplete", onComplete);
        row.put("audioPackage", audio);
        row.put("enabled", true);
        return row;
    }
    private static boolean safeId(String value) {
        return value != null && value.matches("[A-Za-z0-9_-]{1,31}");
    }
    private static boolean bool(Object value, boolean fallback) {
        if (value instanceof Boolean bool) {
            return bool;
        }
        if (value == null) {
            return fallback;
        }
        String text = String.valueOf(value).trim();
        if ("true".equalsIgnoreCase(text) || "1".equals(text)) {
            return true;
        }
        if ("false".equalsIgnoreCase(text) || "0".equals(text)) {
            return false;
        }
        return fallback;
    }
    private static int intVal(Object value, int fallback) {
        if (value instanceof Number number) {
            return number.intValue();
        }
        try {
            return Integer.parseInt(String.valueOf(value).trim());
        } catch (Exception ex) {
            return fallback;
        }
    }
    private static String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/controller/ParentTaskController.java
New file
@@ -0,0 +1,154 @@
package xiaozhi.modules.parent.controller;
import java.util.List;
import java.util.Map;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.parent.catalog.IdiomLessonCatalog;
import xiaozhi.modules.parent.catalog.ParentCatalog;
import xiaozhi.modules.parent.dto.ParentDeviceStatusDTO;
import xiaozhi.modules.parent.dto.ParentHistoryDetailDTO;
import xiaozhi.modules.parent.dto.ParentHistoryListDTO;
import xiaozhi.modules.parent.dto.ParentTaskDTO;
import xiaozhi.modules.parent.dto.ParentTaskListDTO;
import xiaozhi.modules.parent.dto.ParentTaskSaveDTO;
import xiaozhi.modules.parent.service.ParentHistoryService;
import xiaozhi.modules.parent.service.ParentTaskService;
import xiaozhi.modules.security.user.SecurityUser;
@Tag(name = "家长任务下发")
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class ParentTaskController {
    private final ParentTaskService parentTaskService;
    private final ParentHistoryService parentHistoryService;
    @GetMapping("/scenes")
    @Operation(summary = "场景列表(parent-scenes.json,热加载)")
    @RequiresPermissions("sys:role:normal")
    public Result<List<Map<String, Object>>> scenes() {
        return new Result<List<Map<String, Object>>>().ok(ParentCatalog.scenes());
    }
    @GetMapping("/idiom-catalog")
    @Operation(summary = "成语课文清单(级别/分类/故事,增删改 idiom-lessons.json)")
    @RequiresPermissions("sys:role:normal")
    public Result<Map<String, Object>> idiomCatalog() {
        return new Result<Map<String, Object>>().ok(IdiomLessonCatalog.asApiMap());
    }
    @GetMapping("/lessons")
    @Operation(summary = "课文目录")
    @RequiresPermissions("sys:role:normal")
    public Result<List<Map<String, Object>>> lessons(@RequestParam("scene") String scene) {
        return new Result<List<Map<String, Object>>>().ok(ParentCatalog.lessons(scene));
    }
    @GetMapping("/language-pairs")
    @Operation(summary = "语言对")
    @RequiresPermissions("sys:role:normal")
    public Result<List<Map<String, Object>>> languagePairs() {
        return new Result<List<Map<String, Object>>>().ok(ParentCatalog.languagePairs());
    }
    @GetMapping("/parent-device/status")
    @Operation(summary = "查询设备是否在线(家长 MQTT 状态)")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentDeviceStatusDTO> deviceStatus(
            @RequestParam("macAddress") String macAddress,
            @RequestParam("agentId") String agentId) {
        return new Result<ParentDeviceStatusDTO>().ok(
                parentTaskService.deviceStatus(SecurityUser.getUserId(), macAddress, agentId));
    }
    @GetMapping("/parent-task")
    @Operation(summary = "任务列表")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentTaskListDTO> list(
            @RequestParam("macAddress") String macAddress,
            @RequestParam("agentId") String agentId,
            @RequestParam(value = "runMode", required = false) String runMode) {
        return new Result<ParentTaskListDTO>().ok(
                parentTaskService.list(SecurityUser.getUserId(), macAddress, agentId, runMode));
    }
    @GetMapping("/parent-task/{id}")
    @Operation(summary = "任务详情")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentTaskDTO> get(@PathVariable("id") String id) {
        return new Result<ParentTaskDTO>().ok(parentTaskService.get(SecurityUser.getUserId(), id));
    }
    @PostMapping("/parent-task")
    @Operation(summary = "创建任务:立即开始或存为计划")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentTaskDTO> create(@RequestBody ParentTaskSaveDTO body) {
        return new Result<ParentTaskDTO>().ok(parentTaskService.create(SecurityUser.getUserId(), body));
    }
    @PutMapping("/parent-task/{id}")
    @Operation(summary = "更新计划")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentTaskDTO> update(@PathVariable("id") String id, @RequestBody ParentTaskSaveDTO body) {
        return new Result<ParentTaskDTO>().ok(parentTaskService.update(SecurityUser.getUserId(), id, body));
    }
    @DeleteMapping("/parent-task/{id}")
    @Operation(summary = "删除任务")
    @RequiresPermissions("sys:role:normal")
    public Result<Boolean> delete(@PathVariable("id") String id) {
        parentTaskService.delete(SecurityUser.getUserId(), id);
        return new Result<Boolean>().ok(Boolean.TRUE);
    }
    @PostMapping("/parent-task/{id}/run")
    @Operation(summary = "按已有任务定义立即执行(不改周期表)")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentTaskDTO> runNow(@PathVariable("id") String id) {
        return new Result<ParentTaskDTO>().ok(parentTaskService.runNow(SecurityUser.getUserId(), id));
    }
    @PostMapping("/parent-task/{id}/stop")
    @Operation(summary = "结束正在执行的任务,设备回到对话")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentTaskDTO> stop(@PathVariable("id") String id) {
        return new Result<ParentTaskDTO>().ok(parentTaskService.stop(SecurityUser.getUserId(), id));
    }
    @GetMapping("/parent-history")
    @Operation(summary = "家长执行记录列表(无分数)")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentHistoryListDTO> historyList(
            @RequestParam("macAddress") String macAddress,
            @RequestParam("agentId") String agentId,
            @RequestParam(value = "limit", required = false) Integer limit) {
        return new Result<ParentHistoryListDTO>().ok(
                parentHistoryService.list(SecurityUser.getUserId(), macAddress, agentId, limit));
    }
    @GetMapping("/parent-history/{id}")
    @Operation(summary = "家长执行记录详情(跟读句文本/录音,泛听完成态,对话类聊天)")
    @RequiresPermissions("sys:role:normal")
    public Result<ParentHistoryDetailDTO> historyDetail(
            @PathVariable("id") String id,
            @RequestParam("macAddress") String macAddress,
            @RequestParam("agentId") String agentId) {
        return new Result<ParentHistoryDetailDTO>().ok(
                parentHistoryService.get(SecurityUser.getUserId(), macAddress, agentId, id));
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dao/ParentTaskDao.java
New file
@@ -0,0 +1,11 @@
package xiaozhi.modules.parent.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.parent.entity.ParentTaskEntity;
@Mapper
public interface ParentTaskDao extends BaseMapper<ParentTaskEntity> {
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentDeviceStatusDTO.java
New file
@@ -0,0 +1,11 @@
package xiaozhi.modules.parent.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "家长端设备在线状态")
public class ParentDeviceStatusDTO {
    private String macAddress;
    private Boolean online;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryDetailDTO.java
New file
@@ -0,0 +1,15 @@
package xiaozhi.modules.parent.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
@Data
@EqualsAndHashCode(callSuper = true)
public class ParentHistoryDetailDTO extends ParentHistoryItemDTO {
    private List<ParentHistoryTurnDTO> turns = new ArrayList<>();
    private List<AgentChatHistoryDTO> chats = new ArrayList<>();
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryItemDTO.java
New file
@@ -0,0 +1,20 @@
package xiaozhi.modules.parent.dto;
import lombok.Data;
@Data
public class ParentHistoryItemDTO {
    private String id;
    private String scene;
    private String sceneName;
    private String kind;
    private String appId;
    private String taskId;
    private String source;
    private String status;
    private String endReason;
    private Long startedAt;
    private Long endedAt;
    private Long durationMs;
    private int turnCount;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryListDTO.java
New file
@@ -0,0 +1,12 @@
package xiaozhi.modules.parent.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
@Data
public class ParentHistoryListDTO {
    private long total;
    private List<ParentHistoryItemDTO> list = new ArrayList<>();
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryTurnDTO.java
New file
@@ -0,0 +1,11 @@
package xiaozhi.modules.parent.dto;
import lombok.Data;
@Data
public class ParentHistoryTurnDTO {
    private Integer index;
    private String expectedText;
    private String asrText;
    private String audioId;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentImmediateCloseDTO.java
New file
@@ -0,0 +1,31 @@
package xiaozhi.modules.parent.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "xiaozhi-server 关闭家长即时任务")
public class ParentImmediateCloseDTO {
    @JsonProperty("device_id")
    private String deviceId;
    private String reason;
    @JsonProperty("except_app_id")
    private String exceptAppId;
    @JsonProperty("except_task_id")
    private String exceptTaskId;
    @JsonProperty("match_app_id")
    private String matchAppId;
    @JsonProperty("match_task_id")
    private String matchTaskId;
    private String status;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskDTO.java
New file
@@ -0,0 +1,26 @@
package xiaozhi.modules.parent.dto;
import java.util.Date;
import java.util.Map;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "家长任务")
public class ParentTaskDTO {
    private String id;
    private String macAddress;
    private String agentId;
    private String runMode;
    private String scene;
    private String appId;
    private String taskId;
    private String sceneName;
    private Map<String, Object> params;
    private Map<String, Object> schedule;
    private Boolean enabled;
    private String status;
    private Date createdAt;
    private Date updatedAt;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskListDTO.java
New file
@@ -0,0 +1,12 @@
package xiaozhi.modules.parent.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
@Data
public class ParentTaskListDTO {
    private long total;
    private List<ParentTaskDTO> list = new ArrayList<>();
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskSaveDTO.java
New file
@@ -0,0 +1,31 @@
package xiaozhi.modules.parent.dto;
import java.util.Map;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "创建或更新家长任务")
public class ParentTaskSaveDTO {
    @Schema(description = "设备 MAC", requiredMode = Schema.RequiredMode.REQUIRED)
    private String macAddress;
    @Schema(description = "智能体 ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private String agentId;
    @Schema(description = "immediate 立即执行;scheduled 存为计划", requiredMode = Schema.RequiredMode.REQUIRED)
    private String runMode;
    @Schema(description = "extensive/shadowing/bilingual/translation/chat", requiredMode = Schema.RequiredMode.REQUIRED)
    private String scene;
    @Schema(description = "场景参数:lesson_id/en_passes/speed/language/language_pair/duration_minutes/on_complete")
    private Map<String, Object> params;
    @Schema(description = "仅 scheduled:daily/hourly/interval")
    private Map<String, Object> schedule;
    @Schema(description = "计划是否启用,默认 true")
    private Boolean enabled;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/entity/ParentTaskEntity.java
New file
@@ -0,0 +1,51 @@
package xiaozhi.modules.parent.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("ai_parent_task")
public class ParentTaskEntity {
    @TableId(type = IdType.INPUT)
    private String id;
    @TableField("user_id")
    private Long userId;
    @TableField("mac_address")
    private String macAddress;
    @TableField("agent_id")
    private String agentId;
    @TableField("run_mode")
    private String runMode;
    private String scene;
    @TableField("app_id")
    private String appId;
    @TableField("task_id")
    private String taskId;
    private String params;
    private String schedule;
    private Integer enabled;
    private String status;
    @TableField("created_at")
    private Date createdAt;
    @TableField("updated_at")
    private Date updatedAt;
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineEvent.java
New file
@@ -0,0 +1,4 @@
package xiaozhi.modules.parent.mqtt;
public record ParentDeviceOfflineEvent(String macAddress) {
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineListener.java
New file
@@ -0,0 +1,23 @@
package xiaozhi.modules.parent.mqtt;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import xiaozhi.modules.parent.service.ParentTaskService;
@Component
@RequiredArgsConstructor
public class ParentDeviceOfflineListener {
    private final ParentTaskService parentTaskService;
    @Async("taskExecutor")
    @EventListener
    public void onOffline(ParentDeviceOfflineEvent event) {
        if (event == null) {
            return;
        }
        parentTaskService.closeRunningForOffline(event.macAddress());
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDevicePresence.java
New file
@@ -0,0 +1,288 @@
package xiaozhi.modules.parent.mqtt;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.service.SysParamsService;
/**
 * 根据设备 MQTT 状态主题判断是否在线。未在线则不允许保存/下发家长任务。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ParentDevicePresence {
    public static final String OFFLINE_MESSAGE = "设备不在线,无法同步到硬件。请打开设备并连上网络后再试,本次不会保存。";
    private static final long ONLINE_TTL_SEC = 180L;
    private static final long PROBE_WAIT_MS = 2000L;
    private static final TypeReference<Map<String, Object>> MAP = new TypeReference<>() {
    };
    private final SysParamsService sysParamsService;
    private final RedisUtils redisUtils;
    private final ObjectMapper objectMapper;
    private final ApplicationEventPublisher eventPublisher;
    private volatile boolean stopped;
    private volatile MqttClient listener;
    @PostConstruct
    public void start() {
        Thread thread = new Thread(this::listenLoop, "parent-mqtt-presence");
        thread.setDaemon(true);
        thread.start();
    }
    @PreDestroy
    public void stop() {
        stopped = true;
        closeQuietly(listener);
    }
    public void requireOnline(String macAddress) {
        if (!isOnline(macAddress)) {
            throw new RenException(OFFLINE_MESSAGE);
        }
    }
    public boolean isOnline(String macAddress) {
        if (isOnlineCached(macAddress)) {
            return true;
        }
        String mac = ParentMqttPublisher.normalizeMac(macAddress);
        if (StringUtils.isBlank(mac)) {
            return false;
        }
        return probe(mac);
    }
    public boolean isOnlineCached(String macAddress) {
        String mac = ParentMqttPublisher.normalizeMac(macAddress);
        if (StringUtils.isBlank(mac)) {
            return false;
        }
        return redisUtils.get(RedisKeys.getParentDeviceOnlineKey(mac)) != null;
    }
    private void listenLoop() {
        while (!stopped) {
            try {
                listenOnce();
            } catch (Exception ex) {
                log.warn("parent mqtt presence: {}", ex.getMessage());
            }
            sleepQuietly(15_000);
        }
    }
    private void listenOnce() throws MqttException {
        Broker broker = broker();
        if (broker == null) {
            return;
        }
        MqttClient client = new MqttClient(broker.uri(), "parent-api-presence-" + UUID.randomUUID(),
                new MemoryPersistence());
        listener = client;
        client.setCallback(new org.eclipse.paho.client.mqttv3.MqttCallback() {
            @Override
            public void connectionLost(Throwable cause) {
                log.warn("parent mqtt presence disconnected: {}",
                        cause == null ? "" : cause.getMessage());
            }
            @Override
            public void messageArrived(String topic, org.eclipse.paho.client.mqttv3.MqttMessage message) {
                applyState(topic, new String(message.getPayload(), java.nio.charset.StandardCharsets.UTF_8));
            }
            @Override
            public void deliveryComplete(org.eclipse.paho.client.mqttv3.IMqttDeliveryToken token) {
            }
        });
        client.connect(broker.options());
        client.subscribe("snlp/v1/+/rpt/state", 0);
        log.info("parent mqtt presence listening on snlp/v1/+/rpt/state");
        while (!stopped && client.isConnected()) {
            sleepQuietly(1000);
        }
        closeQuietly(client);
        if (listener == client) {
            listener = null;
        }
    }
    private boolean probe(String mac) {
        Broker broker = broker();
        if (broker == null) {
            return false;
        }
        String topic = "snlp/v1/" + mac + "/rpt/state";
        CountDownLatch latch = new CountDownLatch(1);
        MqttClient client = null;
        try {
            client = new MqttClient(broker.uri(), "parent-api-probe-" + UUID.randomUUID(), new MemoryPersistence());
            client.connect(broker.options());
            client.subscribe(topic, 0, (t, message) -> {
                applyState(t, new String(message.getPayload(), java.nio.charset.StandardCharsets.UTF_8));
                latch.countDown();
            });
            latch.await(PROBE_WAIT_MS, TimeUnit.MILLISECONDS);
        } catch (Exception ex) {
            log.warn("parent mqtt presence probe failed: {}", ex.getMessage());
        } finally {
            closeQuietly(client);
        }
        return redisUtils.get(RedisKeys.getParentDeviceOnlineKey(mac)) != null;
    }
    @SuppressWarnings("unchecked")
    private void applyState(String topic, String json) {
        try {
            Map<String, Object> root = objectMapper.readValue(json, MAP);
            Object payloadRaw = root.get("payload");
            Map<String, Object> payload = payloadRaw instanceof Map<?, ?> map ? (Map<String, Object>) map : Map.of();
            String mac = ParentMqttPublisher.normalizeMac(text(payload.get("mac")));
            if (StringUtils.isBlank(mac)) {
                mac = macFromTopic(topic);
            }
            if (StringUtils.isBlank(mac)) {
                return;
            }
            boolean online = payloadMeansOnline(payload);
            String key = RedisKeys.getParentDeviceOnlineKey(mac);
            if (online) {
                redisUtils.set(key, "1", ONLINE_TTL_SEC);
            } else {
                boolean wasOnline = redisUtils.get(key) != null;
                redisUtils.delete(key);
                if (wasOnline) {
                    eventPublisher.publishEvent(new ParentDeviceOfflineEvent(mac));
                }
            }
        } catch (Exception ex) {
            log.debug("ignore parent mqtt state: {}", ex.getMessage());
        }
    }
    private static String macFromTopic(String topic) {
        String[] parts = StringUtils.trimToEmpty(topic).split("/");
        if (parts.length >= 5 && "snlp".equals(parts[0]) && "v1".equals(parts[1])
                && "rpt".equals(parts[3]) && "state".equals(parts[4]) && parts[2].contains(":")) {
            return ParentMqttPublisher.normalizeMac(parts[2]);
        }
        return "";
    }
    private Broker broker() {
        String host = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_host", true));
        String portText = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_port", true));
        String username = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_username", true));
        String password = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_password", true));
        if (StringUtils.isBlank(host) || "null".equalsIgnoreCase(host)) {
            return null;
        }
        int port = 2100;
        try {
            if (StringUtils.isNotBlank(portText) && !"null".equalsIgnoreCase(portText)) {
                port = Integer.parseInt(portText);
            }
        } catch (NumberFormatException ex) {
            return null;
        }
        MqttConnectOptions options = new MqttConnectOptions();
        options.setAutomaticReconnect(false);
        options.setCleanSession(true);
        options.setConnectionTimeout(8);
        options.setKeepAliveInterval(30);
        if (StringUtils.isNotBlank(username) && !"null".equalsIgnoreCase(username)) {
            options.setUserName(username);
        }
        if (StringUtils.isNotBlank(password) && !"null".equalsIgnoreCase(password)) {
            options.setPassword(password.toCharArray());
        }
        return new Broker("tcp://" + host + ":" + port, options);
    }
    static boolean payloadMeansOnline(Map<String, Object> payload) {
        if (payload == null || payload.isEmpty()) {
            return false;
        }
        Boolean online = asBoolean(payload.get("online"));
        if (Boolean.FALSE.equals(online)) {
            return false;
        }
        if (Boolean.TRUE.equals(online)) {
            return true;
        }
        String net = text(payload.get("net"));
        return StringUtils.isNotBlank(net) && !"offline".equalsIgnoreCase(net);
    }
    private static Boolean asBoolean(Object value) {
        if (value instanceof Boolean bool) {
            return bool;
        }
        if (value == null) {
            return null;
        }
        String text = String.valueOf(value).trim();
        if ("true".equalsIgnoreCase(text) || "1".equals(text)) {
            return Boolean.TRUE;
        }
        if ("false".equalsIgnoreCase(text) || "0".equals(text)) {
            return Boolean.FALSE;
        }
        return null;
    }
    private static String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
    private static void closeQuietly(MqttClient client) {
        if (client == null) {
            return;
        }
        try {
            if (client.isConnected()) {
                client.disconnect();
            }
        } catch (Exception ignored) {
        }
        try {
            client.close();
        } catch (Exception ignored) {
        }
    }
    private static void sleepQuietly(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
    }
    private record Broker(String uri, MqttConnectOptions options) {
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentMqttPublisher.java
New file
@@ -0,0 +1,121 @@
package xiaozhi.modules.parent.mqtt;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.RenException;
import xiaozhi.modules.sys.service.SysParamsService;
@Slf4j
@Component
@RequiredArgsConstructor
public class ParentMqttPublisher {
    private static final int PAYLOAD_SOFT_MAX = 7800;
    private final SysParamsService sysParamsService;
    private final ObjectMapper objectMapper;
    public void publishCtl(String macAddress, String action, Object payload) {
        String host = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_host", true));
        String portText = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_port", true));
        String username = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_username", true));
        String password = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_password", true));
        if (StringUtils.isBlank(host) || "null".equalsIgnoreCase(host)) {
            throw new RenException("未配置 server.parent_mqtt_host,无法下发到设备");
        }
        int port = 2100;
        try {
            if (StringUtils.isNotBlank(portText) && !"null".equalsIgnoreCase(portText)) {
                port = Integer.parseInt(portText);
            }
        } catch (NumberFormatException ex) {
            throw new RenException("server.parent_mqtt_port 无效");
        }
        String mac = normalizeMac(macAddress);
        String topic = "snlp/v1/" + mac + "/cmd/ctl";
        Map<String, Object> header = new LinkedHashMap<>();
        header.put("version", "1.0");
        header.put("device_id", mac);
        header.put("req_id", UUID.randomUUID().toString());
        header.put("timestamp", System.currentTimeMillis() / 1000L);
        header.put("ttl", 60);
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("header", header);
        body.put("action", action);
        body.put("payload", payload == null ? Map.of() : payload);
        String json;
        try {
            json = objectMapper.writeValueAsString(body);
        } catch (Exception ex) {
            throw new RenException("家长任务 JSON 序列化失败");
        }
        if (json.length() > PAYLOAD_SOFT_MAX) {
            throw new RenException("下发内容超过设备 MQTT 8KB 上限,请减少计划条数(不要下发展开后的课文清单)");
        }
        String broker = "tcp://" + host + ":" + port;
        String clientId = "parent-api-" + UUID.randomUUID();
        try (MqttClient client = new MqttClient(broker, clientId, new MemoryPersistence())) {
            MqttConnectOptions options = new MqttConnectOptions();
            options.setAutomaticReconnect(false);
            options.setCleanSession(true);
            options.setConnectionTimeout(8);
            if (StringUtils.isNotBlank(username) && !"null".equalsIgnoreCase(username)) {
                options.setUserName(username);
            }
            if (StringUtils.isNotBlank(password) && !"null".equalsIgnoreCase(password)) {
                options.setPassword(password.toCharArray());
            }
            client.connect(options);
            MqttMessage message = new MqttMessage(json.getBytes(java.nio.charset.StandardCharsets.UTF_8));
            message.setQos(0);
            message.setRetained(false);
            client.publish(topic, message);
            client.disconnect();
            log.info("parent mqtt {} -> {} bytes={}", action, topic, json.length());
        } catch (MqttException ex) {
            log.error("parent mqtt publish failed: {}", ex.getMessage());
            throw new RenException("下发设备失败: " + ex.getMessage());
        }
    }
    public void publishLessonManifest(String macAddress, String courseId, String baseUrl, List<String> files) {
        if (files == null || files.isEmpty()) {
            return;
        }
        String course = StringUtils.defaultIfBlank(courseId, "lesson-sync").trim();
        if (!course.matches("[A-Za-z0-9_-]{1,31}")) {
            course = "lesson-sync";
        }
        String base = StringUtils.trimToEmpty(baseUrl).replaceAll("/+$", "");
        if (StringUtils.isBlank(base) || "null".equalsIgnoreCase(base) || base.contains("你")) {
            throw new RenException("未配置课文音频下载地址 server.lesson_audio_public_url");
        }
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("course_id", course);
        payload.put("base_url", base);
        payload.put("files", files);
        publishCtl(macAddress, "UPDATE_MANIFEST", payload);
    }
    public static String normalizeMac(String macAddress) {
        return StringUtils.trimToEmpty(macAddress).toLowerCase().replace('-', ':');
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentHistoryService.java
New file
@@ -0,0 +1,247 @@
package xiaozhi.modules.parent.service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.parent.catalog.ParentCatalog;
import xiaozhi.modules.parent.dto.ParentHistoryDetailDTO;
import xiaozhi.modules.parent.dto.ParentHistoryItemDTO;
import xiaozhi.modules.parent.dto.ParentHistoryListDTO;
import xiaozhi.modules.parent.dto.ParentHistoryTurnDTO;
import xiaozhi.modules.parent.mqtt.ParentMqttPublisher;
import xiaozhi.modules.task.dao.TaskInstanceDao;
import xiaozhi.modules.task.dao.TaskStepDao;
import xiaozhi.modules.task.entity.TaskInstanceEntity;
import xiaozhi.modules.task.entity.TaskStepEntity;
@Service
@RequiredArgsConstructor
public class ParentHistoryService {
    private static final int LIST_MAX = 50;
    private static final Pattern PLAYABLE_AUDIO = Pattern.compile("^[0-9A-Fa-f-]{32,36}$");
    private final ParentTaskService parentTaskService;
    private final TaskInstanceDao instanceDao;
    private final TaskStepDao stepDao;
    private final AgentChatHistoryService agentChatHistoryService;
    public ParentHistoryListDTO list(Long userId, String macAddress, String agentId, Integer limit) {
        DeviceEntity device = parentTaskService.requireUserDevice(userId, macAddress, agentId);
        parentTaskService.expireStaleForDevice(device.getMacAddress());
        Set<String> macs = macCandidates(device.getMacAddress(), macAddress);
        if (macs.isEmpty()) {
            return new ParentHistoryListDTO();
        }
        int size = limit == null ? 20 : Math.min(Math.max(limit, 1), LIST_MAX);
        String aid = StringUtils.defaultIfBlank(device.getAgentId(), agentId);
        Long total = instanceDao.selectCount(ownedInstances(macs, aid));
        List<TaskInstanceEntity> rows = instanceDao.selectList(ownedInstances(macs, aid)
                .orderByDesc(TaskInstanceEntity::getStartedAt)
                .last("LIMIT " + size));
        Map<String, Integer> turnCounts = countTurns(rows);
        ParentHistoryListDTO result = new ParentHistoryListDTO();
        result.setTotal(total == null ? 0 : total);
        for (TaskInstanceEntity row : rows) {
            result.getList().add(toItem(row, turnCounts.getOrDefault(row.getId(), 0)));
        }
        return result;
    }
    public ParentHistoryDetailDTO get(Long userId, String macAddress, String agentId, String id) {
        DeviceEntity device = parentTaskService.requireUserDevice(userId, macAddress, agentId);
        if (StringUtils.isBlank(id)) {
            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
        }
        TaskInstanceEntity entity = instanceDao.selectById(id);
        if (entity == null || !owns(device, entity)) {
            throw new RenException(ErrorCode.RESOURCE_NOT_FOUND);
        }
        List<TaskStepEntity> steps = stepDao.selectList(new LambdaQueryWrapper<TaskStepEntity>()
                .eq(TaskStepEntity::getInstanceId, id)
                .orderByAsc(TaskStepEntity::getStepIndex));
        ParentHistoryDetailDTO detail = new ParentHistoryDetailDTO();
        copyItem(toItem(entity, 0), detail);
        String kind = detail.getKind();
        if ("shadowing".equals(kind)) {
            for (TaskStepEntity step : steps) {
                if (step.getEchoRejected() != null && step.getEchoRejected() == 1) {
                    continue;
                }
                ParentHistoryTurnDTO turn = new ParentHistoryTurnDTO();
                turn.setIndex(step.getStepIndex());
                turn.setExpectedText(displayText(step.getExpectedText()));
                turn.setAsrText(displayText(step.getAsrText()));
                turn.setAudioId(playableAudioId(step.getAudioId()));
                detail.getTurns().add(turn);
            }
            detail.setTurnCount(detail.getTurns().size());
        }
        if ("chat".equals(kind) && StringUtils.isNotBlank(entity.getWsSessionId())) {
            String aid = StringUtils.defaultIfBlank(device.getAgentId(), agentId);
            List<AgentChatHistoryDTO> chats = agentChatHistoryService
                    .getChatHistoryBySessionId(aid, entity.getWsSessionId());
            if (chats != null) {
                for (AgentChatHistoryDTO chat : chats) {
                    chat.setAudioId(playableAudioId(chat.getAudioId()));
                }
                detail.setChats(chats);
                detail.setTurnCount(chats.size());
            }
        }
        return detail;
    }
    private LambdaQueryWrapper<TaskInstanceEntity> ownedInstances(Set<String> macs, String agentId) {
        return new LambdaQueryWrapper<TaskInstanceEntity>()
                .in(TaskInstanceEntity::getMacAddress, macs)
                .and(w -> w.eq(TaskInstanceEntity::getAgentId, agentId)
                        .or()
                        .isNull(TaskInstanceEntity::getAgentId)
                        .or()
                        .eq(TaskInstanceEntity::getAgentId, ""));
    }
    private boolean owns(DeviceEntity device, TaskInstanceEntity entity) {
        Set<String> macs = macCandidates(device.getMacAddress(), null);
        String instanceMac = StringUtils.trimToEmpty(entity.getMacAddress());
        boolean macOk = false;
        for (String candidate : macs) {
            if (candidate.equalsIgnoreCase(instanceMac)
                    || ParentMqttPublisher.normalizeMac(candidate)
                            .equals(ParentMqttPublisher.normalizeMac(instanceMac))) {
                macOk = true;
                break;
            }
        }
        if (!macOk) {
            return false;
        }
        if (StringUtils.isBlank(entity.getAgentId()) || StringUtils.isBlank(device.getAgentId())) {
            return true;
        }
        return entity.getAgentId().equals(device.getAgentId());
    }
    private Map<String, Integer> countTurns(List<TaskInstanceEntity> rows) {
        Map<String, Integer> counts = new HashMap<>();
        if (rows == null || rows.isEmpty()) {
            return counts;
        }
        List<String> ids = new ArrayList<>();
        for (TaskInstanceEntity row : rows) {
            if ("shadowing".equals(ParentCatalog.kindFromAppTask(row.getAppId(), row.getTaskId()))) {
                ids.add(row.getId());
            }
        }
        if (ids.isEmpty()) {
            return counts;
        }
        List<TaskStepEntity> steps = stepDao.selectList(new LambdaQueryWrapper<TaskStepEntity>()
                .in(TaskStepEntity::getInstanceId, ids));
        for (TaskStepEntity step : steps) {
            if (step.getEchoRejected() != null && step.getEchoRejected() == 1) {
                continue;
            }
            counts.merge(step.getInstanceId(), 1, Integer::sum);
        }
        return counts;
    }
    private ParentHistoryItemDTO toItem(TaskInstanceEntity row, int turnCount) {
        ParentHistoryItemDTO item = new ParentHistoryItemDTO();
        item.setId(row.getId());
        String scene = ParentCatalog.sceneIdFromAppTask(row.getAppId(), row.getTaskId());
        item.setScene(scene);
        item.setSceneName(StringUtils.defaultIfBlank(ParentCatalog.sceneName(scene),
                StringUtils.defaultIfBlank(row.getTaskId(), row.getAppId())));
        item.setKind(ParentCatalog.kindFromAppTask(row.getAppId(), row.getTaskId()));
        item.setAppId(row.getAppId());
        item.setTaskId(row.getTaskId());
        item.setSource(row.getSource());
        item.setStatus(row.getStatus());
        item.setEndReason(row.getEndReason());
        item.setStartedAt(row.getStartedAt());
        item.setEndedAt(row.getEndedAt());
        if (row.getStartedAt() != null && row.getEndedAt() != null && row.getEndedAt() > row.getStartedAt()) {
            item.setDurationMs(row.getEndedAt() - row.getStartedAt());
        }
        item.setTurnCount(turnCount);
        return item;
    }
    private void copyItem(ParentHistoryItemDTO source, ParentHistoryDetailDTO target) {
        target.setId(source.getId());
        target.setScene(source.getScene());
        target.setSceneName(source.getSceneName());
        target.setKind(source.getKind());
        target.setAppId(source.getAppId());
        target.setTaskId(source.getTaskId());
        target.setSource(source.getSource());
        target.setStatus(source.getStatus());
        target.setEndReason(source.getEndReason());
        target.setStartedAt(source.getStartedAt());
        target.setEndedAt(source.getEndedAt());
        target.setDurationMs(source.getDurationMs());
        target.setTurnCount(source.getTurnCount());
    }
    private String playableAudioId(String audioId) {
        if (StringUtils.isBlank(audioId)) {
            return null;
        }
        String value = audioId.trim();
        if (value.contains("/") || value.contains("\\") || value.contains(".")) {
            return null;
        }
        return PLAYABLE_AUDIO.matcher(value).matches() ? value : null;
    }
    private String displayText(String text) {
        if (StringUtils.isBlank(text)) {
            return "";
        }
        String value = text.trim();
        if (value.contains("/") || value.contains("\\") || value.endsWith(".mp3") || value.endsWith(".wav")) {
            return "";
        }
        return value;
    }
    private Set<String> macCandidates(String stored, String requested) {
        Set<String> values = new HashSet<>();
        addMac(values, stored);
        addMac(values, requested);
        return values;
    }
    private void addMac(Set<String> values, String raw) {
        String text = StringUtils.trimToEmpty(raw);
        if (text.isEmpty()) {
            return;
        }
        values.add(text);
        values.add(text.toLowerCase());
        values.add(text.toLowerCase().replace('-', ':'));
        values.add(text.toLowerCase().replace(':', '-'));
        values.add(ParentMqttPublisher.normalizeMac(text));
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentTaskService.java
New file
@@ -0,0 +1,764 @@
package xiaozhi.modules.parent.service;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.parent.catalog.IdiomLessonCatalog;
import xiaozhi.modules.parent.catalog.LessonClipIndex;
import xiaozhi.modules.parent.catalog.ParentCatalog;
import xiaozhi.modules.parent.catalog.ParentSceneCatalog;
import xiaozhi.modules.parent.dao.ParentTaskDao;
import xiaozhi.modules.parent.dto.ParentDeviceStatusDTO;
import xiaozhi.modules.parent.dto.ParentImmediateCloseDTO;
import xiaozhi.modules.parent.dto.ParentTaskDTO;
import xiaozhi.modules.parent.dto.ParentTaskListDTO;
import xiaozhi.modules.parent.dto.ParentTaskSaveDTO;
import xiaozhi.modules.parent.entity.ParentTaskEntity;
import xiaozhi.modules.parent.mqtt.ParentDevicePresence;
import xiaozhi.modules.parent.mqtt.ParentMqttPublisher;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.task.dao.TaskInstanceDao;
import xiaozhi.modules.task.dto.LessonSyncDTO;
import xiaozhi.modules.task.entity.TaskInstanceEntity;
@Slf4j
@Service
@RequiredArgsConstructor
public class ParentTaskService {
    private static final Set<String> RUN_MODES = Set.of("immediate", "scheduled");
    private static final Set<String> ON_COMPLETE = Set.of("chat", "power_off");
    private static final Set<String> SCHEDULE_TYPES = Set.of("daily", "hourly", "interval");
    private static final Pattern TIME = Pattern.compile("^([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d)?$");
    private static final long STALE_RUNNING_MS = TimeUnit.HOURS.toMillis(48);
    private static final long OFFLINE_GRACE_MS = TimeUnit.MINUTES.toMillis(5);
    private static final TypeReference<Map<String, Object>> MAP = new TypeReference<>() {
    };
    private final ParentTaskDao parentTaskDao;
    private final DeviceService deviceService;
    private final AgentService agentService;
    private final ParentMqttPublisher mqttPublisher;
    private final ParentDevicePresence devicePresence;
    private final ObjectMapper objectMapper;
    private final SysParamsService sysParamsService;
    private final TaskInstanceDao taskInstanceDao;
    public ParentDeviceStatusDTO deviceStatus(Long userId, String macAddress, String agentId) {
        DeviceEntity device = requireDevice(userId, macAddress, agentId);
        ParentDeviceStatusDTO dto = new ParentDeviceStatusDTO();
        dto.setMacAddress(ParentMqttPublisher.normalizeMac(device.getMacAddress()));
        dto.setOnline(devicePresence.isOnline(device.getMacAddress()));
        return dto;
    }
    public ParentTaskListDTO list(Long userId, String macAddress, String agentId, String runMode) {
        DeviceEntity device = requireDevice(userId, macAddress, agentId);
        String mac = ParentMqttPublisher.normalizeMac(device.getMacAddress());
        expireStaleImmediate(mac, devicePresence.isOnlineCached(mac));
        LambdaQueryWrapper<ParentTaskEntity> wrapper = new LambdaQueryWrapper<ParentTaskEntity>()
                .eq(ParentTaskEntity::getUserId, userId)
                .eq(ParentTaskEntity::getMacAddress, mac)
                .eq(ParentTaskEntity::getAgentId, device.getAgentId())
                .eq(StringUtils.isNotBlank(runMode), ParentTaskEntity::getRunMode, runMode)
                .and(w -> w.eq(ParentTaskEntity::getRunMode, "scheduled")
                        .or(q -> q.in(ParentTaskEntity::getStatus, "queued", "running")))
                .orderByDesc(ParentTaskEntity::getUpdatedAt);
        List<ParentTaskEntity> rows = parentTaskDao.selectList(wrapper);
        ParentTaskListDTO result = new ParentTaskListDTO();
        result.setTotal(rows.size());
        for (ParentTaskEntity row : rows) {
            result.getList().add(toDto(row));
        }
        return result;
    }
    public ParentTaskDTO get(Long userId, String id) {
        return toDto(requireOwned(userId, id));
    }
    @Transactional(rollbackFor = Exception.class)
    public ParentTaskDTO create(Long userId, ParentTaskSaveDTO body) {
        DeviceEntity device = requireDevice(userId, body.getMacAddress(), body.getAgentId());
        devicePresence.requireOnline(device.getMacAddress());
        String runMode = normalizeRunMode(body.getRunMode());
        Map<String, Object> scene = requireSceneRow(body.getScene());
        Map<String, String> mapped = sceneAppTask(scene);
        Map<String, Object> params = compactParams(scene, body.getParams());
        Map<String, Object> schedule = null;
        if ("scheduled".equals(runMode)) {
            schedule = requireSchedule(body.getSchedule());
            int scheduled = countScheduled(device.getMacAddress());
            if (scheduled >= ParentCatalog.SCHEDULED_SOFT_LIMIT) {
                throw new RenException("每台设备最多 " + ParentCatalog.SCHEDULED_SOFT_LIMIT + " 条计划");
            }
        }
        Date now = new Date();
        ParentTaskEntity entity = new ParentTaskEntity();
        entity.setId(UUID.randomUUID().toString());
        entity.setUserId(userId);
        entity.setMacAddress(ParentMqttPublisher.normalizeMac(device.getMacAddress()));
        entity.setAgentId(device.getAgentId());
        entity.setRunMode(runMode);
        entity.setScene(text(scene.get("id")));
        entity.setAppId(mapped.get("appId"));
        entity.setTaskId(mapped.get("taskId"));
        entity.setParams(writeJson(params));
        entity.setSchedule(schedule == null ? null : writeJson(schedule));
        entity.setEnabled(Boolean.FALSE.equals(body.getEnabled()) ? 0 : 1);
        entity.setStatus("scheduled".equals(runMode) ? "active" : "queued");
        entity.setCreatedAt(now);
        entity.setUpdatedAt(now);
        parentTaskDao.insert(entity);
        if ("immediate".equals(runMode)) {
            dispatchImmediate(entity, params);
        } else {
            syncSchedule(device.getMacAddress());
        }
        return toDto(entity);
    }
    @Transactional(rollbackFor = Exception.class)
    public ParentTaskDTO update(Long userId, String id, ParentTaskSaveDTO body) {
        ParentTaskEntity entity = requireOwned(userId, id);
        DeviceEntity device = requireDevice(userId, entity.getMacAddress(), entity.getAgentId());
        devicePresence.requireOnline(device.getMacAddress());
        if (StringUtils.isNotBlank(body.getScene()) && !body.getScene().equals(entity.getScene())) {
            Map<String, Object> scene = requireSceneRow(body.getScene());
            Map<String, String> mapped = sceneAppTask(scene);
            entity.setScene(text(scene.get("id")));
            entity.setAppId(mapped.get("appId"));
            entity.setTaskId(mapped.get("taskId"));
        }
        if (body.getParams() != null) {
            entity.setParams(writeJson(compactParams(requireSceneRow(entity.getScene()), body.getParams())));
        }
        if ("scheduled".equals(entity.getRunMode())) {
            if (body.getSchedule() != null) {
                entity.setSchedule(writeJson(requireSchedule(body.getSchedule())));
            }
            if (body.getEnabled() != null) {
                entity.setEnabled(body.getEnabled() ? 1 : 0);
            }
        }
        entity.setUpdatedAt(new Date());
        parentTaskDao.updateById(entity);
        if ("scheduled".equals(entity.getRunMode())) {
            syncSchedule(device.getMacAddress());
        }
        return toDto(entity);
    }
    @Transactional(rollbackFor = Exception.class)
    public void delete(Long userId, String id) {
        ParentTaskEntity entity = requireOwned(userId, id);
        devicePresence.requireOnline(entity.getMacAddress());
        String mac = entity.getMacAddress();
        boolean scheduled = "scheduled".equals(entity.getRunMode());
        parentTaskDao.deleteById(id);
        if (scheduled) {
            syncSchedule(mac);
        }
    }
    @Transactional(rollbackFor = Exception.class)
    public ParentTaskDTO runNow(Long userId, String id) {
        ParentTaskEntity entity = requireOwned(userId, id);
        devicePresence.requireOnline(entity.getMacAddress());
        Map<String, Object> params = readMap(entity.getParams());
        dispatchImmediate(entity, params);
        return toDto(entity);
    }
    @Transactional(rollbackFor = Exception.class)
    public ParentTaskDTO stop(Long userId, String id) {
        ParentTaskEntity entity = requireOwned(userId, id);
        if (!"immediate".equals(entity.getRunMode())
                || (!"queued".equals(entity.getStatus()) && !"running".equals(entity.getStatus()))) {
            throw new RenException("当前没有正在执行的任务");
        }
        devicePresence.requireOnline(entity.getMacAddress());
        dispatchStopToChat(entity.getMacAddress());
        entity.setStatus("cancelled");
        entity.setUpdatedAt(new Date());
        parentTaskDao.updateById(entity);
        return toDto(entity);
    }
    public void syncSchedule(String macAddress) {
        List<ParentTaskEntity> rows = parentTaskDao.selectList(new LambdaQueryWrapper<ParentTaskEntity>()
                .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress))
                .eq(ParentTaskEntity::getRunMode, "scheduled")
                .eq(ParentTaskEntity::getEnabled, 1)
                .orderByAsc(ParentTaskEntity::getCreatedAt));
        List<Map<String, Object>> tasks = new ArrayList<>();
        for (ParentTaskEntity row : rows) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("id", row.getId());
            item.put("app_id", row.getAppId());
            item.put("task_id", row.getTaskId());
            item.put("enabled", true);
            item.put("params", readMap(row.getParams()));
            Map<String, Object> schedule = readMap(row.getSchedule());
            if (!schedule.isEmpty()) {
                item.put("schedule", schedule);
            }
            tasks.add(item);
        }
        mqttPublisher.publishCtl(macAddress, "SYNC_SCHEDULE", Map.of("tasks", tasks));
        syncScheduledLessonAudio(macAddress, rows);
    }
    private void dispatchImmediate(ParentTaskEntity entity, Map<String, Object> params) {
        Map<String, Object> mqttParams = new LinkedHashMap<>(params == null ? Map.of() : params);
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("app_id", entity.getAppId());
        payload.put("task_id", entity.getTaskId());
        payload.put("source", "mqtt_immediate");
        payload.put("params", mqttParams);
        Map<String, Object> pkg = buildLessonPackage(entity.getScene(), mqttParams);
        if (pkg != null) {
            payload.put("package", pkg);
        }
        mqttPublisher.publishCtl(entity.getMacAddress(), "START_APP_TASK", payload);
        supersedeImmediate(entity.getMacAddress(), entity.getId());
        if ("immediate".equals(entity.getRunMode())) {
            entity.setStatus("running");
            entity.setUpdatedAt(new Date());
            parentTaskDao.updateById(entity);
        }
    }
    private void dispatchStopToChat(String macAddress) {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("app_id", "chat");
        payload.put("task_id", "conversation");
        payload.put("source", "mqtt_immediate");
        payload.put("params", Map.of("on_complete", "chat"));
        mqttPublisher.publishCtl(macAddress, "START_APP_TASK", payload);
    }
    public void syncLessonFromServer(LessonSyncDTO body) {
        if (body == null || StringUtils.isBlank(body.getMacAddress())) {
            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
        }
        List<String> files = ParentCatalog.uniqueLessonPaths(body.getFiles());
        if (files.isEmpty()) {
            return;
        }
        String org = ParentCatalog.resolveOrgId(body.getOrgId());
        String base = StringUtils.trimToEmpty(body.getBaseUrl()).replaceAll("/+$", "");
        if (StringUtils.isBlank(base) || "null".equalsIgnoreCase(base) || base.contains("你")) {
            base = resolveLessonAudioBase(org);
        }
        if (StringUtils.isBlank(base)) {
            throw new RenException("未配置 server.lesson_audio_public_url,无法同步课文");
        }
        mqttPublisher.publishLessonManifest(body.getMacAddress(), body.getCourseId(), base, files);
    }
    @SuppressWarnings("unchecked")
    private Map<String, Object> buildLessonPackage(String sceneId, Map<String, Object> params) {
        Map<String, Object> scene = ParentSceneCatalog.find(sceneId);
        if (!ParentSceneCatalog.flag(scene, "audioPackage")) {
            return null;
        }
        Map<String, Object> src = params == null ? Map.of() : params;
        String lessonId = text(src.get("lesson_id") == null ? src.get("lessonId") : src.get("lesson_id"));
        String language = text(src.get("language") == null ? src.get("lang") : src.get("language"));
        String org = ParentCatalog.resolveOrgId(text(src.get("org_id") == null ? src.get("orgId") : src.get("org_id")));
        String base = resolveLessonAudioBase(org);
        List<String> files = lessonFilesForPackage(base, sceneId, lessonId, language);
        if (StringUtils.isBlank(base)) {
            log.warn("skip lesson package: server.lesson_audio_public_url is empty");
            return null;
        }
        if (files.isEmpty()) {
            return null;
        }
        Map<String, Object> pkg = new LinkedHashMap<>();
        pkg.put("course_id", ParentCatalog.courseId(sceneId, lessonId));
        pkg.put("base_url", base);
        pkg.put("files", files);
        return pkg;
    }
    private void syncScheduledLessonAudio(String macAddress, List<ParentTaskEntity> rows) {
        List<String> files = new ArrayList<>();
        String lessonId = "l1.001";
        String scene = "extensive";
        for (ParentTaskEntity row : rows) {
            if (!ParentSceneCatalog.flag(row.getScene(), "audioPackage")) {
                continue;
            }
            Map<String, Object> params = readMap(row.getParams());
            lessonId = text(params.get("lesson_id") == null ? params.get("lessonId") : params.get("lesson_id"));
            scene = row.getScene();
            String language = text(params.get("language") == null ? params.get("lang") : params.get("language"));
            String org = ParentCatalog.DEFAULT_ORG_ID;
            String base = resolveLessonAudioBase(org);
            files.addAll(lessonFilesForPackage(base, row.getScene(), lessonId, language));
        }
        files = ParentCatalog.uniqueLessonPaths(files);
        if (files.isEmpty()) {
            return;
        }
        String base = resolveLessonAudioBase(ParentCatalog.DEFAULT_ORG_ID);
        if (StringUtils.isBlank(base)) {
            log.warn("skip scheduled lesson sync: server.lesson_audio_public_url is empty");
            return;
        }
        mqttPublisher.publishLessonManifest(macAddress, ParentCatalog.courseId(scene, lessonId), base, files);
    }
    private List<String> lessonFilesForPackage(String base, String sceneId, String lessonId, String language) {
        List<String> listed = LessonClipIndex.fetch(base, sceneId, lessonId, language);
        if (listed != null) {
            return ParentCatalog.uniqueLessonPaths(listed);
        }
        return ParentCatalog.uniqueLessonPaths(ParentCatalog.lessonRelativePaths(sceneId, lessonId, language));
    }
    private String resolveLessonAudioBase(String orgId) {
        String root = StringUtils.trimToEmpty(sysParamsService.getValue("server.lesson_audio_public_url", true));
        if (StringUtils.isBlank(root) || "null".equalsIgnoreCase(root) || root.contains("你")) {
            return "";
        }
        return root.replaceAll("/+$", "") + "/" + ParentCatalog.resolveOrgId(orgId);
    }
    private void supersedeImmediate(String macAddress, String exceptId) {
        LambdaUpdateWrapper<ParentTaskEntity> wrapper = new LambdaUpdateWrapper<ParentTaskEntity>()
                .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress))
                .eq(ParentTaskEntity::getRunMode, "immediate")
                .in(ParentTaskEntity::getStatus, "queued", "running")
                .set(ParentTaskEntity::getStatus, "cancelled")
                .set(ParentTaskEntity::getUpdatedAt, new Date());
        if (StringUtils.isNotBlank(exceptId)) {
            wrapper.ne(ParentTaskEntity::getId, exceptId);
        }
        parentTaskDao.update(null, wrapper);
    }
    public int closeFromServer(ParentImmediateCloseDTO body) {
        if (body == null || StringUtils.isBlank(body.getDeviceId())) {
            return 0;
        }
        String reason = StringUtils.defaultIfBlank(body.getReason(), "server");
        String status = closeStatus(body.getStatus(), reason);
        if (StringUtils.isNotBlank(body.getMatchAppId())) {
            return closeMatchingImmediate(body.getDeviceId(), body.getMatchAppId(), body.getMatchTaskId(), status);
        }
        return closeRunningImmediate(body.getDeviceId(), status, body.getExceptAppId(), body.getExceptTaskId());
    }
    public void closeRunningForOffline(String macAddress) {
        closeRunningImmediate(macAddress, "cancelled", null, null);
        expireRunningInstances(macAddress, "disconnected");
    }
    public void expireStaleForDevice(String macAddress) {
        String mac = ParentMqttPublisher.normalizeMac(macAddress);
        if (StringUtils.isBlank(mac)) {
            return;
        }
        expireStaleImmediate(mac, devicePresence.isOnlineCached(mac));
    }
    public int closeRunningImmediate(String macAddress, String status, String exceptAppId, String exceptTaskId) {
        String mac = ParentMqttPublisher.normalizeMac(macAddress);
        if (StringUtils.isBlank(mac)) {
            return 0;
        }
        LambdaUpdateWrapper<ParentTaskEntity> wrapper = runningImmediateWrapper(mac)
                .set(ParentTaskEntity::getStatus, closeStatus(status, status))
                .set(ParentTaskEntity::getUpdatedAt, new Date());
        if (StringUtils.isNotBlank(exceptAppId) && StringUtils.isNotBlank(exceptTaskId)) {
            wrapper.and(w -> w.ne(ParentTaskEntity::getAppId, exceptAppId)
                    .or()
                    .ne(ParentTaskEntity::getTaskId, exceptTaskId));
        }
        return parentTaskDao.update(null, wrapper);
    }
    public int closeMatchingImmediate(String macAddress, String appId, String taskId, String status) {
        String mac = ParentMqttPublisher.normalizeMac(macAddress);
        if (StringUtils.isBlank(mac) || StringUtils.isBlank(appId)) {
            return 0;
        }
        LambdaUpdateWrapper<ParentTaskEntity> wrapper = runningImmediateWrapper(mac)
                .eq(ParentTaskEntity::getAppId, appId)
                .eq(StringUtils.isNotBlank(taskId), ParentTaskEntity::getTaskId, taskId)
                .set(ParentTaskEntity::getStatus, closeStatus(status, status))
                .set(ParentTaskEntity::getUpdatedAt, new Date());
        return parentTaskDao.update(null, wrapper);
    }
    private void expireStaleImmediate(String mac, boolean online) {
        long ageMs = online ? STALE_RUNNING_MS : OFFLINE_GRACE_MS;
        Date cutoff = new Date(System.currentTimeMillis() - ageMs);
        parentTaskDao.update(null, runningImmediateWrapper(mac)
                .lt(ParentTaskEntity::getUpdatedAt, cutoff)
                .set(ParentTaskEntity::getStatus, "cancelled")
                .set(ParentTaskEntity::getUpdatedAt, new Date()));
        long instanceCutoff = System.currentTimeMillis() - STALE_RUNNING_MS;
        expireRunningInstancesBefore(mac, instanceCutoff, "stale");
    }
    private void expireRunningInstances(String macAddress, String reason) {
        expireRunningInstancesBefore(macAddress, System.currentTimeMillis(), reason);
    }
    private void expireRunningInstancesBefore(String macAddress, long startedBeforeMs, String reason) {
        String colon = ParentMqttPublisher.normalizeMac(macAddress);
        if (StringUtils.isBlank(colon)) {
            return;
        }
        String hyphen = colon.replace(':', '-');
        Date now = new Date();
        taskInstanceDao.update(null, new LambdaUpdateWrapper<TaskInstanceEntity>()
                .in(TaskInstanceEntity::getMacAddress, colon, hyphen)
                .eq(TaskInstanceEntity::getStatus, "running")
                .lt(TaskInstanceEntity::getStartedAt, startedBeforeMs)
                .set(TaskInstanceEntity::getStatus, "cancelled")
                .set(TaskInstanceEntity::getEndReason, StringUtils.left(reason, 32))
                .set(TaskInstanceEntity::getEndedAt, now.getTime())
                .set(TaskInstanceEntity::getUpdatedAt, now));
    }
    private LambdaUpdateWrapper<ParentTaskEntity> runningImmediateWrapper(String mac) {
        return new LambdaUpdateWrapper<ParentTaskEntity>()
                .eq(ParentTaskEntity::getMacAddress, mac)
                .eq(ParentTaskEntity::getRunMode, "immediate")
                .in(ParentTaskEntity::getStatus, "queued", "running");
    }
    private static String closeStatus(String status, String reason) {
        if ("done".equals(status) || "completed".equals(status)) {
            return "done";
        }
        if ("cancelled".equals(status) || "canceled".equals(status)) {
            return "cancelled";
        }
        if ("play_completed".equals(reason) || "finished".equals(reason) || "done".equals(reason)) {
            return "done";
        }
        return "cancelled";
    }
    private DeviceEntity requireDevice(Long userId, String macAddress, String agentId) {
        if (userId == null || userId == 0L) {
            throw new RenException(ErrorCode.UNAUTHORIZED);
        }
        if (StringUtils.isBlank(macAddress) || StringUtils.isBlank(agentId)) {
            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
        }
        DeviceEntity device = findDevice(macAddress);
        if (device == null || device.getUserId() == null || !device.getUserId().equals(userId)) {
            throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND);
        }
        if (!agentService.checkAgentPermission(agentId, userId)) {
            throw new RenException(ErrorCode.NO_PERMISSION);
        }
        if (StringUtils.isNotBlank(device.getAgentId()) && !agentId.equals(device.getAgentId())) {
            throw new RenException("设备与智能体不匹配");
        }
        return device;
    }
    public DeviceEntity requireUserDevice(Long userId, String macAddress, String agentId) {
        return requireDevice(userId, macAddress, agentId);
    }
    private DeviceEntity findDevice(String macAddress) {
        String raw = StringUtils.trimToEmpty(macAddress);
        String lower = raw.toLowerCase();
        String colon = lower.replace('-', ':');
        String hyphen = lower.replace(':', '-');
        for (String candidate : new String[] { raw, lower, colon, hyphen }) {
            if (StringUtils.isBlank(candidate)) {
                continue;
            }
            DeviceEntity device = deviceService.getDeviceByMacAddress(candidate);
            if (device != null) {
                return device;
            }
        }
        return null;
    }
    private ParentTaskEntity requireOwned(Long userId, String id) {
        if (StringUtils.isBlank(id)) {
            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
        }
        ParentTaskEntity entity = parentTaskDao.selectById(id);
        if (entity == null || entity.getUserId() == null || !entity.getUserId().equals(userId)) {
            throw new RenException(ErrorCode.RESOURCE_NOT_FOUND);
        }
        return entity;
    }
    private int countScheduled(String macAddress) {
        Long count = parentTaskDao.selectCount(new LambdaQueryWrapper<ParentTaskEntity>()
                .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress))
                .eq(ParentTaskEntity::getRunMode, "scheduled"));
        return count == null ? 0 : count.intValue();
    }
    private String normalizeRunMode(String runMode) {
        String value = StringUtils.trimToEmpty(runMode);
        if (!RUN_MODES.contains(value)) {
            throw new RenException("run_mode 只能是 immediate 或 scheduled");
        }
        return value;
    }
    private Map<String, Object> requireSceneRow(String scene) {
        try {
            return ParentSceneCatalog.require(StringUtils.trimToEmpty(scene));
        } catch (IllegalArgumentException ex) {
            throw new RenException("不支持的场景");
        }
    }
    private Map<String, String> sceneAppTask(Map<String, Object> scene) {
        Map<String, String> mapped = ParentSceneCatalog.sceneToAppTask(text(scene.get("id")));
        if (mapped == null) {
            throw new RenException("不支持的场景");
        }
        return mapped;
    }
    private Map<String, Object> compactParams(Map<String, Object> scene, Map<String, Object> raw) {
        Map<String, Object> src = raw == null ? Map.of() : raw;
        Map<String, Object> out = new LinkedHashMap<>();
        String lessonId = text(src.get("lesson_id") == null ? src.get("lessonId") : src.get("lesson_id"));
        if (ParentSceneCatalog.flag(scene, "needsLesson")) {
            if (StringUtils.isBlank(lessonId)) {
                lessonId = "l1.001";
            }
            if (IdiomLessonCatalog.findStory(lessonId) == null) {
                throw new RenException("未找到该成语课文,请检查清单 idiom-lessons.json");
            }
            String canonical = IdiomLessonCatalog.canonicalLessonId(lessonId);
            out.put("lesson_id", canonical);
            String lessonName = IdiomLessonCatalog.storyName(canonical);
            if (StringUtils.isNotBlank(lessonName)) {
                out.put("lesson_name", lessonName);
            }
            String lessonNameEn = IdiomLessonCatalog.storyNameEn(canonical);
            if (StringUtils.isNotBlank(lessonNameEn)) {
                out.put("lesson_name_en", lessonNameEn);
            }
            if (ParentSceneCatalog.flag(scene, "needsEnPasses")) {
                out.put("cn_repeat", intInRange(src.get("cn_repeat") == null ? src.get("cnRepeat") : src.get("cn_repeat"),
                        1, 10, 1));
                out.put("cn_speed", doubleInRange(src.get("cn_speed") == null ? src.get("cnSpeed") : src.get("cn_speed"),
                        0.5, 2.0, 1.0));
                out.put("en_passes", normalizeEnPasses(src.get("en_passes") == null ? src.get("enPasses") : src.get("en_passes")));
            }
            if (ParentSceneCatalog.flag(scene, "needsShadowingSettings")) {
                String language = text(src.get("language") == null ? src.get("lang") : src.get("language")).toLowerCase();
                if (!"cn".equals(language) && !"en".equals(language)) {
                    language = "en";
                }
                out.put("language", language);
                out.put("speed", doubleInRange(src.get("speed"), 0.5, 2.0, 0.8));
                out.put("listen_ratio", 1.2);
            }
        }
        if (ParentSceneCatalog.flag(scene, "needsLanguagePair")) {
            String pair = text(src.get("language_pair") == null ? src.get("languagePair") : src.get("language_pair"));
            out.put("language_pair", StringUtils.defaultIfBlank(pair, "zh-en"));
        }
        Object duration = src.get("duration_minutes") == null ? src.get("durationMinutes") : src.get("duration_minutes");
        if (duration != null) {
            int minutes = intInRange(duration, 0, 24 * 60, 0);
            if (minutes > 0) {
                out.put("duration_minutes", minutes);
            }
        }
        if (ParentSceneCatalog.flag(scene, "needsOnComplete")) {
            String complete = text(src.get("on_complete") == null ? src.get("onComplete") : src.get("on_complete"));
            if (StringUtils.isBlank(complete)) {
                complete = "chat";
            }
            if (!ON_COMPLETE.contains(complete)) {
                throw new RenException("on_complete 只能是 chat 或 power_off");
            }
            out.put("on_complete", complete);
        }
        Object announce = src.get("announce");
        if (announce instanceof Boolean bool) {
            out.put("announce", bool);
        }
        return out;
    }
    private Map<String, Object> requireSchedule(Map<String, Object> raw) {
        if (raw == null || raw.isEmpty()) {
            throw new RenException("计划任务需要 schedule");
        }
        String type = text(raw.get("type"));
        if (!SCHEDULE_TYPES.contains(type)) {
            throw new RenException("schedule.type 只能是 daily、hourly、interval");
        }
        Map<String, Object> out = new LinkedHashMap<>();
        out.put("type", type);
        if ("daily".equals(type)) {
            String time = text(raw.get("time"));
            if (!TIME.matcher(time).matches()) {
                throw new RenException("daily.time 格式应为 HH:MM");
            }
            out.put("time", time.length() == 5 ? time : time.substring(0, 5));
            Object weekdays = raw.get("weekdays");
            if (weekdays instanceof List<?> list && !list.isEmpty()) {
                List<Integer> days = new ArrayList<>();
                for (Object item : list) {
                    int day = intInRange(item, 1, 7, 0);
                    if (day >= 1 && day <= 7 && !days.contains(day)) {
                        days.add(day);
                    }
                }
                if (!days.isEmpty()) {
                    out.put("weekdays", days);
                }
            }
            return out;
        }
        if ("hourly".equals(type)) {
            out.put("minute", intInRange(raw.get("minute"), 0, 59, 0));
            return out;
        }
        out.put("every_minutes", intInRange(raw.get("every_minutes") == null ? raw.get("everyMinutes") : raw.get("every_minutes"),
                1, 24 * 60, 60));
        return out;
    }
    private ParentTaskDTO toDto(ParentTaskEntity entity) {
        ParentTaskDTO dto = new ParentTaskDTO();
        dto.setId(entity.getId());
        dto.setMacAddress(entity.getMacAddress());
        dto.setAgentId(entity.getAgentId());
        dto.setRunMode(entity.getRunMode());
        dto.setScene(entity.getScene());
        dto.setAppId(entity.getAppId());
        dto.setTaskId(entity.getTaskId());
        dto.setSceneName(ParentCatalog.sceneName(entity.getScene()));
        dto.setParams(readMap(entity.getParams()));
        dto.setSchedule(readMap(entity.getSchedule()));
        dto.setEnabled(entity.getEnabled() == null || entity.getEnabled() != 0);
        dto.setStatus(entity.getStatus());
        dto.setCreatedAt(entity.getCreatedAt());
        dto.setUpdatedAt(entity.getUpdatedAt());
        return dto;
    }
    private Map<String, Object> readMap(String json) {
        if (StringUtils.isBlank(json)) {
            return new LinkedHashMap<>();
        }
        try {
            Map<String, Object> value = objectMapper.readValue(json, MAP);
            return value == null ? new LinkedHashMap<>() : value;
        } catch (Exception ex) {
            return new LinkedHashMap<>();
        }
    }
    private String writeJson(Object value) {
        try {
            return objectMapper.writeValueAsString(value == null ? Map.of() : value);
        } catch (Exception ex) {
            throw new RenException("JSON 写入失败");
        }
    }
    private String text(Object value) {
        return value == null ? "" : StringUtils.trimToEmpty(String.valueOf(value));
    }
    private List<Map<String, Object>> normalizeEnPasses(Object raw) {
        List<Map<String, Object>> out = new ArrayList<>();
        if (raw instanceof List<?> list) {
            for (Object item : list) {
                Map<String, Object> row = IdiomLessonCatalog.normalizePass(item);
                if (row != null) {
                    out.add(row);
                }
                if (out.size() >= 8) {
                    break;
                }
            }
        }
        if (out.isEmpty()) {
            return new ArrayList<>(IdiomLessonCatalog.defaultEnPasses());
        }
        return out;
    }
    private double doubleInRange(Object value, double min, double max, double fallback) {
        if (value == null) {
            return fallback;
        }
        double parsed;
        try {
            parsed = Double.parseDouble(String.valueOf(value).trim());
        } catch (NumberFormatException ex) {
            return fallback;
        }
        if (Double.isNaN(parsed) || Double.isInfinite(parsed)) {
            return fallback;
        }
        if (parsed < min) {
            return min;
        }
        if (parsed > max) {
            return max;
        }
        return parsed;
    }
    private int intInRange(Object value, int min, int max, int fallback) {
        if (value == null) {
            return fallback;
        }
        int parsed;
        try {
            parsed = Integer.parseInt(String.valueOf(value).trim());
        } catch (NumberFormatException ex) {
            return fallback;
        }
        if (parsed < min) {
            return min;
        }
        if (parsed > max) {
            return max;
        }
        return parsed;
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java
@@ -95,6 +95,8 @@
        filterMap.put("/agent/chat-history/report", "server");
        filterMap.put("/task/instance", "server");
        filterMap.put("/task/instance/**", "server");
        filterMap.put("/task/lesson-sync", "server");
        filterMap.put("/task/lesson-sync/**", "server");
        filterMap.put("/agent/chat-history/download/**", "anon");
        filterMap.put("/agent/chat-summary/**", "server");
        filterMap.put("/agent/chat-title/**", "server");
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/LessonSyncController.java
New file
@@ -0,0 +1,28 @@
package xiaozhi.modules.task.controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.parent.service.ParentTaskService;
import xiaozhi.modules.task.dto.LessonSyncDTO;
@Tag(name = "Lesson Sync")
@RestController
@RequestMapping("/task/lesson-sync")
@RequiredArgsConstructor
public class LessonSyncController {
    private final ParentTaskService parentTaskService;
    @Operation(summary = "xiaozhi-server 请求向设备一次性同步课文 MP3")
    @PostMapping
    public Result<Boolean> sync(@RequestBody LessonSyncDTO body) {
        parentTaskService.syncLessonFromServer(body);
        return new Result<Boolean>().ok(Boolean.TRUE);
    }
}
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java
@@ -2,6 +2,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -14,6 +15,8 @@
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.parent.dto.ParentImmediateCloseDTO;
import xiaozhi.modules.parent.service.ParentTaskService;
import xiaozhi.modules.task.dto.TaskInstanceListDTO;
import xiaozhi.modules.task.dto.TaskInstanceUpsertDTO;
import xiaozhi.modules.task.service.TaskInstanceService;
@@ -24,6 +27,14 @@
@RequiredArgsConstructor
public class TaskInstanceController {
    private final TaskInstanceService taskInstanceService;
    private final ParentTaskService parentTaskService;
    @Operation(summary = "xiaozhi-server 关闭家长即时任务(口令切换/断线)")
    @PostMapping("/close-parent-immediate")
    public Result<Integer> closeParentImmediate(@RequestBody ParentImmediateCloseDTO body) {
        int n = parentTaskService.closeFromServer(body);
        return new Result<Integer>().ok(n);
    }
    @Operation(summary = "xiaozhi-server 上报 TaskSession 全文(幂等覆盖)")
    @PutMapping("/{id}")
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/LessonSyncDTO.java
New file
@@ -0,0 +1,25 @@
package xiaozhi.modules.task.dto;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "xiaozhi-server 请求向设备一次性同步课文 MP3")
public class LessonSyncDTO {
    @Schema(description = "设备 MAC")
    private String macAddress;
    @Schema(description = "固件 course_id,仅字母数字_-")
    private String courseId;
    @Schema(description = "机构 id,空则 default_org")
    private String orgId;
    @Schema(description = "设备可访问的 HTTP 根,含 /lesson-audio/{org}")
    private String baseUrl;
    @Schema(description = "SD 相对路径,如 /english_learning/repeater/lessons/l1/001/cn/01.mp3")
    private List<String> files;
}
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181100.sql
New file
@@ -0,0 +1,4 @@
-- Local recording relative path is ~90 chars; VARCHAR(32) truncated and aborted TaskInstance upsert.
ALTER TABLE `ai_task_step`
    MODIFY COLUMN `audio_id` VARCHAR(255) DEFAULT NULL COMMENT '本地录音相对路径或 ai_agent_chat_audio.id';
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181400.sql
New file
@@ -0,0 +1,35 @@
-- Phase 7a-B parent dispatch. Do not edit older changesets.
CREATE TABLE `ai_parent_task`
(
    `id`           VARCHAR(36)  NOT NULL COMMENT '任务UUID,不是闹钟id',
    `user_id`      BIGINT       NOT NULL COMMENT '家长用户',
    `mac_address`  VARCHAR(50)  NOT NULL COMMENT '设备MAC',
    `agent_id`     VARCHAR(32)  NOT NULL COMMENT '智能体id',
    `run_mode`     VARCHAR(16)  NOT NULL COMMENT 'immediate/scheduled',
    `scene`        VARCHAR(32)  NOT NULL COMMENT 'extensive/shadowing/bilingual/translation/chat',
    `app_id`       VARCHAR(32)  NOT NULL COMMENT '内部 app',
    `task_id`      VARCHAR(32)  NOT NULL COMMENT '内部 task',
    `params`       TEXT         NOT NULL COMMENT '紧凑params,不含展开playlist',
    `schedule`     TEXT         DEFAULT NULL COMMENT '仅 scheduled',
    `enabled`      TINYINT(1)   NOT NULL DEFAULT 1 COMMENT '计划可停用',
    `status`       VARCHAR(16)  NOT NULL DEFAULT 'queued' COMMENT 'immediate: queued/running/done/canceled;scheduled: active',
    `created_at`   DATETIME(3)  DEFAULT CURRENT_TIMESTAMP(3) NOT NULL,
    `updated_at`   DATETIME(3)  DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3),
    PRIMARY KEY (`id`),
    KEY `idx_parent_task_mac_mode` (`mac_address`, `run_mode`, `enabled`),
    KEY `idx_parent_task_user` (`user_id`, `updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家长任务(泛听/跟读/双语/翻译/对话)';
DELETE FROM `sys_params` WHERE `param_code` IN (
    'server.parent_mqtt_host',
    'server.parent_mqtt_port',
    'server.parent_mqtt_username',
    'server.parent_mqtt_password'
);
INSERT INTO `sys_params` (`id`, `param_code`, `param_value`, `value_type`, `param_type`, `remark`) VALUES
(313, 'server.parent_mqtt_host', '124.222.109.197', 'string', 1, '家长任务下发 MQTT 主机(设备 cmd/ctl)'),
(314, 'server.parent_mqtt_port', '2100', 'number', 1, '家长任务下发 MQTT 端口'),
(315, 'server.parent_mqtt_username', 'AI_XIAOZHI', 'string', 1, '家长任务下发 MQTT 用户名'),
(316, 'server.parent_mqtt_password', 'V9qL7!mX2@rT8$kN5^wP1&c', 'string', 1, '家长任务下发 MQTT 密码');
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191800.sql
New file
@@ -0,0 +1,5 @@
-- Lesson MP3 public URL for device DOWNLOAD / UPDATE_MANIFEST.
DELETE FROM `sys_params` WHERE `param_code` = 'server.lesson_audio_public_url';
INSERT INTO `sys_params` (`id`, `param_code`, `param_value`, `value_type`, `param_type`, `remark`) VALUES
(317, 'server.lesson_audio_public_url', 'https://xiaozhi.illegalfm.com/lesson-audio', 'string', 1, '设备下载课文 MP3 的公网根(Caddy /lesson-audio* → 内网 8003);磁盘 data/lesson_audio/{org_id}/,空机构 default_org');
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191930.sql
New file
@@ -0,0 +1,6 @@
-- Device lesson MP3 download via Caddy public path (xiaozhi-server still listens on 8003).
UPDATE `sys_params`
SET `param_value` = 'https://xiaozhi.illegalfm.com/lesson-audio',
    `remark` = '设备下载课文 MP3 的公网根(Caddy /lesson-audio* → 内网 8003);磁盘 data/lesson_audio/{org_id}/,空机构 default_org'
WHERE `param_code` = 'server.lesson_audio_public_url';
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml
@@ -753,3 +753,31 @@
        - sqlFile:
            encoding: utf8
            path: classpath:db/changelog/202609011100.sql
  - changeSet:
      id: 202609181100
      author: xiaozhi
      changes:
        - sqlFile:
            encoding: utf8
            path: classpath:db/changelog/202609181100.sql
  - changeSet:
      id: 202609181400
      author: xiaozhi
      changes:
        - sqlFile:
            encoding: utf8
            path: classpath:db/changelog/202609181400.sql
  - changeSet:
      id: 202609191800
      author: xiaozhi
      changes:
        - sqlFile:
            encoding: utf8
            path: classpath:db/changelog/202609191800.sql
  - changeSet:
      id: 202609191930
      author: xiaozhi
      changes:
        - sqlFile:
            encoding: utf8
            path: classpath:db/changelog/202609191930.sql
xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java
@@ -1079,7 +1079,7 @@
        AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class);
        CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class);
        AgentSnapshotService snapshotService = mock(AgentSnapshotService.class);
        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null,
        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null, null,
                null, null, contextProviderService, null, correctWordFileService, snapshotService);
        ReflectionTestUtils.setField(service, "baseDao", agentDao);
@@ -1113,7 +1113,7 @@
        AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class);
        CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class);
        AgentSnapshotService snapshotService = mock(AgentSnapshotService.class);
        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null,
        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null, null,
                null, null, contextProviderService, null, correctWordFileService, snapshotService);
        ReflectionTestUtils.setField(service, "baseDao", agentDao);
@@ -1149,7 +1149,7 @@
        AgentTemplateService templateService = mock(AgentTemplateService.class);
        ModelProviderService providerService = mock(ModelProviderService.class);
        AgentSnapshotService snapshotService = mock(AgentSnapshotService.class);
        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, timbreService, null, null, null,
        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, timbreService, null, null, null, null,
                pluginMappingService, null, templateService, providerService, null, null, null, snapshotService);
        ReflectionTestUtils.setField(service, "baseDao", agentDao);
xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/device/controller/DeviceControllerTest.java
@@ -19,6 +19,8 @@
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.parent.mqtt.ParentDevicePresence;
import xiaozhi.modules.parent.service.ParentTaskService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
@@ -59,7 +61,9 @@
                deviceService,
                mock(DeviceAddressBookService.class),
                mock(RedisUtils.class),
                mock(SysParamsService.class));
                mock(SysParamsService.class),
                mock(ParentDevicePresence.class),
                mock(ParentTaskService.class));
    }
    private DeviceEntity ownedDevice() {
xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/catalog/ParentSceneCatalogTest.java
New file
@@ -0,0 +1,26 @@
package xiaozhi.modules.parent.catalog;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ParentSceneCatalogTest {
    @Test
    void listsChatTranslationAndBilingual() {
        ParentSceneCatalog.resetForTests();
        List<Map<String, Object>> scenes = ParentSceneCatalog.scenes();
        assertTrue(scenes.stream().anyMatch(row -> "chat".equals(row.get("id"))));
        assertTrue(scenes.stream().anyMatch(row -> "translation".equals(row.get("id"))));
        assertTrue(scenes.stream().anyMatch(row -> "bilingual".equals(row.get("id"))));
        assertEquals("默认聊天", ParentSceneCatalog.sceneName("chat"));
        Map<String, String> translation = ParentSceneCatalog.sceneToAppTask("translation");
        assertEquals("english_translation", translation.get("appId"));
        assertEquals("live_translate", translation.get("taskId"));
        Map<String, String> chat = ParentSceneCatalog.sceneToAppTask("chat");
        assertEquals("conversation", chat.get("taskId"));
    }
}
xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/mqtt/ParentDevicePresenceTest.java
New file
@@ -0,0 +1,37 @@
package xiaozhi.modules.parent.mqtt;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ParentDevicePresenceTest {
    @Test
    void liveStateStaysOnlineWhenWifiApLookupFails() {
        assertTrue(ParentDevicePresence.payloadMeansOnline(Map.of(
                "online", true,
                "net", "offline",
                "mac", "14:c1:9f:49:43:a4")));
    }
    @Test
    void lastWillMarksDeviceOffline() {
        assertFalse(ParentDevicePresence.payloadMeansOnline(Map.of(
                "online", false,
                "net", "offline")));
    }
    @Test
    void wifiHeartbeatStaysOnline() {
        assertTrue(ParentDevicePresence.payloadMeansOnline(Map.of(
                "online", true,
                "net", "wifi")));
    }
    @Test
    void emptyPayloadIsOffline() {
        assertFalse(ParentDevicePresence.payloadMeansOnline(Map.of()));
    }
}
xiaozhi-esp32-server/main/manager-mobile/env/.env
@@ -5,7 +5,7 @@
VITE_WX_APPID = 'wxa2abb91f64032a2b'
# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base
VITE_APP_PUBLIC_BASE=/
VITE_APP_PUBLIC_BASE='/mobile/'
# 登录页面
VITE_LOGIN_URL = '/pages/login/index'
xiaozhi-esp32-server/main/manager-mobile/env/.env.development
@@ -4,3 +4,6 @@
VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = true
# 与 manager-web(8001) 同一套本地 manager-api
VITE_SERVER_BASEURL = 'http://127.0.0.1:8002/xiaozhi'
xiaozhi-esp32-server/main/manager-mobile/manifest.config.ts
@@ -30,7 +30,7 @@
  'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
  'h5': {
    router: {
      // base: VITE_APP_PUBLIC_BASE,
      base: VITE_APP_PUBLIC_BASE,
    },
  },
  /* 5+App特有相关 */
xiaozhi-esp32-server/main/manager-mobile/src/api/agent/types.ts
@@ -11,9 +11,17 @@
  summaryMemory: string | null
  lastConnectedAt: string | null
  deviceCount: number
  onlineDeviceCount?: number
  devices?: AgentDevice[]
  tags: Record<string, string>[]
}
export interface AgentDevice {
  macAddress: string
  alias?: string
  online?: boolean
}
// 智能体创建数据类型
export interface AgentCreateData {
  agentName: string
xiaozhi-esp32-server/main/manager-mobile/src/api/device/types.ts
@@ -13,6 +13,7 @@
  alias?: string
  agentId: string
  appVersion: string
  online?: boolean
  sort: number
  updater?: string
  updateDate: string
xiaozhi-esp32-server/main/manager-mobile/src/api/parent/index.ts
New file
@@ -0,0 +1,2 @@
export * from './parent'
export * from './types'
xiaozhi-esp32-server/main/manager-mobile/src/api/parent/parent.ts
New file
@@ -0,0 +1,141 @@
import type {
  ParentHistoryDetail,
  ParentHistoryList,
  ParentLanguagePair,
  ParentLesson,
  IdiomCatalog,
  ParentScene,
  ParentDeviceStatus,
  ParentTask,
  ParentTaskList,
  ParentTaskSave,
} from './types'
import { http } from '@/http/request/alova'
const noCache = {
  ignoreAuth: false,
  toast: false,
}
export function getParentScenes() {
  return http.Get<ParentScene[]>('/user/scenes', {
    meta: noCache,
    cacheFor: { expire: 0 },
  })
}
export function getParentIdiomCatalog() {
  return http.Get<IdiomCatalog>('/user/idiom-catalog', {
    meta: noCache,
    cacheFor: { expire: 0 },
  })
}
export function getParentLessons(scene: string) {
  return http.Get<ParentLesson[]>('/user/lessons', {
    params: { scene },
    meta: noCache,
    cacheFor: { expire: 0 },
  })
}
export function getParentLanguagePairs() {
  return http.Get<ParentLanguagePair[]>('/user/language-pairs', {
    meta: noCache,
    cacheFor: { expire: 0 },
  })
}
export function getParentDeviceStatus(macAddress: string, agentId: string) {
  return http.Get<ParentDeviceStatus>('/device/presence', {
    params: { macAddress, agentId },
    meta: noCache,
    cacheFor: { expire: 0 },
    timeout: 10000,
  })
}
export function getParentTasks(macAddress: string, agentId: string, runMode?: string) {
  return http.Get<ParentTaskList>('/user/parent-task', {
    params: {
      macAddress,
      agentId,
      ...(runMode ? { runMode } : {}),
    },
    meta: noCache,
    cacheFor: { expire: 0 },
  })
}
export function getParentTask(id: string) {
  return http.Get<ParentTask>(`/user/parent-task/${id}`, {
    meta: noCache,
    cacheFor: { expire: 0 },
  })
}
const mqttMeta = {
  ignoreAuth: false,
  toast: false,
  isExposeError: true,
}
export function createParentTask(data: ParentTaskSave) {
  return http.Post<ParentTask>('/user/parent-task', data, {
    timeout: 20000,
    meta: mqttMeta,
  })
}
export function updateParentTask(id: string, data: ParentTaskSave) {
  return http.Put<ParentTask>(`/user/parent-task/${id}`, data, {
    timeout: 20000,
    meta: mqttMeta,
    cacheFor: { expire: 0 },
  })
}
export function deleteParentTask(id: string) {
  return http.Delete<boolean>(`/user/parent-task/${id}`, {
    timeout: 20000,
    meta: mqttMeta,
  })
}
export function runParentTaskNow(id: string) {
  return http.Post<ParentTask>(`/user/parent-task/${id}/run`, {}, {
    timeout: 20000,
    meta: mqttMeta,
  })
}
export function stopParentTask(id: string) {
  return http.Post<ParentTask>(`/user/parent-task/${id}/stop`, {}, {
    timeout: 20000,
    meta: mqttMeta,
  })
}
export function getParentHistory(macAddress: string, agentId: string, limit = 20) {
  return http.Get<ParentHistoryList>('/user/parent-history', {
    params: { macAddress, agentId, limit },
    meta: noCache,
    cacheFor: { expire: 0 },
  })
}
export function getParentHistoryDetail(id: string, macAddress: string, agentId: string) {
  return http.Get<ParentHistoryDetail>(`/user/parent-history/${id}`, {
    params: { macAddress, agentId },
    meta: noCache,
    cacheFor: { expire: 0 },
  })
}
export function parentTaskError(error: unknown, fallback: string) {
  if (typeof error === 'string' && error.trim())
    return error.trim()
  const message = error instanceof Error ? error.message : ''
  const cut = message.includes(':') ? message.split(':').slice(1).join(':').trim() : message.trim()
  return cut || fallback
}
xiaozhi-esp32-server/main/manager-mobile/src/api/parent/types.ts
New file
@@ -0,0 +1,177 @@
export interface ParentScene {
  id: string
  name: string
  hint?: string
  needsLesson: boolean
  needsRepeat?: boolean
  needsEnPasses?: boolean
  needsShadowingSettings?: boolean
  needsLanguagePair: boolean
  needsDuration: boolean
  needsOnComplete: boolean
  audioPackage?: boolean
  kind?: string
  appId: string
  taskId: string
}
export interface ParentLesson {
  lessonId: string
  name: string
  scene: string
  hint?: string
  levelId?: string
  systemId?: string
  sentenceCount?: number
}
export interface IdiomNamed {
  id: string
  name: string
  nameEn?: string
}
export interface IdiomEnPass {
  speed: number
  repeat: number
}
export interface IdiomStory {
  id: string
  slug?: string
  systemId: string
  name: string
  nameEn?: string
  lessonId?: string
  levelId?: string
  sentenceCount?: number
  enabled?: boolean
}
export interface IdiomCatalog {
  version?: number
  defaults?: {
    cnRepeat?: number
    cnSpeed?: number
    enPasses?: IdiomEnPass[]
    shadowingLanguage?: string
    shadowingSpeed?: number
    listenRatio?: number
    sentenceCount?: number
  }
  levels: IdiomNamed[]
  systems: IdiomNamed[]
  stories: IdiomStory[]
}
export interface ParentLanguagePair {
  id: string
  name: string
  ready: boolean
}
export interface ParentTaskParams {
  lesson_id?: string
  lesson_name?: string
  lesson_name_en?: string
  repeat?: number
  cn_repeat?: number
  cn_speed?: number
  en_passes?: IdiomEnPass[]
  language?: 'cn' | 'en' | string
  speed?: number
  sentence_count?: number
  listen_ratio?: number
  language_pair?: string
  duration_minutes?: number
  on_complete?: 'chat' | 'power_off'
  announce?: boolean
}
export interface ParentSchedule {
  type?: 'daily' | 'hourly' | 'interval'
  time?: string
  weekdays?: number[]
  minute?: number
  every_minutes?: number
}
export interface ParentTask {
  id: string
  macAddress: string
  agentId: string
  runMode: 'immediate' | 'scheduled'
  scene: string
  appId: string
  taskId: string
  sceneName: string
  params: ParentTaskParams
  schedule: ParentSchedule
  enabled: boolean
  status: string
  createdAt?: string
  updatedAt?: string
}
export interface ParentTaskList {
  total: number
  list: ParentTask[]
}
export interface ParentDeviceStatus {
  macAddress: string
  online: boolean
}
export interface ParentTaskSave {
  macAddress: string
  agentId: string
  runMode: 'immediate' | 'scheduled'
  scene: string
  params?: ParentTaskParams
  schedule?: ParentSchedule
  enabled?: boolean
}
export type ParentHistoryKind = 'extensive' | 'shadowing' | 'chat' | string
export interface ParentHistoryItem {
  id: string
  scene: string
  sceneName: string
  kind: ParentHistoryKind
  appId: string
  taskId: string
  source: string
  status: string
  endReason: string
  startedAt?: number
  endedAt?: number
  durationMs?: number
  turnCount: number
}
export interface ParentHistoryList {
  total: number
  list: ParentHistoryItem[]
}
export interface ParentHistoryTurn {
  index: number
  expectedText: string
  asrText: string
  audioId: string | null
}
export interface ParentHistoryChat {
  createdAt: string
  chatType: 1 | 2 | 3
  content: string
  audioId: string | null
  macAddress?: string
}
export interface ParentHistoryDetail extends ParentHistoryItem {
  turns: ParentHistoryTurn[]
  chats: ParentHistoryChat[]
}
xiaozhi-esp32-server/main/manager-mobile/src/i18n/de.ts
@@ -84,6 +84,8 @@
  'home.daysAgo': 'Tage her',
  'home.languageModel': 'LLM',
  'home.voiceModel': 'TTS',
  'home.noBoundDevice': 'Kein Gerät gebunden',
  'home.onlineCount': '{online}/{total} online',
  // Agentenseite
  'agent.pageTitle': 'Agent',
@@ -91,6 +93,7 @@
  'agent.deviceManagement': 'Geräteverwaltung',
  'agent.chatHistory': 'Chat-Verlauf',
  'agent.voiceprintManagement': 'Stimmabdruckverwaltung',
  'agent.parentTasks': 'Lernaufgaben',
  'agent.editTitle': 'Agent bearbeiten',
  'agent.toolsTitle': 'Bearbeiten',
  'agent.voiceActivityDetection': 'Sprachaktivitätserkennung',
@@ -305,6 +308,11 @@
  'device.deviceType': 'Gerätetyp',
  'device.loading': 'Lädt...',
  'device.neverConnected': 'Nie verbunden',
  'device.checkingOnline': 'Gerätestatus wird geprüft…',
  'device.online': 'Gerät online',
  'device.offline': 'Gerät offline',
  'device.onlineHint': 'Sofort- und Planaufgaben können gesendet werden.',
  'device.offlineHint': 'Keine Aufgaben möglich. Chatverlauf bleibt sichtbar.',
  'device.justNow': 'Gerade eben',
  'device.minutesAgo': 'Vor {minutes} Minuten',
  'device.hoursAgo': 'Vor {hours} Stunden',
@@ -594,4 +602,119 @@
  'voiceprint.audioNotExist': 'Audio existiert nicht',
  'voiceprint.getAudioFailed': 'Audio konnte nicht abgerufen werden',
  'voiceprint.audioPlayFailed': 'Audio-Wiedergabe fehlgeschlagen',
  'parentTask.pageTitle': 'Lernaufgaben',
  'parentTask.createTitle': 'Lernaufgabe erstellen',
  'parentTask.editTitle': 'Lernaufgabe bearbeiten',
  'parentTask.device': 'Gerät',
  'parentTask.selectDevice': 'Gerät wählen',
  'parentTask.bindDeviceFirst': 'Bitte zuerst ein Gerät binden',
  'parentTask.runningSection': 'Läuft jetzt',
  'parentTask.scheduledSection': 'Geplante Aufgaben',
  'parentTask.running': 'Läuft',
  'parentTask.emptySchedule': 'Noch keine Pläne. Tippe +, um extensives Hören, Shadowing oder Chat zu erstellen.',
  'parentTask.startNow': 'Jetzt starten',
  'parentTask.saveSchedule': 'Als Plan speichern',
  'parentTask.started': 'An das Gerät gesendet',
  'parentTask.saved': 'Plan mit dem Gerät synchronisiert',
  'parentTask.saveFailed': 'Speichern fehlgeschlagen',
  'parentTask.deviceOffline': 'Gerät offline. Nichts gespeichert. Gerät einschalten und mit dem Netz verbinden, dann erneut versuchen.',
  'parentTask.online': 'Gerät online',
  'parentTask.offline': 'Gerät offline',
  'parentTask.checkingOnline': 'Gerätestatus wird geprüft…',
  'parentTask.onlineHint': 'Sofort- und Planaufgaben können gesendet werden.',
  'parentTask.offlineHint': 'Keine Sofort- oder Planaufgaben möglich. Gerät einschalten und mit dem Netz verbinden.',
  'parentTask.startFailed': 'Senden fehlgeschlagen. Gerät online?',
  'parentTask.stop': 'Beenden',
  'parentTask.stopTitle': 'Aktuelle Aufgabe beenden',
  'parentTask.stopConfirm': 'Das Gerät kehrt sofort zum Chat zurück.',
  'parentTask.stopped': 'Stopp an das Gerät gesendet',
  'parentTask.stopFailed': 'Beenden fehlgeschlagen. Gerät online?',
  'parentTask.deleteTitle': 'Aufgabe löschen',
  'parentTask.deleteConfirm': 'Dieser Plan wird auch vom Gerät entfernt.',
  'parentTask.deleted': 'Gelöscht',
  'parentTask.deleteFailed': 'Löschen fehlgeschlagen',
  'parentTask.scene': 'Szene',
  'parentTask.dispatchHint': 'Sofortstart wechselt den Modus sofort. Ein Zeitplan startet ihn zur eingestellten Zeit, ohne Sprachbefehl.',
  'parentTask.lesson': 'Lektion',
  'parentTask.pickScene': 'Szene wählen',
  'parentTask.pickLesson': 'Lektion wählen',
  'parentTask.repeat': 'Wiederholungen',
  'parentTask.level': 'Stufe',
  'parentTask.system': 'Kategorie',
  'parentTask.story': 'Redewendung',
  'parentTask.pickStory': 'Eine Geschichte wählen',
  'parentTask.noStories': 'In dieser Kategorie gibt es noch keine Geschichten.',
  'parentTask.sentenceCount': 'Nachsprechen: {n} Sätze',
  'parentTask.enPassesTitle': 'Englisch hören',
  'parentTask.cnFixed': 'Zuerst Chinesisch 1× bei 1,0, danach Englisch wie unten.',
  'parentTask.enPassIndex': 'Englisch-Durchgang {n}',
  'parentTask.addEnPass': 'Englischen Durchgang hinzufügen',
  'parentTask.removePass': 'Entfernen',
  'parentTask.shadowingSettings': 'Nachsprechen',
  'parentTask.shadowingLang': 'Sprache',
  'parentTask.shadowingLang.cn': 'Chinesisch',
  'parentTask.shadowingLang.en': 'Englisch',
  'parentTask.shadowingSpeed': 'Abspielgeschwindigkeit',
  'parentTask.shadowingHint': 'Hörfenster = tatsächliche Spieldauer × 1,2. Aktuell {speed}×.',
  'parentTask.languagePair': 'Sprachpaar',
  'parentTask.pickPair': 'Sprachpaar wählen',
  'parentTask.pairSoon': 'in Arbeit',
  'parentTask.pairFallbackHint': 'Die vollständigen Satzpakete folgen noch. Das Gerät übt zuerst Chinesisch-Englisch.',
  'parentTask.duration': 'Dauer (Minuten, 0 = unbegrenzt)',
  'parentTask.noDuration': 'Keine Zeitbegrenzung',
  'parentTask.minutesValue': '{n} Min.',
  'parentTask.onComplete': 'Danach',
  'parentTask.completeChat': 'Zurück zum Chat',
  'parentTask.completePowerOff': 'Sanft ausschalten',
  'parentTask.howToRun': 'Ausführung',
  'parentTask.immediate': 'Sofort',
  'parentTask.schedule': 'Zeitplan',
  'parentTask.schedule.daily': 'Täglich',
  'parentTask.schedule.hourly': 'Stündlich',
  'parentTask.schedule.interval': 'Intervall',
  'parentTask.time': 'Uhrzeit',
  'parentTask.pickTime': 'Uhrzeit wählen',
  'parentTask.timeHint': 'Beliebige Stunde und Minute',
  'parentTask.repeatDays': 'Wiederholen',
  'parentTask.everyDay': 'Täglich',
  'parentTask.weekdays': 'Wochentage',
  'parentTask.hourlyMinute': 'Minute jeder Stunde',
  'parentTask.hourlyOnTheHour': 'Jede volle Stunde',
  'parentTask.hourlyAt': 'Jede Stunde um :{minute}',
  'parentTask.everyMinutes': 'Alle {minutes} Minuten',
  'parentTask.intervalMinutes': 'Intervall (Minuten)',
  'parentTask.weekday.mon': 'Mo',
  'parentTask.weekday.tue': 'Di',
  'parentTask.weekday.wed': 'Mi',
  'parentTask.weekday.thu': 'Do',
  'parentTask.weekday.fri': 'Fr',
  'parentTask.weekday.sat': 'Sa',
  'parentTask.weekday.sun': 'So',
  'parentHistory.pageTitle': 'Verlauf',
  'parentHistory.detailTitle': 'Eintrag',
  'parentHistory.entry': 'Verlauf',
  'parentHistory.empty': 'Noch keine Einträge. Sie erscheinen, nachdem das Kind eine Aufgabe beendet.',
  'parentHistory.loadFailed': 'Laden fehlgeschlagen',
  'parentHistory.status.running': 'Läuft',
  'parentHistory.status.completed': 'Abgeschlossen',
  'parentHistory.status.cancelled': 'Beendet',
  'parentHistory.status.preempted': 'Unterbrochen',
  'parentHistory.status.other': 'Unvollständig',
  'parentHistory.source.immediate': 'Sofort gestartet',
  'parentHistory.source.schedule': 'Zeitplan',
  'parentHistory.source.voice': 'Sprachbefehl',
  'parentHistory.durationSec': '{s}s',
  'parentHistory.durationMin': '{m}m {s}s',
  'parentHistory.turns': '{n} Sätze',
  'parentHistory.sentence': 'Satz {n}',
  'parentHistory.expected': 'Original',
  'parentHistory.asr': 'Nachsprechen',
  'parentHistory.noExpected': 'Kein Originaltext',
  'parentHistory.noAsr': 'Kein Nachsprechen erkannt',
  'parentHistory.noAudio': 'Keine abspielbare Aufnahme',
  'parentHistory.emptyTurns': 'Keine Sätze für diese Nachsprechsitzung.',
  'parentHistory.extensiveHint': 'Umfangreiches Hören hat keine Satzaufnahmen. Hier siehst du Abschluss und Dauer.',
  'parentHistory.noChat': 'In diesem Zeitraum wurde kein Chat gespeichert.',
}
xiaozhi-esp32-server/main/manager-mobile/src/i18n/en.ts
@@ -84,6 +84,8 @@
  'home.daysAgo': 'days ago',
  'home.languageModel': 'LLM',
  'home.voiceModel': 'TTS',
  'home.noBoundDevice': 'No device bound',
  'home.onlineCount': '{online}/{total} online',
  // Agent page
  'agent.pageTitle': 'Agent',
@@ -91,6 +93,7 @@
  'agent.deviceManagement': 'Device Management',
  'agent.chatHistory': 'Chat History',
  'agent.voiceprintManagement': 'Voiceprint Management',
  'agent.parentTasks': 'Learning Tasks',
  'agent.editTitle': 'Edit Agent',
  'agent.toolsTitle': 'Edit Features',
  'agent.voiceActivityDetection': 'Voice Activity Detection',
@@ -305,6 +308,11 @@
  'device.deviceType': 'Device Type',
  'device.loading': 'Loading...',
  'device.neverConnected': 'Never connected',
  'device.checkingOnline': 'Checking device status…',
  'device.online': 'Device online',
  'device.offline': 'Device offline',
  'device.onlineHint': 'You can send immediate and scheduled tasks.',
  'device.offlineHint': 'Tasks cannot be sent. Chat history and other views still work.',
  'device.justNow': 'Just now',
  'device.minutesAgo': '{minutes} minutes ago',
  'device.hoursAgo': '{hours} hours ago',
@@ -594,4 +602,119 @@
  'voiceprint.audioNotExist': 'Audio does not exist',
  'voiceprint.getAudioFailed': 'Failed to get audio',
  'voiceprint.audioPlayFailed': 'Audio playback failed',
  'parentTask.pageTitle': 'Learning Tasks',
  'parentTask.createTitle': 'Create Learning Task',
  'parentTask.editTitle': 'Edit Learning Task',
  'parentTask.device': 'Device',
  'parentTask.selectDevice': 'Select a device',
  'parentTask.bindDeviceFirst': 'Bind a device first',
  'parentTask.runningSection': 'Running now',
  'parentTask.scheduledSection': 'Scheduled tasks',
  'parentTask.running': 'Running',
  'parentTask.emptySchedule': 'No schedules yet. Tap + to create extensive listening, shadowing, or chat.',
  'parentTask.startNow': 'Start now',
  'parentTask.saveSchedule': 'Save schedule',
  'parentTask.started': 'Sent to the device',
  'parentTask.saved': 'Schedule synced to the device',
  'parentTask.saveFailed': 'Save failed',
  'parentTask.deviceOffline': 'Device is offline, so nothing was saved. Turn it on and connect it to the network, then try again.',
  'parentTask.online': 'Device online',
  'parentTask.offline': 'Device offline',
  'parentTask.checkingOnline': 'Checking device status…',
  'parentTask.onlineHint': 'You can send immediate and scheduled tasks.',
  'parentTask.offlineHint': 'Cannot send immediate or scheduled tasks. Turn the device on and connect it to the network.',
  'parentTask.startFailed': 'Dispatch failed. Check that the device is online.',
  'parentTask.stop': 'Stop',
  'parentTask.stopTitle': 'Stop current task',
  'parentTask.stopConfirm': 'The device will return to chat immediately.',
  'parentTask.stopped': 'Sent stop to the device',
  'parentTask.stopFailed': 'Stop failed. Check that the device is online.',
  'parentTask.deleteTitle': 'Delete task',
  'parentTask.deleteConfirm': 'This schedule will also be removed from the device.',
  'parentTask.deleted': 'Deleted',
  'parentTask.deleteFailed': 'Delete failed',
  'parentTask.scene': 'Scene',
  'parentTask.dispatchHint': 'Start now switches the device immediately. A schedule starts that mode at the set time, no voice command needed.',
  'parentTask.lesson': 'Lesson',
  'parentTask.pickScene': 'Choose a scene',
  'parentTask.pickLesson': 'Choose a lesson',
  'parentTask.repeat': 'Repeats',
  'parentTask.level': 'Level',
  'parentTask.system': 'Category',
  'parentTask.story': 'Idiom story',
  'parentTask.pickStory': 'Choose one story',
  'parentTask.noStories': 'No stories in this category yet. Add them in the lesson catalog.',
  'parentTask.sentenceCount': 'Shadowing has {n} sentences',
  'parentTask.enPassesTitle': 'How to listen in English',
  'parentTask.cnFixed': 'Chinese plays once at 1.0×, then English uses the passes below.',
  'parentTask.enPassIndex': 'English pass {n}',
  'parentTask.addEnPass': 'Add an English pass',
  'parentTask.removePass': 'Remove',
  'parentTask.shadowingSettings': 'Shadowing',
  'parentTask.shadowingLang': 'Language',
  'parentTask.shadowingLang.cn': 'Chinese',
  'parentTask.shadowingLang.en': 'English',
  'parentTask.shadowingSpeed': 'Playback speed',
  'parentTask.shadowingHint': 'Listen window = actual play time × 1.2. Current speed {speed}×; slower playback makes a longer window.',
  'parentTask.languagePair': 'Language pair',
  'parentTask.pickPair': 'Choose a language pair',
  'parentTask.pairSoon': 'coming soon',
  'parentTask.pairFallbackHint': 'Full phrase packs are still in progress. The device will practice in Chinese-English first.',
  'parentTask.duration': 'Duration (minutes, 0 = unlimited)',
  'parentTask.noDuration': 'No time limit',
  'parentTask.minutesValue': '{n} min',
  'parentTask.onComplete': 'When finished',
  'parentTask.completeChat': 'Return to chat',
  'parentTask.completePowerOff': 'Soft power off',
  'parentTask.howToRun': 'How to run',
  'parentTask.immediate': 'Run now',
  'parentTask.schedule': 'Schedule',
  'parentTask.schedule.daily': 'Daily',
  'parentTask.schedule.hourly': 'Hourly',
  'parentTask.schedule.interval': 'Interval',
  'parentTask.time': 'Time',
  'parentTask.pickTime': 'Pick a time',
  'parentTask.timeHint': 'Any hour and minute of the day',
  'parentTask.repeatDays': 'Repeat',
  'parentTask.everyDay': 'Every day',
  'parentTask.weekdays': 'Weekdays',
  'parentTask.hourlyMinute': 'Minute of each hour',
  'parentTask.hourlyOnTheHour': 'Every hour on the hour',
  'parentTask.hourlyAt': 'Every hour at :{minute}',
  'parentTask.everyMinutes': 'Every {minutes} minutes',
  'parentTask.intervalMinutes': 'Interval (minutes)',
  'parentTask.weekday.mon': 'Mon',
  'parentTask.weekday.tue': 'Tue',
  'parentTask.weekday.wed': 'Wed',
  'parentTask.weekday.thu': 'Thu',
  'parentTask.weekday.fri': 'Fri',
  'parentTask.weekday.sat': 'Sat',
  'parentTask.weekday.sun': 'Sun',
  'parentHistory.pageTitle': 'History',
  'parentHistory.detailTitle': 'Record',
  'parentHistory.entry': 'History',
  'parentHistory.empty': 'No records yet. They appear after the child finishes a task.',
  'parentHistory.loadFailed': 'Failed to load',
  'parentHistory.status.running': 'In progress',
  'parentHistory.status.completed': 'Completed',
  'parentHistory.status.cancelled': 'Stopped',
  'parentHistory.status.preempted': 'Interrupted',
  'parentHistory.status.other': 'Incomplete',
  'parentHistory.source.immediate': 'Started now',
  'parentHistory.source.schedule': 'Scheduled',
  'parentHistory.source.voice': 'Voice command',
  'parentHistory.durationSec': '{s}s',
  'parentHistory.durationMin': '{m}m {s}s',
  'parentHistory.turns': '{n} lines',
  'parentHistory.sentence': 'Line {n}',
  'parentHistory.expected': 'Original',
  'parentHistory.asr': 'Follow-read',
  'parentHistory.noExpected': 'No original text',
  'parentHistory.noAsr': 'No follow-read recognized',
  'parentHistory.noAudio': 'No playable recording',
  'parentHistory.emptyTurns': 'No sentence records for this shadowing session.',
  'parentHistory.extensiveHint': 'Extensive listening has no per-sentence recordings. This page shows completion and duration.',
  'parentHistory.noChat': 'No chat was saved for this window.',
}
xiaozhi-esp32-server/main/manager-mobile/src/i18n/pt_BR.ts
@@ -84,6 +84,8 @@
  'home.daysAgo': 'dias atrás',
  'home.languageModel': 'LLM',
  'home.voiceModel': 'TTS',
  'home.noBoundDevice': 'Nenhum dispositivo vinculado',
  'home.onlineCount': '{online}/{total} online',
  // Agent page
  'agent.pageTitle': 'Agente',
@@ -91,6 +93,7 @@
  'agent.deviceManagement': 'Gerenciamento de Dispositivos',
  'agent.chatHistory': 'Histórico de Conversas',
  'agent.voiceprintManagement': 'Gerenciamento de Impressão Vocal',
  'agent.parentTasks': 'Tarefas de estudo',
  'agent.editTitle': 'Editar Agente',
  'agent.toolsTitle': 'Editar Funcionalidades',
  'agent.voiceActivityDetection': 'Detecção de Atividade de Voz',
@@ -305,6 +308,11 @@
  'device.deviceType': 'Tipo de Dispositivo',
  'device.loading': 'Carregando...',
  'device.neverConnected': 'Nunca conectado',
  'device.checkingOnline': 'Verificando o status do dispositivo…',
  'device.online': 'Dispositivo online',
  'device.offline': 'Dispositivo offline',
  'device.onlineHint': 'É possível enviar tarefas imediatas e agendadas.',
  'device.offlineHint': 'Não é possível enviar tarefas. O histórico de chat continua disponível.',
  'device.justNow': 'Agora mesmo',
  'device.minutesAgo': '{minutes} minutos atrás',
  'device.hoursAgo': '{hours} horas atrás',
@@ -594,4 +602,119 @@
  'voiceprint.audioNotExist': 'O áudio não existe',
  'voiceprint.getAudioFailed': 'Falha ao obter áudio',
  'voiceprint.audioPlayFailed': 'Falha na reprodução de áudio',
  'parentTask.pageTitle': 'Tarefas de estudo',
  'parentTask.createTitle': 'Criar tarefa',
  'parentTask.editTitle': 'Editar tarefa',
  'parentTask.device': 'Dispositivo',
  'parentTask.selectDevice': 'Selecione o dispositivo',
  'parentTask.bindDeviceFirst': 'Vincule um dispositivo primeiro',
  'parentTask.runningSection': 'Em execução',
  'parentTask.scheduledSection': 'Tarefas agendadas',
  'parentTask.running': 'Em execução',
  'parentTask.emptySchedule': 'Ainda sem agenda. Toque + para criar escuta ampla, shadowing ou conversa.',
  'parentTask.startNow': 'Começar agora',
  'parentTask.saveSchedule': 'Salvar agenda',
  'parentTask.started': 'Enviado ao dispositivo',
  'parentTask.saved': 'Agenda sincronizada com o dispositivo',
  'parentTask.saveFailed': 'Falha ao salvar',
  'parentTask.deviceOffline': 'Dispositivo offline. Nada foi salvo. Ligue o aparelho, conecte à rede e tente de novo.',
  'parentTask.online': 'Dispositivo online',
  'parentTask.offline': 'Dispositivo offline',
  'parentTask.checkingOnline': 'Verificando o status do dispositivo…',
  'parentTask.onlineHint': 'É possível enviar tarefas imediatas e agendadas.',
  'parentTask.offlineHint': 'Não é possível enviar tarefas. Ligue o aparelho e conecte à rede.',
  'parentTask.startFailed': 'Falha no envio. Verifique se o dispositivo está online.',
  'parentTask.stop': 'Encerrar',
  'parentTask.stopTitle': 'Encerrar tarefa atual',
  'parentTask.stopConfirm': 'O dispositivo voltará imediatamente para o chat.',
  'parentTask.stopped': 'Parada enviada ao dispositivo',
  'parentTask.stopFailed': 'Falha ao encerrar. Verifique se o dispositivo está online.',
  'parentTask.deleteTitle': 'Excluir tarefa',
  'parentTask.deleteConfirm': 'Esta agenda também será removida do dispositivo.',
  'parentTask.deleted': 'Excluído',
  'parentTask.deleteFailed': 'Falha ao excluir',
  'parentTask.scene': 'Cena',
  'parentTask.dispatchHint': 'Iniciar agora troca o modo na hora. O agendamento troca no horário, sem comando de voz.',
  'parentTask.lesson': 'Lição',
  'parentTask.pickScene': 'Escolha a cena',
  'parentTask.pickLesson': 'Escolha a lição',
  'parentTask.repeat': 'Repetições',
  'parentTask.level': 'Nível',
  'parentTask.system': 'Categoria',
  'parentTask.story': 'História',
  'parentTask.pickStory': 'Escolha uma história',
  'parentTask.noStories': 'Ainda não há histórias nesta categoria.',
  'parentTask.sentenceCount': 'Shadowing tem {n} frases',
  'parentTask.enPassesTitle': 'Como ouvir em inglês',
  'parentTask.cnFixed': 'Chinês 1 vez em 1.0×, depois inglês conforme abaixo.',
  'parentTask.enPassIndex': 'Passagem em inglês {n}',
  'parentTask.addEnPass': 'Adicionar passagem em inglês',
  'parentTask.removePass': 'Remover',
  'parentTask.shadowingSettings': 'Shadowing',
  'parentTask.shadowingLang': 'Idioma',
  'parentTask.shadowingLang.cn': 'Chinês',
  'parentTask.shadowingLang.en': 'Inglês',
  'parentTask.shadowingSpeed': 'Velocidade',
  'parentTask.shadowingHint': 'Janela de escuta = duração real × 1.2. Velocidade atual {speed}×.',
  'parentTask.languagePair': 'Par de idiomas',
  'parentTask.pickPair': 'Escolha o par de idiomas',
  'parentTask.pairSoon': 'em breve',
  'parentTask.pairFallbackHint': 'Os pacotes de frases ainda estão em andamento. O dispositivo praticará chinês-inglês primeiro.',
  'parentTask.duration': 'Duração (minutos, 0 = ilimitado)',
  'parentTask.noDuration': 'Sem limite',
  'parentTask.minutesValue': '{n} min',
  'parentTask.onComplete': 'Ao terminar',
  'parentTask.completeChat': 'Voltar ao chat',
  'parentTask.completePowerOff': 'Desligar suavemente',
  'parentTask.howToRun': 'Como executar',
  'parentTask.immediate': 'Executar agora',
  'parentTask.schedule': 'Agenda',
  'parentTask.schedule.daily': 'Diário',
  'parentTask.schedule.hourly': 'Por hora',
  'parentTask.schedule.interval': 'Intervalo',
  'parentTask.time': 'Horário',
  'parentTask.pickTime': 'Escolher horário',
  'parentTask.timeHint': 'Qualquer hora e minuto do dia',
  'parentTask.repeatDays': 'Repetir',
  'parentTask.everyDay': 'Todos os dias',
  'parentTask.weekdays': 'Dias úteis',
  'parentTask.hourlyMinute': 'Minuto de cada hora',
  'parentTask.hourlyOnTheHour': 'A cada hora cheia',
  'parentTask.hourlyAt': 'A cada hora às :{minute}',
  'parentTask.everyMinutes': 'A cada {minutes} minutos',
  'parentTask.intervalMinutes': 'Intervalo (minutos)',
  'parentTask.weekday.mon': 'Seg',
  'parentTask.weekday.tue': 'Ter',
  'parentTask.weekday.wed': 'Qua',
  'parentTask.weekday.thu': 'Qui',
  'parentTask.weekday.fri': 'Sex',
  'parentTask.weekday.sat': 'Sáb',
  'parentTask.weekday.sun': 'Dom',
  'parentHistory.pageTitle': 'Histórico',
  'parentHistory.detailTitle': 'Detalhe',
  'parentHistory.entry': 'Histórico',
  'parentHistory.empty': 'Ainda não há registros. Eles aparecem depois que a criança termina uma tarefa.',
  'parentHistory.loadFailed': 'Falha ao carregar',
  'parentHistory.status.running': 'Em andamento',
  'parentHistory.status.completed': 'Concluído',
  'parentHistory.status.cancelled': 'Encerrado',
  'parentHistory.status.preempted': 'Interrompido',
  'parentHistory.status.other': 'Incompleto',
  'parentHistory.source.immediate': 'Início imediato',
  'parentHistory.source.schedule': 'Agenda',
  'parentHistory.source.voice': 'Comando de voz',
  'parentHistory.durationSec': '{s}s',
  'parentHistory.durationMin': '{m}m {s}s',
  'parentHistory.turns': '{n} frases',
  'parentHistory.sentence': 'Frase {n}',
  'parentHistory.expected': 'Original',
  'parentHistory.asr': 'Repetição',
  'parentHistory.noExpected': 'Sem texto original',
  'parentHistory.noAsr': 'Nenhuma repetição reconhecida',
  'parentHistory.noAudio': 'Sem gravação reproduzível',
  'parentHistory.emptyTurns': 'Não há frases nesta sessão de shadowing.',
  'parentHistory.extensiveHint': 'A escuta extensiva não tem gravações por frase. Aqui você vê conclusão e duração.',
  'parentHistory.noChat': 'Não houve conversa salva neste período.',
}
xiaozhi-esp32-server/main/manager-mobile/src/i18n/vi.ts
@@ -84,6 +84,8 @@
  'home.daysAgo': 'ngày trước',
  'home.languageModel': 'LLM',
  'home.voiceModel': 'TTS',
  'home.noBoundDevice': 'Chưa gắn thiết bị',
  'home.onlineCount': '{online}/{total} online',
  // Trang đại lý
  'agent.pageTitle': 'Đại lý',
@@ -91,6 +93,7 @@
  'agent.deviceManagement': 'Quản lý thiết bị',
  'agent.chatHistory': 'Lịch sử trò chuyện',
  'agent.voiceprintManagement': 'Quản lý dấu giọng nói',
  'agent.parentTasks': 'Bài học',
  'agent.editTitle': 'Chỉnh sửa đại lý',
  'agent.toolsTitle': 'Chỉnh sửa tính năng',
  'agent.voiceActivityDetection': 'Phát hiện hoạt động giọng nói',
@@ -305,6 +308,11 @@
  'device.deviceType': 'Loại thiết bị',
  'device.loading': 'Đang tải...',
  'device.neverConnected': 'Chưa kết nối',
  'device.checkingOnline': 'Đang kiểm tra trạng thái thiết bị…',
  'device.online': 'Thiết bị online',
  'device.offline': 'Thiết bị offline',
  'device.onlineHint': 'Có thể gửi nhiệm vụ tức thì và theo lịch.',
  'device.offlineHint': 'Không gửi được nhiệm vụ. Vẫn xem được lịch sử trò chuyện.',
  'device.justNow': 'Vừa xong',
  'device.minutesAgo': '{minutes} phút trước',
  'device.hoursAgo': '{hours} giờ trước',
@@ -594,4 +602,119 @@
  'voiceprint.audioNotExist': 'Âm thanh không tồn tại',
  'voiceprint.getAudioFailed': 'Không thể lấy âm thanh',
  'voiceprint.audioPlayFailed': 'Phát âm thanh thất bại',
  'parentTask.pageTitle': 'Bài học',
  'parentTask.createTitle': 'Tạo bài học',
  'parentTask.editTitle': 'Sửa bài học',
  'parentTask.device': 'Thiết bị',
  'parentTask.selectDevice': 'Chọn thiết bị',
  'parentTask.bindDeviceFirst': 'Hãy gắn thiết bị trước',
  'parentTask.runningSection': 'Đang chạy',
  'parentTask.scheduledSection': 'Lịch trình',
  'parentTask.running': 'Đang chạy',
  'parentTask.emptySchedule': 'Chưa có lịch. Bấm + để tạo nghe rộng, đọc theo hoặc trò chuyện.',
  'parentTask.startNow': 'Bắt đầu ngay',
  'parentTask.saveSchedule': 'Lưu lịch',
  'parentTask.started': 'Đã gửi tới thiết bị',
  'parentTask.saved': 'Đã đồng bộ lịch tới thiết bị',
  'parentTask.saveFailed': 'Lưu thất bại',
  'parentTask.deviceOffline': 'Thiết bị offline, chưa lưu. Bật máy và kết nối mạng rồi thử lại.',
  'parentTask.online': 'Thiết bị online',
  'parentTask.offline': 'Thiết bị offline',
  'parentTask.checkingOnline': 'Đang kiểm tra trạng thái thiết bị…',
  'parentTask.onlineHint': 'Có thể gửi nhiệm vụ tức thì và theo lịch.',
  'parentTask.offlineHint': 'Không gửi được nhiệm vụ. Bật máy và kết nối mạng.',
  'parentTask.startFailed': 'Gửi thất bại, kiểm tra thiết bị online',
  'parentTask.stop': 'Dừng',
  'parentTask.stopTitle': 'Dừng nhiệm vụ hiện tại',
  'parentTask.stopConfirm': 'Thiết bị sẽ quay lại trò chuyện ngay.',
  'parentTask.stopped': 'Đã gửi lệnh dừng tới thiết bị',
  'parentTask.stopFailed': 'Dừng thất bại, kiểm tra thiết bị online',
  'parentTask.deleteTitle': 'Xóa nhiệm vụ',
  'parentTask.deleteConfirm': 'Lịch trên thiết bị cũng sẽ bị xóa.',
  'parentTask.deleted': 'Đã xóa',
  'parentTask.deleteFailed': 'Xóa thất bại',
  'parentTask.scene': 'Cảnh',
  'parentTask.dispatchHint': 'Bắt đầu ngay sẽ đổi chế độ ngay. Lịch sẽ đổi đúng giờ, không cần lệnh thoại.',
  'parentTask.lesson': 'Bài',
  'parentTask.pickScene': 'Chọn cảnh',
  'parentTask.pickLesson': 'Chọn bài',
  'parentTask.repeat': 'Số lần',
  'parentTask.level': 'Cấp',
  'parentTask.system': 'Nhóm',
  'parentTask.story': 'Thành ngữ',
  'parentTask.pickStory': 'Chọn một thành ngữ',
  'parentTask.noStories': 'Nhóm này chưa có bài. Hãy thêm trong danh mục.',
  'parentTask.sentenceCount': 'Bài shadowing có {n} câu',
  'parentTask.enPassesTitle': 'Nghe tiếng Anh',
  'parentTask.cnFixed': 'Tiếng Trung 1 lần, 1.0×, sau đó tiếng Anh theo bên dưới.',
  'parentTask.enPassIndex': 'Lượt tiếng Anh {n}',
  'parentTask.addEnPass': 'Thêm lượt tiếng Anh',
  'parentTask.removePass': 'Xóa',
  'parentTask.shadowingSettings': 'Shadowing',
  'parentTask.shadowingLang': 'Ngôn ngữ',
  'parentTask.shadowingLang.cn': 'Tiếng Trung',
  'parentTask.shadowingLang.en': 'Tiếng Anh',
  'parentTask.shadowingSpeed': 'Tốc độ phát',
  'parentTask.shadowingHint': 'Cửa sổ ghi = thời lượng phát thực tế × 1.2. Hiện {speed}×.',
  'parentTask.languagePair': 'Cặp ngôn ngữ',
  'parentTask.pickPair': 'Chọn cặp ngôn ngữ',
  'parentTask.pairSoon': 'đang hoàn thiện',
  'parentTask.pairFallbackHint': 'Gói câu đầy đủ vẫn đang làm. Thiết bị sẽ luyện Trung-Anh trước.',
  'parentTask.duration': 'Thời lượng (phút, 0 = không giới hạn)',
  'parentTask.noDuration': 'Không giới hạn',
  'parentTask.minutesValue': '{n} phút',
  'parentTask.onComplete': 'Khi xong',
  'parentTask.completeChat': 'Về trò chuyện',
  'parentTask.completePowerOff': 'Tắt mềm',
  'parentTask.howToRun': 'Cách chạy',
  'parentTask.immediate': 'Chạy ngay',
  'parentTask.schedule': 'Lịch',
  'parentTask.schedule.daily': 'Hàng ngày',
  'parentTask.schedule.hourly': 'Hàng giờ',
  'parentTask.schedule.interval': 'Khoảng',
  'parentTask.time': 'Giờ',
  'parentTask.pickTime': 'Chọn giờ',
  'parentTask.timeHint': 'Có thể chọn bất kỳ giờ:phút trong ngày',
  'parentTask.repeatDays': 'Lặp',
  'parentTask.everyDay': 'Mỗi ngày',
  'parentTask.weekdays': 'Ngày làm việc',
  'parentTask.hourlyMinute': 'Phút mỗi giờ',
  'parentTask.hourlyOnTheHour': 'Mỗi giờ đúng điểm',
  'parentTask.hourlyAt': 'Mỗi giờ :{minute}',
  'parentTask.everyMinutes': 'Mỗi {minutes} phút',
  'parentTask.intervalMinutes': 'Khoảng (phút)',
  'parentTask.weekday.mon': 'T2',
  'parentTask.weekday.tue': 'T3',
  'parentTask.weekday.wed': 'T4',
  'parentTask.weekday.thu': 'T5',
  'parentTask.weekday.fri': 'T6',
  'parentTask.weekday.sat': 'T7',
  'parentTask.weekday.sun': 'CN',
  'parentHistory.pageTitle': 'Lịch sử',
  'parentHistory.detailTitle': 'Chi tiết',
  'parentHistory.entry': 'Lịch sử',
  'parentHistory.empty': 'Chưa có bản ghi. Chúng sẽ xuất hiện sau khi bé hoàn thành nhiệm vụ.',
  'parentHistory.loadFailed': 'Tải thất bại',
  'parentHistory.status.running': 'Đang chạy',
  'parentHistory.status.completed': 'Hoàn thành',
  'parentHistory.status.cancelled': 'Đã thoát',
  'parentHistory.status.preempted': 'Bị ngắt',
  'parentHistory.status.other': 'Chưa xong',
  'parentHistory.source.immediate': 'Bắt đầu ngay',
  'parentHistory.source.schedule': 'Lịch',
  'parentHistory.source.voice': 'Lệnh thoại',
  'parentHistory.durationSec': '{s} giây',
  'parentHistory.durationMin': '{m} phút {s} giây',
  'parentHistory.turns': '{n} câu',
  'parentHistory.sentence': 'Câu {n}',
  'parentHistory.expected': 'Nguyên văn',
  'parentHistory.asr': 'Đọc theo',
  'parentHistory.noExpected': 'Không có nguyên văn',
  'parentHistory.noAsr': 'Không nhận được phần đọc theo',
  'parentHistory.noAudio': 'Không có bản ghi phát được',
  'parentHistory.emptyTurns': 'Lần đọc theo này chưa có câu.',
  'parentHistory.extensiveHint': 'Nghe rộng không có bản ghi từng câu. Trang này chỉ xem hoàn thành và thời lượng.',
  'parentHistory.noChat': 'Khoảng thời gian này không có hội thoại.',
}
xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_CN.ts
@@ -84,6 +84,8 @@
  'home.daysAgo': '天前',
  'home.languageModel': '语言模型',
  'home.voiceModel': '音色模型',
  'home.noBoundDevice': '暂无绑定设备',
  'home.onlineCount': '{online}/{total} 台在线',
  // Agent页面
  'agent.pageTitle': '智能体',
@@ -91,6 +93,7 @@
  'agent.deviceManagement': '设备管理',
  'agent.chatHistory': '聊天记录',
  'agent.voiceprintManagement': '声纹管理',
  'agent.parentTasks': '学习任务',
  'agent.editTitle': '编辑智能体',
  'agent.toolsTitle': '编辑功能',
  'agent.voiceActivityDetection': '语音活动检测',
@@ -305,6 +308,11 @@
  'device.deviceType': '设备类型',
  'device.loading': '加载中...',
  'device.neverConnected': '从未连接',
  'device.checkingOnline': '正在检测设备状态…',
  'device.online': '设备在线',
  'device.offline': '设备不在线',
  'device.onlineHint': '可以下发即时任务和计划任务。',
  'device.offlineHint': '无法下发任务,仍可查看聊天记录等。',
  'device.justNow': '刚刚',
  'device.minutesAgo': '{minutes}分钟前',
  'device.hoursAgo': '{hours}小时前',
@@ -594,4 +602,120 @@
  'voiceprint.audioNotExist': '该音频不存在',
  'voiceprint.getAudioFailed': '获取音频失败',
  'voiceprint.audioPlayFailed': '音频播放失败',
  // 家长学习任务(7a-C)
  'parentTask.pageTitle': '学习任务',
  'parentTask.createTitle': '创建学习任务',
  'parentTask.editTitle': '编辑学习任务',
  'parentTask.device': '设备',
  'parentTask.selectDevice': '请选择设备',
  'parentTask.bindDeviceFirst': '请先绑定设备',
  'parentTask.runningSection': '正在执行',
  'parentTask.scheduledSection': '计划任务',
  'parentTask.running': '正在执行',
  'parentTask.emptySchedule': '还没有计划。点右下角 + 创建泛听、跟读或对话任务。',
  'parentTask.startNow': '立即开始',
  'parentTask.saveSchedule': '存为计划',
  'parentTask.started': '已下发到设备',
  'parentTask.saved': '计划已同步到设备',
  'parentTask.saveFailed': '保存失败',
  'parentTask.deviceOffline': '设备不在线,无法同步到硬件。请打开设备并连上网络后再试,本次不会保存。',
  'parentTask.online': '设备在线',
  'parentTask.offline': '设备不在线',
  'parentTask.checkingOnline': '正在检测设备状态…',
  'parentTask.onlineHint': '可以下发即时任务和计划任务。',
  'parentTask.offlineHint': '无法下发即时任务或计划任务。请打开设备并连上网络。',
  'parentTask.startFailed': '下发失败,请确认设备在线',
  'parentTask.stop': '结束',
  'parentTask.stopTitle': '结束当前任务',
  'parentTask.stopConfirm': '结束后设备会马上回到对话。',
  'parentTask.stopped': '已通知设备结束',
  'parentTask.stopFailed': '结束失败,请确认设备在线',
  'parentTask.deleteTitle': '删除任务',
  'parentTask.deleteConfirm': '删除后设备上的这条计划也会一起去掉。',
  'parentTask.deleted': '已删除',
  'parentTask.deleteFailed': '删除失败',
  'parentTask.scene': '场景',
  'parentTask.dispatchHint': '立即开始会打断设备当前模式;计划任务到点自动切换,不需要口头口令。',
  'parentTask.lesson': '课文',
  'parentTask.pickScene': '请选择场景',
  'parentTask.pickLesson': '请选择课文',
  'parentTask.repeat': '遍数',
  'parentTask.level': '级别',
  'parentTask.system': '分类',
  'parentTask.story': '成语故事',
  'parentTask.pickStory': '请选择一篇成语',
  'parentTask.noStories': '这个分类还没有成语,请在课程清单里添加。',
  'parentTask.sentenceCount': '播放该课目录里的全部音频,数量以服务器上的 MP3 为准',
  'parentTask.enPassesTitle': '英文怎么听',
  'parentTask.cnFixed': '先中文 1 遍、1.0 倍速,再按下面设置听英文。',
  'parentTask.enPassIndex': '英文第 {n} 段',
  'parentTask.addEnPass': '加一段英文',
  'parentTask.removePass': '删除',
  'parentTask.shadowingSettings': '跟读设置',
  'parentTask.shadowingLang': '跟读语言',
  'parentTask.shadowingLang.cn': '中文',
  'parentTask.shadowingLang.en': '英文',
  'parentTask.shadowingSpeed': '播放倍速',
  'parentTask.shadowingHint': '录音窗口 = 实际播放时长 × 1.2。当前 {speed} 倍速,播得越慢窗口越长。',
  'parentTask.languagePair': '语言对',
  'parentTask.pickPair': '请选择语言对',
  'parentTask.pairSoon': '句式完善中',
  'parentTask.pairFallbackHint': '这套语言的完整句式还在完善,设备会先用中英来练。',
  'parentTask.duration': '时长(分钟,0 表示不限)',
  'parentTask.noDuration': '不限时长',
  'parentTask.minutesValue': '{n}分钟',
  'parentTask.onComplete': '结束后',
  'parentTask.completeChat': '回对话',
  'parentTask.completePowerOff': '软关机',
  'parentTask.howToRun': '怎么跑',
  'parentTask.immediate': '立即执行',
  'parentTask.schedule': '计划时间',
  'parentTask.schedule.daily': '每天',
  'parentTask.schedule.hourly': '每小时',
  'parentTask.schedule.interval': '每隔',
  'parentTask.time': '时刻',
  'parentTask.pickTime': '选择时间',
  'parentTask.timeHint': '可选择全天任意时:分',
  'parentTask.repeatDays': '重复',
  'parentTask.everyDay': '每天',
  'parentTask.weekdays': '工作日',
  'parentTask.hourlyMinute': '每小时的第几分',
  'parentTask.hourlyOnTheHour': '每小时整点',
  'parentTask.hourlyAt': '每小时 :{minute}',
  'parentTask.everyMinutes': '每隔 {minutes} 分钟',
  'parentTask.intervalMinutes': '间隔分钟',
  'parentTask.weekday.mon': '一',
  'parentTask.weekday.tue': '二',
  'parentTask.weekday.wed': '三',
  'parentTask.weekday.thu': '四',
  'parentTask.weekday.fri': '五',
  'parentTask.weekday.sat': '六',
  'parentTask.weekday.sun': '日',
  'parentHistory.pageTitle': '执行记录',
  'parentHistory.detailTitle': '记录详情',
  'parentHistory.entry': '执行记录',
  'parentHistory.empty': '还没有执行记录。孩子完成任务后会出现在这里。',
  'parentHistory.loadFailed': '加载失败',
  'parentHistory.status.running': '进行中',
  'parentHistory.status.completed': '已完成',
  'parentHistory.status.cancelled': '已退出',
  'parentHistory.status.preempted': '被打断',
  'parentHistory.status.other': '未完成',
  'parentHistory.source.immediate': '立即下发',
  'parentHistory.source.schedule': '计划',
  'parentHistory.source.voice': '口令',
  'parentHistory.durationSec': '{s}秒',
  'parentHistory.durationMin': '{m}分{s}秒',
  'parentHistory.turns': '{n}句',
  'parentHistory.sentence': '第{n}句',
  'parentHistory.expected': '原文',
  'parentHistory.asr': '跟读',
  'parentHistory.noExpected': '没有原文',
  'parentHistory.noAsr': '没有识别到跟读',
  'parentHistory.noAudio': '没有可播放的录音',
  'parentHistory.emptyTurns': '这次跟读还没有句子记录。',
  'parentHistory.extensiveHint': '泛听没有逐句录音,这里只看是否播完和用了多久。',
  'parentHistory.noChat': '这段时间没有留下对话。',
}
xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_TW.ts
@@ -105,6 +105,8 @@
  'home.daysAgo': '天前',
  'home.languageModel': '語言模型',
  'home.voiceModel': '音色模型',
  'home.noBoundDevice': '暫無綁定裝置',
  'home.onlineCount': '{online}/{total} 台在線',
  // Agent頁面
  'agent.pageTitle': '智能體',
@@ -112,6 +114,7 @@
  'agent.deviceManagement': '設備管理',
  'agent.chatHistory': '聊天記錄',
  'agent.voiceprintManagement': '聲紋管理',
  'agent.parentTasks': '學習任務',
  'agent.editTitle': '編輯智能體',
  'agent.toolsTitle': '編輯功能',
  'agent.voiceActivityDetection': '語音活動檢測',
@@ -326,6 +329,11 @@
  'device.deviceType': '設備類型',
  'device.loading': '加載中...',
  'device.neverConnected': '從未連接',
  'device.checkingOnline': '正在檢測裝置狀態…',
  'device.online': '裝置在線',
  'device.offline': '裝置不在線',
  'device.onlineHint': '可以下發即時任務和計畫任務。',
  'device.offlineHint': '無法下發任務,仍可查看聊天記錄等。',
  'device.justNow': '剛剛',
  'device.minutesAgo': '{minutes}分鐘前',
  'device.hoursAgo': '{hours}小時前',
@@ -594,4 +602,119 @@
  'voiceprint.audioNotExist': '該音頻不存在',
  'voiceprint.getAudioFailed': '獲取音頻失敗',
  'voiceprint.audioPlayFailed': '音頻播放失敗',
  'parentTask.pageTitle': '學習任務',
  'parentTask.createTitle': '建立學習任務',
  'parentTask.editTitle': '編輯學習任務',
  'parentTask.device': '裝置',
  'parentTask.selectDevice': '請選擇裝置',
  'parentTask.bindDeviceFirst': '請先綁定裝置',
  'parentTask.runningSection': '正在執行',
  'parentTask.scheduledSection': '計畫任務',
  'parentTask.running': '正在執行',
  'parentTask.emptySchedule': '還沒有計畫。點右下角 + 建立泛聽、跟讀或對話任務。',
  'parentTask.startNow': '立即開始',
  'parentTask.saveSchedule': '存為計畫',
  'parentTask.started': '已下發到裝置',
  'parentTask.saved': '計畫已同步到裝置',
  'parentTask.saveFailed': '儲存失敗',
  'parentTask.deviceOffline': '裝置不在線,無法同步到硬體。請打開裝置並連上網路後再試,本次不會儲存。',
  'parentTask.online': '裝置在線',
  'parentTask.offline': '裝置不在線',
  'parentTask.checkingOnline': '正在檢測裝置狀態…',
  'parentTask.onlineHint': '可以下發即時任務和計畫任務。',
  'parentTask.offlineHint': '無法下發即時任務或計畫任務。請打開裝置並連上網路。',
  'parentTask.startFailed': '下發失敗,請確認裝置在線',
  'parentTask.stop': '結束',
  'parentTask.stopTitle': '結束當前任務',
  'parentTask.stopConfirm': '結束後裝置會马上回到對話。',
  'parentTask.stopped': '已通知裝置結束',
  'parentTask.stopFailed': '結束失敗,請確認裝置在線',
  'parentTask.deleteTitle': '刪除任務',
  'parentTask.deleteConfirm': '刪除後裝置上的這條計畫也會一起去掉。',
  'parentTask.deleted': '已刪除',
  'parentTask.deleteFailed': '刪除失敗',
  'parentTask.scene': '場景',
  'parentTask.dispatchHint': '立即開始會打斷裝置目前模式;計畫任務到點自動切換,不需要口頭指令。',
  'parentTask.lesson': '課文',
  'parentTask.pickScene': '請選擇場景',
  'parentTask.pickLesson': '請選擇課文',
  'parentTask.repeat': '遍數',
  'parentTask.level': '級別',
  'parentTask.system': '分類',
  'parentTask.story': '成語故事',
  'parentTask.pickStory': '請選擇一篇成語',
  'parentTask.noStories': '這個分類還沒有成語,請在課程清單裡添加。',
  'parentTask.sentenceCount': '跟讀拆成 {n} 句',
  'parentTask.enPassesTitle': '英文怎麼聽',
  'parentTask.cnFixed': '先中文 1 遍、1.0 倍速,再按下面設置聽英文。',
  'parentTask.enPassIndex': '英文第 {n} 段',
  'parentTask.addEnPass': '加一段英文',
  'parentTask.removePass': '刪除',
  'parentTask.shadowingSettings': '跟讀設置',
  'parentTask.shadowingLang': '跟讀語言',
  'parentTask.shadowingLang.cn': '中文',
  'parentTask.shadowingLang.en': '英文',
  'parentTask.shadowingSpeed': '播放倍速',
  'parentTask.shadowingHint': '錄音窗口 = 實際播放時長 × 1.2。目前 {speed} 倍速,播得越慢窗口越長。',
  'parentTask.languagePair': '語言對',
  'parentTask.pickPair': '請選擇語言對',
  'parentTask.pairSoon': '句式完善中',
  'parentTask.pairFallbackHint': '這套語言的完整句式還在完善,裝置會先用中英來練。',
  'parentTask.duration': '時長(分鐘,0 表示不限)',
  'parentTask.noDuration': '不限時長',
  'parentTask.minutesValue': '{n}分鐘',
  'parentTask.onComplete': '結束後',
  'parentTask.completeChat': '回對話',
  'parentTask.completePowerOff': '軟關機',
  'parentTask.howToRun': '怎麼跑',
  'parentTask.immediate': '立即執行',
  'parentTask.schedule': '計畫時間',
  'parentTask.schedule.daily': '每天',
  'parentTask.schedule.hourly': '每小時',
  'parentTask.schedule.interval': '每隔',
  'parentTask.time': '時刻',
  'parentTask.pickTime': '選擇時間',
  'parentTask.timeHint': '可選擇全天任意時:分',
  'parentTask.repeatDays': '重複',
  'parentTask.everyDay': '每天',
  'parentTask.weekdays': '工作日',
  'parentTask.hourlyMinute': '每小時的第幾分',
  'parentTask.hourlyOnTheHour': '每小時整點',
  'parentTask.hourlyAt': '每小時 :{minute}',
  'parentTask.everyMinutes': '每隔 {minutes} 分鐘',
  'parentTask.intervalMinutes': '間隔分鐘',
  'parentTask.weekday.mon': '一',
  'parentTask.weekday.tue': '二',
  'parentTask.weekday.wed': '三',
  'parentTask.weekday.thu': '四',
  'parentTask.weekday.fri': '五',
  'parentTask.weekday.sat': '六',
  'parentTask.weekday.sun': '日',
  'parentHistory.pageTitle': '執行紀錄',
  'parentHistory.detailTitle': '紀錄詳情',
  'parentHistory.entry': '執行紀錄',
  'parentHistory.empty': '還沒有執行紀錄。孩子完成任務後會出現在這裡。',
  'parentHistory.loadFailed': '載入失敗',
  'parentHistory.status.running': '進行中',
  'parentHistory.status.completed': '已完成',
  'parentHistory.status.cancelled': '已退出',
  'parentHistory.status.preempted': '被打斷',
  'parentHistory.status.other': '未完成',
  'parentHistory.source.immediate': '立即下發',
  'parentHistory.source.schedule': '計畫',
  'parentHistory.source.voice': '口令',
  'parentHistory.durationSec': '{s}秒',
  'parentHistory.durationMin': '{m}分{s}秒',
  'parentHistory.turns': '{n}句',
  'parentHistory.sentence': '第{n}句',
  'parentHistory.expected': '原文',
  'parentHistory.asr': '跟讀',
  'parentHistory.noExpected': '沒有原文',
  'parentHistory.noAsr': '沒有識別到跟讀',
  'parentHistory.noAudio': '沒有可播放的錄音',
  'parentHistory.emptyTurns': '這次跟讀還沒有句子紀錄。',
  'parentHistory.extensiveHint': '泛聽沒有逐句錄音,這裡只看是否播完和用了多久。',
  'parentHistory.noChat': '這段時間沒有留下對話。',
}
xiaozhi-esp32-server/main/manager-mobile/src/manifest.json
@@ -119,6 +119,8 @@
  },
  "vueVersion": "3",
  "h5": {
    "router": {}
    "router": {
      "base": "/mobile/"
    }
  }
}
xiaozhi-esp32-server/main/manager-mobile/src/pages.json
@@ -181,6 +181,42 @@
      }
    },
    {
      "path": "pages/parent-task/edit",
      "type": "page",
      "layout": "default",
      "style": {
        "navigationBarTitleText": "创建学习任务",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/parent-task/history-detail",
      "type": "page",
      "layout": "default",
      "style": {
        "navigationBarTitleText": "记录详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/parent-task/history",
      "type": "page",
      "layout": "default",
      "style": {
        "navigationBarTitleText": "执行记录",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/parent-task/index",
      "type": "page",
      "layout": "default",
      "style": {
        "navigationBarTitleText": "学习任务",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/register/index",
      "type": "page",
      "layout": "default",
xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/edit.vue
@@ -25,6 +25,10 @@
const agentId = computed(() => props.agentId)
function isUsableAgentId(id?: string) {
  return Boolean(id && id !== 'default')
}
// 表单数据
const formData = ref<Partial<AgentDetail>>({
  agentName: '',
@@ -326,7 +330,7 @@
// 加载智能体详情
async function loadAgentDetail(targetAgentId = agentId.value) {
  if (!targetAgentId)
  if (!isUsableAgentId(targetAgentId))
    return false
  const requestId = ++agentDetailRequestSequence
@@ -1100,7 +1104,7 @@
// 获取智能体标签
async function loadAgentTags(targetAgentId = agentId.value) {
  if (!targetAgentId) {
  if (!isUsableAgentId(targetAgentId)) {
    return false
  }
  const requestId = ++agentTagRequestSequence
@@ -1233,16 +1237,18 @@
  invalidateTtsMetadataRequest()
  loading.value = false
  snapshotReloadFailed.value = false
  snapshotReloadBlocked.value = Boolean(currentAgentId)
  snapshotReloadBlocked.value = isUsableAgentId(currentAgentId)
  originalAgentDetail.value = null
  originalTagNames.value = []
  if (currentAgentId) {
  if (isUsableAgentId(currentAgentId)) {
    void reloadAgentAfterSnapshotRestore(currentAgentId)
  }
}, { flush: 'sync' })
onMounted(async () => {
  loadAgentTags()
  if (isUsableAgentId(agentId.value)) {
    loadAgentTags()
  }
  // 先加载模型选项和角色模板
  await Promise.all([
@@ -1252,7 +1258,7 @@
  ])
  // 然后加载智能体详情,这样可以正确映射显示名称
  if (agentId.value && !snapshotReloadBlocked.value) {
  if (isUsableAgentId(agentId.value) && !snapshotReloadBlocked.value) {
    await loadAgentDetail()
  }
})
xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/index.vue
@@ -9,12 +9,13 @@
</route>
<script lang="ts" setup>
import { onLoad } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { onMounted, ref, watch } from 'vue'
import CustomTabs from '@/components/custom-tabs/index.vue'
import { t } from '@/i18n'
import ChatHistory from '@/pages/chat-history/index.vue'
import DeviceManagement from '@/pages/device/index.vue'
import ParentTaskList from '@/pages/parent-task/index.vue'
import VoiceprintManagement from '@/pages/voiceprint/index.vue'
import AgentEdit from './edit.vue'
@@ -44,7 +45,7 @@
// #endif
// 智能体ID
const currentAgentId = ref('default')
const currentAgentId = ref('')
// 当前 tab
const currentTab = ref('agent-config')
@@ -57,6 +58,7 @@
const deviceRef = ref()
const chatRef = ref()
const voiceprintRef = ref()
const parentTaskRef = ref()
// 更新刷新器状态
function updateRefresherEnabled(value: boolean) {
@@ -88,6 +90,12 @@
    value: 'voiceprint-management',
    icon: '/static/tabbar/microphone.png',
    activeIcon: '/static/tabbar/microphone_activate.png',
  },
  {
    label: t('agent.parentTasks'),
    value: 'parent-tasks',
    icon: '/static/tabbar/system.png',
    activeIcon: '/static/tabbar/system_activate.png',
  },
]
@@ -127,6 +135,11 @@
          await voiceprintRef.value.refresh()
        }
        break
      case 'parent-tasks':
        if (parentTaskRef.value?.refresh) {
          await parentTaskRef.value.refresh()
        }
        break
    }
  }
  catch (error) {
@@ -154,6 +167,12 @@
  if (options?.agentId) {
    currentAgentId.value = options.agentId
    console.log('接收到智能体ID:', options.agentId)
  }
})
onShow(() => {
  if (currentTab.value === 'parent-tasks' && parentTaskRef.value?.refresh) {
    parentTaskRef.value.refresh()
  }
})
@@ -192,7 +211,7 @@
      <!-- Tab 内容 -->
      <view class="flex-1">
        <AgentEdit
          v-if="currentTab === 'agent-config'"
          v-if="currentTab === 'agent-config' && currentAgentId"
          :agent-id="currentAgentId"
        />
        <DeviceManagement
@@ -211,6 +230,12 @@
          :agent-id="currentAgentId"
          @update-refresher-enabled="updateRefresherEnabled"
        />
        <ParentTaskList
          v-else-if="currentTab === 'parent-tasks'"
          ref="parentTaskRef"
          embedded
          :agent-id="currentAgentId"
        />
      </view>
    </scroll-view>
  </view>
xiaozhi-esp32-server/main/manager-mobile/src/pages/device/index.vue
@@ -126,6 +126,14 @@
}
// 获取设备类型名称
function presenceState(device: Device): 'online' | 'offline' | 'unknown' {
  if (device.online === true)
    return 'online'
  if (device.online === false)
    return 'offline'
  return 'unknown'
}
function getDeviceTypeName(boardKey: string): string {
  const firmwareType = firmwareTypes.value.find(type => type.key === boardKey)
  return firmwareType?.name || boardKey
@@ -209,6 +217,16 @@
    const errorMessage = error?.message || t('device.bindFailed')
    toast.error(errorMessage)
  }
}
function goToParentTasks(device: Device) {
  if (!currentAgentId.value || !device?.macAddress) {
    toast.warning(t('parentTask.bindDeviceFirst'))
    return
  }
  uni.navigateTo({
    url: `/pages/parent-task/index?agentId=${encodeURIComponent(currentAgentId.value)}&macAddress=${encodeURIComponent(device.macAddress)}`,
  })
}
// 打开绑定设备对话框
@@ -397,9 +415,55 @@
            <view class="cursor-pointer bg-[#fbfbfb] p-[32rpx] transition-all duration-200 active:bg-[#f8f9fa]">
              <view class="flex items-start justify-between">
                <view class="flex-1">
                  <view class="mb-[16rpx] flex items-center justify-between">
                    <text class="max-w-[60%] break-all text-[32rpx] text-[#232338] font-semibold">
                  <view class="mb-[16rpx] flex items-start justify-between gap-[16rpx]">
                    <text class="max-w-[52%] break-all text-[32rpx] text-[#232338] font-semibold">
                      {{ getDeviceTypeName(device.board) }}
                    </text>
                    <view
                      class="shrink-0 rounded-[24rpx] px-[16rpx] py-[6rpx]"
                      :class="{
                        'bg-[#e8f8ef]': presenceState(device) === 'online',
                        'bg-[#fff1f0]': presenceState(device) === 'offline',
                        'bg-[#f5f7fb]': presenceState(device) === 'unknown',
                      }"
                    >
                      <text
                        class="text-[24rpx] font-bold"
                        :class="{
                          'text-[#146c36]': presenceState(device) === 'online',
                          'text-[#d4380d]': presenceState(device) === 'offline',
                          'text-[#65686f]': presenceState(device) === 'unknown',
                        }"
                      >
                        {{ presenceState(device) === 'online' ? t('device.online') : (presenceState(device) === 'offline' ? t('device.offline') : t('device.checkingOnline')) }}
                      </text>
                    </view>
                  </view>
                  <view
                    class="mb-[16rpx] flex items-start gap-[12rpx] rounded-[16rpx] p-[16rpx]"
                    :class="{
                      'bg-[#e8f8ef]': presenceState(device) === 'online',
                      'bg-[#fff1f0]': presenceState(device) === 'offline',
                      'bg-[#f5f7fb]': presenceState(device) === 'unknown',
                    }"
                  >
                    <view
                      class="mt-[8rpx] h-[16rpx] w-[16rpx] shrink-0 rounded-full"
                      :class="{
                        'bg-[#1aa34a]': presenceState(device) === 'online',
                        'bg-[#d4380d]': presenceState(device) === 'offline',
                        'bg-[#c0c4cc]': presenceState(device) === 'unknown',
                      }"
                    />
                    <text
                      class="text-[24rpx] leading-[1.45]"
                      :class="{
                        'text-[#2f6f45]': presenceState(device) === 'online',
                        'text-[#a8071a]': presenceState(device) === 'offline',
                        'text-[#65686f]': presenceState(device) === 'unknown',
                      }"
                    >
                      {{ presenceState(device) === 'online' ? t('device.onlineHint') : (presenceState(device) === 'offline' ? t('device.offlineHint') : t('device.checkingOnline')) }}
                    </text>
                  </view>
@@ -425,6 +489,15 @@
                      @change="toggleAutoUpdate(device)"
                    />
                  </view>
                  <view
                    class="mt-[16rpx] flex items-center justify-between border-[1rpx] border-[#d6e4ff] rounded-[12rpx] bg-[#f5f8ff] p-[16rpx_20rpx]"
                    @click.stop="goToParentTasks(device)"
                  >
                    <text class="text-[28rpx] text-[#336cff] font-medium">
                      {{ t('parentTask.pageTitle') }}
                    </text>
                    <wd-icon name="arrow-right" size="16" color="#336cff" />
                  </view>
                </view>
              </view>
            </view>
xiaozhi-esp32-server/main/manager-mobile/src/pages/index/index.vue
@@ -11,12 +11,13 @@
</route>
<script lang="ts" setup>
import type { Agent } from '@/api/agent/types'
import type { Agent, AgentDevice } from '@/api/agent/types'
import { onShow } from '@dcloudio/uni-app'
import { computed, onMounted, ref } from 'vue'
// 在组件挂载后设置导航栏标题
import { useMessage } from 'wot-design-uni/components/wd-message-box'
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent'
import { getBindDevices } from '@/api/device'
import { t } from '@/i18n'
import { toast } from '@/utils/toast'
@@ -68,6 +69,7 @@
    console.log('z-paging获取智能体列表')
    const response = await getAgentList()
    await attachDevices(response)
    // 更新本地列表
    agentList.value = response
@@ -144,6 +146,73 @@
  finally {
    deleteAgentLoading.value = false
  }
}
async function attachDevices(agents: Agent[]) {
  await Promise.all((agents || []).map(async (agent) => {
    if (Array.isArray(agent.devices))
      return
    try {
      const list = await getBindDevices(agent.id)
      agent.devices = (list || []).map(device => ({
        macAddress: device.macAddress,
        alias: device.alias,
        online: device.online,
      }))
      agent.onlineDeviceCount = (agent.devices || []).filter(item => item.online === true).length
      agent.deviceCount = agent.devices?.length || 0
    }
    catch (error) {
      console.error('load home device status failed', error)
      agent.devices = []
    }
  }))
}
function agentPresence(agent: Agent): 'online' | 'offline' | 'none' | 'unknown' {
  const devices = agent.devices
  if (!Array.isArray(devices))
    return 'unknown'
  if (devices.length === 0)
    return 'none'
  if (devices.some(item => item.online === true))
    return 'online'
  if (devices.every(item => item.online === false))
    return 'offline'
  return 'unknown'
}
function agentPresenceTitle(agent: Agent) {
  const state = agentPresence(agent)
  if (state === 'online') {
    const devices = agent.devices || []
    const online = devices.filter(item => item.online === true).length
    if (devices.length > 1)
      return t('home.onlineCount', { online, total: devices.length })
    return t('device.online')
  }
  if (state === 'offline')
    return t('device.offline')
  if (state === 'none')
    return t('home.noBoundDevice')
  return t('device.checkingOnline')
}
function devicePresence(device: AgentDevice): 'online' | 'offline' | 'unknown' {
  if (device.online === true)
    return 'online'
  if (device.online === false)
    return 'offline'
  return 'unknown'
}
function devicePresenceText(device: AgentDevice) {
  const state = devicePresence(device)
  if (state === 'online')
    return t('device.online')
  if (state === 'offline')
    return t('device.offline')
  return t('device.checkingOnline')
}
// 进入编辑页面
@@ -260,6 +329,28 @@
                  <text class="agent-name">
                    {{ agent.agentName }}
                  </text>
                  <view class="presence-chip" :class="agentPresence(agent)">
                    <view class="presence-chip-dot" :class="agentPresence(agent)" />
                    <text class="presence-chip-text" :class="agentPresence(agent)">
                      {{ agentPresenceTitle(agent) }}
                    </text>
                  </view>
                </view>
                <view v-if="agent.devices && agent.devices.length" class="device-presence-list">
                  <view
                    v-for="device in agent.devices"
                    :key="device.macAddress"
                    class="device-presence-row"
                  >
                    <view class="device-presence-dot" :class="devicePresence(device)" />
                    <text class="device-presence-name">
                      {{ device.macAddress }}
                    </text>
                    <text class="device-presence-state" :class="devicePresence(device)">
                      {{ devicePresenceText(device) }}
                    </text>
                  </view>
                </view>
                <view class="model-info">
@@ -557,15 +648,143 @@
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12rpx;
    margin-bottom: 12rpx;
    .agent-name {
      flex: 1;
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      font-size: 32rpx;
      font-weight: 600;
      color: #1a1a1a;
    }
  }
  .presence-chip {
    display: flex;
    align-items: center;
    gap: 8rpx;
    flex-shrink: 0;
    padding: 6rpx 14rpx;
    border-radius: 24rpx;
    &.online {
      background: #e8f8ef;
    }
    &.offline {
      background: #fff1f0;
    }
    &.none,
    &.unknown {
      background: #f5f7fb;
    }
  }
  .presence-chip-dot {
    width: 12rpx;
    height: 12rpx;
    flex-shrink: 0;
    border-radius: 50%;
    &.online {
      background: #1aa34a;
    }
    &.offline {
      background: #d4380d;
    }
    &.none,
    &.unknown {
      background: #c0c4cc;
    }
  }
  .presence-chip-text {
    font-size: 22rpx;
    font-weight: 600;
    line-height: 1.4;
    &.online {
      color: #146c36;
    }
    &.offline {
      color: #d4380d;
    }
    &.none,
    &.unknown {
      color: #65686f;
    }
  }
  .device-presence-list {
    display: flex;
    flex-direction: column;
    gap: 8rpx;
    margin-bottom: 16rpx;
  }
  .device-presence-row {
    display: flex;
    align-items: center;
    gap: 10rpx;
    padding: 8rpx 4rpx;
  }
  .device-presence-dot {
    width: 12rpx;
    height: 12rpx;
    flex-shrink: 0;
    border-radius: 50%;
    &.online {
      background: #1aa34a;
    }
    &.offline {
      background: #d4380d;
    }
    &.unknown {
      background: #c0c4cc;
    }
  }
  .device-presence-name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 24rpx;
    color: #232338;
  }
  .device-presence-state {
    flex-shrink: 0;
    font-size: 22rpx;
    font-weight: 600;
    &.online {
      color: #146c36;
    }
    &.offline {
      color: #d4380d;
    }
    &.unknown {
      color: #65686f;
    }
  }
  .model-info {
    margin-bottom: 16rpx;
xiaozhi-esp32-server/main/manager-mobile/src/pages/login/index.vue
@@ -266,15 +266,13 @@
  showLanguageSheet.value = false
}
// 组件挂载时确保配置已加载
// 每次进登录页都拉当前接口的公钥,避免沿用上次外网缓存导致密码对不上
onMounted(async () => {
  if (!configStore.config.name) {
    try {
      await configStore.fetchPublicConfig()
    }
    catch (error) {
      console.error(t('login.fetchConfigError'), error)
    }
  try {
    await configStore.fetchPublicConfig()
  }
  catch (error) {
    console.error(t('login.fetchConfigError'), error)
  }
})
</script>
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/edit.vue
New file
@@ -0,0 +1,907 @@
<route lang="jsonc" type="page">
{
  "layout": "default",
  "style": {
    "navigationBarTitleText": "创建学习任务",
    "navigationStyle": "custom"
  }
}
</route>
<script lang="ts" setup>
import type {
  IdiomCatalog,
  IdiomEnPass,
  IdiomNamed,
  IdiomStory,
  ParentLanguagePair,
  ParentScene,
  ParentTask,
  ParentTaskParams,
  ParentSchedule,
} from '@/api/parent'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { computed, ref, watch } from 'vue'
import {
  createParentTask,
  getParentDeviceStatus,
  getParentIdiomCatalog,
  getParentLanguagePairs,
  getParentScenes,
  getParentTask,
  parentTaskError,
  runParentTaskNow,
  updateParentTask,
} from '@/api/parent'
import { t } from '@/i18n'
import { toast } from '@/utils/toast'
import { normalizeTime, pad2 } from './scheduleText'
defineOptions({
  name: 'ParentTaskEdit',
})
const FALLBACK_SCENES: ParentScene[] = [
  { id: 'extensive', name: '泛听', hint: '按课文循环播放,不对话。', needsLesson: true, needsEnPasses: true, needsShadowingSettings: false, needsLanguagePair: false, needsDuration: false, needsOnComplete: true, appId: 'english_learning', taskId: 'repeater' },
  { id: 'shadowing', name: '跟读', hint: '播一句、跟读一句。', needsLesson: true, needsEnPasses: false, needsShadowingSettings: true, needsLanguagePair: false, needsDuration: false, needsOnComplete: true, appId: 'english_learning', taskId: 'shadowing' },
  { id: 'chat', name: '默认聊天', hint: '回到日常对话。', needsLesson: false, needsEnPasses: false, needsShadowingSettings: false, needsLanguagePair: false, needsDuration: true, needsOnComplete: true, appId: 'chat', taskId: 'conversation' },
  { id: 'translation', name: '翻译', hint: '立即或到点进入中英互译,无需口头切换。', needsLesson: false, needsEnPasses: false, needsShadowingSettings: false, needsLanguagePair: true, needsDuration: true, needsOnComplete: true, appId: 'english_translation', taskId: 'live_translate' },
  { id: 'bilingual', name: '中英对话', hint: '立即或到点进入中英对话,无需口头切换。', needsLesson: false, needsEnPasses: false, needsShadowingSettings: false, needsLanguagePair: true, needsDuration: true, needsOnComplete: true, appId: 'english_conversation', taskId: 'bilingual_chat' },
]
const FALLBACK_LEVELS: IdiomNamed[] = [
  { id: 'l1', name: '一级' },
  { id: 'l2', name: '二级' },
  { id: 'l3', name: '三级' },
  { id: 'l4', name: '四级' },
  { id: 'l5', name: '五级' },
  { id: 'l6', name: '六级' },
]
const FALLBACK_SYSTEMS: IdiomNamed[] = [
  { id: 'origin', name: '起源与存在理解', nameEn: 'Understanding Origins and Existence' },
  { id: 'growth', name: '学习与成长机制', nameEn: 'Learning & Growth Mechanisms' },
  { id: 'language', name: '语言与意义建构', nameEn: 'Construction of Language & Meaning' },
  { id: 'cognition', name: '认知与思维偏差', nameEn: 'Cognitive & Thinking Biases' },
  { id: 'values', name: '价值与行为选择', nameEn: 'Values & Behavioral Choices' },
  { id: 'society', name: '社会互动与结构关系', nameEn: 'Social Interaction & Structural Relationships' },
  { id: 'destiny', name: '命运与自我超越', nameEn: 'Destiny & Self-Transcendence' },
]
const DEFAULT_EN_PASSES: IdiomEnPass[] = [
  { speed: 0.8, repeat: 1 },
  { speed: 0.9, repeat: 2 },
  { speed: 1.0, repeat: 5 },
]
const SPEED_OPTIONS = [0.7, 0.8, 0.9, 1.0, 1.1, 1.2]
const WEEKDAYS = [
  { value: 1, key: 'mon' },
  { value: 2, key: 'tue' },
  { value: 3, key: 'wed' },
  { value: 4, key: 'thu' },
  { value: 5, key: 'fri' },
  { value: 6, key: 'sat' },
  { value: 7, key: 'sun' },
]
const SCHEDULE_TYPES: { id: 'daily' | 'hourly' | 'interval', key: string }[] = [
  { id: 'daily', key: 'parentTask.schedule.daily' },
  { id: 'hourly', key: 'parentTask.schedule.hourly' },
  { id: 'interval', key: 'parentTask.schedule.interval' },
]
const agentId = ref('')
const macAddress = ref('')
const taskId = ref('')
const loading = ref(false)
const submitting = ref('')
const deviceOnline = ref<boolean | null>(null)
const statusLoading = ref(false)
let statusSeq = 0
const scenes = ref<ParentScene[]>(FALLBACK_SCENES)
const languagePairs = ref<ParentLanguagePair[]>([])
const levels = ref<IdiomNamed[]>(FALLBACK_LEVELS)
const systems = ref<IdiomNamed[]>(FALLBACK_SYSTEMS)
const stories = ref<IdiomStory[]>([{
  id: '001',
  slug: 'pangu',
  systemId: 'origin',
  name: '开天辟地',
  nameEn: 'Pangu splits the chaos to create the world',
  sentenceCount: 58,
}])
const sceneId = ref('shadowing')
const runMode = ref<'immediate' | 'scheduled'>('immediate')
const levelId = ref('l1')
const systemId = ref('origin')
const lessonId = ref('l1.001')
const enPasses = ref<IdiomEnPass[]>(DEFAULT_EN_PASSES.map(item => ({ ...item })))
const shadowingLanguage = ref<'cn' | 'en'>('en')
const shadowingSpeed = ref(0.8)
const languagePair = ref('zh-en')
const durationMinutes = ref(0)
const onComplete = ref<'chat' | 'power_off'>('chat')
const scheduleType = ref<'daily' | 'hourly' | 'interval'>('daily')
const dailyHour = ref(7)
const dailyMinute = ref(30)
const hourlyMinute = ref(0)
const intervalMinutes = ref(60)
const weekdays = ref<number[]>([1, 2, 3, 4, 5, 6, 7])
const isEdit = computed(() => !!taskId.value)
const currentScene = computed(() => scenes.value.find(item => item.id === sceneId.value) || FALLBACK_SCENES[1])
const pageTitle = computed(() => isEdit.value ? t('parentTask.editTitle') : t('parentTask.createTitle'))
const canDispatch = computed(() => deviceOnline.value === true)
const statusTitle = computed(() => {
  if (statusLoading.value || deviceOnline.value === null)
    return t('parentTask.checkingOnline')
  return deviceOnline.value ? t('parentTask.online') : t('parentTask.offline')
})
const statusHint = computed(() => {
  if (statusLoading.value || deviceOnline.value === null)
    return ''
  return deviceOnline.value ? t('parentTask.onlineHint') : t('parentTask.offlineHint')
})
const pairColumns = computed(() => languagePairs.value.map(item => ({
  value: item.id,
  label: item.ready ? item.name : `${item.name}(${t('parentTask.pairSoon')})`,
})))
const selectedPair = computed(() => languagePairs.value.find(item => item.id === languagePair.value))
const filteredStories = computed(() => stories.value.filter(item => item.systemId === systemId.value))
const selectedStory = computed(() => {
  const parts = lessonId.value.split('.')
  const sid = parts.length >= 2 ? parts[parts.length - 1] : lessonId.value
  return filteredStories.value.find(item => item.id === sid)
    || stories.value.find(item => item.id === sid)
})
function composeLessonId(story?: IdiomStory, level = levelId.value) {
  if (!story)
    return ''
  return `${level}.${story.id}`
}
function goBack() {
  uni.navigateBack()
}
async function loadDeviceStatus() {
  if (!agentId.value || !macAddress.value) {
    deviceOnline.value = null
    statusLoading.value = false
    return
  }
  const seq = ++statusSeq
  statusLoading.value = true
  try {
    const result = await getParentDeviceStatus(macAddress.value, agentId.value)
    if (seq !== statusSeq)
      return
    deviceOnline.value = !!result?.online
  }
  catch (error) {
    console.error('load device status failed', error)
    if (seq !== statusSeq)
      return
  }
  finally {
    if (seq === statusSeq)
      statusLoading.value = false
  }
}
function normalizeScene(raw: ParentScene): ParentScene {
  const fallback = FALLBACK_SCENES.find(item => item.id === raw.id)
  return {
    ...fallback,
    ...raw,
    name: raw.name || fallback?.name || raw.id,
    needsLesson: !!raw.needsLesson,
    needsEnPasses: raw.needsEnPasses ?? fallback?.needsEnPasses ?? raw.id === 'extensive',
    needsShadowingSettings: raw.needsShadowingSettings ?? fallback?.needsShadowingSettings ?? raw.id === 'shadowing',
    needsLanguagePair: !!raw.needsLanguagePair,
    needsDuration: !!raw.needsDuration,
    needsOnComplete: raw.needsOnComplete !== false,
    appId: raw.appId || fallback?.appId || '',
    taskId: raw.taskId || fallback?.taskId || '',
  }
}
function selectScene(id: string) {
  sceneId.value = id
}
function selectLevel(id: string) {
  levelId.value = id
  const story = selectedStory.value || filteredStories.value[0]
  if (story)
    lessonId.value = composeLessonId(story, id)
}
function selectSystem(id: string) {
  systemId.value = id
  ensureStorySelection()
}
function selectStory(story: IdiomStory) {
  lessonId.value = composeLessonId(story)
}
function ensureStorySelection() {
  const current = selectedStory.value
  if (current && current.systemId === systemId.value) {
    lessonId.value = composeLessonId(current)
    return
  }
  const firstInSystem = filteredStories.value[0]
  if (firstInSystem) {
    lessonId.value = composeLessonId(firstInSystem)
    return
  }
  const first = stories.value[0]
  if (first) {
    systemId.value = first.systemId
    lessonId.value = composeLessonId(first)
  }
}
function parseLessonId(raw?: string) {
  const value = String(raw || '').trim()
  if (!value) {
    return { levelId: 'l1', systemId: 'origin', lessonId: 'l1.001' }
  }
  const parts = value.split('.')
  if (parts.length === 2 && /^l[1-6]$/i.test(parts[0]) && /^\d{3}$/.test(parts[1])) {
    const matched = stories.value.find(item => item.id === parts[1])
    return {
      levelId: parts[0],
      systemId: matched?.systemId || systemId.value,
      lessonId: `${parts[0]}.${parts[1]}`,
    }
  }
  return { levelId: levelId.value, systemId: systemId.value, lessonId: value }
}
function applyCatalog(catalog: IdiomCatalog) {
  if (catalog.levels?.length)
    levels.value = catalog.levels
  if (catalog.systems?.length)
    systems.value = catalog.systems
  if (catalog.stories?.length)
    stories.value = catalog.stories
  if (catalog.defaults?.enPasses?.length && !taskId.value)
    enPasses.value = catalog.defaults.enPasses.map(item => ({ speed: Number(item.speed), repeat: Number(item.repeat) }))
  if (catalog.defaults?.shadowingSpeed && !taskId.value)
    shadowingSpeed.value = Number(catalog.defaults.shadowingSpeed)
}
function normalizePasses(raw: unknown): IdiomEnPass[] {
  if (!Array.isArray(raw) || raw.length === 0)
    return DEFAULT_EN_PASSES.map(item => ({ ...item }))
  return raw.slice(0, 8).map((item: any) => ({
    speed: Math.min(2, Math.max(0.5, Number(item?.speed) || 1)),
    repeat: Math.min(10, Math.max(1, Number(item?.repeat) || 1)),
  }))
}
function addEnPass() {
  if (enPasses.value.length >= 8)
    return
  enPasses.value = [...enPasses.value, { speed: 1.0, repeat: 1 }]
}
function removeEnPass(index: number) {
  if (enPasses.value.length <= 1)
    return
  enPasses.value = enPasses.value.filter((_, i) => i !== index)
}
function setPassSpeed(index: number, speed: number) {
  enPasses.value = enPasses.value.map((item, i) => i === index ? { ...item, speed } : item)
}
function toggleWeekday(day: number) {
  if (weekdays.value.includes(day)) {
    if (weekdays.value.length === 1)
      return
    weekdays.value = weekdays.value.filter(item => item !== day)
  }
  else {
    weekdays.value = [...weekdays.value, day].sort()
  }
}
function savedDailyTime(): string {
  const hour = Math.min(23, Math.max(0, Number(dailyHour.value) || 0))
  const minute = Math.min(59, Math.max(0, Number(dailyMinute.value) || 0))
  return `${pad2(hour)}:${pad2(minute)}`
}
function applyDailyTime(raw?: string) {
  const time = normalizeTime(raw)
  const [hour, minute] = time.split(':')
  dailyHour.value = Number(hour)
  dailyMinute.value = Number(minute)
}
async function loadCatalog() {
  try {
    const [sceneList, pairList, catalog] = await Promise.all([
      getParentScenes(),
      getParentLanguagePairs(),
      getParentIdiomCatalog(),
    ])
    if (sceneList?.length)
      scenes.value = sceneList.map(normalizeScene)
    if (pairList?.length)
      languagePairs.value = pairList
    if (catalog?.stories?.length)
      applyCatalog(catalog)
  }
  catch (error) {
    console.error('load parent catalog failed', error)
    languagePairs.value = [
      { id: 'zh-en', name: '中英', ready: true },
      { id: 'zh-fr', name: '中法', ready: false },
      { id: 'zh-ja', name: '中日', ready: false },
    ]
  }
}
async function refreshScenes() {
  try {
    const sceneList = await getParentScenes()
    if (sceneList?.length)
      scenes.value = sceneList.map(normalizeScene)
  }
  catch (error) {
    console.error('refresh parent scenes failed', error)
  }
}
function applyTask(task: ParentTask) {
  sceneId.value = task.scene || 'shadowing'
  runMode.value = task.runMode === 'scheduled' ? 'scheduled' : 'immediate'
  const parsed = parseLessonId(task.params?.lesson_id)
  levelId.value = parsed.levelId
  systemId.value = parsed.systemId
  lessonId.value = parsed.lessonId
  enPasses.value = normalizePasses(task.params?.en_passes)
  shadowingLanguage.value = task.params?.language === 'cn' ? 'cn' : 'en'
  shadowingSpeed.value = Number(task.params?.speed || 0.8)
  languagePair.value = task.params?.language_pair || 'zh-en'
  durationMinutes.value = Number(task.params?.duration_minutes || 0)
  onComplete.value = task.params?.on_complete === 'power_off' ? 'power_off' : 'chat'
  const schedule = task.schedule || {}
  scheduleType.value = schedule.type || 'daily'
  applyDailyTime(schedule.time)
  hourlyMinute.value = Number(schedule.minute || 0)
  intervalMinutes.value = Number(schedule.every_minutes || 60)
  weekdays.value = (schedule.weekdays && schedule.weekdays.length > 0)
    ? [...schedule.weekdays]
    : [1, 2, 3, 4, 5, 6, 7]
}
function buildParams(): ParentTaskParams {
  const params: ParentTaskParams = {}
  if (currentScene.value.needsLesson)
    params.lesson_id = lessonId.value
  if (sceneId.value === 'extensive')
    params.en_passes = enPasses.value.map(item => ({ speed: item.speed, repeat: item.repeat }))
  if (sceneId.value === 'shadowing') {
    params.language = shadowingLanguage.value
    params.speed = shadowingSpeed.value
  }
  if (currentScene.value.needsLanguagePair)
    params.language_pair = languagePair.value || 'zh-en'
  if (currentScene.value.needsDuration && Number(durationMinutes.value) > 0)
    params.duration_minutes = Math.min(24 * 60, Math.max(1, Number(durationMinutes.value)))
  if (currentScene.value.needsOnComplete)
    params.on_complete = onComplete.value
  return params
}
function buildSchedule(): ParentSchedule | undefined {
  if (runMode.value !== 'scheduled')
    return undefined
  if (scheduleType.value === 'hourly') {
    return { type: 'hourly', minute: Math.min(59, Math.max(0, Number(hourlyMinute.value) || 0)) }
  }
  if (scheduleType.value === 'interval') {
    return { type: 'interval', every_minutes: Math.min(24 * 60, Math.max(1, Number(intervalMinutes.value) || 60)) }
  }
  const schedule: ParentSchedule = {
    type: 'daily',
    time: savedDailyTime(),
  }
  if (weekdays.value.length > 0 && weekdays.value.length < 7)
    schedule.weekdays = [...weekdays.value]
  return schedule
}
function validate(): boolean {
  if (!macAddress.value || !agentId.value) {
    toast.warning(t('parentTask.bindDeviceFirst'))
    return false
  }
  if (!sceneId.value) {
    toast.warning(t('parentTask.pickScene'))
    return false
  }
  if (currentScene.value.needsLesson && !lessonId.value) {
    toast.warning(t('parentTask.pickStory'))
    return false
  }
  return true
}
async function submit(mode: 'immediate' | 'scheduled') {
  if (submitting.value || !validate())
    return
  if (!canDispatch.value) {
    toast.warning(t('parentTask.deviceOffline'))
    return
  }
  runMode.value = mode
  submitting.value = mode
  const payload = {
    macAddress: macAddress.value,
    agentId: agentId.value,
    runMode: mode,
    scene: sceneId.value,
    params: buildParams(),
    schedule: mode === 'scheduled' ? buildSchedule() : undefined,
    enabled: true,
  }
  try {
    if (isEdit.value) {
      await updateParentTask(taskId.value, payload)
      toast.success(payload.schedule?.time ? `${t('parentTask.saved')} ${payload.schedule.time}` : t('parentTask.saved'))
    }
    else {
      await createParentTask(payload)
      toast.success(mode === 'immediate'
        ? t('parentTask.started')
        : (payload.schedule?.time ? `${t('parentTask.saved')} ${payload.schedule.time}` : t('parentTask.saved')))
    }
    setTimeout(() => uni.navigateBack(), 400)
  }
  catch (error: any) {
    toast.error(parentTaskError(error, t('parentTask.saveFailed')))
  }
  finally {
    submitting.value = ''
  }
}
async function startExisting() {
  if (!taskId.value || submitting.value)
    return
  if (!canDispatch.value) {
    toast.warning(t('parentTask.deviceOffline'))
    return
  }
  submitting.value = 'run'
  try {
    await runParentTaskNow(taskId.value)
    toast.success(t('parentTask.started'))
    setTimeout(() => uni.navigateBack(), 400)
  }
  catch (error: any) {
    toast.error(parentTaskError(error, t('parentTask.startFailed')))
  }
  finally {
    submitting.value = ''
  }
}
watch(sceneId, () => {
  if (currentScene.value.needsOnComplete && !onComplete.value)
    onComplete.value = 'chat'
})
onLoad(async (options) => {
  agentId.value = String(options?.agentId || '')
  macAddress.value = decodeURIComponent(String(options?.macAddress || ''))
  taskId.value = String(options?.taskId || '')
  loading.value = true
  loadDeviceStatus()
  try {
    await loadCatalog()
    if (taskId.value) {
      const task = await getParentTask(taskId.value)
      applyTask(task)
    }
    ensureStorySelection()
  }
  finally {
    loading.value = false
  }
})
onShow(() => {
  if (agentId.value && macAddress.value)
    loadDeviceStatus()
  refreshScenes()
})
</script>
<template>
  <view class="min-h-screen bg-[#f5f7fb]">
    <wd-navbar :title="pageTitle" safe-area-inset-top>
      <template #left>
        <wd-icon name="arrow-left" size="18" @click="goBack" />
      </template>
    </wd-navbar>
    <view v-if="loading" class="flex flex-col items-center py-[80rpx]">
      <wd-loading color="#336cff" />
    </view>
    <view v-else class="box-border px-[24rpx] py-[20rpx] pb-[200rpx]">
      <view
        class="mb-[20rpx] flex items-start gap-[16rpx] rounded-[20rpx] p-[24rpx]"
        :class="deviceOnline ? 'bg-[#e8f8ef]' : (statusLoading || deviceOnline === null ? 'bg-white' : 'bg-[#fff1f0]')"
      >
        <view
          class="mt-[10rpx] h-[20rpx] w-[20rpx] shrink-0 rounded-full"
          :class="deviceOnline ? 'bg-[#1aa34a]' : (statusLoading || deviceOnline === null ? 'bg-[#c0c4cc]' : 'bg-[#d4380d]')"
        />
        <view class="min-w-0 flex-1">
          <text
            class="block text-[34rpx] font-bold leading-[1.3]"
            :class="deviceOnline ? 'text-[#146c36]' : (statusLoading || deviceOnline === null ? 'text-[#65686f]' : 'text-[#d4380d]')"
          >
            {{ statusTitle }}
          </text>
          <text
            v-if="statusHint"
            class="mt-[6rpx] block text-[24rpx] leading-[1.45]"
            :class="deviceOnline ? 'text-[#2f6f45]' : 'text-[#a8071a]'"
          >
            {{ statusHint }}
          </text>
        </view>
      </view>
      <view class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
        <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-semibold">
          {{ t('parentTask.scene') }}
        </text>
        <view class="flex flex-wrap gap-[16rpx]">
          <view
            v-for="scene in scenes"
            :key="scene.id"
            class="rounded-[32rpx] px-[28rpx] py-[12rpx] text-[26rpx]"
            :class="sceneId === scene.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
            @click="selectScene(scene.id)"
          >
            {{ scene.name }}
          </view>
        </view>
        <text class="mt-[16rpx] block text-[22rpx] text-[#8a8d96]">
          {{ t('parentTask.dispatchHint') }}
        </text>
        <text v-if="currentScene.hint" class="mt-[8rpx] block text-[22rpx] text-[#8a8d96]">
          {{ currentScene.hint }}
        </text>
      </view>
      <view v-if="currentScene.needsLesson" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
        <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-semibold">
          {{ t('parentTask.story') }}
        </text>
        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
          {{ t('parentTask.level') }}
        </text>
        <view class="mb-[20rpx] flex flex-wrap gap-[12rpx]">
          <view
            v-for="level in levels"
            :key="level.id"
            class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
            :class="levelId === level.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
            @click="selectLevel(level.id)"
          >
            {{ level.name }}
          </view>
        </view>
        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
          {{ t('parentTask.system') }}
        </text>
        <view class="mb-[20rpx] flex flex-col gap-[12rpx]">
          <view
            v-for="item in systems"
            :key="item.id"
            class="rounded-[16rpx] px-[20rpx] py-[14rpx]"
            :class="systemId === item.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
            @click="selectSystem(item.id)"
          >
            <text class="block text-[26rpx] font-medium">
              {{ item.name }}
            </text>
            <text
              class="mt-[4rpx] block text-[20rpx] leading-[1.4]"
              :class="systemId === item.id ? 'text-white/80' : 'text-[#8a8d96]'"
            >
              {{ item.nameEn }}
            </text>
          </view>
        </view>
        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
          {{ t('parentTask.pickStory') }}
        </text>
        <view v-if="filteredStories.length" class="flex flex-col gap-[12rpx]">
          <view
            v-for="story in filteredStories"
            :key="story.id"
            class="rounded-[16rpx] px-[20rpx] py-[16rpx]"
            :class="selectedStory && selectedStory.id === story.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
            @click="selectStory(story)"
          >
            <text class="block text-[28rpx] font-medium">
              {{ story.name }}
            </text>
            <text
              class="mt-[6rpx] block text-[22rpx] leading-[1.4]"
              :class="selectedStory && selectedStory.id === story.id ? 'text-white/85' : 'text-[#65686f]'"
            >
              {{ story.nameEn }}
            </text>
          </view>
        </view>
        <text v-else class="block text-[24rpx] text-[#fa8c16]">
          {{ t('parentTask.noStories') }}
        </text>
        <text v-if="selectedStory" class="mt-[16rpx] block text-[22rpx] text-[#8a8d96]">
          {{ t('parentTask.sentenceCount', { n: selectedStory.sentenceCount || 0 }) }}
        </text>
      </view>
      <view v-if="currentScene.needsEnPasses" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
        <text class="mb-[8rpx] block text-[28rpx] text-[#232338] font-semibold">
          {{ t('parentTask.enPassesTitle') }}
        </text>
        <text class="mb-[16rpx] block text-[22rpx] text-[#8a8d96]">
          {{ t('parentTask.cnFixed') }}
        </text>
        <view
          v-for="(pass, index) in enPasses"
          :key="index"
          class="mb-[16rpx] rounded-[16rpx] bg-[#f5f7fb] p-[16rpx]"
        >
          <view class="mb-[12rpx] flex items-center justify-between">
            <text class="text-[24rpx] text-[#232338]">
              {{ t('parentTask.enPassIndex', { n: index + 1 }) }}
            </text>
            <text
              v-if="enPasses.length > 1"
              class="text-[24rpx] text-[#fa5151]"
              @click="removeEnPass(index)"
            >
              {{ t('parentTask.removePass') }}
            </text>
          </view>
          <view class="mb-[12rpx] flex flex-wrap gap-[12rpx]">
            <view
              v-for="speed in SPEED_OPTIONS"
              :key="speed"
              class="rounded-[24rpx] px-[16rpx] py-[6rpx] text-[22rpx]"
              :class="pass.speed === speed ? 'bg-[#336cff] text-white' : 'bg-white text-[#232338]'"
              @click="setPassSpeed(index, speed)"
            >
              {{ speed.toFixed(1) }}×
            </view>
          </view>
          <view class="flex items-center gap-[12rpx]">
            <text class="text-[24rpx] text-[#65686f]">
              {{ t('parentTask.repeat') }}
            </text>
            <wd-input-number v-model="enPasses[index].repeat" :min="1" :max="10" />
          </view>
        </view>
        <view
          v-if="enPasses.length < 8"
          class="rounded-[16rpx] border border-dashed border-[#336cff] py-[12rpx] text-center text-[24rpx] text-[#336cff]"
          @click="addEnPass"
        >
          {{ t('parentTask.addEnPass') }}
        </view>
      </view>
      <view v-if="currentScene.needsShadowingSettings" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
        <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-semibold">
          {{ t('parentTask.shadowingSettings') }}
        </text>
        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
          {{ t('parentTask.shadowingLang') }}
        </text>
        <view class="mb-[20rpx] flex gap-[16rpx]">
          <view
            class="flex-1 rounded-[16rpx] py-[16rpx] text-center text-[26rpx]"
            :class="shadowingLanguage === 'cn' ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
            @click="shadowingLanguage = 'cn'"
          >
            {{ t('parentTask.shadowingLang.cn') }}
          </view>
          <view
            class="flex-1 rounded-[16rpx] py-[16rpx] text-center text-[26rpx]"
            :class="shadowingLanguage === 'en' ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
            @click="shadowingLanguage = 'en'"
          >
            {{ t('parentTask.shadowingLang.en') }}
          </view>
        </view>
        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
          {{ t('parentTask.shadowingSpeed') }}
        </text>
        <view class="mb-[16rpx] flex flex-wrap gap-[12rpx]">
          <view
            v-for="speed in SPEED_OPTIONS"
            :key="speed"
            class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
            :class="shadowingSpeed === speed ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
            @click="shadowingSpeed = speed"
          >
            {{ speed.toFixed(1) }}×
          </view>
        </view>
        <text class="block text-[22rpx] text-[#8a8d96]">
          {{ t('parentTask.shadowingHint', { speed: shadowingSpeed.toFixed(1) }) }}
        </text>
      </view>
      <view v-if="currentScene.needsLanguagePair" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
          {{ t('parentTask.languagePair') }}
        </text>
        <wd-picker
          v-model="languagePair"
          :columns="pairColumns"
          :placeholder="t('parentTask.pickPair')"
          :cancel-button-text="t('common.cancel')"
          :confirm-button-text="t('common.confirm')"
        />
        <text v-if="selectedPair && !selectedPair.ready" class="mt-[12rpx] block text-[24rpx] text-[#fa8c16]">
          {{ t('parentTask.pairFallbackHint') }}
        </text>
      </view>
      <view v-if="currentScene.needsDuration || currentScene.needsOnComplete" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
        <view v-if="currentScene.needsDuration" class="mb-[24rpx]">
          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
            {{ t('parentTask.duration') }}
          </text>
          <view class="mb-[16rpx] flex flex-wrap gap-[12rpx]">
            <view
              v-for="item in [0, 10, 30]"
              :key="item"
              class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
              :class="Number(durationMinutes) === item ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
              @click="durationMinutes = item"
            >
              {{ item === 0 ? t('parentTask.noDuration') : t('parentTask.minutesValue', { n: item }) }}
            </view>
          </view>
          <wd-input-number v-model="durationMinutes" :min="0" :max="240" />
        </view>
        <view v-if="currentScene.needsOnComplete">
          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
            {{ t('parentTask.onComplete') }}
          </text>
          <view class="flex gap-[16rpx]">
            <view
              class="flex-1 rounded-[16rpx] py-[16rpx] text-center text-[26rpx]"
              :class="onComplete === 'chat' ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
              @click="onComplete = 'chat'"
            >
              {{ t('parentTask.completeChat') }}
            </view>
            <view
              class="flex-1 rounded-[16rpx] py-[16rpx] text-center text-[26rpx]"
              :class="onComplete === 'power_off' ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
              @click="onComplete = 'power_off'"
            >
              {{ t('parentTask.completePowerOff') }}
            </view>
          </view>
        </view>
      </view>
      <view class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
        <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-semibold">
          {{ t('parentTask.schedule') }}
        </text>
        <view class="mb-[20rpx] flex flex-wrap gap-[12rpx]">
          <view
            v-for="item in SCHEDULE_TYPES"
            :key="item.id"
            class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
            :class="scheduleType === item.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
            @click="scheduleType = item.id"
          >
            {{ t(item.key) }}
          </view>
        </view>
        <view v-if="scheduleType === 'daily'">
          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
            {{ t('parentTask.time') }}
          </text>
          <view class="flex items-center gap-[16rpx]">
            <wd-input-number v-model="dailyHour" :min="0" :max="23" />
            <text class="text-[32rpx] text-[#232338]">:</text>
            <wd-input-number v-model="dailyMinute" :min="0" :max="59" />
          </view>
          <text class="mb-[12rpx] mt-[12rpx] block text-[22rpx] text-[#8a8d96]">
            {{ t('parentTask.timeHint') }} {{ savedDailyTime() }}
          </text>
          <text class="mb-[12rpx] mt-[20rpx] block text-[26rpx] text-[#65686f]">
            {{ t('parentTask.repeatDays') }}
          </text>
          <view class="flex flex-wrap gap-[12rpx]">
            <view
              v-for="day in WEEKDAYS"
              :key="day.value"
              class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
              :class="weekdays.includes(day.value) ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
              @click="toggleWeekday(day.value)"
            >
              {{ t(`parentTask.weekday.${day.key}`) }}
            </view>
          </view>
        </view>
        <view v-else-if="scheduleType === 'hourly'">
          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
            {{ t('parentTask.hourlyMinute') }}
          </text>
          <wd-input-number v-model="hourlyMinute" :min="0" :max="59" />
        </view>
        <view v-else>
          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
            {{ t('parentTask.intervalMinutes') }}
          </text>
          <wd-input-number v-model="intervalMinutes" :min="1" :max="1440" />
        </view>
      </view>
    </view>
    <view class="action-bar">
      <wd-button
        class="flex-1"
        type="primary"
        plain
        :disabled="!canDispatch"
        :loading="submitting === 'immediate' || submitting === 'run'"
        @click="isEdit ? startExisting() : submit('immediate')"
      >
        {{ t('parentTask.startNow') }}
      </wd-button>
      <wd-button
        class="flex-1"
        type="primary"
        :disabled="!canDispatch"
        :loading="submitting === 'scheduled'"
        @click="submit('scheduled')"
      >
        {{ t('parentTask.saveSchedule') }}
      </wd-button>
    </view>
  </view>
</template>
<style scoped>
.action-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  gap: 16rpx;
  padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
  background: #ffffff;
  border-top: 1rpx solid #eeeeee;
  z-index: 20;
}
</style>
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history-detail.vue
New file
@@ -0,0 +1,268 @@
<route lang="jsonc" type="page">
{
  "layout": "default",
  "style": {
    "navigationBarTitleText": "记录详情",
    "navigationStyle": "custom"
  }
}
</route>
<script lang="ts" setup>
import type { ParentHistoryChat, ParentHistoryDetail } from '@/api/parent'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { getAudioId } from '@/api/chat-history/chat-history'
import { getParentHistoryDetail } from '@/api/parent'
import { t } from '@/i18n'
import { debounce, getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
import { durationMsOf, formatDuration, formatStartedAt, sourceLabel, statusLabel } from './historyText'
defineOptions({
  name: 'ParentHistoryDetail',
})
const instanceId = ref('')
const agentId = ref('')
const macAddress = ref('')
const loading = ref(false)
const detail = ref<ParentHistoryDetail | null>(null)
const audioContext = ref<UniApp.InnerAudioContext | null>(null)
const playingAudioId = ref<string | null>(null)
const headerMeta = computed(() => {
  if (!detail.value)
    return ''
  return [
    formatStartedAt(detail.value.startedAt),
    formatDuration(durationMsOf(detail.value), t),
    sourceLabel(detail.value.source, t),
  ].filter(Boolean).join(' · ')
})
function goBack() {
  uni.navigateBack()
}
function parseUserMessage(content: string) {
  try {
    return JSON.parse(content)
  }
  catch {
    return null
  }
}
function chatText(message: ParentHistoryChat): string {
  if (message.chatType === 1) {
    const parsed = parseUserMessage(message.content)
    return parsed?.content || message.content
  }
  return message.content
}
async function loadDetail() {
  if (!instanceId.value || !agentId.value || !macAddress.value)
    return
  loading.value = true
  try {
    detail.value = await getParentHistoryDetail(instanceId.value, macAddress.value, agentId.value)
  }
  catch (error) {
    console.error('load parent history detail failed', error)
    toast.error(t('parentHistory.loadFailed'))
    detail.value = null
  }
  finally {
    loading.value = false
  }
}
function destroyAudio() {
  const ctx = audioContext.value
  audioContext.value = null
  playingAudioId.value = null
  if (!ctx)
    return
  ctx.onEnded(() => {})
  ctx.onError(() => {})
  try {
    ctx.stop()
  }
  catch {
    // already stopped
  }
  ctx.destroy()
}
const playAudio = debounce(async (audioId: string) => {
  if (!audioId) {
    toast.error(t('parentHistory.noAudio'))
    return
  }
  try {
    if (playingAudioId.value === audioId) {
      destroyAudio()
      return
    }
    destroyAudio()
    const downloadId = await getAudioId(audioId)
    const audioUrl = `${getEnvBaseUrl()}/agent/play/${downloadId}`
    audioContext.value = uni.createInnerAudioContext()
    audioContext.value.src = audioUrl
    playingAudioId.value = audioId
    audioContext.value.onEnded(() => {
      destroyAudio()
    })
    audioContext.value.onError(() => {
      toast.error(t('chatHistory.audioPlayFailed'))
      destroyAudio()
    })
    audioContext.value.play()
  }
  catch (error) {
    console.error('play history audio failed', error)
    toast.error(t('chatHistory.playAudioFailed'))
    destroyAudio()
  }
}, 400)
onLoad((options) => {
  if (options?.id)
    instanceId.value = String(options.id)
  if (options?.agentId)
    agentId.value = String(options.agentId)
  if (options?.macAddress)
    macAddress.value = decodeURIComponent(String(options.macAddress))
  loadDetail()
})
onUnload(() => {
  destroyAudio()
})
</script>
<template>
  <view class="history-detail-page">
    <wd-navbar :title="t('parentHistory.detailTitle')" safe-area-inset-top>
      <template #left>
        <wd-icon name="arrow-left" size="18" @click="goBack" />
      </template>
    </wd-navbar>
    <view v-if="loading && !detail" class="flex flex-col items-center py-[80rpx]">
      <wd-loading color="#336cff" />
      <text class="mt-[16rpx] text-[28rpx] text-[#666]">
        {{ t('common.loading') }}
      </text>
    </view>
    <view v-else-if="detail" class="px-[20rpx] py-[16rpx] pb-[40rpx]">
      <view class="mb-[16rpx] rounded-[20rpx] bg-white p-[28rpx]">
        <view class="mb-[8rpx] flex items-center justify-between">
          <text class="text-[32rpx] text-[#232338] font-semibold">
            {{ detail.sceneName || detail.scene }}
          </text>
          <text
            class="rounded-[8rpx] px-[12rpx] py-[4rpx] text-[22rpx] text-white"
            :class="detail.status === 'completed' ? 'bg-[#2ba471]' : detail.status === 'running' ? 'bg-[#336cff]' : 'bg-[#9d9ea3]'"
          >
            {{ statusLabel(detail, t) }}
          </text>
        </view>
        <text class="text-[26rpx] text-[#65686f]">
          {{ headerMeta }}
        </text>
      </view>
      <view v-if="detail.kind === 'extensive'" class="rounded-[20rpx] bg-white p-[28rpx]">
        <text class="block text-[28rpx] leading-[1.6] text-[#65686f]">
          {{ t('parentHistory.extensiveHint') }}
        </text>
      </view>
      <view v-else-if="detail.kind === 'shadowing'">
        <view v-if="detail.turns.length === 0" class="rounded-[20rpx] bg-white p-[48rpx] text-center text-[26rpx] text-[#999]">
          {{ t('parentHistory.emptyTurns') }}
        </view>
        <view
          v-for="turn in detail.turns"
          :key="turn.index"
          class="mb-[16rpx] rounded-[20rpx] border-[1rpx] border-[#eeeeee] bg-white p-[28rpx]"
        >
          <view class="mb-[12rpx] flex items-center justify-between">
            <text class="text-[24rpx] text-[#9d9ea3]">
              {{ t('parentHistory.sentence', { n: (turn.index ?? 0) + 1 }) }}
            </text>
            <view
              v-if="turn.audioId"
              class="text-[#336cff]"
              @click="playAudio(turn.audioId)"
            >
              <wd-icon
                :name="playingAudioId === turn.audioId ? 'pause-circle-filled' : 'play-circle-filled'"
                size="22"
              />
            </view>
          </view>
          <text class="mb-[8rpx] block text-[24rpx] text-[#9d9ea3]">
            {{ t('parentHistory.expected') }}
          </text>
          <text class="mb-[16rpx] block text-[28rpx] text-[#232338]">
            {{ turn.expectedText || t('parentHistory.noExpected') }}
          </text>
          <text class="mb-[8rpx] block text-[24rpx] text-[#9d9ea3]">
            {{ t('parentHistory.asr') }}
          </text>
          <text class="block text-[28rpx] text-[#232338]">
            {{ turn.asrText || t('parentHistory.noAsr') }}
          </text>
        </view>
      </view>
      <view v-else>
        <view v-if="!detail.chats || detail.chats.length === 0" class="rounded-[20rpx] bg-white p-[48rpx] text-center text-[26rpx] text-[#999]">
          {{ t('parentHistory.noChat') }}
        </view>
        <view v-else class="flex flex-col gap-[20rpx]">
          <view
            v-for="(message, index) in detail.chats"
            :key="index"
            class="w-full flex"
            :class="message.chatType === 1 ? 'justify-end' : 'justify-start'"
          >
            <view
              class="max-w-[80%] rounded-[20rpx] p-[24rpx]"
              :class="message.chatType === 1 ? 'bg-[#336cff] text-white' : 'border-[1rpx] border-[#eeeeee] bg-white text-[#232338]'"
            >
              <view class="flex items-center gap-[12rpx]">
                <view
                  v-if="message.audioId"
                  class="flex-shrink-0"
                  :class="message.chatType === 1 ? 'text-white' : 'text-[#336cff]'"
                  @click="playAudio(message.audioId)"
                >
                  <wd-icon
                    :name="playingAudioId === message.audioId ? 'pause-circle-filled' : 'play-circle-filled'"
                    size="20"
                  />
                </view>
                <text class="text-[28rpx] leading-[1.4]">
                  {{ chatText(message) }}
                </text>
              </view>
            </view>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>
<style scoped>
.history-detail-page {
  min-height: 100%;
  background: #f5f7fb;
}
</style>
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history.vue
New file
@@ -0,0 +1,131 @@
<route lang="jsonc" type="page">
{
  "layout": "default",
  "style": {
    "navigationBarTitleText": "执行记录",
    "navigationStyle": "custom"
  }
}
</route>
<script lang="ts" setup>
import type { ParentHistoryItem } from '@/api/parent'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { getParentHistory } from '@/api/parent'
import { t } from '@/i18n'
import { durationMsOf, formatDuration, formatStartedAt, sourceLabel, statusLabel } from './historyText'
defineOptions({
  name: 'ParentHistoryList',
})
const agentId = ref('')
const macAddress = ref('')
const items = ref<ParentHistoryItem[]>([])
const loading = ref(false)
function goBack() {
  uni.navigateBack()
}
async function loadHistory() {
  if (!agentId.value || !macAddress.value) {
    items.value = []
    return
  }
  loading.value = true
  try {
    const result = await getParentHistory(macAddress.value, agentId.value, 50)
    items.value = result?.list || []
  }
  catch (error) {
    console.error('load parent history failed', error)
    items.value = []
  }
  finally {
    loading.value = false
  }
}
function goDetail(item: ParentHistoryItem) {
  uni.navigateTo({
    url: `/pages/parent-task/history-detail?id=${encodeURIComponent(item.id)}&agentId=${encodeURIComponent(agentId.value)}&macAddress=${encodeURIComponent(macAddress.value)}`,
  })
}
function itemMeta(item: ParentHistoryItem) {
  const bits = [
    formatStartedAt(item.startedAt),
    formatDuration(durationMsOf(item), t),
    sourceLabel(item.source, t),
    item.kind === 'shadowing' && item.turnCount > 0 ? t('parentHistory.turns', { n: item.turnCount }) : '',
  ].filter(Boolean)
  return bits.join(' · ')
}
onLoad((options) => {
  if (options?.agentId)
    agentId.value = String(options.agentId)
  if (options?.macAddress)
    macAddress.value = decodeURIComponent(String(options.macAddress))
})
onShow(() => {
  loadHistory()
})
</script>
<template>
  <view class="history-page">
    <wd-navbar :title="t('parentHistory.pageTitle')" safe-area-inset-top>
      <template #left>
        <wd-icon name="arrow-left" size="18" @click="goBack" />
      </template>
    </wd-navbar>
    <view v-if="loading && items.length === 0" class="flex flex-col items-center py-[80rpx]">
      <wd-loading color="#336cff" />
      <text class="mt-[16rpx] text-[28rpx] text-[#666]">
        {{ t('common.loading') }}
      </text>
    </view>
    <view v-else-if="items.length === 0" class="px-[40rpx] py-[80rpx] text-center">
      <text class="text-[30rpx] text-[#666]">
        {{ t('parentHistory.empty') }}
      </text>
    </view>
    <view v-else class="px-[20rpx] py-[16rpx]">
      <view
        v-for="item in items"
        :key="item.id"
        class="mb-[16rpx] rounded-[20rpx] border-[1rpx] border-[#eeeeee] bg-white p-[28rpx]"
        @click="goDetail(item)"
      >
        <view class="mb-[8rpx] flex items-center justify-between">
          <text class="text-[32rpx] text-[#232338] font-semibold">
            {{ item.sceneName || item.scene }}
          </text>
          <text
            class="rounded-[8rpx] px-[12rpx] py-[4rpx] text-[22rpx] text-white"
            :class="item.status === 'completed' ? 'bg-[#2ba471]' : item.status === 'running' ? 'bg-[#336cff]' : 'bg-[#9d9ea3]'"
          >
            {{ statusLabel(item, t) }}
          </text>
        </view>
        <text class="text-[26rpx] text-[#65686f]">
          {{ itemMeta(item) }}
        </text>
      </view>
    </view>
  </view>
</template>
<style scoped>
.history-page {
  min-height: 100%;
  background: #f5f7fb;
}
</style>
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/historyText.ts
New file
@@ -0,0 +1,61 @@
import type { ParentHistoryItem } from '@/api/parent'
function pad2(value: number): string {
  return String(value).padStart(2, '0')
}
export function formatStartedAt(ms?: number | null): string {
  const value = Number(ms || 0)
  if (!value)
    return ''
  const date = new Date(value)
  if (Number.isNaN(date.getTime()))
    return ''
  return `${date.getMonth() + 1}/${date.getDate()} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
}
export function durationMsOf(item: Pick<ParentHistoryItem, 'durationMs' | 'startedAt' | 'endedAt' | 'status'>): number {
  if (item.durationMs && item.durationMs > 0)
    return item.durationMs
  if (item.startedAt && item.endedAt && item.endedAt > item.startedAt)
    return item.endedAt - item.startedAt
  if (item.status === 'running' && item.startedAt)
    return Math.max(0, Date.now() - item.startedAt)
  return 0
}
export function formatDuration(ms: number, t: (key: string, params?: Record<string, string | number>) => string): string {
  if (!ms || ms < 0)
    return ''
  const totalSec = Math.round(ms / 1000)
  if (totalSec < 60)
    return t('parentHistory.durationSec', { s: totalSec })
  const minutes = Math.floor(totalSec / 60)
  const seconds = totalSec % 60
  return t('parentHistory.durationMin', { m: minutes, s: seconds })
}
export function statusLabel(
  item: Pick<ParentHistoryItem, 'status' | 'endReason'>,
  t: (key: string) => string,
): string {
  if (item.status === 'running')
    return t('parentHistory.status.running')
  if (item.status === 'completed')
    return t('parentHistory.status.completed')
  if (item.endReason === 'preempted')
    return t('parentHistory.status.preempted')
  if (item.status === 'cancelled')
    return t('parentHistory.status.cancelled')
  return t('parentHistory.status.other')
}
export function sourceLabel(source: string | undefined, t: (key: string) => string): string {
  if (source === 'mqtt_immediate')
    return t('parentHistory.source.immediate')
  if (source === 'schedule')
    return t('parentHistory.source.schedule')
  if (source === 'voice')
    return t('parentHistory.source.voice')
  return ''
}
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/index.vue
New file
@@ -0,0 +1,488 @@
<route lang="jsonc" type="page">
{
  "layout": "default",
  "style": {
    "navigationBarTitleText": "学习任务",
    "navigationStyle": "custom"
  }
}
</route>
<script lang="ts" setup>
import type { Device } from '@/api/device'
import type { ParentTask } from '@/api/parent'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { computed, onMounted, ref, watch } from 'vue'
import { useMessage } from 'wot-design-uni/components/wd-message-box'
import { getBindDevices } from '@/api/device'
import { deleteParentTask, getParentDeviceStatus, getParentTasks, parentTaskError, runParentTaskNow, stopParentTask, updateParentTask } from '@/api/parent'
import { t } from '@/i18n'
import { toast } from '@/utils/toast'
import { formatLesson, formatOnComplete, formatSchedule } from './scheduleText'
defineOptions({
  name: 'ParentTaskList',
})
interface Props {
  agentId?: string
  macAddress?: string
  embedded?: boolean
}
const props = withDefaults(defineProps<Props>(), {
  agentId: '',
  macAddress: '',
  embedded: false,
})
const message = useMessage()
const pageAgentId = ref('')
const pageMac = ref('')
const devices = ref<Device[]>([])
const selectedMac = ref('')
const tasks = ref<ParentTask[]>([])
const loading = ref(false)
const busyId = ref('')
const deviceOnline = ref<boolean | null>(null)
const statusLoading = ref(false)
let statusSeq = 0
const currentAgentId = computed(() => props.agentId || pageAgentId.value)
const canDispatch = computed(() => deviceOnline.value === true)
const runningTasks = computed(() =>
  tasks.value.filter(item => item.runMode === 'immediate' && (item.status === 'queued' || item.status === 'running')),
)
const scheduledTasks = computed(() => tasks.value.filter(item => item.runMode === 'scheduled'))
const deviceColumns = computed(() =>
  devices.value.map(device => ({
    value: device.macAddress,
    label: device.alias || device.macAddress,
  })),
)
const statusTitle = computed(() => {
  if (!selectedMac.value)
    return ''
  if (statusLoading.value || deviceOnline.value === null)
    return t('parentTask.checkingOnline')
  return deviceOnline.value ? t('parentTask.online') : t('parentTask.offline')
})
const statusHint = computed(() => {
  if (!selectedMac.value || statusLoading.value || deviceOnline.value === null)
    return ''
  return deviceOnline.value ? t('parentTask.onlineHint') : t('parentTask.offlineHint')
})
function goBack() {
  uni.navigateBack()
}
function deviceLabel(mac: string) {
  const device = devices.value.find(item => item.macAddress === mac)
  return device?.alias || mac
}
async function loadDevices() {
  if (!currentAgentId.value)
    return
  devices.value = (await getBindDevices(currentAgentId.value)) || []
  const preferred = props.macAddress || pageMac.value
  if (preferred && devices.value.some(item => item.macAddress === preferred)) {
    selectedMac.value = preferred
  }
  else if (!selectedMac.value && devices.value.length > 0) {
    selectedMac.value = devices.value[0].macAddress
  }
  else if (selectedMac.value && !devices.value.some(item => item.macAddress === selectedMac.value)) {
    selectedMac.value = devices.value[0]?.macAddress || ''
  }
}
async function loadTasks() {
  if (!currentAgentId.value || !selectedMac.value) {
    tasks.value = []
    return
  }
  loading.value = true
  try {
    const result = await getParentTasks(selectedMac.value, currentAgentId.value)
    tasks.value = result?.list || []
  }
  catch (error) {
    console.error('load parent tasks failed', error)
    tasks.value = []
  }
  finally {
    loading.value = false
  }
}
function selectedDeviceOnline(): boolean | null {
  const device = devices.value.find(item => item.macAddress === selectedMac.value)
  if (!device || typeof device.online !== 'boolean')
    return null
  return device.online
}
async function loadDeviceStatus() {
  if (!currentAgentId.value || !selectedMac.value) {
    deviceOnline.value = null
    statusLoading.value = false
    return
  }
  const fromList = selectedDeviceOnline()
  if (fromList !== null)
    deviceOnline.value = fromList
  const seq = ++statusSeq
  statusLoading.value = fromList === null
  try {
    const result = await getParentDeviceStatus(selectedMac.value, currentAgentId.value)
    if (seq !== statusSeq)
      return
    deviceOnline.value = !!result?.online
  }
  catch (error) {
    console.error('load device status failed', error)
    if (seq !== statusSeq)
      return
    const fallback = selectedDeviceOnline()
    if (fallback !== null)
      deviceOnline.value = fallback
  }
  finally {
    if (seq === statusSeq)
      statusLoading.value = false
  }
}
async function refresh() {
  await loadDevices()
  await Promise.all([loadTasks(), loadDeviceStatus()])
}
function goCreate() {
  if (!currentAgentId.value || !selectedMac.value) {
    toast.warning(t('parentTask.bindDeviceFirst'))
    return
  }
  if (!canDispatch.value) {
    toast.warning(t('parentTask.deviceOffline'))
    return
  }
  uni.navigateTo({
    url: `/pages/parent-task/edit?agentId=${encodeURIComponent(currentAgentId.value)}&macAddress=${encodeURIComponent(selectedMac.value)}`,
  })
}
function goHistory() {
  if (!currentAgentId.value || !selectedMac.value) {
    toast.warning(t('parentTask.bindDeviceFirst'))
    return
  }
  uni.navigateTo({
    url: `/pages/parent-task/history?agentId=${encodeURIComponent(currentAgentId.value)}&macAddress=${encodeURIComponent(selectedMac.value)}`,
  })
}
function goEdit(task: ParentTask) {
  uni.navigateTo({
    url: `/pages/parent-task/edit?agentId=${encodeURIComponent(task.agentId)}&macAddress=${encodeURIComponent(task.macAddress)}&taskId=${encodeURIComponent(task.id)}`,
  })
}
async function toggleEnabled(task: ParentTask) {
  if (task.runMode !== 'scheduled' || busyId.value)
    return
  if (!canDispatch.value) {
    toast.warning(t('parentTask.deviceOffline'))
    return
  }
  busyId.value = task.id
  try {
    await updateParentTask(task.id, {
      macAddress: task.macAddress,
      agentId: task.agentId,
      runMode: task.runMode,
      scene: task.scene,
      params: task.params,
      schedule: task.schedule,
      enabled: !task.enabled,
    })
    await loadTasks()
  }
  catch (error: any) {
    toast.error(parentTaskError(error, t('parentTask.saveFailed')))
  }
  finally {
    busyId.value = ''
  }
}
async function runNow(task: ParentTask) {
  if (busyId.value)
    return
  if (!canDispatch.value) {
    toast.warning(t('parentTask.deviceOffline'))
    return
  }
  busyId.value = task.id
  try {
    await runParentTaskNow(task.id)
    toast.success(t('parentTask.started'))
    await loadTasks()
  }
  catch (error: any) {
    toast.error(parentTaskError(error, t('parentTask.startFailed')))
  }
  finally {
    busyId.value = ''
  }
}
function confirmStop(task: ParentTask) {
  message.confirm({
    title: t('parentTask.stopTitle'),
    msg: t('parentTask.stopConfirm'),
    confirmButtonText: t('parentTask.stop'),
    cancelButtonText: t('common.cancel'),
  }).then(async () => {
    if (busyId.value)
      return
    busyId.value = task.id
    try {
      await stopParentTask(task.id)
      toast.success(t('parentTask.stopped'))
      await loadTasks()
    }
    catch (error: any) {
      toast.error(parentTaskError(error, t('parentTask.stopFailed')))
    }
    finally {
      busyId.value = ''
    }
  }).catch(() => {})
}
function confirmDelete(task: ParentTask) {
  message.confirm({
    title: t('parentTask.deleteTitle'),
    msg: t('parentTask.deleteConfirm'),
    confirmButtonText: t('common.delete'),
    cancelButtonText: t('common.cancel'),
  }).then(async () => {
    busyId.value = task.id
    try {
      await deleteParentTask(task.id)
      toast.success(t('parentTask.deleted'))
      await loadTasks()
    }
    catch (error: any) {
      toast.error(parentTaskError(error, t('parentTask.deleteFailed')))
    }
    finally {
      busyId.value = ''
    }
  }).catch(() => {})
}
function taskSubtitle(task: ParentTask) {
  const bits = [
    formatLesson(task.params),
    task.runMode === 'scheduled' ? formatSchedule(task.schedule, t) : t('parentTask.immediate'),
    formatOnComplete(task.params, t),
  ].filter(Boolean)
  return bits.join(' · ')
}
onLoad((options) => {
  if (options?.agentId)
    pageAgentId.value = String(options.agentId)
  if (options?.macAddress)
    pageMac.value = decodeURIComponent(String(options.macAddress))
})
onShow(() => {
  refresh()
})
onMounted(() => {
  refresh()
})
watch(() => selectedMac.value, () => {
  loadTasks()
  loadDeviceStatus()
})
defineExpose({
  refresh,
})
</script>
<template>
  <view class="parent-task-page" :class="{ embedded }">
    <wd-navbar v-if="!embedded" :title="t('parentTask.pageTitle')" safe-area-inset-top>
      <template #left>
        <wd-icon name="arrow-left" size="18" @click="goBack" />
      </template>
      <template #right>
        <text class="px-[8rpx] text-[28rpx] text-[#336cff]" @click="goHistory">
          {{ t('parentHistory.entry') }}
        </text>
      </template>
    </wd-navbar>
    <view class="px-[20rpx] py-[16rpx]">
      <view v-if="devices.length > 0 && selectedMac" class="mb-[16rpx] rounded-[16rpx] bg-white p-[24rpx]">
        <view class="mb-[12rpx] flex items-center justify-between">
          <text class="text-[26rpx] text-[#65686f]">
            {{ t('parentTask.device') }}
          </text>
          <text v-if="embedded" class="text-[26rpx] text-[#336cff]" @click="goHistory">
            {{ t('parentHistory.entry') }}
          </text>
        </view>
        <wd-picker
          v-if="devices.length > 1"
          v-model="selectedMac"
          :columns="deviceColumns"
          :placeholder="t('parentTask.selectDevice')"
          :cancel-button-text="t('common.cancel')"
          :confirm-button-text="t('common.confirm')"
        />
        <text v-else class="mb-[8rpx] block text-[32rpx] text-[#232338] font-semibold">
          {{ deviceLabel(selectedMac) }}
        </text>
        <view
          class="mt-[16rpx] flex items-start gap-[16rpx] rounded-[16rpx] p-[20rpx]"
          :class="deviceOnline ? 'bg-[#e8f8ef]' : (statusLoading || deviceOnline === null ? 'bg-[#f5f7fb]' : 'bg-[#fff1f0]')"
        >
          <view
            class="mt-[10rpx] h-[20rpx] w-[20rpx] shrink-0 rounded-full"
            :class="deviceOnline ? 'bg-[#1aa34a]' : (statusLoading || deviceOnline === null ? 'bg-[#c0c4cc]' : 'bg-[#d4380d]')"
          />
          <view class="min-w-0 flex-1">
            <text
              class="block text-[34rpx] font-bold leading-[1.3]"
              :class="deviceOnline ? 'text-[#146c36]' : (statusLoading || deviceOnline === null ? 'text-[#65686f]' : 'text-[#d4380d]')"
            >
              {{ statusTitle }}
            </text>
            <text
              v-if="statusHint"
              class="mt-[6rpx] block text-[24rpx] leading-[1.45]"
              :class="deviceOnline ? 'text-[#2f6f45]' : 'text-[#a8071a]'"
            >
              {{ statusHint }}
            </text>
          </view>
        </view>
      </view>
    </view>
    <view v-if="loading && tasks.length === 0" class="flex flex-col items-center py-[80rpx]">
      <wd-loading color="#336cff" />
      <text class="mt-[16rpx] text-[28rpx] text-[#666]">
        {{ t('common.loading') }}
      </text>
    </view>
    <view v-else-if="devices.length === 0" class="px-[40rpx] py-[80rpx] text-center">
      <text class="text-[30rpx] text-[#666]">
        {{ t('parentTask.bindDeviceFirst') }}
      </text>
    </view>
    <view v-else class="px-[20rpx] pb-[160rpx]">
      <view v-if="runningTasks.length > 0" class="mb-[24rpx]">
        <text class="mb-[12rpx] block px-[8rpx] text-[28rpx] text-[#232338] font-semibold">
          {{ t('parentTask.runningSection') }}
        </text>
        <view
          v-for="task in runningTasks"
          :key="task.id"
          class="mb-[16rpx] rounded-[20rpx] border-[1rpx] border-[#d6e4ff] bg-white p-[28rpx]"
        >
          <view class="mb-[8rpx] flex items-center justify-between">
            <text class="text-[32rpx] text-[#232338] font-semibold">
              {{ task.sceneName || task.scene }}
            </text>
            <text class="rounded-[8rpx] bg-[#336cff] px-[12rpx] py-[4rpx] text-[22rpx] text-white">
              {{ t('parentTask.running') }}
            </text>
          </view>
          <text class="mb-[16rpx] block text-[26rpx] text-[#65686f]">
            {{ taskSubtitle(task) }}
          </text>
          <wd-button size="small" type="error" plain :loading="busyId === task.id" @click="confirmStop(task)">
            {{ t('parentTask.stop') }}
          </wd-button>
        </view>
      </view>
      <text class="mb-[12rpx] block px-[8rpx] text-[28rpx] text-[#232338] font-semibold">
        {{ t('parentTask.scheduledSection') }}
      </text>
      <view v-if="scheduledTasks.length === 0" class="rounded-[20rpx] bg-white p-[48rpx] text-center text-[26rpx] text-[#999]">
        {{ t('parentTask.emptySchedule') }}
      </view>
      <view
        v-for="task in scheduledTasks"
        :key="task.id"
        class="mb-[16rpx] rounded-[20rpx] border-[1rpx] border-[#eeeeee] bg-white p-[28rpx]"
        @click="goEdit(task)"
      >
        <view class="mb-[8rpx] flex items-center justify-between">
          <text class="text-[32rpx] text-[#232338] font-semibold">
            {{ task.sceneName || task.scene }}
          </text>
          <wd-switch
            :model-value="task.enabled"
            size="22"
            :disabled="busyId === task.id || !canDispatch"
            @click.stop
            @change="toggleEnabled(task)"
          />
        </view>
        <text class="mb-[16rpx] block text-[26rpx] text-[#65686f]">
          {{ taskSubtitle(task) }}
        </text>
        <view class="flex gap-[16rpx]" @click.stop>
          <wd-button size="small" type="primary" plain :disabled="!canDispatch" :loading="busyId === task.id" @click="runNow(task)">
            {{ t('parentTask.startNow') }}
          </wd-button>
          <wd-button size="small" type="error" plain :loading="busyId === task.id" @click="confirmDelete(task)">
            {{ t('common.delete') }}
          </wd-button>
        </view>
      </view>
    </view>
    <wd-fab
      v-if="devices.length > 0"
      type="primary"
      size="small"
      icon="add"
      :disabled="!canDispatch"
      :draggable="true"
      :expandable="false"
      @click="goCreate"
    />
    <wd-message-box />
  </view>
</template>
<style scoped>
.parent-task-page {
  min-height: 100%;
  background: #f5f7fb;
}
.parent-task-page.embedded {
  min-height: auto;
}
</style>
xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/scheduleText.ts
New file
@@ -0,0 +1,55 @@
import type { ParentSchedule, ParentTaskParams } from '@/api/parent/types'
const WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
export function pad2(value: number): string {
  return String(value).padStart(2, '0')
}
export function normalizeTime(raw?: string): string {
  const text = String(raw || '').trim()
  const match = text.match(/^(\d{1,2}):(\d{1,2})/)
  if (!match)
    return '07:30'
  const hour = Math.min(23, Math.max(0, Number(match[1])))
  const minute = Math.min(59, Math.max(0, Number(match[2])))
  return `${pad2(hour)}:${pad2(minute)}`
}
export function weekdayLabel(days: number[] | undefined, t: (key: string) => string): string {
  const unique = [...new Set((days || []).filter(day => day >= 1 && day <= 7))].sort()
  if (unique.length === 0 || unique.length === 7)
    return t('parentTask.everyDay')
  if (unique.join(',') === '1,2,3,4,5')
    return t('parentTask.weekdays')
  return unique.map(day => t(`parentTask.weekday.${WEEKDAY_KEYS[day - 1]}`)).join('、')
}
export function formatSchedule(schedule: ParentSchedule | undefined, t: (key: string) => string): string {
  if (!schedule?.type)
    return ''
  if (schedule.type === 'hourly') {
    const minute = Number(schedule.minute || 0)
    return minute === 0
      ? t('parentTask.hourlyOnTheHour')
      : t('parentTask.hourlyAt', { minute: pad2(minute) })
  }
  if (schedule.type === 'interval') {
    return t('parentTask.everyMinutes', { minutes: schedule.every_minutes || 60 })
  }
  return `${weekdayLabel(schedule.weekdays, t)} ${normalizeTime(schedule.time)}`
}
export function formatOnComplete(params: ParentTaskParams | undefined, t: (key: string) => string): string {
  if (params?.on_complete === 'power_off')
    return t('parentTask.completePowerOff')
  return t('parentTask.completeChat')
}
export function formatLesson(params: ParentTaskParams | undefined): string {
  if (params?.lesson_name && params?.lesson_name_en)
    return `${params.lesson_name} / ${params.lesson_name_en}`
  if (params?.lesson_name)
    return params.lesson_name
  return params?.lesson_id || ''
}
xiaozhi-esp32-server/main/manager-mobile/src/utils/index.ts
@@ -1,7 +1,7 @@
import smCrypto from 'sm-crypto'
import { pages, subPackages } from '@/pages.json'
import { isMpWeixin } from './platform'
import { isH5, isMpWeixin } from './platform'
/**
 * 运行时服务端地址覆盖存储键
@@ -136,6 +136,11 @@
  if (override)
    return override
  // H5 开发走 Vite 代理,与 manager-web 一样打到本机 8002,而不是 env 里的外网地址
  if (isH5 && import.meta.env.DEV && import.meta.env.VITE_APP_PROXY === 'true') {
    return import.meta.env.VITE_APP_PROXY_PREFIX || '/xiaozhi'
  }
  // 请求基准地址(默认来源于 env)
  let baseUrl = import.meta.env.VITE_SERVER_BASEURL
xiaozhi-esp32-server/main/manager-mobile/vite.config.ts
@@ -159,9 +159,9 @@
      proxy: JSON.parse(VITE_APP_PROXY)
        ? {
            [VITE_APP_PROXY_PREFIX]: {
              target: VITE_SERVER_BASEURL,
              // 保留 /xiaozhi context-path,与 manager-web 代理行为一致
              target: VITE_SERVER_BASEURL.replace(/\/xiaozhi\/?$/, '') || 'http://127.0.0.1:8002',
              changeOrigin: true,
              rewrite: path => path.replace(new RegExp(`^${VITE_APP_PROXY_PREFIX}`), ''),
            },
          }
        : undefined,
xiaozhi-esp32-server/main/xiaozhi-server/config.yaml
@@ -25,6 +25,10 @@
  # 所以如果你使用docker部署时,将vision_explain设置成局域网地址
  # 如果你使用公网部署时,将vision_explain设置成公网地址
  vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain
  # 设备 DOWNLOAD 课文 MP3 的公网根。内网仍由 xiaozhi-server http_port(8003) 提供
  # /lesson-audio/{org_id}/english_learning/... ;Caddy 用独立 path 反代到 8003,不新开端口
  # 磁盘:data/lesson_audio/{org_id}/... 与 SD 相对路径一致;空机构用 default_org
  lesson_audio_public_url: https://xiaozhi.illegalfm.com/lesson-audio
  # OTA返回信息时区偏移量
  timezone_offset: +8
  # 认证配置
@@ -78,6 +82,9 @@
proactive_chat_greeting: 我们来聊天吧。
# 跟读模式默认句子,可按课程替换
app_runtime:
  # 空则从平台公共区 default_org 取课文;后续多机构填各自 org id
  lesson_org_id: ""
  lesson_sync_timeout_sec: 90
  shadowing_sentences:
    - "Hello, how are you today?"
    - "I like to read English books."
xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py
@@ -284,6 +284,36 @@
        return None
async def close_parent_immediate_tasks(payload: Dict) -> Optional[Dict]:
    """关闭家长即时任务。失败返回 None,不抛给语音热路径。未配置管理端视为成功。"""
    if not payload or not ManageApiClient._instance:
        return {"skipped": True}
    try:
        return await ManageApiClient._instance._async_request(
            "POST",
            "/task/instance/close-parent-immediate",
            json=payload,
        )
    except Exception as e:
        print(f"关闭家长即时任务失败: {e}")
        return None
async def sync_lesson_audio(payload: Dict) -> Optional[Dict]:
    """请 manager-api 向设备发 UPDATE_MANIFEST。失败返回 None,不抛给热路径。"""
    if not payload or not ManageApiClient._instance:
        return {"skipped": True}
    try:
        return await ManageApiClient._instance._async_request(
            "POST",
            "/task/lesson-sync",
            json=payload,
        )
    except Exception as e:
        print(f"课文同步下发失败: {e}")
        return None
def init_service(config):
    ManageApiClient(config)
xiaozhi-esp32-server/main/xiaozhi-server/core/api/lesson_audio_handler.py
New file
@@ -0,0 +1,64 @@
"""Serve org-scoped lesson MP3s for device DOWNLOAD / UPDATE_MANIFEST."""
from __future__ import annotations
import re
from aiohttp import web
from core.api.base_handler import BaseHandler
from core.app_runtime.lesson_audio import (
    DEFAULT_ORG_ID,
    ensure_org_dirs,
    lesson_audio_public_root,
    list_lesson_clips,
    org_audio_dir,
    resolve_disk_file,
)
_LEVEL_RE = re.compile(r"^l[1-6]$", re.I)
_STORY_RE = re.compile(r"^\d{3}$")
TAG = __name__
class LessonAudioHandler(BaseHandler):
    def __init__(self, config: dict):
        super().__init__(config)
        ensure_org_dirs(config)
    async def handle_index(self, request: web.Request) -> web.StreamResponse:
        org_id = request.match_info.get("org_id") or DEFAULT_ORG_ID
        kind = str(request.match_info.get("kind") or "").strip().lower()
        level = str(request.match_info.get("level") or "").strip().lower()
        story = str(request.match_info.get("story") or "").strip()
        lang = str(request.match_info.get("lang") or "").strip().lower()
        if (
            kind not in ("repeater", "shadowing")
            or lang not in ("cn", "en")
            or not _LEVEL_RE.fullmatch(level)
            or not _STORY_RE.fullmatch(story)
        ):
            return web.json_response({"present": False, "files": []}, status=400)
        files = list_lesson_clips(self.config, org_id, kind, f"{level}.{story}", lang)
        return web.json_response({"present": files is not None, "files": files or []})
    async def handle_get(self, request: web.Request) -> web.StreamResponse:
        org_id = request.match_info.get("org_id") or DEFAULT_ORG_ID
        rel = request.match_info.get("path") or ""
        disk = resolve_disk_file(self.config, org_id, rel)
        if not disk:
            return web.Response(status=404, text="lesson audio not found")
        return web.FileResponse(path=disk, headers={"Content-Type": "audio/mpeg"})
    def public_root(self) -> str:
        return lesson_audio_public_root(self.config)
    def log_ready(self) -> None:
        ensure_org_dirs(self.config)
        self.logger.bind(tag=TAG).info(
            f"课文音频 HTTP: {self.public_root()}/{{org_id}}/english_learning/..."
        )
        self.logger.bind(tag=TAG).info(
            f"课文音频目录: {org_audio_dir(self.config, DEFAULT_ORG_ID)} (空 org 用 {DEFAULT_ORG_ID})"
        )
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json
@@ -1,7 +1,17 @@
{
  "default_app_id": "chat",
  "voice_menu_phrases": ["切换模式", "模式切换", "qie huan mo shi", "mo shi qie huan"],
  "reserved_voice_enter": ["数字背诵", "成语接龙", "口算", "加减法"],
  "voice_menu_phrases": [
    "切换模式",
    "模式切换",
    "qie huan mo shi",
    "mo shi qie huan"
  ],
  "reserved_voice_enter": [
    "数字背诵",
    "成语接龙",
    "口算",
    "加减法"
  ],
  "apps": {
    "chat": {
      "name": "聊天",
@@ -53,42 +63,72 @@
      "system_prompt_mode": "none",
      "tasks": {
        "repeater": {
          "display_name": "复读机模式",
          "menu_name": "听复读机",
          "enter_prompt": "现在已经切换至复读机模式,只循环播放音频。",
          "voice_enter": ["开始复读", "打开复读", "英语学习", "听复读", "复读机"],
          "voice_exit": ["退出复读", "结束复读", "停止复读", "关掉复读", "退出复读机"],
          "display_name": "泛听模式",
          "menu_name": "泛听",
          "enter_prompt": "现在已经切换至泛听模式,只循环播放音频。",
          "voice_enter": [
            "开始泛听",
            "打开泛听",
            "英语学习",
            "泛听模式",
            "泛听"
          ],
          "voice_exit": [
            "退出泛听",
            "结束泛听",
            "停止泛听",
            "关掉泛听"
          ],
          "interaction_mode": "PLAY_AUDIO",
          "skills": [
            {
              "name": "self.mp3.play",
              "args_from": ["name", "repeat", "power_off_after", "speed"]
              "args_from": [
                "name",
                "repeat",
                "power_off_after",
                "speed"
              ]
            }
          ],
          "playback": {
            "speed": 1.0,
            "daily_speeds": [0.8, 0.9, 1.0, 1.05, 1.1, 1.15, 1.2],
            "repeat_speeds": [0.8, 0.9, 1.0, 1.1, 1.2]
          },
          "default_playlist": [
            {
              "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
              "repeat": 10,
              "speed": 1.0
            },
            {
              "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
              "repeat": 5,
              "speed": 1.0
            }
          ]
            "daily_speeds": [
              0.8,
              0.9,
              1.0,
              1.05,
              1.1,
              1.15,
              1.2
            ],
            "repeat_speeds": [
              0.8,
              0.9,
              1.0,
              1.1,
              1.2
            ]
          }
        },
        "shadowing": {
          "display_name": "跟读模式",
          "menu_name": "跟读",
          "enter_prompt": "现在已经切换至跟读模式,请跟着音频读。",
          "voice_enter": ["开始跟读", "打开跟读", "跟读模式", "我要跟读", "跟读", "开始听读", "打开听读", "听读模式", "听读"],
          "voice_exit": ["退出跟读", "结束跟读", "停止跟读", "关掉跟读", "不想跟读", "退出听读", "结束听读"],
          "voice_enter": [
            "开始跟读",
            "打开跟读",
            "跟读模式",
            "我要跟读",
            "跟读"
          ],
          "voice_exit": [
            "退出跟读",
            "结束跟读",
            "停止跟读",
            "关掉跟读",
            "不想跟读"
          ],
          "interaction_mode": "SHADOWING",
          "listen_ratio": 1.2,
          "playback": {
@@ -96,23 +136,13 @@
          },
          "assessment": {
            "enabled": true,
            "methods": ["text_similarity_v0"],
            "methods": [
              "text_similarity_v0"
            ],
            "defer": true,
            "endpoint": ""
          },
          "skills": [],
          "default_playlist": [
            {
              "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
              "repeat": 5,
              "speed": 1.0
            },
            {
              "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
              "repeat": 10,
              "speed": 1.0
            }
          ],
          "sentences": [
            "Hello, how are you today?",
            "I like to read English books.",
@@ -129,15 +159,30 @@
      "mode": "TRANSLATION",
      "default_task": "live_translate",
      "tool_policy": "allowlist",
      "tools": ["switch_app", "translate"],
      "tools": [
        "switch_app",
        "translate"
      ],
      "system_prompt_mode": "translator",
      "tasks": {
        "live_translate": {
          "display_name": "翻译模式",
          "menu_name": "翻译",
          "enter_prompt": "现在已经切换至翻译模式,自动进行中英互译。",
          "voice_enter": ["打开翻译", "开始翻译", "英语翻译", "翻译模式", "翻译"],
          "voice_exit": ["退出翻译", "结束翻译", "停止翻译", "关掉翻译", "不想翻译"],
          "voice_enter": [
            "打开翻译",
            "开始翻译",
            "英语翻译",
            "翻译模式",
            "翻译"
          ],
          "voice_exit": [
            "退出翻译",
            "结束翻译",
            "停止翻译",
            "关掉翻译",
            "不想翻译"
          ],
          "interaction_mode": "TRANSLATION",
          "skills": [
            {
@@ -154,15 +199,24 @@
      "mode": "BILINGUAL",
      "default_task": "bilingual_chat",
      "tool_policy": "inherit_agent",
      "tools": ["switch_app"],
      "tools": [
        "switch_app"
      ],
      "system_prompt_mode": "bilingual",
      "tasks": {
        "bilingual_chat": {
          "display_name": "中英对话模式",
          "menu_name": "中英对话",
          "enter_prompt": "现在已经切换至中英对话模式。你说中文,我用英文回答;你说英文,我用中文回答。",
          "voice_enter": ["打开中英对话", "中英对话模式", "中英对话"],
          "voice_exit": ["退出中英对话", "结束中英对话"],
          "voice_enter": [
            "打开中英对话",
            "中英对话模式",
            "中英对话"
          ],
          "voice_exit": [
            "退出中英对话",
            "结束中英对话"
          ],
          "interaction_mode": "BILINGUAL",
          "skills": []
        }
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py
@@ -127,6 +127,7 @@
    step_index: int,
    wav_bytes: bytes,
    conn: Any = None,
    text: Optional[str] = None,
) -> Optional[str]:
    """Persist the follow-read utterance. Returns relative audio_id or None."""
    if not wav_bytes:
@@ -135,13 +136,14 @@
    if conn is not None:
        from core.audio_archive import archive_wav
        archived = archive_wav(conn, wav_bytes, kind="asr", extra=extra)
        archived = archive_wav(conn, wav_bytes, kind="asr", extra=extra, text=text)
        if archived:
            return archived
    if not session_id:
        return None
    try:
        from config.config_loader import get_project_dir
        from core.audio_archive import write_sidecar_text
        directory = os.path.join(get_project_dir(), "data", "task_sessions", "audio")
        os.makedirs(directory, exist_ok=True)
@@ -149,6 +151,8 @@
        path = os.path.join(directory, filename)
        with open(path, "wb") as handle:
            handle.write(wav_bytes)
        if text is not None:
            write_sidecar_text(path, text)
        return filename
    except Exception:
        return None
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom-lessons.json
New file
@@ -0,0 +1,792 @@
{
  "_comment": "增删成语只改 stories。id 为三位序号(001起,删除后勿复用)。systemId 仅供家长端分类,不进文件路径。lessonId=级别.序号,例如 l1.001。磁盘/HTTP: data/lesson_audio/{org}/english_learning/... ;SD: /english_learning/...",
  "version": 3,
  "defaults": {
    "cnRepeat": 1,
    "cnSpeed": 1.0,
    "enPasses": [
      {
        "speed": 0.8,
        "repeat": 1
      },
      {
        "speed": 0.9,
        "repeat": 2
      },
      {
        "speed": 1.0,
        "repeat": 5
      }
    ],
    "shadowingLanguage": "en",
    "shadowingSpeed": 0.8,
    "listenRatio": 1.2,
    "sentenceCount": 30
  },
  "pathPatterns": {
    "repeater": "/english_learning/repeater/lessons/{level}/{story}/{lang}/{nn}.mp3",
    "repeaterCn": "/english_learning/repeater/lessons/{level}/{story}/cn/01.mp3",
    "repeaterEn": "/english_learning/repeater/lessons/{level}/{story}/en/01.mp3",
    "shadowing": "/english_learning/shadowing/lessons/{level}/{story}/{lang}/{nn}.mp3"
  },
  "levels": [
    {
      "id": "l1",
      "name": "一级",
      "nameEn": "Level 1"
    },
    {
      "id": "l2",
      "name": "二级",
      "nameEn": "Level 2"
    },
    {
      "id": "l3",
      "name": "三级",
      "nameEn": "Level 3"
    },
    {
      "id": "l4",
      "name": "四级",
      "nameEn": "Level 4"
    },
    {
      "id": "l5",
      "name": "五级",
      "nameEn": "Level 5"
    },
    {
      "id": "l6",
      "name": "六级",
      "nameEn": "Level 6"
    }
  ],
  "systems": [
    {
      "id": "origin",
      "name": "起源与存在理解",
      "nameEn": "Understanding Origins and Existence"
    },
    {
      "id": "growth",
      "name": "学习与成长机制",
      "nameEn": "Learning & Growth Mechanisms"
    },
    {
      "id": "language",
      "name": "语言与意义建构",
      "nameEn": "Construction of Language & Meaning"
    },
    {
      "id": "cognition",
      "name": "认知与思维偏差",
      "nameEn": "Cognitive & Thinking Biases"
    },
    {
      "id": "values",
      "name": "价值与行为选择",
      "nameEn": "Values & Behavioral Choices"
    },
    {
      "id": "society",
      "name": "社会互动与结构关系",
      "nameEn": "Social Interaction & Structural Relationships"
    },
    {
      "id": "destiny",
      "name": "命运与自我超越",
      "nameEn": "Destiny & Self-Transcendence"
    }
  ],
  "stories": [
    {
      "id": "001",
      "slug": "pangu",
      "systemId": "origin",
      "name": "开天辟地",
      "nameEn": "Pangu splits the chaos to create the world",
      "enabled": true,
      "sentenceCount": 58
    },
    {
      "id": "002",
      "slug": "nvwa",
      "systemId": "origin",
      "name": "女娲造人",
      "nameEn": "Nuwa makes humans out of clay",
      "enabled": true
    },
    {
      "id": "003",
      "slug": "houyi",
      "systemId": "origin",
      "name": "后羿射日",
      "nameEn": "Houyi shoots down nine extra suns",
      "enabled": true
    },
    {
      "id": "004",
      "slug": "fuxi",
      "systemId": "origin",
      "name": "伏羲结网画卦",
      "nameEn": "Fuxi invents nets and the Eight Trigrams",
      "enabled": true
    },
    {
      "id": "005",
      "slug": "shennong",
      "systemId": "origin",
      "name": "神农尝百草",
      "nameEn": "Shennong tastes herbs to find medicine",
      "enabled": true
    },
    {
      "id": "006",
      "slug": "huangdi",
      "systemId": "origin",
      "name": "黄帝合诸立国",
      "nameEn": "Huangdi unites tribes to form a nation",
      "enabled": true
    },
    {
      "id": "007",
      "slug": "dayu",
      "systemId": "origin",
      "name": "大禹治水",
      "nameEn": "Great Yu tames the giant floods",
      "enabled": true
    },
    {
      "id": "008",
      "slug": "zaobi",
      "systemId": "growth",
      "name": "凿壁借光",
      "nameEn": "Digging a hole in the wall to steal light for reading",
      "enabled": true
    },
    {
      "id": "009",
      "slug": "nangying",
      "systemId": "growth",
      "name": "囊萤映雪",
      "nameEn": "Catching fireflies or using snow-light to study",
      "enabled": true
    },
    {
      "id": "010",
      "slug": "mengmu",
      "systemId": "growth",
      "name": "孟母三迁",
      "nameEn": "A mother moves three times to find a better school",
      "enabled": true
    },
    {
      "id": "011",
      "slug": "duanzhi",
      "systemId": "growth",
      "name": "断织劝学",
      "nameEn": "Cutting the weaving cloth to show the harm of quitting",
      "enabled": true
    },
    {
      "id": "012",
      "slug": "xuanliang",
      "systemId": "growth",
      "name": "悬梁刺股",
      "nameEn": "Tying hair to the ceiling and poking the leg to stay awake",
      "enabled": true
    },
    {
      "id": "013",
      "slug": "chengmen",
      "systemId": "growth",
      "name": "程门立雪",
      "nameEn": "Standing in the snow to show respect for the teacher",
      "enabled": true
    },
    {
      "id": "014",
      "slug": "shuneng",
      "systemId": "growth",
      "name": "熟能生巧",
      "nameEn": "Practice makes perfect",
      "enabled": true
    },
    {
      "id": "015",
      "slug": "shuidi",
      "systemId": "growth",
      "name": "水滴石穿",
      "nameEn": "Constant dripping wears away the stone",
      "enabled": true
    },
    {
      "id": "016",
      "slug": "wenji",
      "systemId": "growth",
      "name": "闻鸡起舞",
      "nameEn": "Waking up at the rooster's crow to practice",
      "enabled": true
    },
    {
      "id": "017",
      "slug": "tiechu",
      "systemId": "growth",
      "name": "铁杵磨针",
      "nameEn": "Grinding an iron bar into a needle",
      "enabled": true
    },
    {
      "id": "018",
      "slug": "benniao",
      "systemId": "growth",
      "name": "笨鸟先飞",
      "nameEn": "Clumsy birds fly early",
      "enabled": true
    },
    {
      "id": "019",
      "slug": "xiongyou",
      "systemId": "growth",
      "name": "胸有成竹",
      "nameEn": "Having a full picture of the bamboo in mind before painting",
      "enabled": true
    },
    {
      "id": "020",
      "slug": "hulun",
      "systemId": "growth",
      "name": "囫囵吞枣",
      "nameEn": "Swallowing a date whole without chewing",
      "enabled": true
    },
    {
      "id": "021",
      "slug": "handan",
      "systemId": "growth",
      "name": "邯郸学步",
      "nameEn": "Forgetting how to walk while trying to copy others",
      "enabled": true
    },
    {
      "id": "022",
      "slug": "cangjie",
      "systemId": "language",
      "name": "仓颉造字",
      "nameEn": "Cangjie invents written characters",
      "enabled": true
    },
    {
      "id": "023",
      "slug": "zhilu",
      "systemId": "language",
      "name": "指鹿为马",
      "nameEn": "Pointing at a deer and calling it a horse",
      "enabled": true
    },
    {
      "id": "024",
      "slug": "zixiang",
      "systemId": "language",
      "name": "自相矛盾",
      "nameEn": "Using your unbreakable spear against your unbreakable shield",
      "enabled": true
    },
    {
      "id": "025",
      "slug": "wangmei",
      "systemId": "language",
      "name": "望梅止渴",
      "nameEn": "Thinking of sour plums to stop thirst",
      "enabled": true
    },
    {
      "id": "026",
      "slug": "huabing",
      "systemId": "language",
      "name": "画饼充饥",
      "nameEn": "Drawing a pancake to satisfy hunger",
      "enabled": true
    },
    {
      "id": "027",
      "slug": "duiniu",
      "systemId": "language",
      "name": "对牛弹琴",
      "nameEn": "Playing a harp to a cow",
      "enabled": true
    },
    {
      "id": "028",
      "slug": "gouwei",
      "systemId": "language",
      "name": "狗尾续貂",
      "nameEn": "Using a dog's tail to extend a mink coat",
      "enabled": true
    },
    {
      "id": "029",
      "slug": "yiye",
      "systemId": "cognition",
      "name": "一叶障目",
      "nameEn": "A single leaf blocking the view of the forest",
      "enabled": true
    },
    {
      "id": "030",
      "slug": "mangren",
      "systemId": "cognition",
      "name": "盲人摸象",
      "nameEn": "Blind men touching an elephant to guess what it is",
      "enabled": true
    },
    {
      "id": "031",
      "slug": "jingdi",
      "systemId": "cognition",
      "name": "井底之蛙",
      "nameEn": "A frog at the bottom of a well",
      "enabled": true
    },
    {
      "id": "032",
      "slug": "kezhou",
      "systemId": "cognition",
      "name": "刻舟求剑",
      "nameEn": "Carving a mark on a moving boat to find a lost sword",
      "enabled": true
    },
    {
      "id": "033",
      "slug": "nanyuan",
      "systemId": "cognition",
      "name": "南辕北辙",
      "nameEn": "Driving south to reach the north",
      "enabled": true
    },
    {
      "id": "034",
      "slug": "shouzhu",
      "systemId": "cognition",
      "name": "守株待兔",
      "nameEn": "Waiting by a tree for a rabbit to crash into it",
      "enabled": true
    },
    {
      "id": "035",
      "slug": "yaner",
      "systemId": "cognition",
      "name": "掩耳盗铃",
      "nameEn": "Covering your own ears while stealing a bell",
      "enabled": true
    },
    {
      "id": "036",
      "slug": "yamiao",
      "systemId": "cognition",
      "name": "揠苗助长",
      "nameEn": "Pulling up sprouts to help them grow faster",
      "enabled": true
    },
    {
      "id": "037",
      "slug": "qiren",
      "systemId": "cognition",
      "name": "杞人忧天",
      "nameEn": "Worrying that the sky might fall down",
      "enabled": true
    },
    {
      "id": "038",
      "slug": "beigong",
      "systemId": "cognition",
      "name": "杯弓蛇影",
      "nameEn": "Mistaking a bow's reflection in a cup for a snake",
      "enabled": true
    },
    {
      "id": "039",
      "slug": "yegong",
      "systemId": "cognition",
      "name": "叶公好龙",
      "nameEn": "Lord Ye says he loves dragons but is terrified by a real one",
      "enabled": true
    },
    {
      "id": "040",
      "slug": "antu",
      "systemId": "cognition",
      "name": "按图索骥",
      "nameEn": "Looking for a great horse based only on a drawing",
      "enabled": true
    },
    {
      "id": "041",
      "slug": "zhishang",
      "systemId": "cognition",
      "name": "纸上谈兵",
      "nameEn": "Fighting a war only on paper",
      "enabled": true
    },
    {
      "id": "042",
      "slug": "yuanmu",
      "systemId": "cognition",
      "name": "缘木求鱼",
      "nameEn": "Climbing a tree to catch a fish",
      "enabled": true
    },
    {
      "id": "043",
      "slug": "xuezu",
      "systemId": "cognition",
      "name": "削足适履",
      "nameEn": "Cutting your feet to fit the shoes",
      "enabled": true
    },
    {
      "id": "044",
      "slug": "ziqie",
      "systemId": "cognition",
      "name": "自欺欺人",
      "nameEn": "Deceiving yourself and others",
      "enabled": true
    },
    {
      "id": "045",
      "slug": "dangju",
      "systemId": "cognition",
      "name": "当局者迷",
      "nameEn": "The players are confused, but the observers see clearly",
      "enabled": true
    },
    {
      "id": "046",
      "slug": "yelang",
      "systemId": "cognition",
      "name": "夜郎自大",
      "nameEn": "A small kingdom thinking it's the biggest",
      "enabled": true
    },
    {
      "id": "047",
      "slug": "maidu",
      "systemId": "cognition",
      "name": "买椟还珠",
      "nameEn": "Buying the fancy box but returning the pearl inside",
      "enabled": true
    },
    {
      "id": "048",
      "slug": "dongshi",
      "systemId": "cognition",
      "name": "东施效颦",
      "nameEn": "An ugly girl mimicking a beauty's frown and looking worse",
      "enabled": true
    },
    {
      "id": "049",
      "slug": "shesheng",
      "systemId": "values",
      "name": "舍生取义",
      "nameEn": "Sacrificing life for justice",
      "enabled": true
    },
    {
      "id": "050",
      "slug": "wanbi",
      "systemId": "values",
      "name": "完璧归赵",
      "nameEn": "Returning the jade intact to its owner",
      "enabled": true
    },
    {
      "id": "051",
      "slug": "jugong",
      "systemId": "values",
      "name": "鞠躬尽瘁",
      "nameEn": "Giving one's all until the very end",
      "enabled": true
    },
    {
      "id": "052",
      "slug": "kongrong",
      "systemId": "values",
      "name": "孔融让梨",
      "nameEn": "Kong Rong giving the bigger pear to his brother",
      "enabled": true
    },
    {
      "id": "053",
      "slug": "pofu",
      "systemId": "values",
      "name": "破釜沉舟",
      "nameEn": "Breaking the pots and sinking the boats",
      "enabled": true
    },
    {
      "id": "054",
      "slug": "wangyang",
      "systemId": "values",
      "name": "亡羊补牢",
      "nameEn": "Fixing the fence after the sheep is stolen",
      "enabled": true
    },
    {
      "id": "055",
      "slug": "shaji",
      "systemId": "values",
      "name": "杀鸡取卵",
      "nameEn": "Killing the hen to get the eggs",
      "enabled": true
    },
    {
      "id": "056",
      "slug": "sheben",
      "systemId": "values",
      "name": "舍本逐末",
      "nameEn": "Chasing the branches and ignoring the root",
      "enabled": true
    },
    {
      "id": "057",
      "slug": "delong",
      "systemId": "values",
      "name": "得陇望蜀",
      "nameEn": "Getting one place and wanting the next one immediately",
      "enabled": true
    },
    {
      "id": "058",
      "slug": "yiluan",
      "systemId": "values",
      "name": "以卵击石",
      "nameEn": "Hitting a rock with an egg",
      "enabled": true
    },
    {
      "id": "059",
      "slug": "nongqiao",
      "systemId": "values",
      "name": "弄巧成拙",
      "nameEn": "Trying to be smart but making a mess",
      "enabled": true
    },
    {
      "id": "060",
      "slug": "huashe",
      "systemId": "values",
      "name": "画蛇添足",
      "nameEn": "Adding legs to a drawing of a snake",
      "enabled": true
    },
    {
      "id": "061",
      "slug": "lebusi",
      "systemId": "values",
      "name": "乐不思蜀",
      "nameEn": "Being so happy that you forget your home",
      "enabled": true
    },
    {
      "id": "062",
      "slug": "zhidian",
      "systemId": "values",
      "name": "指点江山",
      "nameEn": "Pointing at the land and discussing politics",
      "enabled": true
    },
    {
      "id": "063",
      "slug": "dairuo",
      "systemId": "values",
      "name": "呆若木鸡",
      "nameEn": "Standing still like a wooden rooster",
      "enabled": true
    },
    {
      "id": "064",
      "slug": "sanren",
      "systemId": "society",
      "name": "三人成虎",
      "nameEn": "Three people lying about a tiger makes everyone believe it",
      "enabled": true
    },
    {
      "id": "065",
      "slug": "chunwang",
      "systemId": "society",
      "name": "唇亡齿寒",
      "nameEn": "When the lips are gone, the teeth get cold",
      "enabled": true
    },
    {
      "id": "066",
      "slug": "simian",
      "systemId": "society",
      "name": "四面楚歌",
      "nameEn": "Hearing enemy songs from all sides",
      "enabled": true
    },
    {
      "id": "067",
      "slug": "weiwei",
      "systemId": "society",
      "name": "围魏救赵",
      "nameEn": "Attacking the enemy's home to save your own ally",
      "enabled": true
    },
    {
      "id": "068",
      "slug": "hujia",
      "systemId": "society",
      "name": "狐假虎威",
      "nameEn": "The fox using the tiger's power to scare others",
      "enabled": true
    },
    {
      "id": "069",
      "slug": "lanyu",
      "systemId": "society",
      "name": "滥竽充数",
      "nameEn": "Pretending to play the flute in an orchestra",
      "enabled": true
    },
    {
      "id": "070",
      "slug": "yubang",
      "systemId": "society",
      "name": "鹬蚌相争",
      "nameEn": "The bird and the clam fight, and the fisherman catches both",
      "enabled": true
    },
    {
      "id": "071",
      "slug": "caochuan",
      "systemId": "society",
      "name": "草船借箭",
      "nameEn": "Using straw boats to borrow arrows from the enemy",
      "enabled": true
    },
    {
      "id": "072",
      "slug": "jiedao",
      "systemId": "society",
      "name": "借刀杀人",
      "nameEn": "Killing someone with a borrowed knife",
      "enabled": true
    },
    {
      "id": "073",
      "slug": "taoyuan",
      "systemId": "society",
      "name": "桃园结义",
      "nameEn": "Swearing brotherhood in a peach garden",
      "enabled": true
    },
    {
      "id": "074",
      "slug": "sangu",
      "systemId": "society",
      "name": "三顾茅庐",
      "nameEn": "Visiting a cottage three times to hire a genius",
      "enabled": true
    },
    {
      "id": "075",
      "slug": "ertao",
      "systemId": "society",
      "name": "二桃杀三士",
      "nameEn": "Using two peaches to kill three warriors",
      "enabled": true
    },
    {
      "id": "076",
      "slug": "zuoshan",
      "systemId": "society",
      "name": "坐山观虎斗",
      "nameEn": "Sitting on a mountain to watch two tigers fight",
      "enabled": true
    },
    {
      "id": "077",
      "slug": "gean",
      "systemId": "society",
      "name": "隔岸观火",
      "nameEn": "Watching a fire from the opposite bank",
      "enabled": true
    },
    {
      "id": "078",
      "slug": "yugong",
      "systemId": "destiny",
      "name": "愚公移山",
      "nameEn": "The foolish old man moving the mountains",
      "enabled": true
    },
    {
      "id": "079",
      "slug": "jingwei",
      "systemId": "destiny",
      "name": "精卫填海",
      "nameEn": "The Jingwei bird trying to fill the sea with pebbles",
      "enabled": true
    },
    {
      "id": "080",
      "slug": "kuafu",
      "systemId": "destiny",
      "name": "夸父逐日",
      "nameEn": "Kuafu chasing the sun",
      "enabled": true
    },
    {
      "id": "081",
      "slug": "change",
      "systemId": "destiny",
      "name": "嫦娥奔月",
      "nameEn": "Chang'e flying to the moon",
      "enabled": true
    },
    {
      "id": "082",
      "slug": "saiweng",
      "systemId": "destiny",
      "name": "塞翁失马",
      "nameEn": "An old man losing his horse",
      "enabled": true
    },
    {
      "id": "083",
      "slug": "woxin",
      "systemId": "destiny",
      "name": "卧薪尝胆",
      "nameEn": "Sleeping on brushwood and tasting gall",
      "enabled": true
    },
    {
      "id": "084",
      "slug": "yiming",
      "systemId": "destiny",
      "name": "一鸣惊人",
      "nameEn": "One chirp that shocks everyone",
      "enabled": true
    },
    {
      "id": "085",
      "slug": "maosui",
      "systemId": "destiny",
      "name": "毛遂自荐",
      "nameEn": "Maosui recommending himself",
      "enabled": true
    },
    {
      "id": "086",
      "slug": "nanke",
      "systemId": "destiny",
      "name": "南柯一梦",
      "nameEn": "A dream under a tree",
      "enabled": true
    }
  ]
}
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom_catalog.py
New file
@@ -0,0 +1,146 @@
"""Idiom story catalog. Edit idiom-lessons.json to add or remove lessons."""
from __future__ import annotations
import json
import os
import re
from functools import lru_cache
from typing import Any, Dict, List, Optional, Tuple
_CATALOG_FILE = os.path.join(os.path.dirname(__file__), "idiom-lessons.json")
_COMPACT_RE = re.compile(r"^(l[1-6])\.(\d{3})$", re.I)
@lru_cache(maxsize=1)
def load_catalog() -> Dict[str, Any]:
    with open(_CATALOG_FILE, "r", encoding="utf-8") as handle:
        data = json.load(handle)
    return data if isinstance(data, dict) else {}
def _stories() -> List[Dict[str, Any]]:
    rows = load_catalog().get("stories") or []
    return [row for row in rows if isinstance(row, dict)]
@lru_cache(maxsize=1)
def _index() -> Dict[str, Dict[str, Any]]:
    index: Dict[str, Dict[str, Any]] = {}
    for story in _stories():
        sid = str(story.get("id") or "").strip().lower()
        if sid and sid not in index:
            index[sid] = story
    return index
def parse_lesson_id(lesson_id: Any) -> Tuple[str, str]:
    key = str(lesson_id or "").strip().lower()
    match = _COMPACT_RE.match(key)
    if match:
        return match.group(1).lower(), match.group(2)
    return "", ""
def find_story(lesson_id: Any) -> Optional[Dict[str, Any]]:
    key = str(lesson_id or "").strip().lower()
    if not key:
        return None
    level, sid = parse_lesson_id(key)
    if sid:
        return _index().get(sid)
    return _index().get(key)
def bind_story(story: Dict[str, Any], lesson_id: Any = "") -> Dict[str, Any]:
    row = dict(story)
    level, _sid = parse_lesson_id(lesson_id)
    row["levelId"] = level or "l1"
    return row
def canonical_lesson_id(lesson_id: Any) -> str:
    story = find_story(lesson_id)
    if story is None:
        return str(lesson_id or "").strip()
    bound = bind_story(story, lesson_id)
    return f"{bound.get('levelId')}.{story.get('id')}"
def default_en_passes() -> List[Dict[str, Any]]:
    raw = ((load_catalog().get("defaults") or {}).get("enPasses")) or []
    out: List[Dict[str, Any]] = []
    for item in raw:
        row = normalize_pass(item)
        if row:
            out.append(row)
    return out or [
        {"speed": 0.8, "repeat": 1},
        {"speed": 0.9, "repeat": 2},
        {"speed": 1.0, "repeat": 5},
    ]
def normalize_pass(raw: Any) -> Optional[Dict[str, Any]]:
    if not isinstance(raw, dict):
        return None
    try:
        speed = float(raw.get("speed", 1.0))
    except (TypeError, ValueError):
        speed = 1.0
    try:
        repeat = int(raw.get("repeat", 1))
    except (TypeError, ValueError):
        repeat = 1
    speed = min(2.0, max(0.5, speed))
    repeat = min(10, max(1, repeat))
    return {"speed": speed, "repeat": repeat}
def sentence_count(story: Dict[str, Any]) -> int:
    try:
        count = int(story.get("sentenceCount") or ((load_catalog().get("defaults") or {}).get("sentenceCount")) or 30)
    except (TypeError, ValueError):
        count = 30
    return min(200, max(1, count))
def extensive_paths(story: Dict[str, Any], lesson_id: Any = "") -> Tuple[str, str]:
    bound = bind_story(story, lesson_id)
    patterns = load_catalog().get("pathPatterns") or {}
    pattern = str(patterns.get("repeater") or "")
    if pattern:
        return (
            _fill(pattern, bound, "cn", "01"),
            _fill(pattern, bound, "en", "01"),
        )
    return (
        _fill(str(patterns.get("repeaterCn") or ""), bound, "cn", "01"),
        _fill(str(patterns.get("repeaterEn") or ""), bound, "en", "01"),
    )
def shadowing_paths(
    story: Dict[str, Any],
    language: str = "en",
    count: Optional[int] = None,
    lesson_id: Any = "",
) -> List[str]:
    bound = bind_story(story, lesson_id)
    lang = "cn" if str(language or "").strip().lower() == "cn" else "en"
    total = sentence_count(bound) if count is None else min(200, max(1, int(count)))
    width = 3 if total > 99 else 2
    pattern = str((load_catalog().get("pathPatterns") or {}).get("shadowing") or "")
    return [
        _fill(pattern, bound, lang, f"{index:0{width}d}")
        for index in range(1, total + 1)
    ]
def _fill(pattern: str, story: Dict[str, Any], lang: str, nn: str) -> str:
    return (
        pattern.replace("{level}", str(story.get("levelId") or "l1"))
        .replace("{story}", str(story.get("id") or ""))
        .replace("{lang}", lang)
        .replace("{nn}", nn)
    )
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/lesson_audio.py
New file
@@ -0,0 +1,210 @@
"""Platform lesson MP3s live under xiaozhi-server, keyed by org. Device paths have no org."""
from __future__ import annotations
import os
import re
from typing import Any, Dict, Iterable, List, Optional
DEFAULT_ORG_ID = "default_org"
LESSON_AUDIO_DIRNAME = "lesson_audio"
ALLOWED_PREFIX = "english_learning/"
_ORG_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
_REL_RE = re.compile(
    r"^english_learning/(repeater|shadowing)/lessons/[A-Za-z0-9][A-Za-z0-9_./-]*\.mp3$"
)
def resolve_org_id(org_id: Optional[str]) -> str:
    value = str(org_id or "").strip()
    if not value:
        return DEFAULT_ORG_ID
    if not _ORG_RE.fullmatch(value):
        return DEFAULT_ORG_ID
    return value
def normalize_sd_name(name: str) -> str:
    text = str(name or "").replace("\\", "/").strip()
    if not text:
        return ""
    if not text.startswith("/"):
        text = "/" + text
    return text
def unique_lesson_names(items: Iterable[Any]) -> List[str]:
    seen = set()
    names: List[str] = []
    for raw in items or []:
        if isinstance(raw, dict):
            name = normalize_sd_name(str(raw.get("name") or raw.get("path") or raw.get("url") or ""))
        else:
            name = normalize_sd_name(str(raw or ""))
        if not name or name in seen:
            continue
        seen.add(name)
        names.append(name)
    return names
def missing_lesson_names(needed: Iterable[str], present: Iterable[str]) -> List[str]:
    have = {normalize_sd_name(name) for name in present or []}
    have.discard("")
    return [name for name in unique_lesson_names(needed) if name not in have]
def lesson_audio_root(config: Optional[Dict[str, Any]] = None) -> str:
    from config.config_loader import get_project_dir
    data_dir = "data"
    if isinstance(config, dict):
        log_cfg = config.get("log") if isinstance(config.get("log"), dict) else {}
        data_dir = str(log_cfg.get("data_dir") or data_dir)
    root = data_dir if os.path.isabs(data_dir) else os.path.join(get_project_dir(), data_dir)
    return os.path.join(root, LESSON_AUDIO_DIRNAME)
def org_audio_dir(config: Optional[Dict[str, Any]], org_id: Optional[str]) -> str:
    return os.path.join(lesson_audio_root(config), resolve_org_id(org_id))
def sanitize_relative_path(rel: str) -> Optional[str]:
    text = str(rel or "").replace("\\", "/").lstrip("/")
    if not text or ".." in text.split("/"):
        return None
    if not _REL_RE.fullmatch(text):
        return None
    return text
def resolve_disk_file(
    config: Optional[Dict[str, Any]], org_id: Optional[str], rel: str
) -> Optional[str]:
    relative = sanitize_relative_path(rel)
    if not relative:
        return None
    org = resolve_org_id(org_id)
    primary = os.path.normpath(os.path.join(org_audio_dir(config, org), relative))
    root = os.path.normpath(lesson_audio_root(config))
    if not primary.startswith(root + os.sep) and primary != root:
        return None
    if os.path.isfile(primary):
        return primary
    if org != DEFAULT_ORG_ID:
        fallback = os.path.normpath(os.path.join(org_audio_dir(config, DEFAULT_ORG_ID), relative))
        if fallback.startswith(root + os.sep) and os.path.isfile(fallback):
            return fallback
    return None
def lesson_audio_public_root(config: Optional[Dict[str, Any]]) -> str:
    server = (config or {}).get("server") if isinstance(config, dict) else {}
    if not isinstance(server, dict):
        server = {}
    raw = str(server.get("lesson_audio_public_url") or "").strip()
    port = int(server.get("http_port", 8003) or 8003)
    if not raw or "你" in raw:
        raw = f"http://{_local_ip()}:{port}/lesson-audio"
    return raw.rstrip("/")
def _local_ip() -> str:
    import socket
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.connect(("8.8.8.8", 80))
        ip = sock.getsockname()[0]
        sock.close()
        return ip
    except Exception:
        return "127.0.0.1"
def lesson_audio_base_url(config: Optional[Dict[str, Any]], org_id: Optional[str]) -> str:
    return f"{lesson_audio_public_root(config)}/{resolve_org_id(org_id)}"
_CLIP_RE = re.compile(r"^(\d+)\.mp3$", re.I)
def list_lesson_clips(
    config: Optional[Dict[str, Any]],
    org_id: Optional[str],
    kind: str,
    lesson_id: str,
    lang: str,
) -> Optional[List[str]]:
    """SD paths for one lesson folder.
    None means the folder is not on disk yet (caller may use the catalog).
    A list, including empty, means the folder exists and those MP3s are the lesson.
    """
    level, story = _split_lesson_id(lesson_id)
    kind_name = str(kind or "").strip().lower()
    language = str(lang or "").strip().lower()
    if (
        not level
        or not story
        or kind_name not in ("repeater", "shadowing")
        or language not in ("cn", "en")
    ):
        return None
    rel_dir = f"english_learning/{kind_name}/lessons/{level}/{story}/{language}"
    folder = _existing_lesson_dir(config, org_id, rel_dir)
    if folder is None:
        return None
    numbered = []
    try:
        for entry in os.listdir(folder):
            match = _CLIP_RE.match(entry)
            if match:
                numbered.append((int(match.group(1)), entry))
    except OSError:
        return None
    numbered.sort()
    return [f"/{rel_dir}/{name}" for _, name in numbered]
def _split_lesson_id(lesson_id: str) -> tuple:
    from core.app_runtime.idiom_catalog import parse_lesson_id
    return parse_lesson_id(lesson_id)
def _existing_lesson_dir(
    config: Optional[Dict[str, Any]], org_id: Optional[str], rel_dir: str
) -> Optional[str]:
    org = resolve_org_id(org_id)
    primary = os.path.join(org_audio_dir(config, org), *rel_dir.split("/"))
    if os.path.isdir(primary):
        return primary
    if org != DEFAULT_ORG_ID:
        fallback = os.path.join(org_audio_dir(config, DEFAULT_ORG_ID), *rel_dir.split("/"))
        if os.path.isdir(fallback):
            return fallback
    return None
def course_id_for(task_id: str, lesson_id: str) -> str:
    lesson = re.sub(r"[^A-Za-z0-9_-]+", "-", str(lesson_id or "l1.001").strip()) or "l1-001"
    kind = "rpt" if str(task_id or "").strip() == "repeater" else "shd"
    course = f"{lesson}-{kind}"
    return course[:31]
def conn_org_id(conn: Any, params: Optional[Dict[str, Any]] = None) -> str:
    params = params or {}
    raw = params.get("org_id") if params.get("org_id") is not None else params.get("orgId")
    if raw:
        return resolve_org_id(str(raw))
    cfg = getattr(conn, "config", None) if conn is not None else None
    runtime = cfg.get("app_runtime") if isinstance(cfg, dict) else None
    if isinstance(runtime, dict) and runtime.get("lesson_org_id") is not None:
        return resolve_org_id(str(runtime.get("lesson_org_id")))
    return DEFAULT_ORG_ID
def ensure_org_dirs(config: Optional[Dict[str, Any]] = None) -> None:
    os.makedirs(org_audio_dir(config, DEFAULT_ORG_ID), exist_ok=True)
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py
@@ -10,6 +10,15 @@
from config.config_loader import get_project_dir
from config.logger import setup_logging
from core.app_runtime.idiom_catalog import (
    default_en_passes,
    extensive_paths,
    find_story,
    normalize_pass,
    sentence_count,
    shadowing_paths,
)
from core.app_runtime.lesson_audio import list_lesson_clips
TAG = __name__
logger = setup_logging()
@@ -18,32 +27,187 @@
PLAY_SPEED_MAX = 2.0
PLAY_SPEED_DEFAULT = 1.0
# 小程序/定时任务下发清单之前的临时课文。路径相对 SD 卡根(/english_learning/...)。
# 语音口令未带 lesson 时,不再使用测试清单;必须先有家长下发的课文。
TEMP_REPEATER_PLAYLIST = (
    {
        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
        "repeat": 10,
        "speed": PLAY_SPEED_DEFAULT,
        "name": "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
        "repeat": 1,
        "speed": 1.0,
    },
    {
        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
        "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3",
        "repeat": 1,
        "speed": 0.8,
    },
    {
        "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3",
        "repeat": 2,
        "speed": 0.9,
    },
    {
        "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3",
        "repeat": 5,
        "speed": PLAY_SPEED_DEFAULT,
        "speed": 1.0,
    },
)
TEMP_SHADOWING_PLAYLIST = (
TEMP_SHADOWING_PLAYLIST = tuple(
    {
        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
        "repeat": 5,
        "speed": PLAY_SPEED_DEFAULT,
    },
    {
        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
        "repeat": 10,
        "speed": PLAY_SPEED_DEFAULT,
    },
        "name": f"/english_learning/shadowing/lessons/l1/001/en/{index:02d}.mp3",
        "repeat": 1,
        "speed": 0.8,
    }
    for index in range(1, 59)
)
def expand_lesson_playlist(
    task_id: str,
    lesson_id: str,
    repeat: Any = None,
    speed: Any = None,
    language: Any = None,
    en_passes: Any = None,
    cn_repeat: Any = None,
    cn_speed: Any = None,
    sentence_count_override: Any = None,
) -> List[Dict[str, Any]]:
    """lesson_id + optional repeat/speed → compact playlist. Unknown lesson returns []."""
    lesson = str(lesson_id or "").strip()
    tid = str(task_id or "").strip()
    if not lesson:
        return []
    story = find_story(lesson)
    if tid == "repeater":
        items = _expand_repeater(lesson, story, en_passes, cn_repeat, cn_speed)
    elif tid == "shadowing":
        items = _expand_shadowing(lesson, story, speed, language, sentence_count_override)
    else:
        return []
    return _apply_repeat_speed(items, repeat, speed if tid == "repeater" else None)
def _lesson_lang_paths(kind: str, lesson: str, lang: str, catalog_paths: List[str]) -> List[str]:
    """Prefer MP3s that actually exist under the lesson folder. Catalog is only a fallback."""
    listed = list_lesson_clips(None, None, kind, lesson, lang)
    if listed is not None:
        return listed
    return [path for path in catalog_paths if path]
def _expand_repeater(
    lesson: str,
    story: Optional[Dict[str, Any]],
    en_passes: Any,
    cn_repeat: Any,
    cn_speed: Any,
) -> List[Dict[str, Any]]:
    if story is None:
        return []
    catalog_cn, catalog_en = extensive_paths(story, lesson)
    cn_paths = _lesson_lang_paths("repeater", lesson, "cn", [catalog_cn])
    en_paths = _lesson_lang_paths("repeater", lesson, "en", [catalog_en])
    if not cn_paths or not en_paths:
        return []
    try:
        cn_times = max(1, int(cn_repeat)) if cn_repeat is not None else 1
    except (TypeError, ValueError):
        cn_times = 1
    cn_rate = clamp_play_speed(cn_speed) if cn_speed is not None else 1.0
    items: List[Dict[str, Any]] = [
        {"name": path, "repeat": min(10, cn_times), "speed": cn_rate}
        for path in cn_paths
    ]
    passes = []
    if isinstance(en_passes, list):
        for raw in en_passes:
            row = normalize_pass(raw)
            if row:
                passes.append(row)
    if not passes:
        passes = default_en_passes()
    for row in passes[:8]:
        for path in en_paths:
            items.append(
                {"name": path, "repeat": row["repeat"], "speed": clamp_play_speed(row["speed"])}
            )
    return items
def _expand_shadowing(
    lesson: str,
    story: Optional[Dict[str, Any]],
    speed: Any,
    language: Any,
    sentence_count_override: Any,
) -> List[Dict[str, Any]]:
    if story is None:
        return []
    lang = str(language or "en").strip().lower()
    if lang not in ("cn", "en"):
        lang = "en"
    listed = list_lesson_clips(None, None, "shadowing", lesson, lang)
    if listed is not None:
        paths = listed
    else:
        count = None
        if sentence_count_override is not None:
            try:
                count = int(sentence_count_override)
            except (TypeError, ValueError):
                count = None
        if count is None:
            count = sentence_count(story)
        paths = shadowing_paths(story, lang, count, lesson)
    if not paths:
        return []
    rate = clamp_play_speed(speed) if speed is not None else 0.8
    return [
        {"name": name, "repeat": 1, "speed": rate}
        for name in paths
    ]
def _apply_repeat_speed(items: List[Dict[str, Any]], repeat: Any, speed: Any) -> List[Dict[str, Any]]:
    if repeat is not None:
        try:
            count = max(1, int(repeat))
        except (TypeError, ValueError):
            count = 1
        for item in items:
            item["repeat"] = count
    if speed is not None:
        value = clamp_play_speed(speed)
        for item in items:
            item["speed"] = value
    return items
def playlist_from_params(
    task: Optional[Dict[str, Any]],
    params: Optional[Dict[str, Any]],
    task_id: str,
    default_factory=None,
) -> List[Dict[str, Any]]:
    """Explicit items win; lesson_id expands catalog. No last lesson → empty (not the test playlist)."""
    params = params or {}
    explicit = parse_playlist(params)
    if explicit:
        return explicit
    lesson_id = str(params.get("lesson_id") or params.get("lesson") or "").strip()
    if lesson_id:
        return expand_lesson_playlist(
            task_id,
            lesson_id,
            params.get("repeat"),
            params.get("speed"),
            language=params.get("language") or params.get("lang"),
            en_passes=params.get("en_passes"),
            cn_repeat=params.get("cn_repeat"),
            cn_speed=params.get("cn_speed"),
            sentence_count_override=params.get("sentence_count"),
        )
    return []
def clamp_play_speed(raw: Any) -> float:
@@ -91,6 +255,82 @@
            json.dump(data, handle, ensure_ascii=False, indent=2)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"保存播放进度失败: {exc}")
_LAST_LESSON_KEYS = (
    "lesson_id",
    "lesson",
    "en_passes",
    "cn_repeat",
    "cn_speed",
    "language",
    "lang",
    "speed",
    "on_complete",
    "listen_ratio",
    "sentence_count",
    "org_id",
    "orgId",
)
def _last_lesson_path(device_id: str) -> str:
    safe = re.sub(r"[^A-Za-z0-9_-]+", "_", device_id or "unknown") or "unknown"
    return os.path.join(get_project_dir(), "data", "last_lesson", f"{safe}.json")
def _compact_last_lesson(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    src = params or {}
    out: Dict[str, Any] = {}
    for key in _LAST_LESSON_KEYS:
        if key in src and src[key] not in (None, ""):
            out[key] = src[key]
    lesson = str(out.get("lesson_id") or out.get("lesson") or "").strip()
    if lesson:
        out["lesson_id"] = lesson
    return out
def remember_last_lesson_params(device_id: str, task_id: str, params: Optional[Dict[str, Any]]) -> None:
    kind = str(task_id or "").strip()
    if kind not in ("repeater", "shadowing"):
        return
    compact = _compact_last_lesson(params)
    if not str(compact.get("lesson_id") or "").strip():
        return
    path = _last_lesson_path(device_id)
    data: Dict[str, Any] = {}
    if os.path.isfile(path):
        try:
            with open(path, "r", encoding="utf-8") as handle:
                loaded = json.load(handle)
            if isinstance(loaded, dict):
                data = loaded
        except Exception:
            data = {}
    data[kind] = compact
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w", encoding="utf-8") as handle:
            json.dump(data, handle, ensure_ascii=False, indent=2)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"保存上次课文失败: {exc}")
def load_last_lesson_params(device_id: str, task_id: str) -> Dict[str, Any]:
    kind = str(task_id or "").strip()
    path = _last_lesson_path(device_id)
    if kind not in ("repeater", "shadowing") or not os.path.isfile(path):
        return {}
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
    except Exception:
        return {}
    row = data.get(kind) if isinstance(data, dict) else None
    if not isinstance(row, dict):
        return {}
    return _compact_last_lesson(row)
def _lesson_key(app_id: str, task_id: str) -> str:
@@ -255,6 +495,9 @@
    return item
PLAYLIST_ITEM_MAX = 256
def parse_playlist(params: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Repeater/shadowing playlist. Each item is {name, repeat, optional speed}."""
    params = params or {}
@@ -265,6 +508,11 @@
            item = playlist_item_from_value(raw)
            if item:
                items.append(item)
        if len(items) > PLAYLIST_ITEM_MAX:
            logger.bind(tag=TAG).warning(
                f"playlist truncated incoming={len(items)} max={PLAYLIST_ITEM_MAX}"
            )
            items = items[:PLAYLIST_ITEM_MAX]
        return items
    item = playlist_item_from_value(
        {
@@ -293,20 +541,20 @@
def default_repeater_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
    """No downlink yet: task.default_playlist, else the unit01 01×10 / 02×5 stub."""
    """Only used when a caller still has an explicit default_playlist on the task."""
    raw = (task or {}).get("default_playlist")
    if isinstance(raw, list) and raw:
        items = parse_playlist({"items": raw})
        if items:
            return items
    return [dict(item) for item in TEMP_REPEATER_PLAYLIST]
    return []
def default_shadowing_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
    """No downlink yet: task.default_playlist, else the unit01 01×5 / 02×10 stub."""
    """Only used when a caller still has an explicit default_playlist on the task."""
    raw = (task or {}).get("default_playlist")
    if isinstance(raw, list) and raw:
        items = parse_playlist({"items": raw})
        if items:
            return items
    return [dict(item) for item in TEMP_SHADOWING_PLAYLIST]
    return []
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py
@@ -18,6 +18,8 @@
_REGISTRY: Optional[Dict[str, Any]] = None
_VOICE_INDEX: Optional[list] = None
_REGISTRY_PATH: Optional[str] = None
_REGISTRY_MTIME: Optional[float] = None
_BUNDLED_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "apps.json")
_MENU_PRIO = 0
@@ -27,22 +29,37 @@
_RESERVED_PRIO = 4
def _load_registry() -> Dict[str, Any]:
    candidates = [
def _registry_candidates() -> list:
    return [
        os.path.join(get_project_dir(), "data", "apps.json"),
        _BUNDLED_PATH,
    ]
    for path in candidates:
def _file_mtime(path: str) -> Optional[float]:
    try:
        return os.path.getmtime(path)
    except OSError:
        return None
def _load_registry() -> Dict[str, Any]:
    global _REGISTRY_PATH, _REGISTRY_MTIME
    for path in _registry_candidates():
        if not os.path.isfile(path):
            continue
        try:
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            if isinstance(data, dict) and data.get("apps"):
                _REGISTRY_PATH = path
                _REGISTRY_MTIME = _file_mtime(path)
                logger.bind(tag=TAG).info(f"已加载 App 注册表: {path}")
                return data
        except Exception as e:
            logger.bind(tag=TAG).error(f"加载 App 注册表失败 {path}: {e}")
    _REGISTRY_PATH = None
    _REGISTRY_MTIME = None
    logger.bind(tag=TAG).warning("未找到 apps.json,使用内置最小注册表")
    return {
        "default_app_id": "chat",
@@ -60,9 +77,16 @@
def get_registry() -> Dict[str, Any]:
    global _REGISTRY
    global _REGISTRY, _VOICE_INDEX
    if _REGISTRY is None:
        _REGISTRY = _load_registry()
        return _REGISTRY
    if _REGISTRY_PATH:
        top = next((item for item in _registry_candidates() if os.path.isfile(item)), None)
        mtime = _file_mtime(_REGISTRY_PATH)
        if top != _REGISTRY_PATH or (mtime is not None and mtime != _REGISTRY_MTIME):
            _REGISTRY = _load_registry()
            _VOICE_INDEX = None
    return _REGISTRY
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
@@ -30,8 +30,10 @@
    default_shadowing_playlist,
    expand_playlist_plays,
    lesson_day_index,
    load_last_lesson_params,
    mark_lesson_played_today,
    parse_playlist,
    playlist_from_params,
    remember_last_lesson_params,
    resolve_play_speed,
    speeds_for_repeats,
)
@@ -43,7 +45,7 @@
    match_registry_voice,
    voice_menu_text,
)
from .session import begin_session, current_session, end_session, parse_skill_event
from .session import begin_session, current_session, end_session, parse_skill_event, schedule_close_parent_except
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
@@ -70,6 +72,26 @@
    "用户说退出中英对话、回到聊天时调用 switch_app,app_id=chat。"
)
BILINGUAL_PAIR_PROMPTS = {
    "zh-en": BILINGUAL_PROMPT,
    "zh-fr": (
        "你正在进行中法对照对话练习,不是翻译机,不要把用户的话原样译回去交差。"
        "用户说中文时你用法语完整回答;用户说法语时你用中文完整回答。"
        "不要两种语言同时输出。用户说退出对话、回到聊天时调用 switch_app,app_id=chat。"
    ),
    "zh-ja": (
        "你正在进行中日对照对话练习,不是翻译机,不要把用户的话原样译回去交差。"
        "用户说中文时你用日语完整回答;用户说日语时你用中文完整回答。"
        "不要两种语言同时输出。用户说退出对话、回到聊天时调用 switch_app,app_id=chat。"
    ),
}
TRANSLATOR_PAIR_PROMPTS = {
    "zh-en": TRANSLATOR_PROMPT,
    "zh-fr": "你是一台翻译机。只输出译文。中文译成法语,法语译成中文;其它语言默认译成中文。",
    "zh-ja": "你是一台翻译机。只输出译文。中文译成日语,日语译成中文;其它语言默认译成中文。",
}
CHAT_PROACTIVE_HINT = (
    "你是用户的语音伙伴。进入对话后可以先主动短句邀请聊天,例如「我们来聊天吧」。"
    "用户开口后按正常对话回答,不要反复催促。"
@@ -92,8 +114,13 @@
SD_LAYOUT_MODES = ("PLAY_AUDIO", "SHADOWING")
SHADOWING_LISTEN_RATIO = 1.2
SHADOWING_LISTEN_MIN_MS = 800
REPEATER_END_PHRASE = "复读结束"
REPEATER_END_PHRASE = "泛听结束"
SHADOWING_END_PHRASE = "跟读结束"
REPEATER_START_PHRASE = "开始泛听任务"
SHADOWING_START_PHRASE = "开始跟读任务"
CHAT_START_PHRASE = "开始对话任务"
TRANSLATION_START_PHRASE = "开始翻译任务"
BILINGUAL_START_PHRASE = "开始中英对话任务"
def source_priority(source: str) -> int:
@@ -282,17 +309,30 @@
        handler.upload_functions_desc()
def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any]) -> None:
def _normalize_language_pair(raw: Any) -> str:
    pair = str(raw or "zh-en").strip().lower().replace("_", "-")
    if pair in ("cn-en", "zh-cn-en", "zhcn-en"):
        return "zh-en"
    return pair or "zh-en"
def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> None:
    mode = app.get("system_prompt_mode") or "none"
    if conn.original_system_prompt is None:
        conn.original_system_prompt = conn.prompt
    pair = _normalize_language_pair((params or {}).get("language_pair"))
    conn.language_pair = pair
    conn.language_pair_fallback = pair not in BILINGUAL_PAIR_PROMPTS and pair not in TRANSLATOR_PAIR_PROMPTS
    prompt_pair = "zh-en" if conn.language_pair_fallback else pair
    if mode == "translator":
        pair_prompt = TRANSLATOR_PAIR_PROMPTS.get(prompt_pair, TRANSLATOR_PROMPT)
        conn.change_system_prompt(
            "你现在是英语翻译机。用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。"
            pair_prompt
            + "用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。"
            "不要讲笑话、不要查天气、不要闲聊。"
        )
    elif mode == "bilingual":
        conn.change_system_prompt(BILINGUAL_PROMPT)
        conn.change_system_prompt(BILINGUAL_PAIR_PROMPTS.get(prompt_pair, BILINGUAL_PROMPT))
    elif mode == "agent_proactive":
        base = conn.original_system_prompt or conn.prompt or ""
        if CHAT_PROACTIVE_HINT not in (base or ""):
@@ -378,6 +418,28 @@
        speak_txt(conn, text)
    except Exception as e:
        logger.bind(tag=TAG).warning(f"主动播报失败: {e}")
async def _play_bind_prompt(conn: "ConnectionHandler") -> None:
    from core.handle.receiveAudioHandle import check_bind_device
    await check_bind_device(conn)
async def prompt_bind_if_needed(conn: "ConnectionHandler") -> bool:
    """未绑定设备时播 6 位绑定码,并阻止欢迎词/进场词。已绑定则返回 False。"""
    event = getattr(conn, "bind_completed_event", None)
    if event is not None and not event.is_set():
        try:
            await asyncio.wait_for(event.wait(), timeout=8)
        except asyncio.TimeoutError:
            pass
    if not getattr(conn, "need_bind", False):
        return False
    if not await wait_for_tts_channels(conn, timeout=8):
        logger.bind(tag=TAG).warning("TTS通道未就绪,仍尝试播放绑定码")
    await _play_bind_prompt(conn)
    return True
def _greeting_text(conn: "ConnectionHandler") -> str:
@@ -575,7 +637,15 @@
            step_index,
            wav_bytes,
            conn=conn,
            text=asr,
        )
    elif audio_id and asr:
        try:
            from core.audio_archive import write_sidecar_for_relative
            write_sidecar_for_relative(conn, audio_id, asr)
        except Exception:
            pass
    try:
        from core.handle.reportHandle import enqueue_asr_report
@@ -829,6 +899,112 @@
    return _parse_mp3_list_names(result)
async def _list_sd_mp3_names_safe(conn: "ConnectionHandler"):
    try:
        names = await _list_sd_mp3_names(conn)
        return names if isinstance(names, list) else []
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"列举 SD 课文失败: {exc}")
        return None
def _play_lesson_names(play_params: Optional[Dict[str, Any]]) -> list:
    from .lesson_audio import unique_lesson_names
    if not isinstance(play_params, dict):
        return []
    items = play_params.get("items")
    if isinstance(items, list) and items:
        return unique_lesson_names(items)
    return unique_lesson_names([play_params])
def _lesson_sync_timeout_sec(conn: "ConnectionHandler") -> float:
    runtime = {}
    cfg = getattr(conn, "config", None)
    if isinstance(cfg, dict) and isinstance(cfg.get("app_runtime"), dict):
        runtime = cfg.get("app_runtime") or {}
    raw = runtime.get("lesson_sync_timeout_sec", 90)
    try:
        return max(0.0, float(raw))
    except (TypeError, ValueError):
        return 90.0
async def _request_lesson_sync(
    conn: "ConnectionHandler",
    missing: list,
    task_id: str,
    params: Optional[Dict[str, Any]],
) -> None:
    from config.manage_api_client import sync_lesson_audio
    from .lesson_audio import conn_org_id, course_id_for, lesson_audio_base_url, unique_lesson_names
    files = unique_lesson_names(missing)
    if not files:
        return
    params = params or {}
    lesson_id = str(params.get("lesson_id") or params.get("lesson") or "l1.001").strip() or "l1.001"
    org_id = conn_org_id(conn, params)
    payload = {
        "macAddress": getattr(conn, "device_id", None) or "",
        "courseId": course_id_for(task_id, lesson_id),
        "orgId": org_id,
        "baseUrl": lesson_audio_base_url(getattr(conn, "config", None), org_id),
        "files": files,
    }
    result = await sync_lesson_audio(payload)
    if result is None:
        logger.bind(tag=TAG).warning(f"课文 MQTT 同步失败 files={len(files)}")
    else:
        logger.bind(tag=TAG).info(f"课文 MQTT 同步已下发 org={org_id} files={len(files)}")
async def _wait_lesson_files(conn: "ConnectionHandler", needed: list, timeout_sec: float) -> bool:
    from .lesson_audio import missing_lesson_names
    if not needed:
        return False
    deadline = asyncio.get_running_loop().time() + max(0.0, timeout_sec)
    while True:
        present = await _list_sd_mp3_names_safe(conn)
        if present is not None and not missing_lesson_names(needed, present):
            return True
        remaining = deadline - asyncio.get_running_loop().time()
        if remaining <= 0:
            return False
        await asyncio.sleep(min(2.0, remaining))
async def _ensure_lesson_mp3s(
    conn: "ConnectionHandler",
    names: list,
    task_id: str,
    params: Optional[Dict[str, Any]],
) -> bool:
    """READY if SD already has the package. Otherwise PRELOAD via MQTT then wait. Device paths have no org."""
    from .lesson_audio import missing_lesson_names, unique_lesson_names
    needed = unique_lesson_names(names)
    if not needed:
        return False
    present = await _list_sd_mp3_names_safe(conn)
    if present is None:
        logger.bind(tag=TAG).warning("无法列举 SD 课文,跳过同步以免误报")
        return True
    missing = missing_lesson_names(needed, present)
    if not missing:
        return True
    params = params or {}
    conn.lesson_syncing = True
    _speak(conn, "正在同步音频,请稍等。")
    await _request_lesson_sync(conn, missing, task_id, params)
    timeout = _lesson_sync_timeout_sec(conn)
    ready = await _wait_lesson_files(conn, needed, timeout)
    conn.lesson_syncing = False
    return ready
def _is_full_lesson_mp3(name: str) -> bool:
    base = str(name or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
    return base == "full.mp3" or base.endswith("_full.mp3")
@@ -839,9 +1015,7 @@
    params: Optional[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    """Keep {name, repeat, speed}. Device expands repeats locally with a listen window each pass."""
    items = parse_playlist(params)
    if not items:
        items = default_shadowing_playlist(_task_for(conn))
    items = playlist_from_params(_task_for(conn), params, "shadowing", default_shadowing_playlist)
    compact: List[Dict[str, Any]] = []
    for raw in items:
        if not isinstance(raw, dict):
@@ -919,13 +1093,17 @@
    items = _compact_shadowing_playlist(conn, params)
    clips = expand_playlist_plays(items)
    if not clips:
        names = await _list_sd_mp3_names(conn)
        lesson = str(params.get("lesson") or "").strip()
        lesson = str(params.get("lesson_id") or params.get("lesson") or "").strip()
        single = str(params.get("name") or params.get("path") or params.get("url") or "")
        if not lesson and single and not single.lower().endswith(".mp3"):
            lesson = single
        if lesson.lower().endswith(".mp3"):
            lesson = ""
        if not lesson:
            conn.shadowing_clips = []
            conn.shadowing_prompt = ""
            return False
        names = await _list_sd_mp3_names(conn)
        clips = lesson_mp3_clips(
            names,
            getattr(conn, "active_app_id", "english_learning"),
@@ -948,6 +1126,9 @@
    if not await _prepare_shadowing_sd(conn, params):
        return False
    play = _begin_shadowing_playlist(conn, params)
    names = _play_lesson_names(play)
    if not await _ensure_lesson_mp3s(conn, names, getattr(conn, "current_task_id", "shadowing") or "shadowing", params):
        return False
    conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
    notice = await _execute_named_tool_play(conn, play)
    if notice and notice.action in (Action.ERROR, Action.NOTFOUND):
@@ -972,20 +1153,6 @@
        if play.get("speed") is None and first.get("speed") is not None:
            play["speed"] = first.get("speed")
        conn.repeater_play_name = play["name"]
        return play
    try:
        names = await _list_sd_mp3_names(conn)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"列举复读课文失败: {exc}")
        return play
    picked = repeater_lesson_mp3(
        names,
        getattr(conn, "active_app_id", "") or "english_learning",
        getattr(conn, "current_task_id", "") or "repeater",
    )
    if picked:
        play["name"] = picked
        conn.repeater_play_name = picked
    return play
@@ -1010,6 +1177,32 @@
            await _execute_named_tool_play(conn, play_params)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"延后播放课文失败: {exc}")
def _lesson_start_phrase(task_id: str) -> str:
    if task_id == "repeater":
        return REPEATER_START_PHRASE
    if task_id == "shadowing":
        return SHADOWING_START_PHRASE
    return ""
def _schedule_talk_start_phrase(task_id: str) -> str:
    if task_id == "conversation":
        return CHAT_START_PHRASE
    if task_id == "live_translate":
        return TRANSLATION_START_PHRASE
    if task_id == "bilingual_chat":
        return BILINGUAL_START_PHRASE
    return ""
def _speak_lesson_start(conn: "ConnectionHandler", task_id: str) -> None:
    phrase = _lesson_start_phrase(task_id)
    if not phrase:
        return
    conn.sentence_id = uuid.uuid4().hex
    _speak(conn, phrase)
def _lesson_end_speech(conn: "ConnectionHandler", phrase: str) -> str:
@@ -1065,7 +1258,7 @@
            getattr(conn, "repeater_play_name", "") or "",
        )
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"记录复读天数失败: {exc}")
        logger.bind(tag=TAG).warning(f"记录泛听天数失败: {exc}")
def _apply_playback_speed(
@@ -1102,7 +1295,7 @@
        conn.repeater_speed_queue = []
        play["speed"] = speeds[0]
        play["repeat"] = repeat_n
    # 复读清单结束由 Server 收 audio.completed 后切回对话。设备不要 finish_task 写 NVS。
    # 泛听清单结束由 Server 收 audio.completed 后切回对话。设备不要 finish_task 写 NVS。
    play["finish_task"] = False
    return play
@@ -1113,9 +1306,7 @@
    params: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
    """One shot: send the whole playlist. Repeat is just the same clip again, no mode switch."""
    items = parse_playlist(params)
    if not items:
        items = default_repeater_playlist(task)
    items = playlist_from_params(task, params, "repeater", default_repeater_playlist)
    normalized = []
    for raw in items:
        if not isinstance(raw, dict):
@@ -1170,7 +1361,7 @@
            )
            await _execute_named_tool_play(conn, play_params)
        except Exception as exc:
            logger.bind(tag=TAG).warning(f"复读下一遍失败: {exc}")
            logger.bind(tag=TAG).warning(f"泛听下一遍失败: {exc}")
            conn.repeater_speed_queue = []
            return False
        return True
@@ -1185,7 +1376,7 @@
        play_params = await _fill_repeater_play_name(conn, play_params)
        await _execute_named_tool_play(conn, play_params)
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"复读下一首失败: {exc}")
        logger.bind(tag=TAG).warning(f"泛听下一首失败: {exc}")
        conn.repeater_playlist = []
        return False
    return True
@@ -1246,13 +1437,45 @@
        if not play_args["name"]:
            return ActionResponse(
                action=Action.RESPONSE,
                response="复读机已打开,但 SD 卡上还没有可播放的课文。",
                response="泛听已打开,但 SD 卡上还没有可播放的课文。",
            )
        return ActionResponse(
            action=Action.RESPONSE,
            response=(result.response if result else None) or "这台设备没有本地复读播放能力",
            response=(result.response if result else None) or "这台设备没有本地泛听播放能力",
        )
    return None
def _apply_last_lesson_params(
    conn: "ConnectionHandler",
    task_id: str,
    params: Optional[Dict[str, Any]],
    source: str,
) -> Dict[str, Any]:
    params = dict(params or {})
    if task_id not in ("repeater", "shadowing"):
        return params
    device_id = str(getattr(conn, "device_id", "") or "")
    has_lesson = str(params.get("lesson_id") or params.get("lesson") or "").strip()
    if has_lesson:
        remember_last_lesson_params(device_id, task_id, params)
        return params
    saved = load_last_lesson_params(device_id, task_id)
    if saved:
        merged = dict(saved)
        for key, value in params.items():
            if value not in (None, "", []):
                merged[key] = value
        return merged
    return params
def _lesson_missing_speech(task_id: str, params: Optional[Dict[str, Any]], fallback: str) -> str:
    kind = "泛听" if task_id == "repeater" else "跟读"
    lesson = str((params or {}).get("lesson_id") or (params or {}).get("lesson") or "").strip()
    if not lesson:
        return f"还没有下发过{kind}课文,请先在家长端选一课。"
    return fallback
async def start_app_task(
@@ -1267,7 +1490,15 @@
) -> ActionResponse:
    """Single entry for MQTT / schedule / voice / default chat."""
    _ensure_runtime_fields(conn)
    if await prompt_bind_if_needed(conn):
        return ActionResponse(action=Action.NONE, response="设备未绑定")
    conn.client_abort = False
    tts = getattr(conn, "tts", None)
    if tts is not None:
        tts.tts_stop_request = False
    params = params or {}
    if params.get("announce") is False:
        speak_switch = False
    app, task = get_task(app_id, task_id)
    if not app:
        return ActionResponse(action=Action.ERROR, response=f"未知的 App: {app_id}")
@@ -1278,13 +1509,18 @@
        return ActionResponse(action=Action.ERROR, response=f"未知的 Task: {app_id}/{task_id}")
    resolved_task_id = task_id or app.get("default_task") or "conversation"
    params = _apply_last_lesson_params(conn, resolved_task_id, params, source)
    new_p = source_priority(source)
    cur_p = source_priority(getattr(conn, "current_source", "default_chat"))
    same_task = (
        getattr(conn, "active_app_id", "") == app_id
        and getattr(conn, "current_task_id", "") == resolved_task_id
    )
    if new_p < cur_p and not same_task:
    # 口令回对话要能退出家长即时/计划任务。其它低优先级切换仍然拦住。
    voice_exit_to_chat = (
        source == "voice" and app_id == "chat" and resolved_task_id == "conversation"
    )
    if new_p < cur_p and not same_task and not voice_exit_to_chat:
        logger.bind(tag=TAG).info(
            f"拒绝低优先级任务 {source}({new_p}) < {conn.current_source}({cur_p})"
        )
@@ -1292,6 +1528,9 @@
            action=Action.NONE,
            response="当前有更高优先级的任务,先完成后再切换",
        )
    if source in ("voice", "mqtt_immediate", "schedule"):
        schedule_close_parent_except(conn, source, app_id, resolved_task_id)
    prev_app_id = getattr(conn, "active_app_id", "chat")
    prev_task_id = getattr(conn, "current_task_id", "conversation")
@@ -1310,29 +1549,56 @@
    conn.current_source = source
    conn.tool_policy = app.get("tool_policy") or "inherit_agent"
    conn.tool_allowlist = list(app.get("tools") or [])
    _apply_prompt(conn, app)
    _apply_prompt(conn, app, params)
    _refresh_tools(conn)
    logger.bind(tag=TAG).info(
        f"start_app_task app={app_id} task={resolved_task_id} mode={conn.interaction_mode} source={source}"
    )
    device_ready = bool(params.get("sd_ready"))
    if device_ready:
        speak_switch = False
        logger.bind(tag=TAG).info("课文已在 SD,先播开始语,再由设备本地开播")
        _speak_lesson_start(conn, resolved_task_id)
    if app_id == "chat":
        end_session(conn, "user_exit")
        # 家长下发的对话要记一条执行记录;开机欢迎词 / 口令回对话不要另开 session。
        if source in ("mqtt_immediate", "schedule"):
            begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
    elif switching:
        begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
    pending_play = None
    if execute_skills:
    lesson_missing = False
    if execute_skills and not device_ready:
        if app_sd_layout_paths(app_id, app):
            await ensure_app_sd_dirs(conn, app_id)
        for skill in task.get("skills") or []:
            skill_name = (skill or {}).get("name") or ""
            if skill_name in ("self.mp3.play", DEVICE_PLAY_TOOL):
                play_params = _begin_repeater_playlist(conn, task, params)
                pending_play = {"kind": "repeater", "params": play_params}
                items = play_params.get("items") if isinstance(play_params, dict) else None
                if not items and not (isinstance(play_params, dict) and play_params.get("name")):
                    lesson_missing = True
                else:
                    names = _play_lesson_names(play_params)
                    if not await _ensure_lesson_mp3s(conn, names, resolved_task_id, params):
                        lesson_missing = True
                    else:
                        pending_play = {"kind": "repeater", "params": play_params}
            elif skill_name == "self.power_off":
                await _execute_named_tool(conn, "self_power_off", {})
    if source == "schedule":
        talk_phrase = _schedule_talk_start_phrase(resolved_task_id)
        if talk_phrase:
            conn.sentence_id = uuid.uuid4().hex
            _speak(conn, talk_phrase)
            speak_switch = False
            if resolved_task_id == "conversation":
                conn.chat_greeted = True
    announce = ""
    boot_chat = app_id == "chat" and source == "default_chat"
@@ -1348,17 +1614,48 @@
                _speak(conn, _greeting_text(conn))
    elif switching:
        announce = _switch_announcement(prev_app, prev_task, app, task, app_id)
        if conn.interaction_mode == "SHADOWING":
            started = await _prepare_shadowing_sd(conn, params)
            if not started:
        if lesson_missing:
            prefix = announce or "现在已经切换至泛听模式。"
            announce = _lesson_missing_speech(
                resolved_task_id,
                params,
                f"{prefix}这课还没有放到SD卡。",
            )
        if getattr(conn, "language_pair_fallback", False) and conn.interaction_mode in (
            "BILINGUAL",
            "TRANSLATION",
        ):
            announce = f"{announce or ''}这套语言先用中英来练。"
        if conn.interaction_mode == "SHADOWING" and not device_ready:
            play_params = _begin_shadowing_playlist(conn, params)
            names = _play_lesson_names(play_params)
            if not names:
                if await _prepare_shadowing_sd(conn, params):
                    play_params = _begin_shadowing_playlist(conn, params)
                    names = _play_lesson_names(play_params)
            if not names:
                prefix = announce or "现在已经切换至跟读模式。"
                announce = f"{prefix}跟读课文还没有放到SD卡。"
                announce = _lesson_missing_speech(
                    resolved_task_id,
                    params,
                    f"{prefix}跟读课文还没有放到SD卡。",
                )
            elif not await _ensure_lesson_mp3s(conn, names, resolved_task_id, params):
                prefix = announce or "现在已经切换至跟读模式。"
                announce = _lesson_missing_speech(
                    resolved_task_id,
                    params,
                    f"{prefix}跟读课文还没有放到SD卡。",
                )
            else:
                announce = announce or "现在已经切换至跟读模式,请跟着课文读。"
                announce = _lesson_start_phrase(resolved_task_id) or announce or "开始跟读任务"
                pending_play = {
                    "kind": "shadowing",
                    "params": _begin_shadowing_playlist(conn, params),
                    "params": play_params,
                }
        start_phrase = _lesson_start_phrase(resolved_task_id)
        if start_phrase and pending_play and not lesson_missing:
            announce = start_phrase
        if speak_switch and announce:
            conn.sentence_id = uuid.uuid4().hex
            conn.pending_play_after_tts = pending_play
@@ -1375,11 +1672,11 @@
            conn.chat_greeted = True
            conn.sentence_id = uuid.uuid4().hex
            _speak(conn, _greeting_text(conn))
    elif conn.interaction_mode == "SHADOWING" and not getattr(conn, "shadowing_clips", None):
    elif conn.interaction_mode == "SHADOWING" and not device_ready and not getattr(conn, "shadowing_clips", None):
        started = await _start_shadowing_sd(conn, params)
        if not started and speak_switch:
            conn.sentence_id = uuid.uuid4().hex
            _speak(conn, "跟读课文还没有放到SD卡。")
            _speak(conn, _lesson_missing_speech(resolved_task_id, params, "跟读课文还没有放到SD卡。"))
    elif pending_play:
        conn.pending_play_after_tts = pending_play
        await maybe_flush_pending_play_after_tts(conn)
@@ -1634,7 +1931,10 @@
        )
        return True
    if mode == "PLAY_AUDIO" and name == "audio.completed":
    repeater_done = name == "audio.completed" and (
        mode == "PLAY_AUDIO" or task_id == "repeater"
    )
    if repeater_done:
        continued = await _continue_repeater_if_needed(conn)
        session = getattr(conn, "task_session", None)
        if session:
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py
@@ -240,6 +240,7 @@
    logger.bind(tag=TAG).info(
        f"TaskSession 结束 {session.task_session_id} status={session.status} reason={reason}"
    )
    _schedule_close_parent_matching(conn, session, reason)
def current_session(conn: "ConnectionHandler") -> Optional[TaskSession]:
@@ -249,6 +250,59 @@
    return None
def schedule_close_parent_except(
    conn: "ConnectionHandler",
    reason: str,
    except_app: str = "",
    except_task: str = "",
) -> None:
    device_id = str(getattr(conn, "device_id", "") or "")
    if not device_id:
        return
    payload: Dict[str, Any] = {
        "device_id": device_id,
        "reason": reason or "server",
        "except_app_id": except_app or "",
        "except_task_id": except_task or "",
    }
    _schedule_close_parent(payload)
def _schedule_close_parent_matching(
    conn: "ConnectionHandler",
    session: TaskSession,
    reason: str,
) -> None:
    device_id = str(getattr(conn, "device_id", "") or getattr(session, "device_id", "") or "")
    if not device_id:
        return
    status = "done" if getattr(session, "status", "") == "completed" else "cancelled"
    payload: Dict[str, Any] = {
        "device_id": device_id,
        "reason": reason or "",
        "status": status,
        "match_app_id": session.app_id or "",
        "match_task_id": session.task_id or "",
    }
    _schedule_close_parent(payload)
def _schedule_close_parent(payload: Dict[str, Any]) -> None:
    async def _run():
        try:
            from config.manage_api_client import close_parent_immediate_tasks
            await close_parent_immediate_tasks(payload)
        except Exception as exc:
            logger.bind(tag=TAG).warning(f"关闭家长即时任务失败: {exc}")
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        return
    loop.create_task(_run())
async def _upsert_task_instance(payload: Dict[str, Any]) -> bool:
    from config.manage_api_client import upsert_task_instance
xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import os
import time
from typing import Any, Optional
@@ -59,14 +60,63 @@
    return os.path.join(get_project_dir(), rel)
def plain_transcript(text: Any) -> str:
    """ASR/TTS 归档文本。FunASR 可能是 {content, speaker, ...} JSON。"""
    if text is None:
        return ""
    raw = str(text)
    stripped = raw.strip()
    if stripped.startswith("{") and stripped.endswith("}"):
        try:
            data = json.loads(stripped)
            if isinstance(data, dict) and "content" in data:
                return str(data.get("content") or "")
        except Exception:
            pass
    return raw
def write_sidecar_text(wav_path: str, text: Optional[str]) -> Optional[str]:
    """Write UTF-8 .txt next to a wav. Same stem, suffix .txt. None skips."""
    if text is None or not wav_path:
        return None
    sidecar = os.path.splitext(wav_path)[0] + ".txt"
    try:
        body = plain_transcript(text)
        with open(sidecar, "w", encoding="utf-8") as handle:
            handle.write(body)
            if body and not body.endswith("\n"):
                handle.write("\n")
        return sidecar
    except Exception as exc:
        logger.bind(tag=TAG).warning(f"文本归档失败: {exc}")
        return None
def write_sidecar_for_relative(conn: Any, relative: str, text: Optional[str]) -> Optional[str]:
    """Update the .txt next to an already archived wav (relative audio_id)."""
    if not relative or text is None:
        return None
    rel = str(relative).replace("\\", "/").lstrip("/")
    if "/" in rel:
        path = os.path.join(archive_root(conn), *rel.split("/"))
    else:
        path = os.path.join(get_project_dir(), "data", "task_sessions", "audio", rel)
    return write_sidecar_text(path, text)
def archive_wav(
    conn: Any,
    wav_bytes: bytes,
    *,
    kind: str = "asr",
    extra: str = "",
    text: Optional[str] = None,
) -> Optional[str]:
    """Write WAV under recordings/{device}/{mode}/{date}/{time}_{kind}_{extra}.wav."""
    """Write WAV under recordings/{device}/{mode}/{date}/{time}_{kind}_{extra}.wav.
    If text is not None, also write the same stem with .txt for later analysis.
    """
    if not wav_bytes or not archive_enabled(conn):
        return None
    try:
@@ -86,6 +136,8 @@
        path = os.path.join(directory, filename)
        with open(path, "wb") as handle:
            handle.write(wav_bytes)
        if text is not None:
            write_sidecar_text(path, text)
        relative = os.path.join(device, mode, day, filename).replace("\\", "/")
        logger.bind(tag=TAG).info(f"音频归档 {relative} bytes={len(wav_bytes)}")
        return relative
@@ -94,10 +146,16 @@
        return None
def archive_pcm_snapshot(conn: Any, snapshot: Any, *, extra: str = "") -> Optional[str]:
def archive_pcm_snapshot(
    conn: Any,
    snapshot: Any,
    *,
    extra: str = "",
    text: Optional[str] = None,
) -> Optional[str]:
    from core.app_runtime.assessment import pcm_snapshot_to_wav
    wav_bytes = pcm_snapshot_to_wav(snapshot)
    if not wav_bytes:
        return None
    return archive_wav(conn, wav_bytes, kind="asr", extra=extra)
    return archive_wav(conn, wav_bytes, kind="asr", extra=extra, text=text)
xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py
@@ -1616,9 +1616,10 @@
            if self.server is not None:
                await self.server.unregister_device_handler(self.device_id, self)
            try:
                from core.app_runtime.session import end_session
                from core.app_runtime.session import end_session, schedule_close_parent_except
                end_session(self, "disconnected")
                schedule_close_parent_except(self, "disconnected")
            except Exception as session_error:
                self.logger.bind(tag=TAG).warning(f"断线 flush TaskSession 失败: {session_error}")
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py
@@ -25,7 +25,7 @@
        if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
            conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
        return
    # 服务端AEC:仅对话类模式允许打断。复读/跟读/翻译时喇叭回声不能当成插话。
    # 服务端AEC:仅对话类模式允许打断。泛听/跟读/翻译时喇叭回声不能当成插话。
    if conn.client_aec and have_voice:
        if (
            conn.client_is_speaking
@@ -107,7 +107,7 @@
    mode = getattr(conn, "interaction_mode", "CHAT")
    # 菜单 TTS 里的「跟读/复读机/翻译」不能当成切 App;退出聊天口令仍要生效
    # 菜单 TTS 里的「跟读/泛听/翻译」不能当成切 App;退出聊天口令仍要生效
    if conn.client_is_speaking and getattr(conn, "awaiting_voice_app_choice", False):
        mapped = match_voice_switch(actual_text)
        if mapped and mapped[0] == "chat":
@@ -162,7 +162,7 @@
        return
    if mode == "PLAY_AUDIO":
        conn.logger.bind(tag=TAG).info("复读机模式忽略上行对话")
        conn.logger.bind(tag=TAG).info("泛听模式忽略上行对话")
        return
    if mode == "SHADOWING":
@@ -250,6 +250,10 @@
async def check_bind_device(conn: "ConnectionHandler"):
    conn.last_bind_prompt_time = time.time()
    if getattr(conn, "tts", None) is None:
        conn.logger.bind(tag=TAG).error("TTS未就绪,无法播放绑定码")
        return
    if conn.bind_code:
        # 确保bind_code是6位数字
        if len(conn.bind_code) != 6:
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py
@@ -48,7 +48,7 @@
            try:
                from core.audio_archive import archive_wav
                archive_wav(conn, wav_data, kind="tts")
                archive_wav(conn, wav_data, kind="tts", text=text or "")
            except Exception:
                pass
        # 执行异步上报
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py
@@ -294,6 +294,9 @@
    message = {"type": "tts", "state": state, "session_id": conn.session_id}
    if text is not None:
        message["text"] = textUtils.check_emoji(text)
    # 再见/超时结束:设备收到 stop 后应直接 idle 等唤醒,不要先回 listening。
    if state == "stop" and getattr(conn, "close_after_chat", False):
        message["session_end"] = True
    # TTS播放结束
    if state == "stop":
@@ -321,6 +324,10 @@
    # 发送消息到客户端
    await conn.websocket.send(json.dumps(message))
    if state == "stop":
        from core.app_runtime.runtime import maybe_flush_pending_play_after_tts
        await maybe_flush_pending_play_after_tts(conn)
async def send_stt_message(conn: "ConnectionHandler", text):
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py
@@ -87,10 +87,20 @@
                # App Runtime: 设备上报的 Task 启动(复用 listen detect.text,不新增 WS type)
                if original_text.startswith("[app_task]"):
                    payload = original_text[len("[app_task]"):].strip()
                    parts = [p.strip() for p in payload.split("|") if p.strip()]
                    app_id = parts[0] if parts else "chat"
                    task_id = parts[1] if len(parts) > 1 else ""
                    source = parts[2] if len(parts) > 2 else "mqtt_immediate"
                    parts = payload.split("|", 3)
                    app_id = parts[0].strip() if parts else "chat"
                    task_id = parts[1].strip() if len(parts) > 1 else ""
                    source = parts[2].strip() if len(parts) > 2 else "mqtt_immediate"
                    task_params = {}
                    if len(parts) > 3 and parts[3].strip():
                        try:
                            import json
                            parsed = json.loads(parts[3].strip())
                            if isinstance(parsed, dict):
                                task_params = parsed
                        except Exception:
                            task_params = {}
                    conn.logger.bind(tag=TAG).info(
                        f"收到 app_task: {app_id}/{task_id} source={source}"
                    )
@@ -105,16 +115,39 @@
                        conn,
                        app_id,
                        task_id,
                        {},
                        task_params,
                        source=source,
                        execute_skills=(source == "voice"),
                        execute_skills=True,
                    )
                    return
                if original_text.startswith("[voice_entry]"):
                    from core.app_runtime.runtime import voice_menu_text, wait_for_tts_channels
                if original_text.startswith("[lesson_sync]"):
                    from core.app_runtime.runtime import wait_for_tts_channels
                    from core.handle.intentHandler import speak_txt
                    conn.client_abort = False
                    tts = getattr(conn, "tts", None)
                    if tts is not None:
                        tts.tts_stop_request = False
                    conn.sentence_id = uuid.uuid4().hex
                    conn.logger.bind(tag=TAG).info("收到 lesson_sync,播报同步提示")
                    if await wait_for_tts_channels(conn, timeout=8):
                        speak_txt(conn, "正在同步音频,请稍等。")
                    else:
                        conn.logger.bind(tag=TAG).warning("TTS通道未就绪,跳过同步提示")
                    return
                if original_text.startswith("[voice_entry]"):
                    from core.app_runtime.runtime import (
                        prompt_bind_if_needed,
                        voice_menu_text,
                        wait_for_tts_channels,
                    )
                    from core.handle.intentHandler import speak_txt
                    if await prompt_bind_if_needed(conn):
                        return
                    conn.awaiting_voice_app_choice = True
                    conn.sentence_id = uuid.uuid4().hex
                    if await wait_for_tts_channels(conn, timeout=8):
xiaozhi-esp32-server/main/xiaozhi-server/core/http_server.py
@@ -1,6 +1,7 @@
import asyncio
from aiohttp import web
from config.logger import setup_logging
from core.api.lesson_audio_handler import LessonAudioHandler
from core.api.ota_handler import OTAHandler
from core.api.vision_handler import VisionHandler
@@ -13,6 +14,7 @@
        self.logger = setup_logging()
        self.ota_handler = OTAHandler(config)
        self.vision_handler = VisionHandler(config)
        self.lesson_audio_handler = LessonAudioHandler(config)
    def _get_websocket_url(self, local_ip: str, port: int) -> str:
        """获取websocket地址
@@ -72,6 +74,14 @@
                        web.options(
                            "/mcp/vision/explain", self.vision_handler.handle_options
                        ),
                        web.get(
                            "/lesson-audio/{org_id}/index/{kind}/{level}/{story}/{lang}",
                            self.lesson_audio_handler.handle_index,
                        ),
                        web.get(
                            "/lesson-audio/{org_id}/{path:.*}",
                            self.lesson_audio_handler.handle_get,
                        ),
                    ]
                )
@@ -80,6 +90,7 @@
                await runner.setup()
                site = web.TCPSite(runner, host, port)
                await site.start()
                self.lesson_audio_handler.log_ready()
                # 保持服务运行
                while True:
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py
@@ -192,7 +192,7 @@
                    try:
                        from core.audio_archive import archive_pcm_snapshot
                        archive_pcm_snapshot(conn, audio_snapshot)
                        archive_pcm_snapshot(conn, audio_snapshot, text=raw_text or enhanced_text)
                    except Exception:
                        pass
                    enqueue_asr_report(conn, enhanced_text, audio_snapshot)
xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py
@@ -53,4 +53,9 @@
    if data.get("state") != "detect":
        return False
    text = str(data.get("text") or "")
    return text.startswith("[app_task]") or text.startswith("[voice_entry]") or text.startswith("[skill_event]")
    return (
        text.startswith("[app_task]")
        or text.startswith("[voice_entry]")
        or text.startswith("[skill_event]")
        or text.startswith("[lesson_sync]")
    )
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py
@@ -9,13 +9,13 @@
    "type": "function",
    "function": {
        "name": "start_repeater",
        "description": "打开复读机,在设备 SD 卡上循环播放 MP3。name 与 self.mp3.get_list 的 files[].name 一致;省略 name 时设备会播放上次音频或 SD 卡第一首。repeat 是整首循环次数。speed 是播放倍率 0.5~2.0,默认由音频熟悉天数决定。不要用 play_music。播放期间不会聊天。",
        "description": "打开泛听。省略课文时播放家长上次即时/计划任务下发的课文;还没有下发过时会提示先在家长端选一课。不要再用测试清单。name 与 self.mp3.get_list 的 files[].name 一致。repeat 是整首循环次数。speed 是播放倍率 0.5~2.0。不要用 play_music。播放期间不会聊天。",
        "parameters": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "可选。SD 卡 MP3 文件名,必须与 get_list 的 files[].name 一致;省略则播上次或第一首",
                    "description": "可选。SD 卡 MP3 文件名,必须与 get_list 的 files[].name 一致;省略则播家长上次下发的课文",
                },
                "repeat": {
                    "type": "integer",
@@ -72,7 +72,7 @@
        return ActionResponse(
            action=Action.RESPONSE,
            result=result.result,
            response=result.response or "无法开始复读",
            response=result.response or "无法开始泛听",
        )
    return ActionResponse(
        action=Action.NONE,
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py
@@ -9,7 +9,7 @@
    "type": "function",
    "function": {
        "name": "start_shadowing",
        "description": "开始跟读:设备用语音读一句英语,用户跟着读,录音会上报,但不会进入聊天。用户说退出跟读或回到聊天时结束。",
        "description": "开始跟读:播放家长上次即时/计划任务下发的课文。设备读一句,用户跟着读,录音会上报,但不会进入聊天。还没有下发过跟读课文时会提示先在家长端选一课。用户说退出跟读或回到聊天时结束。",
        "parameters": {
            "type": "object",
            "properties": {},
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py
@@ -12,7 +12,8 @@
        "description": (
            "切换设备当前 App。"
            "chat=对话;english_translation=翻译;english_conversation=中英对话;"
            "english_learning 需配合 task_id:repeater=复读机,shadowing=跟读。"
            "english_learning 需配合 task_id:repeater=泛听,shadowing=跟读。"
            "语音切换泛听/跟读时执行家长上次下发的课文,没有上次课文则提示先选课。"
            "退出当前模式、回到聊天时 app_id=chat。"
        ),
        "parameters": {
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py
@@ -33,7 +33,7 @@
    assert match_voice_switch("退出翻译") == ("chat", "conversation")
    assert match_voice_switch("返回聊天") == ("chat", "conversation")
    assert match_voice_switch("退出跟读") == ("chat", "conversation")
    assert match_voice_switch("退出复读") == ("chat", "conversation")
    assert match_voice_switch("退出泛听") == ("chat", "conversation")
def test_enter_prompts_announce_mode_without_exit_echo():
@@ -43,8 +43,8 @@
    _, shadowing = get_task("english_learning", "shadowing")
    repeater_prompt = (repeater or {}).get("enter_prompt") or ""
    shadowing_prompt = (shadowing or {}).get("enter_prompt") or ""
    assert "现在已经切换至复读机模式" in repeater_prompt
    assert "退出复读" not in repeater_prompt
    assert "现在已经切换至泛听模式" in repeater_prompt
    assert "退出泛听" not in repeater_prompt
    assert "现在已经切换至跟读模式" in shadowing_prompt
    assert "退出跟读" not in shadowing_prompt
@@ -53,7 +53,7 @@
    from core.app_runtime.runtime import voice_menu_text
    menu = voice_menu_text()
    assert "听复读机" in menu
    assert "泛听" in menu
    assert "跟读" in menu
    assert "翻译" in menu
    assert "中英对话" in menu
@@ -64,11 +64,11 @@
    from core.app_runtime.runtime import is_exit_to_chat
    assert match_voice_switch("跟读") == ("english_learning", "shadowing")
    assert match_voice_switch("听读") == ("english_learning", "shadowing")
    assert match_voice_switch("泛听") == ("english_learning", "repeater")
    assert match_voice_switch("我想退出跟读") == ("chat", "conversation")
    assert is_exit_to_chat("退出翻译") is True
    assert is_exit_to_chat("开始跟读") is False
    assert is_exit_to_chat("退出听读") is True
    assert is_exit_to_chat("退出泛听") is True
def test_new_task_phrases_come_from_registry_only(monkeypatch):
@@ -207,27 +207,27 @@
def test_lesson_mp3_clips_picks_first_lesson_folder():
    names = [
        "/english_learning/repeater/lessons/unit01/full.mp3",
        "/english_learning/shadowing/lessons/unit02/02.mp3",
        "/english_learning/shadowing/lessons/unit01/02.mp3",
        "/english_learning/shadowing/lessons/unit01/01.mp3",
        "/english_learning/shadowing/lessons/unit01/full.mp3",
        "/english_learning/shadowing/lessons/unit01/audio/03.mp3",
        "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
        "/english_learning/shadowing/lessons/l2/002/en/02.mp3",
        "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
        "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
        "/english_learning/shadowing/lessons/l1/001/full.mp3",
        "/english_learning/shadowing/lessons/l1/001/en/03.mp3",
        "loose.mp3",
    ]
    clips = lesson_mp3_clips(names, "english_learning", "shadowing")
    assert clips == [
        "/english_learning/shadowing/lessons/unit01/01.mp3",
        "/english_learning/shadowing/lessons/unit01/02.mp3",
        "/english_learning/shadowing/lessons/unit01/audio/03.mp3",
        "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
        "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
        "/english_learning/shadowing/lessons/l1/001/en/03.mp3",
    ]
    only_unit2 = lesson_mp3_clips(names, "english_learning", "shadowing", "unit02")
    assert only_unit2 == ["/english_learning/shadowing/lessons/unit02/02.mp3"]
    only_l2 = lesson_mp3_clips(names, "english_learning", "shadowing", "l2")
    assert only_l2 == ["/english_learning/shadowing/lessons/l2/002/en/02.mp3"]
    assert repeater_lesson_mp3(names, "english_learning", "repeater") == (
        "/english_learning/repeater/lessons/unit01/full.mp3"
        "/english_learning/repeater/lessons/l1/001/cn/01.mp3"
    )
    assert repeater_lesson_mp3(names, "english_learning", "shadowing") == (
        "/english_learning/shadowing/lessons/unit01/full.mp3"
        "/english_learning/shadowing/lessons/l1/001/full.mp3"
    )
@@ -244,7 +244,7 @@
        started_at=1,
        status="running",
    )
    session.add_turn(0, "/english_learning/shadowing/lessons/unit01/01.mp3", "hello", False)
    session.add_turn(0, "/english_learning/shadowing/lessons/l1/001/en/01.mp3", "hello", False)
    session.add_event("audio.completed", "english_learning", "shadowing")
    data = session.to_dict()
    assert data["task_session_id"] == "abc123"
@@ -252,6 +252,19 @@
    assert "agent_id" in data
    assert data["turns"][0]["prompt_text"].endswith("01.mp3")
    assert data["events"][0]["name"] == "audio.completed"
def test_turn_audio_id_keeps_local_recording_path():
    session = TaskSession(
        task_session_id="abc123",
        app_id="english_learning",
        task_id="shadowing",
        status="running",
    )
    long_path = "14-c1-9f-49-43-94/shadowing/2026-09-18/091728893_asr_b5a60ebacb9544bfba4782a5cc978fb9_0.wav"
    session.upsert_turn(0, prompt_text="01.mp3", asr_text="hello", audio_id=long_path)
    assert session.to_dict()["turns"][0]["audio_id"] == long_path
    assert len(long_path) > 32
def test_save_survives_upsert_scheduler_error(tmp_path, monkeypatch):
@@ -281,7 +294,7 @@
    assert len(loaded.turns) == 1
def test_repeater_queues_play_until_listen_start(monkeypatch):
def test_repeater_queues_play_until_listen_start(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
@@ -296,34 +309,86 @@
    async def fake_list(conn):
        return [
            "/english_learning/shadowing/lessons/unit01/full.mp3",
            "/english_learning/repeater/lessons/unit01/full.mp3",
            "/english_learning/shadowing/lessons/unit01/01.mp3",
            "/english_learning/shadowing/lessons/l1/001/full.mp3",
            "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
            "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
        ]
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    monkeypatch.setattr("core.app_runtime.runtime._list_sd_mp3_names", fake_list)
    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
    async def fake_ensure(*args, **kwargs):
        return True
    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(config={}, prompt="", func_handler=None)
    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
    assert spoken and "复读机" in spoken[0]
    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-queue")
    asyncio.run(start_app_task(conn, "english_learning", "repeater", {"lesson_id": "l1.001"}, source="voice"))
    assert spoken and "泛听" in spoken[0]
    assert plays == []
    assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "repeater"
    asyncio.run(maybe_flush_pending_play_after_tts(conn))
    assert len(plays) == 1
    assert plays[0]["name"] == "/english_learning/shadowing/lessons/unit01/01.mp3"
    assert plays[0]["repeat"] == 10
    assert plays[0]["name"] == "/english_learning/repeater/lessons/l1/001/cn/01.mp3"
    assert plays[0]["repeat"] == 1
    assert plays[0]["speed"] == 1.0
    assert plays[0].get("finish_task") is False
    assert len(plays[0]["items"]) == 2
    assert plays[0]["items"][1]["name"].endswith("/02.mp3")
    assert plays[0]["items"][1]["repeat"] == 5
    assert len(plays[0]["items"]) == 4
    assert plays[0]["items"][1]["name"].endswith("/l1/001/en/01.mp3")
    assert plays[0]["items"][1]["speed"] == 0.8
    assert plays[0]["items"][3]["repeat"] == 5
    assert plays[0]["items"][3]["speed"] == 1.0
    assert conn.repeater_playlist == []
    assert conn.pending_play_after_tts is None
def test_shadowing_queues_whole_playlist_until_listen_start(monkeypatch):
def test_sd_ready_skips_sync_and_queued_play(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    plays = []
    spoken = []
    dirs = []
    ensures = []
    async def fake_play(conn, params):
        plays.append(params)
        return None
    async def fake_dirs(conn, app_id):
        dirs.append(app_id)
        return None
    async def fake_ensure(*args, **kwargs):
        ensures.append(args)
        return True
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-sd-ready")
    asyncio.run(start_app_task(
        conn,
        "english_learning",
        "repeater",
        {"lesson_id": "l1.001", "sd_ready": True},
        source="mqtt_immediate",
    ))
    assert plays == []
    assert dirs == []
    assert ensures == []
    assert getattr(conn, "pending_play_after_tts", None) is None
    assert conn.interaction_mode == "PLAY_AUDIO"
    assert spoken == ["开始泛听任务"]
    assert not any("同步" in str(text) for text in spoken)
def test_shadowing_queues_whole_playlist_until_listen_start(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
@@ -336,47 +401,192 @@
    async def fake_dirs(conn, app_id):
        return None
    async def fake_ensure(*args, **kwargs):
        return True
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(config={}, prompt="", func_handler=None)
    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {}, source="voice"))
    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-shadow-queue")
    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {"lesson_id": "l1.001"}, source="voice"))
    assert spoken and "跟读" in spoken[0]
    assert plays == []
    assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "shadowing"
    queued = conn.pending_play_after_tts["params"]["items"]
    assert len(queued) == 2
    assert queued[0]["repeat"] == 5
    assert queued[1]["name"].endswith("/02.mp3")
    assert queued[1]["repeat"] == 10
    assert len(queued) == 58
    assert queued[0]["repeat"] == 1
    assert queued[0]["speed"] == 0.8
    assert queued[-1]["name"].endswith("/58.mp3")
    assert queued[-1]["repeat"] == 1
    asyncio.run(maybe_flush_pending_play_after_tts(conn))
    assert len(plays) == 1
    assert len(plays[0]["items"]) == 2
    assert plays[0]["items"][0]["repeat"] == 5
    assert plays[0]["items"][1]["repeat"] == 10
    assert len(plays[0]["items"]) == 58
    assert plays[0]["items"][0]["repeat"] == 1
    assert plays[0]["items"][-1]["repeat"] == 1
    assert plays[0].get("finish_task") is False
    assert plays[0].get("listen_ratio") == 1.2
    assert conn.pending_play_after_tts is None
def test_shadowing_expands_default_playlist_into_listen_turns():
def test_shadowing_ensures_mp3s_even_when_sd_empty(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    ensure_calls = []
    async def fake_play(conn, params):
        return None
    async def fake_dirs(conn, app_id):
        return None
    async def fake_list(conn):
        return []
    async def fake_ensure(conn, names, task_id, params):
        ensure_calls.append((list(names), task_id, dict(params or {})))
        return True
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    monkeypatch.setattr("core.app_runtime.runtime._list_sd_mp3_names", fake_list)
    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: None)
    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-shadow-sync")
    asyncio.run(start_app_task(
        conn,
        "english_learning",
        "shadowing",
        {"lesson_id": "l1.001"},
        source="mqtt_immediate",
    ))
    assert len(ensure_calls) == 1
    names, task_id, params = ensure_calls[0]
    assert task_id == "shadowing"
    assert params.get("lesson_id") == "l1.001"
    assert len(names) == 58
    assert names[0].endswith("/en/01.mp3")
    assert names[-1].endswith("/58.mp3")
    assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "shadowing"
def test_shadowing_expands_default_playlist_into_listen_turns(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import _prepare_shadowing_sd
    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
    conn = SimpleNamespace(
        config={},
        active_app_id="english_learning",
        current_task_id="shadowing",
    )
    ok = asyncio.run(_prepare_shadowing_sd(conn, {}))
    ok = asyncio.run(_prepare_shadowing_sd(conn, {"lesson_id": "l1.001"}))
    assert ok is True
    unit01_01 = "/english_learning/shadowing/lessons/unit01/01.mp3"
    unit01_02 = "/english_learning/shadowing/lessons/unit01/02.mp3"
    assert conn.shadowing_clips.count(unit01_01) == 5
    assert conn.shadowing_clips.count(unit01_02) == 10
    assert len(conn.shadowing_clips) == 15
    assert conn.shadowing_prompt == unit01_01
    clip_01 = "/english_learning/shadowing/lessons/l1/001/en/01.mp3"
    clip_58 = "/english_learning/shadowing/lessons/l1/001/en/58.mp3"
    assert conn.shadowing_clips.count(clip_01) == 1
    assert conn.shadowing_clips[-1] == clip_58
    assert len(conn.shadowing_clips) == 58
    assert conn.shadowing_prompt == clip_01
def test_voice_repeater_without_last_lesson_prompts_parent(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
    async def fake_dirs(conn, app_id):
        return None
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-empty")
    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
    assert spoken and "还没有下发过泛听课文" in spoken[0]
    assert getattr(conn, "pending_play_after_tts", None) is None
def test_voice_repeater_replays_last_parent_task(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
    async def fake_dirs(conn, app_id):
        return None
    async def fake_ensure(*args, **kwargs):
        return True
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-last")
    asyncio.run(
        start_app_task(
            conn,
            "english_learning",
            "repeater",
            {"lesson_id": "l2.010", "en_passes": [{"speed": 0.8, "repeat": 1}]},
            source="mqtt_immediate",
        )
    )
    conn.active_app_id = "chat"
    conn.current_task_id = "conversation"
    conn.current_source = "default_chat"
    conn.interaction_mode = "CHAT"
    spoken.clear()
    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
    pending = conn.pending_play_after_tts
    assert pending and pending["kind"] == "repeater"
    assert pending["params"]["name"].endswith("/l2/010/cn/01.mp3")
    assert spoken and "泛听" in spoken[0]
def test_voice_shadowing_replays_last_parent_task(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
    async def fake_dirs(conn, app_id):
        return None
    async def fake_ensure(*args, **kwargs):
        return True
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-last-shd")
    asyncio.run(
        start_app_task(
            conn,
            "english_learning",
            "shadowing",
            {"lesson_id": "l1.001", "language": "en", "speed": 0.8},
            source="schedule",
        )
    )
    conn.active_app_id = "chat"
    conn.current_task_id = "conversation"
    conn.current_source = "default_chat"
    conn.interaction_mode = "CHAT"
    conn.shadowing_clips = []
    spoken.clear()
    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {}, source="voice"))
    pending = conn.pending_play_after_tts
    assert pending and pending["kind"] == "shadowing"
    assert pending["params"]["items"][0]["name"].endswith("/l1/001/en/01.mp3")
    assert spoken and "跟读" in spoken[0] and "还没有下发过" not in spoken[0]
def test_app_sd_layout_paths_from_registry():
@@ -422,6 +632,48 @@
    assert conn.chat_greeted is True
def test_boot_unbound_skips_greeting_and_plays_bind_code(monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    bind_codes = []
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    async def _ready(conn, timeout=8):
        return True
    monkeypatch.setattr("core.app_runtime.runtime.wait_for_tts_channels", _ready)
    async def fake_bind(conn):
        bind_codes.append(conn.bind_code)
    monkeypatch.setattr("core.app_runtime.runtime._play_bind_prompt", fake_bind)
    conn = SimpleNamespace(
        config={
            "enable_proactive_greeting": True,
            "proactive_chat_greeting": "我们来聊天吧。",
        },
        prompt="",
        func_handler=None,
        just_woken_up=False,
        chat_greeted=False,
        active_app_id="chat",
        current_task_id="conversation",
        current_source="default_chat",
        interaction_mode="CHAT",
        need_bind=True,
        bind_code="123456",
        bind_completed_event=asyncio.Event(),
    )
    conn.bind_completed_event.set()
    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="default_chat"))
    assert spoken == []
    assert bind_codes == ["123456"]
    assert conn.chat_greeted is False
def test_client_hello_is_recognized_before_bind():
    from core.websocket_handshake import is_client_hello
@@ -449,6 +701,9 @@
        '{"type":"listen","state":"detect","text":"[app_task]chat|conversation|default_chat"}'
    ) is True
    assert is_priority_listen_detect(
        '{"type":"listen","state":"detect","text":"[lesson_sync]"}'
    ) is True
    assert is_priority_listen_detect(
        '{"type":"listen","state":"detect","text":"你好小智"}'
    ) is False
@@ -472,3 +727,195 @@
        }
    }
    assert app_sd_layout_paths("story_time", app) == ["/story_time/listen/lessons"]
def test_language_pair_uses_bilingual_prompt_and_unknown_falls_back():
    from core.app_runtime.runtime import BILINGUAL_PROMPT, _apply_prompt
    conn = SimpleNamespace(prompt="base", original_system_prompt=None)
    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
    app = {"system_prompt_mode": "bilingual"}
    _apply_prompt(conn, app, {"language_pair": "zh-en"})
    assert conn.prompt == BILINGUAL_PROMPT
    assert conn.language_pair_fallback is False
    conn2 = SimpleNamespace(prompt="base", original_system_prompt=None)
    conn2.change_system_prompt = lambda text: setattr(conn2, "prompt", text)
    _apply_prompt(conn2, app, {"language_pair": "zh-ko"})
    assert conn2.prompt == BILINGUAL_PROMPT
    assert conn2.language_pair_fallback is True
    assert conn2.language_pair == "zh-ko"
def test_unknown_repeater_lesson_does_not_queue_play(monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    async def _true(*args, **kwargs):
        return True
    async def _false(*args, **kwargs):
        return False
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    monkeypatch.setattr("core.app_runtime.runtime.wait_for_tts_channels", _true)
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", _true)
    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
    conn = SimpleNamespace(
        config={},
        prompt="",
        func_handler=None,
        just_woken_up=False,
        chat_greeted=True,
        active_app_id="chat",
        current_task_id="conversation",
        current_source="default_chat",
        interaction_mode="CHAT",
        pending_play_after_tts=None,
    )
    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
    asyncio.run(
        start_app_task(
            conn,
            "english_learning",
            "repeater",
            {"lesson_id": "unit99"},
            source="mqtt_immediate",
        )
    )
    assert conn.pending_play_after_tts is None
    assert any("SD卡" in text for text in spoken)
def test_parent_translation_clears_abort_and_announces(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    async def _false(*args, **kwargs):
        return False
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
    conn = SimpleNamespace(
        config={},
        prompt="base",
        func_handler=None,
        device_id="mac-translate",
        client_abort=True,
        tts=SimpleNamespace(tts_stop_request=True),
    )
    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
    asyncio.run(start_app_task(
        conn,
        "english_translation",
        "live_translate",
        {"language_pair": "zh-en"},
        source="mqtt_immediate",
    ))
    assert conn.client_abort is False
    assert conn.tts.tts_stop_request is False
    assert spoken and "翻译模式" in spoken[0]
    assert conn.interaction_mode == "TRANSLATION"
def test_schedule_talk_announces_start_phrase(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    async def _false(*args, **kwargs):
        return False
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
    conn = SimpleNamespace(
        config={"enable_proactive_greeting": True},
        prompt="base",
        func_handler=None,
        device_id="mac-schedule-talk",
        client_abort=False,
        tts=SimpleNamespace(tts_stop_request=False),
        active_app_id="chat",
        current_task_id="conversation",
        current_source="default_chat",
        interaction_mode="CHAT",
        chat_greeted=True,
    )
    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
    asyncio.run(start_app_task(conn, "english_translation", "live_translate", {}, source="schedule"))
    assert spoken == ["开始翻译任务"]
    assert conn.interaction_mode == "TRANSLATION"
    spoken.clear()
    conn.active_app_id = "chat"
    conn.current_task_id = "conversation"
    conn.interaction_mode = "CHAT"
    asyncio.run(start_app_task(conn, "english_conversation", "bilingual_chat", {}, source="schedule"))
    assert spoken == ["开始中英对话任务"]
    assert conn.interaction_mode == "BILINGUAL"
    spoken.clear()
    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="schedule"))
    assert spoken == ["开始对话任务"]
    assert conn.chat_greeted is True
    assert conn.interaction_mode == "CHAT"
def test_voice_exit_overrides_parent_bilingual_and_translation(tmp_path, monkeypatch):
    import asyncio
    from core.app_runtime.runtime import start_app_task
    spoken = []
    async def _false(*args, **kwargs):
        return False
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
    conn = SimpleNamespace(
        config={},
        prompt="base",
        func_handler=None,
        device_id="mac-exit",
        client_abort=False,
        tts=SimpleNamespace(tts_stop_request=False),
    )
    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
    asyncio.run(start_app_task(conn, "english_conversation", "bilingual_chat", {}, source="schedule"))
    assert conn.current_source == "schedule"
    assert conn.interaction_mode == "BILINGUAL"
    spoken.clear()
    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="voice"))
    assert conn.interaction_mode == "CHAT"
    assert conn.current_source == "voice"
    assert spoken and "对话模式" in spoken[0]
    asyncio.run(start_app_task(conn, "english_translation", "live_translate", {}, source="mqtt_immediate"))
    assert conn.interaction_mode == "TRANSLATION"
    spoken.clear()
    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
    assert conn.interaction_mode == "TRANSLATION"
    spoken.clear()
    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="voice"))
    assert conn.interaction_mode == "CHAT"
    assert spoken and "对话模式" in spoken[0]
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py
@@ -14,15 +14,15 @@
CLIPS = [
    "/english_learning/shadowing/lessons/unit01/01.mp3",
    "/english_learning/shadowing/lessons/unit01/02.mp3",
    "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
    "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
]
def test_resolve_expected_text_from_transcripts_not_path():
    task = {
        "transcripts": {"01.mp3": "Hello, how are you today?", "01": "Hello, how are you today?"},
        "sentences": ["/english_learning/shadowing/lessons/unit01/01.mp3"],
        "sentences": ["/english_learning/shadowing/lessons/l1/001/en/01.mp3"],
    }
    expected = resolve_expected_text(task, CLIPS, 0)
    assert expected == "Hello, how are you today?"
@@ -32,7 +32,7 @@
def test_resolve_expected_text_rejects_path_as_original():
    task = {
        "transcripts": {"01.mp3": "/english_learning/shadowing/lessons/unit01/01.mp3"},
        "transcripts": {"01.mp3": "/english_learning/shadowing/lessons/l1/001/en/01.mp3"},
        "sentences": [CLIPS[0]],
    }
    assert resolve_expected_text(task, CLIPS, 0) is None
@@ -294,7 +294,7 @@
        )
    )
    assert switched == [("chat", "conversation", False, False)]
    assert spoken == ["复读结束。我们来聊天吧。"]
    assert spoken == ["泛听结束。我们来聊天吧。"]
def test_late_asr_after_listen_window_still_scores_previous_sentence(tmp_path, monkeypatch):
@@ -320,8 +320,9 @@
    captured = {}
    def fake_save(session_id, step_index, wav_bytes, conn=None):
    def fake_save(session_id, step_index, wav_bytes, conn=None, text=None):
        captured["wav"] = wav_bytes
        captured["text"] = text
        captured["id"] = f"{session_id}_{step_index}.wav"
        return captured["id"]
@@ -334,10 +335,33 @@
    turn = conn.task_session.find_turn(0)
    assert turn["metrics"][0]["method"] == "text_similarity_v0"
    assert turn["audio_id"] == captured["id"]
    assert captured["text"] == "Hello, how are you today?"
    wav = pcm_snapshot_to_wav(conn.last_asr_audio_snapshot)
    assert captured["wav"][:4] == b"RIFF"
    assert wav[8:12] == b"WAVE"
    assert captured["wav"] == wav
def test_later_asr_updates_sidecar_without_new_wav(tmp_path, monkeypatch):
    from pathlib import Path
    from core.app_runtime.runtime import (
        apply_deferred_assessment,
        apply_shadowing_user_text,
        save_shadowing_listen_audio,
    )
    conn = _shadowing_conn(tmp_path, monkeypatch)
    conn.shadowing_listen_pcm = [b"\x00\x00" * 80]
    save_shadowing_listen_audio(conn, 0)
    turn = conn.task_session.find_turn(0)
    wav_path = tmp_path / "data" / "recordings" / Path(turn["audio_id"])
    txt_path = wav_path.with_suffix(".txt")
    assert txt_path.read_text(encoding="utf-8").strip() == ""
    apply_shadowing_user_text(conn, "Hello, how are you today?")
    apply_deferred_assessment(conn, 0, persist_audio=False)
    recordings = list((tmp_path / "data" / "recordings").rglob("*.wav"))
    assert len(recordings) == 1
    assert txt_path.read_text(encoding="utf-8").strip() == "Hello, how are you today?"
def test_shadowing_playlist_end_announces_then_chat(tmp_path, monkeypatch):
@@ -373,6 +397,42 @@
    assert played == []
    assert switched == [("chat", "conversation", False)]
    assert spoken == ["跟读结束。我们来聊天吧。"]
def test_repeater_completed_returns_chat_after_reconnect(monkeypatch):
    """WS 掉线后设备可能在新的 CHAT 连接上补发 audio.completed。"""
    from core.app_runtime.runtime import handle_skill_event
    switched = []
    spoken = []
    async def fake_start(conn, app_id, task_id, params, source="voice", **kwargs):
        switched.append((app_id, task_id, kwargs.get("speak_switch")))
        conn.interaction_mode = "CHAT"
        return None
    monkeypatch.setattr("core.app_runtime.runtime.start_app_task", fake_start)
    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(
        interaction_mode="CHAT",
        active_app_id="chat",
        current_task_id="conversation",
        task_session=None,
        repeater_speed_queue=[],
        repeater_playlist=[],
        shadowing_awaiting_user=False,
        prompt="",
        config={},
        device_id="mac",
        tts=None,
    )
    asyncio.run(
        handle_skill_event(
            conn, "[skill_event]audio.completed|english_learning|repeater|101290"
        )
    )
    assert switched == [("chat", "conversation", False)]
    assert spoken == ["泛听结束。我们来聊天吧。"]
def test_playlist_completed_returns_chat_after_reconnect(monkeypatch):
@@ -438,6 +498,9 @@
    wav_path = tmp_path / "data" / "recordings" / Path(turn["audio_id"])
    assert wav_path.is_file()
    assert wav_path.stat().st_size > 44
    txt_path = wav_path.with_suffix(".txt")
    assert txt_path.is_file()
    assert txt_path.read_text(encoding="utf-8").strip() == ""
def test_fifteen_listen_windows_write_fifteen_wavs(tmp_path, monkeypatch):
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py
@@ -3,7 +3,7 @@
from pathlib import Path
from types import SimpleNamespace
from core.audio_archive import archive_pcm_snapshot, archive_wav
from core.audio_archive import archive_pcm_snapshot, archive_wav, plain_transcript
from core.app_runtime.assessment import pcm_snapshot_to_wav, save_turn_wav
@@ -30,7 +30,7 @@
        config={"audio_archive": {"enabled": False}},
    )
    wav = pcm_snapshot_to_wav([b"\x00\x00" * 80])
    assert archive_wav(conn, wav) is None
    assert archive_wav(conn, wav, text="hello") is None
def test_save_turn_wav_uses_archive_layout(tmp_path, monkeypatch):
@@ -41,10 +41,12 @@
        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
    )
    wav = pcm_snapshot_to_wav([b"\x00\x00" * 160])
    audio_id = save_turn_wav("sess", 3, wav, conn=conn)
    audio_id = save_turn_wav("sess", 3, wav, conn=conn, text="hello world")
    assert audio_id is not None
    assert "mac1/shadowing/" in audio_id
    assert audio_id.endswith(".wav")
    txt = tmp_path / "data" / "recordings" / Path(audio_id).with_suffix(".txt")
    assert txt.read_text(encoding="utf-8").strip() == "hello world"
def test_chat_pcm_archive_uses_chat_dir(tmp_path, monkeypatch):
@@ -54,6 +56,44 @@
        interaction_mode="CHAT",
        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
    )
    relative = archive_pcm_snapshot(conn, [b"\x00\x00" * 80])
    relative = archive_pcm_snapshot(conn, [b"\x00\x00" * 80], text="你好小智")
    assert relative is not None
    assert relative.startswith("dev2/chat/")
    txt = tmp_path / "data" / "recordings" / Path(relative).with_suffix(".txt")
    assert txt.read_text(encoding="utf-8").strip() == "你好小智"
def test_archive_writes_sidecar_txt_for_asr_and_tts(tmp_path, monkeypatch):
    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
    conn = SimpleNamespace(
        device_id="dev3",
        interaction_mode="CHAT",
        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
    )
    wav = pcm_snapshot_to_wav([b"\x00\x00" * 80])
    asr_rel = archive_wav(conn, wav, kind="asr", text="用户说的话")
    tts_rel = archive_wav(conn, wav, kind="tts", text="泛听结束。我们来聊天吧。")
    asr_txt = tmp_path / "data" / "recordings" / Path(asr_rel).with_suffix(".txt")
    tts_txt = tmp_path / "data" / "recordings" / Path(tts_rel).with_suffix(".txt")
    assert asr_txt.read_text(encoding="utf-8").strip() == "用户说的话"
    assert tts_txt.read_text(encoding="utf-8").strip() == "泛听结束。我们来聊天吧。"
def test_plain_transcript_unwraps_funasr_json():
    wrapped = '{"speaker": "tom", "content": "今天天气不错"}'
    assert plain_transcript(wrapped) == "今天天气不错"
    assert plain_transcript("直接文本") == "直接文本"
def test_empty_asr_still_writes_sidecar(tmp_path, monkeypatch):
    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
    conn = SimpleNamespace(
        device_id="dev4",
        interaction_mode="SHADOWING",
        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
    )
    wav = pcm_snapshot_to_wav([b"\x00\x00" * 80])
    relative = archive_wav(conn, wav, kind="asr", extra="sess_0", text="")
    txt = tmp_path / "data" / "recordings" / Path(relative).with_suffix(".txt")
    assert txt.is_file()
    assert txt.read_text(encoding="utf-8") == ""
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_lesson_audio.py
New file
@@ -0,0 +1,205 @@
"""Org-scoped lesson audio paths and one-time SD sync."""
import asyncio
from types import SimpleNamespace
from core.app_runtime.lesson_audio import (
    DEFAULT_ORG_ID,
    course_id_for,
    missing_lesson_names,
    resolve_disk_file,
    resolve_org_id,
    sanitize_relative_path,
    unique_lesson_names,
)
from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST, TEMP_SHADOWING_PLAYLIST
def test_empty_org_is_platform_default():
    assert resolve_org_id(None) == DEFAULT_ORG_ID
    assert resolve_org_id("") == DEFAULT_ORG_ID
    assert resolve_org_id("  ") == DEFAULT_ORG_ID
    assert resolve_org_id("school_a") == "school_a"
    assert resolve_org_id("../etc") == DEFAULT_ORG_ID
def test_unique_repeater_names_drop_repeats():
    names = unique_lesson_names(TEMP_REPEATER_PLAYLIST)
    assert names == [
        "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
        "/english_learning/repeater/lessons/l1/001/en/01.mp3",
    ]
def test_unique_shadowing_names_keep_sd_relative_paths():
    names = unique_lesson_names(TEMP_SHADOWING_PLAYLIST)
    assert len(names) == 58
    assert names[0] == "/english_learning/shadowing/lessons/l1/001/en/01.mp3"
    assert names[-1].endswith("/58.mp3")
    assert all(not name.startswith("/default_org") for name in names)
def test_missing_lesson_names_ignore_present_files():
    needed = unique_lesson_names(TEMP_REPEATER_PLAYLIST)
    present = [needed[0], "english_learning/repeater/lessons/l1/001/en/01.mp3"]
    assert missing_lesson_names(needed, present) == []
    assert missing_lesson_names(needed, [needed[0]]) == [needed[1]]
def test_sanitize_rejects_traversal_and_non_lesson_paths():
    assert sanitize_relative_path("english_learning/repeater/lessons/l1/001/cn/01.mp3")
    assert sanitize_relative_path("/english_learning/shadowing/lessons/l1/001/en/01.mp3")
    assert sanitize_relative_path("english_learning/../secret.mp3") is None
    assert sanitize_relative_path("other/lessons/a.mp3") is None
    assert sanitize_relative_path("english_learning/repeater/lessons/l1/001/cn.txt") is None
def test_course_id_fits_firmware_limit():
    assert course_id_for("repeater", "l1.001") == "l1-001-rpt"
    assert course_id_for("shadowing", "l1.001") == "l1-001-shd"
    assert len(course_id_for("shadowing", "l1.001")) < 32
def test_resolve_disk_file_falls_back_to_default_org(tmp_path):
    rel = "english_learning/repeater/lessons/l1/001/cn/01.mp3"
    platform = tmp_path / "lesson_audio" / DEFAULT_ORG_ID
    for part in rel.split("/"):
        platform = platform / part
    platform.parent.mkdir(parents=True)
    platform.write_bytes(b"ID3")
    found = resolve_disk_file({"log": {"data_dir": str(tmp_path)}}, "school_a", rel)
    assert found and found.replace("\\", "/").endswith("cn/01.mp3")
    assert DEFAULT_ORG_ID in found.replace("\\", "/")
def test_ensure_lesson_mp3s_downloads_missing_once(monkeypatch):
    from core.app_runtime import runtime
    lists = [
        [],
        [
            "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
            "/english_learning/repeater/lessons/l1/001/en/01.mp3",
        ],
    ]
    posted = []
    async def fake_list(conn):
        return lists.pop(0) if lists else lists[-1:] or []
    async def fake_sync(payload):
        posted.append(payload)
        return {"ok": True}
    monkeypatch.setattr(runtime, "_list_sd_mp3_names", fake_list)
    monkeypatch.setattr("config.manage_api_client.sync_lesson_audio", fake_sync)
    conn = SimpleNamespace(
        config={
            "app_runtime": {"lesson_sync_timeout_sec": 0},
            "server": {
                "http_port": 8003,
                "lesson_audio_public_url": "http://127.0.0.1:8003/lesson-audio",
            },
        },
        device_id="aa:bb:cc:dd:ee:ff",
    )
    names = unique_lesson_names(TEMP_REPEATER_PLAYLIST)
    ok = asyncio.run(runtime._ensure_lesson_mp3s(conn, names, "repeater", {"lesson_id": "l1.001"}))
    assert ok is True
    assert len(posted) == 1
    assert posted[0]["orgId"] == DEFAULT_ORG_ID
    assert posted[0]["files"] == names
    assert posted[0]["baseUrl"].endswith("/lesson-audio/" + DEFAULT_ORG_ID)
    assert "default_org" not in posted[0]["files"][0][1:]
def test_ensure_skips_preload_when_cached(monkeypatch):
    from core.app_runtime import runtime
    posted = []
    async def fake_list(conn):
        return unique_lesson_names(TEMP_REPEATER_PLAYLIST)
    async def fake_sync(payload):
        posted.append(payload)
        return {"ok": True}
    monkeypatch.setattr(runtime, "_list_sd_mp3_names", fake_list)
    monkeypatch.setattr("config.manage_api_client.sync_lesson_audio", fake_sync)
    conn = SimpleNamespace(config={"app_runtime": {"lesson_sync_timeout_sec": 0}}, device_id="mac")
    ok = asyncio.run(
        runtime._ensure_lesson_mp3s(conn, unique_lesson_names(TEMP_REPEATER_PLAYLIST), "repeater", {})
    )
    assert ok is True
    assert posted == []
def test_ensure_skips_sync_when_list_raises(monkeypatch):
    from core.app_runtime import runtime
    posted = []
    spoken = []
    async def fake_list(conn):
        raise RuntimeError("mcp timeout")
    async def fake_sync(payload):
        posted.append(payload)
        return {"ok": True}
    monkeypatch.setattr(runtime, "_list_sd_mp3_names", fake_list)
    monkeypatch.setattr("config.manage_api_client.sync_lesson_audio", fake_sync)
    monkeypatch.setattr(runtime, "_speak", lambda conn, text: spoken.append(text))
    conn = SimpleNamespace(config={"app_runtime": {"lesson_sync_timeout_sec": 0}}, device_id="mac")
    ok = asyncio.run(
        runtime._ensure_lesson_mp3s(conn, unique_lesson_names(TEMP_REPEATER_PLAYLIST), "repeater", {})
    )
    assert ok is True
    assert posted == []
    assert spoken == []
def test_list_lesson_clips_uses_files_on_disk(tmp_path, monkeypatch):
    from core.app_runtime.lesson_audio import list_lesson_clips
    from core.app_runtime.playback import expand_lesson_playlist
    monkeypatch.setattr(
        "core.app_runtime.playback.list_lesson_clips",
        lambda *_args, **_kwargs: list_lesson_clips(
            {"log": {"data_dir": str(tmp_path)}}, None, _args[2], _args[3], _args[4]
        ),
    )
    root = tmp_path / "lesson_audio" / "default_org"
    cn = root / "english_learning" / "shadowing" / "lessons" / "l1" / "001" / "cn"
    en = root / "english_learning" / "shadowing" / "lessons" / "l1" / "001" / "en"
    rpt_cn = root / "english_learning" / "repeater" / "lessons" / "l1" / "002" / "cn"
    rpt_en = root / "english_learning" / "repeater" / "lessons" / "l1" / "002" / "en"
    for folder, names in (
        (en, ["01.mp3", "03.mp3", "02.mp3"]),
        (cn, ["01.mp3"]),
        (rpt_cn, ["01.mp3", "02.mp3"]),
        (rpt_en, ["01.mp3"]),
    ):
        folder.mkdir(parents=True)
        for name in names:
            (folder / name).write_bytes(b"ID3")
    listed = list_lesson_clips({"log": {"data_dir": str(tmp_path)}}, None, "shadowing", "l1.001", "en")
    assert listed == [
        "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
        "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
        "/english_learning/shadowing/lessons/l1/001/en/03.mp3",
    ]
    shadowing = expand_lesson_playlist("shadowing", "l1.001", language="en", speed=0.8)
    assert [item["name"] for item in shadowing] == listed
    repeater = expand_lesson_playlist(
        "repeater",
        "l1.002",
        en_passes=[{"speed": 0.8, "repeat": 1}],
    )
    assert [item["name"] for item in repeater] == [
        "/english_learning/repeater/lessons/l1/002/cn/01.mp3",
        "/english_learning/repeater/lessons/l1/002/cn/02.mp3",
        "/english_learning/repeater/lessons/l1/002/en/01.mp3",
    ]
xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py
@@ -2,11 +2,14 @@
from core.app_runtime.playback import (
    PLAY_SPEED_DEFAULT,
    PLAYLIST_ITEM_MAX,
    clamp_play_speed,
    daily_base_speed,
    expand_lesson_playlist,
    lesson_day_index,
    mark_lesson_played_today,
    parse_playlist,
    playlist_from_params,
    speeds_for_repeats,
)
from core.app_runtime.runtime import (
@@ -54,11 +57,11 @@
def test_lesson_day_index_counts_distinct_days(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 0
    mark_lesson_played_today("mac1", "english_learning", "repeater", "unit01.mp3")
    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 1
    mark_lesson_played_today("mac1", "english_learning", "repeater", "unit01.mp3")
    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 1
    assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 0
    mark_lesson_played_today("mac1", "english_learning", "repeater", "l1.001.mp3")
    assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 1
    mark_lesson_played_today("mac1", "english_learning", "repeater", "l1.001.mp3")
    assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 1
def test_apply_playback_speed_queues_gradient():
@@ -72,7 +75,7 @@
        repeater_power_off_after=False,
    )
    play = _apply_playback_speed(
        conn, task, {"name": "/english_learning/repeater/lessons/unit01/full.mp3", "repeat": 5}
        conn, task, {"name": "/english_learning/repeater/lessons/l1/001/cn/01.mp3", "repeat": 5}
    )
    assert play["speed"] == 0.8
    assert play["repeat"] == 1
@@ -142,6 +145,21 @@
    assert items[1]["speed"] == 0.9
def test_parse_playlist_safety_cap_is_256_not_a_lesson_limit():
    items = parse_playlist(
        {
            "items": [
                {"name": f"/english_learning/shadowing/lessons/l1/001/en/{i:03d}.mp3"}
                for i in range(1, PLAYLIST_ITEM_MAX + 4)
            ]
        }
    )
    assert PLAYLIST_ITEM_MAX == 256
    assert len(items) == PLAYLIST_ITEM_MAX
    assert items[0]["name"].endswith("001.mp3")
    assert items[-1]["name"].endswith("256.mp3")
def test_begin_repeater_queues_remaining_items():
    conn = SimpleNamespace(
        device_id="mac",
@@ -196,7 +214,22 @@
    assert asyncio.run(_continue_repeater_if_needed(conn)) is False
def test_begin_repeater_uses_temp_unit01_playlist():
def test_begin_repeater_without_lesson_has_no_playlist():
    conn = SimpleNamespace(
        device_id="mac",
        active_app_id="english_learning",
        current_task_id="repeater",
        repeater_speed_queue=[],
        repeater_play_name="",
        repeater_power_off_after=False,
        repeater_playlist=[],
    )
    first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {})
    assert first.get("items") == []
    assert not first.get("name")
def test_begin_repeater_uses_catalog_lesson():
    from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST
    conn = SimpleNamespace(
@@ -208,19 +241,26 @@
        repeater_power_off_after=False,
        repeater_playlist=[],
    )
    first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {})
    first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {"lesson_id": "l1.001"})
    assert first["name"] == TEMP_REPEATER_PLAYLIST[0]["name"]
    assert first["repeat"] == 10
    assert first["repeat"] == 1
    assert first["speed"] == 1.0
    assert first.get("finish_task") is False
    assert len(first["items"]) == 2
    assert first["items"][0]["repeat"] == 10
    assert len(first["items"]) == 4
    assert first["items"][0]["repeat"] == 1
    assert first["items"][1]["name"] == TEMP_REPEATER_PLAYLIST[1]["name"]
    assert first["items"][1]["repeat"] == 5
    assert first["items"][1]["repeat"] == 1
    assert first["items"][1]["speed"] == 0.8
    assert first["items"][2]["speed"] == 0.9
    assert first["items"][3]["repeat"] == 5
    assert first["items"][3]["speed"] == 1.0
    assert conn.repeater_playlist == []
def test_begin_shadowing_playlist_one_shot():
def test_begin_shadowing_playlist_one_shot(tmp_path, monkeypatch):
    from core.app_runtime.playback import TEMP_SHADOWING_PLAYLIST
    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
    conn = SimpleNamespace(
        device_id="mac",
@@ -232,32 +272,33 @@
        shadowing_prompt="",
        shadowing_awaiting_user=True,
    )
    play = _begin_shadowing_playlist(conn, {})
    play = _begin_shadowing_playlist(conn, {"lesson_id": "l1.001"})
    assert play["name"] == TEMP_SHADOWING_PLAYLIST[0]["name"]
    assert play["repeat"] == 5
    assert play["repeat"] == 1
    assert play["speed"] == 0.8
    assert play.get("finish_task") is False
    assert play.get("listen_ratio") == 1.2
    assert len(play["items"]) == 2
    assert play["items"][0]["repeat"] == 5
    assert play["items"][1]["name"] == TEMP_SHADOWING_PLAYLIST[1]["name"]
    assert play["items"][1]["repeat"] == 10
    assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 5
    assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[1]["name"]) == 10
    assert len(conn.shadowing_clips) == 15
    assert len(play["items"]) == 58
    assert play["items"][0]["repeat"] == 1
    assert play["items"][-1]["name"] == TEMP_SHADOWING_PLAYLIST[-1]["name"]
    assert play["items"][-1]["speed"] == 0.8
    assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 1
    assert conn.shadowing_clips[-1] == TEMP_SHADOWING_PLAYLIST[-1]["name"]
    assert len(conn.shadowing_clips) == 58
    assert conn.shadowing_awaiting_user is False
def test_expand_shadowing_playlist_one_clip_per_listen_window():
def test_expand_shadowing_playlist_one_clip_per_listen_window(tmp_path, monkeypatch):
    from core.app_runtime.playback import (
        TEMP_SHADOWING_PLAYLIST,
        default_shadowing_playlist,
        expand_playlist_plays,
    )
    clips = expand_playlist_plays(default_shadowing_playlist())
    assert clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 5
    assert clips.count(TEMP_SHADOWING_PLAYLIST[1]["name"]) == 10
    assert len(clips) == 15
    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
    clips = expand_playlist_plays(expand_lesson_playlist("shadowing", "l1.001"))
    assert clips[0] == TEMP_SHADOWING_PLAYLIST[0]["name"]
    assert clips[-1] == TEMP_SHADOWING_PLAYLIST[-1]["name"]
    assert len(clips) == 58
def test_audio_completion_timeout_scales_with_queue():
@@ -287,18 +328,18 @@
        _execute_named_tool_play(
            conn,
            {
                "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
                "name": "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
                "repeat": 5,
                "speed": 1.0,
                "finish_task": True,
                "items": [
                    {
                        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
                        "name": "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
                        "repeat": 10,
                        "speed": 1.0,
                    },
                    {
                        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
                        "name": "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
                        "repeat": 5,
                        "speed": 1.0,
                    },
@@ -338,7 +379,7 @@
        _execute_named_tool_play(
            conn,
            {
                "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
                "name": "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
                "repeat": 1,
                "speed": 1.0,
                "finish_task": False,
@@ -370,7 +411,7 @@
        current_task_id="shadowing",
        config={},
    )
    ok = asyncio.run(_play_shadowing_clip(conn, "/english_learning/shadowing/lessons/unit01/01.mp3"))
    ok = asyncio.run(_play_shadowing_clip(conn, "/english_learning/shadowing/lessons/l1/001/en/01.mp3"))
    assert ok is True
    assert captured["params"]["finish_task"] is False
    assert captured["params"]["repeat"] == 1
@@ -380,5 +421,151 @@
    from core.app_runtime.runtime import _lesson_end_speech
    conn = SimpleNamespace(config={})
    assert _lesson_end_speech(conn, "复读结束") == "复读结束。我们来聊天吧。"
    assert _lesson_end_speech(conn, "泛听结束") == "泛听结束。我们来聊天吧。"
    assert _lesson_end_speech(conn, "跟读结束") == "跟读结束。我们来聊天吧。"
def test_expand_l1_001_repeater_and_shadowing(tmp_path, monkeypatch):
    from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST, TEMP_SHADOWING_PLAYLIST
    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
    repeater = expand_lesson_playlist("repeater", "l1.001")
    assert [item["name"] for item in repeater] == [item["name"] for item in TEMP_REPEATER_PLAYLIST]
    assert repeater[3]["repeat"] == 5
    assert "schedule" not in repeater[0]
    overridden = expand_lesson_playlist("repeater", "l1.001", repeat=1)
    assert all(item["repeat"] == 1 for item in overridden)
    shadowing = expand_lesson_playlist("shadowing", "l1.001")
    assert len(shadowing) == 58
    assert shadowing[0]["name"] == TEMP_SHADOWING_PLAYLIST[0]["name"]
    assert shadowing[-1]["name"].endswith("/58.mp3")
    assert all(item["speed"] == 0.8 for item in shadowing)
def test_expand_catalog_story_en_passes():
    from core.app_runtime.idiom_catalog import find_story
    story = find_story("l1.002")
    assert story is not None
    assert story["id"] == "002"
    items = expand_lesson_playlist(
        "repeater",
        "l1.002",
        en_passes=[
            {"speed": 0.8, "repeat": 1},
            {"speed": 0.9, "repeat": 2},
            {"speed": 1.0, "repeat": 5},
        ],
    )
    assert items[0]["name"].endswith("/l1/002/cn/01.mp3")
    assert items[0]["repeat"] == 1
    assert items[0]["speed"] == 1.0
    assert [item["speed"] for item in items[1:]] == [0.8, 0.9, 1.0]
    assert [item["repeat"] for item in items[1:]] == [1, 2, 5]
    assert all(item["name"].endswith("/l1/002/en/01.mp3") for item in items[1:])
def test_expand_catalog_shadowing_sentence_files():
    items = expand_lesson_playlist(
        "shadowing",
        "l1.079",
        language="en",
        speed=0.8,
    )
    assert len(items) == 30
    assert items[0]["name"].endswith("/l1/079/en/01.mp3")
    assert items[-1]["name"].endswith("/l1/079/en/30.mp3")
    assert all(item["speed"] == 0.8 for item in items)
    cn_items = expand_lesson_playlist(
        "shadowing",
        "l1.079",
        language="cn",
        speed=0.9,
    )
    assert cn_items[0]["name"].endswith("/l1/079/cn/01.mp3")
    assert cn_items[0]["speed"] == 0.9
def test_expand_l1_001_custom_en_pass(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
    items = expand_lesson_playlist(
        "repeater",
        "l1.001",
        en_passes=[{"speed": 0.8, "repeat": 1}],
    )
    assert items[0]["name"].endswith("/l1/001/cn/01.mp3")
    assert items[1]["name"].endswith("/l1/001/en/01.mp3")
    shadowing = expand_lesson_playlist(
        "shadowing",
        "l1.001",
        language="en",
        speed=0.8,
        sentence_count_override=58,
    )
    assert shadowing[0]["name"].endswith("/l1/001/en/01.mp3")
    assert len(shadowing) == 58
def test_idiom_catalog_lists_all_chapters():
    from core.app_runtime.idiom_catalog import find_story, load_catalog
    data = load_catalog()
    assert len(data["systems"]) == 7
    assert len(data["stories"]) == 86
    assert find_story("l1.001")["name"] == "开天辟地"
    assert find_story("l1.001")["id"] == "001"
    assert find_story("l3.048")["nameEn"].startswith("An ugly girl")
    assert find_story("l6.086")["id"] == "086"
def test_unknown_lesson_does_not_fallback_to_temp():
    assert expand_lesson_playlist("repeater", "l9.999") == []
    assert expand_lesson_playlist("shadowing", "missing") == []
    items = playlist_from_params(
        {"default_playlist": [{"name": "/x.mp3", "repeat": 1}]},
        {"lesson_id": "l9.999"},
        "repeater",
        lambda task: [{"name": "/fallback.mp3", "repeat": 1}],
    )
    assert items == []
def test_voice_without_lesson_does_not_use_temp_default():
    from core.app_runtime.playback import default_repeater_playlist
    items = playlist_from_params(None, {}, "repeater", default_repeater_playlist)
    assert items == []
def test_remember_and_load_last_lesson(tmp_path, monkeypatch):
    from core.app_runtime.playback import load_last_lesson_params, remember_last_lesson_params
    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
    remember_last_lesson_params("mac-1", "repeater", {"lesson_id": "l2.010", "en_passes": [{"speed": 0.8, "repeat": 1}]})
    remember_last_lesson_params("mac-1", "shadowing", {"lesson_id": "l3.048", "speed": 0.8, "language": "en"})
    loaded = load_last_lesson_params("mac-1", "repeater")
    assert loaded["lesson_id"] == "l2.010"
    assert load_last_lesson_params("mac-1", "shadowing")["lesson_id"] == "l3.048"
    assert load_last_lesson_params("mac-1", "chat") == {}
    assert load_last_lesson_params("other", "repeater") == {}
def test_begin_repeater_expands_lesson_id():
    conn = SimpleNamespace(
        device_id="mac",
        active_app_id="english_learning",
        current_task_id="repeater",
        repeater_speed_queue=[],
        repeater_play_name="",
        repeater_power_off_after=False,
        repeater_playlist=[],
    )
    play = _begin_repeater_playlist(conn, {}, {"lesson_id": "l1.001", "repeat": 1})
    assert play["name"].endswith("/l1/001/cn/01.mp3")
    assert len(play["items"]) == 4
    assert all(item["repeat"] == 1 for item in play["items"])
    assert play.get("finish_task") is False