| | |
| | | --- |
| | | description: 已验收模式不可回退;只改复读/听读清单与播放逻辑 |
| | | description: 已验收模式不可回退;只改泛听/跟读清单与播放逻辑 |
| | | alwaysApply: true |
| | | --- |
| | | |
| | | # 已锁定:不要改这些已正确路径 |
| | | |
| | | 开机欢迎词、对话模式、翻译模式、中英对话已经验收通过。后续只许改复读机 / 听读(跟读)。不要为修复读/听读去改 chat、translation、bilingual、hello/TTS 欢迎词时序。 |
| | | 开机欢迎词、对话模式、翻译模式、中英对话已经验收通过。后续只许改泛听(内部 repeater)/ 跟读(shadowing)。不要为修泛听/跟读去改 chat、translation、bilingual、hello/TTS 欢迎词时序。 |
| | | |
| | | 不要动: |
| | | |
| | |
| | | - `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。清单结束播「跟读结束」再回对话。 |
| | |
| | | 若设备软关机 / 硬件关机后由 RTC 唤醒 → 联网后立刻执行 pending,不进 chat |
| | | ``` |
| | | |
| | | 这是 **小智硬件重启 / 闹钟唤醒的主要产品目标**:不是为了回到待机聊天,而是到点自动听读、跟读等。 |
| | | 这是 **小智硬件重启 / 闹钟唤醒的主要产品目标**:不是为了回到待机聊天,而是到点自动泛听、跟读等。 |
| | | |
| | | 现有代码已经具备半截能力,**尚未接到 App/Task**: |
| | | |
| New file |
| | |
| | | # 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,再云端与小程序。对外文案用 **泛听 / 跟读**。 |
| New file |
| | |
| | | # 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(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**。 |
| | | |
| | | --- |
| | | |
| | | --- |
| | | |
| | |
| | | | 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;就这一款硬件时不开工 | |
| | | |
| | | --- |
| | | |
| | |
| | | ↓ |
| | | 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 | |
| | | |
| | | --- |
| | | |
| | |
| | | |
| | | --- |
| | | |
| | | ### Phase 4 — 跟读:时钟步进 + 后评估(定量) |
| | | ### Phase 4 — 跟读:清单循环 + 后评估(定量) ✅ |
| | | |
| | | 1. **Objective** |
| | | **仅跟读这一类**:预定 MP3 序列 + 定时录音。分析全部后置、定量、允许失败。设备不宣读对错。 |
| | | **不要**把倒背、接龙套进本阶段的「分析永不挡喇叭」。 |
| | | **完成事实:** 见 `phase-4-eval-and-roadmap.md`。步进在**设备播放列表**(一次 JSON 下发),不是逐句 MCP;后评估仍在 Server。 |
| | | |
| | | 2. **Why Now** |
| | | 跟读/打卡录音的价值在归档与事后看指标。若把云端比对绑在句间,喇叭会卡。 |
| | |
| | | 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** |
| | | 把三套独立的编号钉死,并在连接时对上: |
| | |
| | | |
| | | 施工单:以后写 `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,不是第五套协议。 |
| | |
| | | |
| | | --- |
| | | |
| | | ### 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** |
| | | 开发者平台、支付、在小程序里训练唤醒词。 |
| | | 开发者平台、支付、在小程序里训练唤醒词、硬件代际管理。 |
| | | |
| | | --- |
| | | |
| | |
| | | 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,短超时) |
| | |
| | | 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 |
| | | ``` |
| | | |
| | | --- |
| | |
| | | | 长期目标 | 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 | |
| | | |
| | |
| | | - 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 后置。 |
| | |
| | | #include <cstring> |
| | | #include <memory> |
| | | #include <esp_log.h> |
| | | #include <esp_heap_caps.h> |
| | | #include <cJSON.h> |
| | | #include <driver/gpio.h> |
| | | #include <arpa/inet.h> |
| | |
| | | |
| | | 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)); |
| | | |
| | |
| | | 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(); |
| | |
| | | static void ResumeSdCardMp3() { |
| | | if (SDCard_Resume_Play) { |
| | | SDCard_Resume_Play(); |
| | | } |
| | | } |
| | | |
| | | static void StopSdCardMp3() { |
| | | if (SDCard_Stop_Play) { |
| | | SDCard_Stop_Play(); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | void Application::Run() { |
| | | main_loop_task_ = xTaskGetCurrentTaskHandle(); |
| | | // Set the priority of the main task to 10 |
| | | vTaskPrioritySet(nullptr, 10); |
| | | |
| | |
| | | display->UpdateStatusBar(); |
| | | auto state = GetDeviceState(); |
| | | if (state == kDeviceStateConnecting || state == kDeviceStateSpeaking || |
| | | state == kDeviceStateActivating || |
| | | (state == kDeviceStateIdle && suppress_wake_until_listen_)) { |
| | | audio_service_.KeepCodecReadyForPlayback(); |
| | | } |
| | |
| | | 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; |
| | |
| | | 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()) { |
| | |
| | | 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(); |
| | |
| | | if (GetDeviceState() == kDeviceStateIdle) { |
| | | break; |
| | | } |
| | | // 激活请求失败时再播一遍 6 位码,避免只在开机瞬间播一次被听漏。 |
| | | if (ota_->HasActivationCode() && i < 9) { |
| | | ShowActivationCode(ota_->GetActivationCode(), ota_->GetActivationMessage()); |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | 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, |
| | |
| | | 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; |
| | |
| | | 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(); |
| | |
| | | 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_++; |
| | |
| | | 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(); |
| | | }); |
| | |
| | | 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; |
| | | } |
| | |
| | | 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; |
| | |
| | | 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)); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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()) { |
| | |
| | | Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE); |
| | | protocol_->MarkIncomingActivity(); |
| | | if (protocol_->SendWakeWordDetected(text)) { |
| | | Board_NoteLessonStartDetectSent(); |
| | | return; |
| | | } |
| | | ESP_LOGW(TAG, "detect send failed, reopen audio channel"); |
| | |
| | | // 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); |
| | | |
| | |
| | | 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); |
| | |
| | | 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; |
| | | } |
| | |
| | | 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))) { |
| | |
| | | |
| | | 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()) { |
| | |
| | | tts_stop_pending_ = false; |
| | | tts_stop_waiting_ = false; |
| | | tts_idle_waiting_ = false; |
| | | tts_session_end_ = false; |
| | | tts_speaking_state_ready_ = false; |
| | | tts_activity_generation_++; |
| | | } |
| | |
| | | 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); |
| | |
| | | // Do nothing |
| | | break; |
| | | } |
| | | } |
| | | |
| | | bool Application::IsOnMainLoop() const { |
| | | return main_loop_task_ != nullptr && xTaskGetCurrentTaskHandle() == main_loop_task_; |
| | | } |
| | | |
| | | void Application::Schedule(std::function<void()>&& callback) { |
| | |
| | | tts_stop_pending_ = false; |
| | | tts_stop_waiting_ = false; |
| | | tts_idle_waiting_ = false; |
| | | tts_session_end_ = false; |
| | | tts_speaking_state_ready_ = false; |
| | | tts_activity_generation_++; |
| | | |
| | |
| | | } |
| | | |
| | | 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; |
| | |
| | | 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(); |
| | |
| | | if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) { |
| | | ApplyMp3CommandOnlyMic(); |
| | | } |
| | | Board_FlushPendingLessonPlayback(); |
| | | if (power_off_requested_) { |
| | | power_off_requested_ = false; |
| | | Board_Enter_Power_Off(); |
| | |
| | | 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"); |
| | |
| | | |
| | | // 欢迎词/长回答: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(); |
| | | } |
| | | } |
| | |
| | | return false; |
| | | } |
| | | |
| | | // 泛听是 idle 播 SD MP3,AudioService 队列是空的,会被当成可休眠。 |
| | | // 跟读每句有听窗所以不会进 WiFi 待机;泛听会把网关掉,结束语就发不出去。 |
| | | if (IsSdCardMp3Playing() || Board_IsRepeaterTask() || Board_IsShadowingTask()) { |
| | | return false; |
| | | } |
| | | |
| | | if (!audio_service_.IsIdle()) { |
| | | return false; |
| | | } |
| | |
| | | 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); |
| | |
| | | void PlaySound(const std::string_view& sound); |
| | | AudioService& GetAudioService() { return audio_service_; } |
| | | void ApplyMp3CommandOnlyMic(); |
| | | bool IsOnMainLoop() const; |
| | | void ResumeListeningAfterMp3Clip(); |
| | | void CloseShadowingListenWindow(); |
| | | |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | int sleep_inhibit_count_ = 0; |
| | | int clock_ticks_ = 0; |
| | | TaskHandle_t activation_task_handle_ = nullptr; |
| | | TaskHandle_t main_loop_task_ = nullptr; |
| | | |
| | | |
| | | // Event handlers |
| | |
| | | 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; |
| | |
| | | #include "audio_service.h" |
| | | #include "processors/audio_debugger.h" |
| | | #include <esp_log.h> |
| | | #include <cstring> |
| | | #include <chrono> |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | void AudioService::AbortWakeWordEncode() { |
| | | if (wake_word_) { |
| | | wake_word_->AbortWakeWordEncode(); |
| | | } |
| | | } |
| | | |
| | | const std::string& AudioService::GetLastWakeWord() const { |
| | | return wake_word_->GetLastDetectedWakeWord(); |
| | | } |
| | |
| | | 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 |
| | |
| | | } |
| | | } |
| | | |
| | | 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) { |
| | |
| | | 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 */ |
| | |
| | | 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); |
| | |
| | | |
| | | #include <memory> |
| | | #include <deque> |
| | | #include <vector> |
| | | #include <condition_variable> |
| | | #include <chrono> |
| | | #include <mutex> |
| | |
| | | |
| | | #include "audio_codec.h" |
| | | #include "audio_processor.h" |
| | | #include "processors/audio_debugger.h" |
| | | #include "wake_word.h" |
| | | #include "protocol.h" |
| | | #include "ogg_demuxer.h" |
| | |
| | | uint32_t playback_count = 0; |
| | | }; |
| | | |
| | | class AudioDebugger; |
| | | |
| | | class AudioService { |
| | | public: |
| | | AudioService(); |
| | |
| | | void Start(); |
| | | void Stop(); |
| | | void EncodeWakeWord(); |
| | | void AbortWakeWordEncode(); |
| | | std::unique_ptr<AudioStreamPacket> PopWakeWordPacket(); |
| | | const std::string& GetLastWakeWord() const; |
| | | bool IsVoiceDetected() const { return voice_detected_; } |
| | |
| | | |
| | | void EnableWakeWordDetection(bool enable); |
| | | void EnableVoiceProcessing(bool enable); |
| | | void ReleaseAfeRuntime(); |
| | | void ReleaseVoiceProcessorRuntime(); |
| | | void ClearWakeWordBuffers(); |
| | | void EnsureVoiceProcessorReady(); |
| | | void EnableAudioTesting(bool enable); |
| | | void EnableDeviceAec(bool enable); |
| | |
| | | |
| | | 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'); |
| | |
| | | } |
| | | |
| | | 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; |
| | |
| | | |
| | | 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() { |
| | |
| | | } |
| | | |
| | | 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; |
| | | } |
| | |
| | | } |
| | | |
| | | 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_); |
| | |
| | | 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; |
| | |
| | | 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(); |
| | | }; |
| | | |
| | |
| | | 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; |
| | |
| | | #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 |
| | |
| | | |
| | | 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"); |
| | |
| | | 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); |
| | |
| | | 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'); |
| | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | void AfeWakeWord::Feed(const std::vector<int16_t>& data) { |
| | | if (afe_data_ == nullptr) { |
| | | if (afe_data_ == nullptr || detection_task_handle_ == nullptr) { |
| | | return; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | } |
| | | |
| | | 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(); |
| | |
| | | } |
| | | 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(); |
| | |
| | | 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); |
| | |
| | | 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(); |
| | | }; |
| | |
| | | #include <esp_mn_models.h> |
| | | #include <esp_mn_speech_commands.h> |
| | | #include <cJSON.h> |
| | | #include <chrono> |
| | | |
| | | #define TAG "CustomWakeWord" |
| | | |
| | |
| | | } |
| | | |
| | | 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() { |
| | |
| | | } |
| | | 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(); |
| | |
| | | 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); |
| | |
| | | #include "app_task.h" |
| | | #include "parent_schedule.h" |
| | | |
| | | #include "application.h" |
| | | #include "board.h" |
| | |
| | | #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; |
| | |
| | | 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) |
| | |
| | | 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) |
| | | { |
| | |
| | | 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, |
| | |
| | | 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); |
| | |
| | | } |
| | | 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)); |
| | |
| | | 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 && |
| | |
| | | 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; |
| | | } |
| | |
| | | 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; |
| | | |
| | |
| | | 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(); |
| | | } |
| | |
| | | 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"); |
| | |
| | | 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); |
| | |
| | | } |
| | | 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; |
| | | } |
| | |
| | | } |
| | | 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; |
| | | } |
| | |
| | | |
| | | 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); |
| | |
| | | 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 |
| | |
| | | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | | } |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | { |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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(); |
| | |
| | | } |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | |
| | |
| | | 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(); |
| | | } |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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; |
| | | } |
| | |
| | | }); |
| | | } |
| | | |
| | | 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; |
| | |
| | | #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 |
| | |
| | | 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, |
| | |
| | | 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); |
| | |
| | | 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]; |
| | |
| | | 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); |
| | | |
| | |
| | | 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; |
| | | |
| | |
| | | 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) |
| | |
| | | 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) |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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 { |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | cJSON_Delete(root); |
| | | } |
| | | |
| | | static void BoardMqtt_ShowLessonSyncProgress(int percent, size_t asset_index, size_t asset_count) |
| | | { |
| | | char msg[96]; |
| | | if (percent < 0) { |
| | | percent = 0; |
| | | } else if (percent > 100) { |
| | | percent = 100; |
| | | } |
| | | if (asset_count == 0) { |
| | | snprintf(msg, sizeof(msg), "正在同步课文,请稍候"); |
| | | } else { |
| | | snprintf(msg, sizeof(msg), "正在下载 %u/%u,已完成 %d%%", |
| | | (unsigned)(asset_index + 1), (unsigned)asset_count, percent); |
| | | } |
| | | Board_Show_Alert("正在同步", msg, "cloud_arrow_down"); |
| | | } |
| | | |
| | | static char *BoardMqtt_CopySegment(const char *data, int data_len) |
| | | { |
| | | if (!data || data_len <= 0) { |
| | |
| | | return written > 0 && (size_t)written < dest_size; |
| | | } |
| | | |
| | | static bool BoardMqtt_SdRegularFileReady(const char *business_path) |
| | | { |
| | | char sd_path[SDCARD_PATH_MAX_LEN]; |
| | | if (!business_path || business_path[0] == '\0') { |
| | | return false; |
| | | } |
| | | if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) { |
| | | return false; |
| | | } |
| | | struct stat st; |
| | | if (stat(sd_path, &st) != 0) { |
| | | return false; |
| | | } |
| | | return S_ISREG(st.st_mode) && st.st_size > 0; |
| | | } |
| | | |
| | | static bool BoardMqtt_JoinDownloadUrl(char *dest, size_t dest_size, const char *base_url, const char *rel) |
| | | { |
| | | if (!dest || dest_size == 0 || !base_url || !rel || rel[0] == '\0') { |
| | | return false; |
| | | } |
| | | size_t base_len = strlen(base_url); |
| | | while (base_len > 0 && base_url[base_len - 1] == '/') { |
| | | --base_len; |
| | | } |
| | | if (base_len == 0) { |
| | | return false; |
| | | } |
| | | int written; |
| | | if (rel[0] == '/') { |
| | | written = snprintf(dest, dest_size, "%.*s%s", (int)base_len, base_url, rel); |
| | | } else { |
| | | written = snprintf(dest, dest_size, "%.*s/%s", (int)base_len, base_url, rel); |
| | | } |
| | | return written > 0 && (size_t)written < dest_size; |
| | | } |
| | | |
| | | static bool BoardMqtt_IsSupportedResourcePath(const char *sd_path) |
| | | { |
| | | const char *dot = sd_path ? strrchr(sd_path, '.') : NULL; |
| | |
| | | return BoardMqtt_CopyString(dest, dest_size, md5->valuestring); |
| | | } |
| | | |
| | | static bool BoardMqtt_IsLessonAppTask(const char *app_id, const char *task_id) |
| | | { |
| | | return app_id && task_id && strcmp(app_id, "english_learning") == 0 && |
| | | (strcmp(task_id, "repeater") == 0 || strcmp(task_id, "shadowing") == 0); |
| | | } |
| | | |
| | | static void BoardMqtt_ClearQueuedAppTask(void) |
| | | { |
| | | free(s_queued_app_task.params_json); |
| | | memset(&s_queued_app_task, 0, sizeof(s_queued_app_task)); |
| | | s_queued_app_gen++; |
| | | } |
| | | |
| | | static bool BoardMqtt_QueueAppTask(const char *app_id, const char *task_id, const char *source, const char *params_json) |
| | | { |
| | | BoardMqtt_ClearQueuedAppTask(); |
| | | if (!BoardMqtt_CopyString(s_queued_app_task.app_id, sizeof(s_queued_app_task.app_id), app_id) || |
| | | !BoardMqtt_CopyString(s_queued_app_task.task_id, sizeof(s_queued_app_task.task_id), task_id) || |
| | | !BoardMqtt_CopyString(s_queued_app_task.source, sizeof(s_queued_app_task.source), source && source[0] ? source : "mqtt_immediate")) { |
| | | BoardMqtt_ClearQueuedAppTask(); |
| | | return false; |
| | | } |
| | | const char *params = (params_json && params_json[0]) ? params_json : "{}"; |
| | | size_t params_len = strlen(params) + 1; |
| | | s_queued_app_task.params_json = (char *)malloc(params_len); |
| | | if (!s_queued_app_task.params_json) { |
| | | BoardMqtt_ClearQueuedAppTask(); |
| | | return false; |
| | | } |
| | | memcpy(s_queued_app_task.params_json, params, params_len); |
| | | s_queued_app_task.active = true; |
| | | s_queued_app_task.gen = s_queued_app_gen; |
| | | return true; |
| | | } |
| | | |
| | | static bool BoardMqtt_PackageCached(cJSON *files) |
| | | { |
| | | if (!cJSON_IsArray(files) || cJSON_GetArraySize(files) <= 0) { |
| | | return false; |
| | | } |
| | | cJSON *file = NULL; |
| | | cJSON_ArrayForEach(file, files) { |
| | | if (!cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') { |
| | | return false; |
| | | } |
| | | char business_path[SDCARD_PATH_MAX_LEN]; |
| | | if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), file->valuestring)) { |
| | | return false; |
| | | } |
| | | if (!BoardMqtt_SdRegularFileReady(business_path)) { |
| | | return false; |
| | | } |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | static void BoardMqtt_StartQueuedAppTaskIfReady(bool preload_ok, uint32_t queued_gen) |
| | | { |
| | | if (!s_queued_app_task.active || s_queued_app_task.gen != queued_gen) { |
| | | return; |
| | | } |
| | | if (!preload_ok) { |
| | | Board_ReleaseHoldIdle(); |
| | | ESP_LOGW(TAG, "preload failed, drop queued %s/%s", s_queued_app_task.app_id, s_queued_app_task.task_id); |
| | | Board_Show_Alert("同步失败", "课文下载失败,请稍后重试", "sad"); |
| | | BoardMqtt_ClearQueuedAppTask(); |
| | | return; |
| | | } |
| | | ESP_LOGI(TAG, "package READY, start AI task %s/%s", s_queued_app_task.app_id, s_queued_app_task.task_id); |
| | | char app_id[32]; |
| | | char task_id[32]; |
| | | char source[24]; |
| | | char *params = s_queued_app_task.params_json; |
| | | s_queued_app_task.params_json = NULL; |
| | | BoardMqtt_CopyString(app_id, sizeof(app_id), s_queued_app_task.app_id); |
| | | BoardMqtt_CopyString(task_id, sizeof(task_id), s_queued_app_task.task_id); |
| | | BoardMqtt_CopyString(source, sizeof(source), s_queued_app_task.source); |
| | | BoardMqtt_ClearQueuedAppTask(); |
| | | Board_StartAppTask(app_id, task_id, params, source); |
| | | free(params); |
| | | } |
| | | |
| | | static bool BoardMqtt_ActionIsCtl(const char *action) |
| | | { |
| | | return strcmp(action, "SET_VOLUME") == 0 || strcmp(action, "SET_POWER") == 0 || |
| | |
| | | strcmp(action, "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; |
| | | } |
| | | |
| | |
| | | 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"); |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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; |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | |
| | | 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}; |
| | |
| | | |
| | | 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)); |
| | |
| | | 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) { |
| | |
| | | .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; |
| | |
| | | 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); |
| | |
| | | |
| | | 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}; |
| | |
| | | } |
| | | |
| | | s_download_voice_yield_requested = false; |
| | | Board_SetLessonDownloadBoost(false); |
| | | BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD); |
| | | Board_Release_Sleep_Inhibit(); |
| | | free(job); |
| | |
| | | } |
| | | 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)) { |
| | |
| | | { |
| | | 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(); |
| | |
| | | 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"); |
| | | } |
| | | |
| | |
| | | } |
| | | 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); \ |
| | |
| | | } |
| | | |
| | | 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(); |
| | | } |
| | | |
| | |
| | | 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"); |
| | |
| | | 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) |
| | |
| | | return BoardMqtt_ResultOk("app dirs ensured"); |
| | | } |
| | | |
| | | static board_mqtt_action_result_t BoardMqtt_HandleSyncSchedule(cJSON *payload) |
| | | { |
| | | if (!payload) { |
| | | return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload"); |
| | | } |
| | | char *json = cJSON_PrintUnformatted(payload); |
| | | if (!json) { |
| | | return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "schedule json alloc failed"); |
| | | } |
| | | esp_err_t err = Board_Schedule_SyncJson(json); |
| | | free(json); |
| | | if (err == ESP_ERR_INVALID_ARG) { |
| | | return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid schedule"); |
| | | } |
| | | if (err == ESP_ERR_NO_MEM) { |
| | | return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "too many scheduled tasks"); |
| | | } |
| | | if (err != ESP_OK) { |
| | | return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err)); |
| | | } |
| | | return BoardMqtt_ResultOk("schedule synced"); |
| | | } |
| | | |
| | | static board_mqtt_action_result_t BoardMqtt_DispatchAction(const char *req_id, const char *action, cJSON *payload) |
| | | { |
| | | if (strcmp(action, "SET_VOLUME") == 0) return BoardMqtt_HandleSetVolume(payload); |
| | |
| | | if (strcmp(action, "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; |
| | |
| | | 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"); |
| | |
| | | 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); |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |
| | |
| | | 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); |
| New file |
| | |
| | | #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(); |
| | | } |
| New file |
| | |
| | | #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 |
| | |
| | | #include <cmath> |
| | | #include <cstdio> |
| | | #include <cstring> |
| | | #include <memory> |
| | | #include <string> |
| | | |
| | | #include <esp_lcd_panel_io.h> |
| | |
| | | #include <ds3231.h> |
| | | #include "system.h" |
| | | #include "mqtt.h" |
| | | #include "parent_schedule.h" |
| | | |
| | | #define TAG "sening_xiaozhi_s3" |
| | | #define WIFI_RECONNECT_CONNECTED_BIT BIT0 |
| | |
| | | { |
| | | 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) |
| | |
| | | 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; |
| | | } |
| | |
| | | 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); |
| | |
| | | } |
| | | |
| | | 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; |
| | | } |
| | |
| | | } |
| | | |
| | | 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"); |
| | |
| | | } |
| | | |
| | | 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; |
| | |
| | | fclose(fp); |
| | | http->Close(); |
| | | heap_caps_free(buffer); |
| | | if (io_buf) { |
| | | heap_caps_free(io_buf); |
| | | } |
| | | |
| | | if (!ok) { |
| | | if (check_md5) { |
| | |
| | | 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; |
| | |
| | | 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"); |
| | |
| | | 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++; |
| | | } |
| | | } |
| | |
| | | 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 && |
| | |
| | | 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); |
| | |
| | | |
| | | 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(); |
| | | } |
| | |
| | | #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> |
| | |
| | | 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; |
| | | |
| | |
| | | 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); |
| | |
| | | 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) |
| | |
| | | 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(¤t_alarm1_entry, now); |
| | | if (next_delta < current_delta || !alarm_entry_equals(best, ¤t_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; |
| | |
| | | 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) |
| | |
| | | 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) |
| | |
| | | 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; |
| | |
| | | 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(¤t_alarm1_entry, &now); |
| | | if (next_delta < current_delta || !alarm_entry_equals(next_alarm, ¤t_alarm1_entry)) { |
| | | program_alarm1(i2c_dev, next_alarm); |
| | | } |
| | | Board_Schedule_OnLessonFilesUpdated(); |
| | | program_soonest_alarm(i2c_dev, &now); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | #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]; |
| | |
| | | |
| | | /** |
| | | * @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 "我们聊天吧" |
| | |
| | | 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 表示未暂停。 |
| | |
| | | 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); |
| | |
| | | #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' |
| | |
| | | } |
| | | |
| | | // MODEM 从 MAX_MODEM 切回 NONE 不是瞬时完成的。立刻握手会 |
| | | // WebSocket handshake timeout,开机欢迎词和复读结束后回 chat 都会失败。 |
| | | // WebSocket handshake timeout,开机欢迎词和泛听结束后回 chat 都会失败。 |
| | | vTaskDelay(pdMS_TO_TICKS(400)); |
| | | |
| | | Settings settings("websocket", false); |
| | |
| | | <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> |
| | |
| | | return "device:address_book:all"; |
| | | } |
| | | |
| | | /** |
| | | * 家长任务:设备 MQTT 在线标记 |
| | | */ |
| | | public static String getParentDeviceOnlineKey(String macAddress) { |
| | | return "parent:device:online:" + macAddress; |
| | | } |
| | | |
| | | } |
| | |
| | | @Schema(description = "设备数量", example = "10") |
| | | private Integer deviceCount; |
| | | |
| | | @Schema(description = "在线设备数量") |
| | | private Integer onlineDeviceCount; |
| | | |
| | | @Schema(description = "绑定设备及在线状态") |
| | | private List<AgentDeviceStatusDTO> devices; |
| | | |
| | | @Schema(description = "标签列表") |
| | | private List<AgentTagDTO> tags; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | // 获取智能体最近的最后连接时长 |
| | | 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()); |
| | |
| | | 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; |
| | | |
| | |
| | | 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}") |
| | |
| | | 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") |
| | |
| | | @Schema(description = "用户显示设备列表VO") |
| | | public class UserShowDeviceListVO { |
| | | |
| | | @Schema(description = "是否在线(家长 MQTT 状态)") |
| | | private Boolean online; |
| | | |
| | | @Schema(description = "app版本") |
| | | private String appVersion; |
| | | |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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<>(); |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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<>(); |
| | | } |
| New file |
| | |
| | | package xiaozhi.modules.parent.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | @Data |
| | | public class ParentHistoryTurnDTO { |
| | | private Integer index; |
| | | private String expectedText; |
| | | private String asrText; |
| | | private String audioId; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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<>(); |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | package xiaozhi.modules.parent.mqtt; |
| | | |
| | | public record ParentDeviceOfflineEvent(String macAddress) { |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| New file |
| | |
| | | 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) { |
| | | } |
| | | } |
| New file |
| | |
| | | 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('-', ':'); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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"); |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | |
| | | 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 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; |
| | |
| | | @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}") |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | -- 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'; |
| New file |
| | |
| | | -- 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 密码'); |
| New file |
| | |
| | | -- 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'); |
| New file |
| | |
| | | -- 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'; |
| | |
| | | - 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 |
| | |
| | | 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); |
| | | |
| | |
| | | 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); |
| | | |
| | |
| | | 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); |
| | | |
| | |
| | | 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; |
| | | |
| | |
| | | deviceService, |
| | | mock(DeviceAddressBookService.class), |
| | | mock(RedisUtils.class), |
| | | mock(SysParamsService.class)); |
| | | mock(SysParamsService.class), |
| | | mock(ParentDevicePresence.class), |
| | | mock(ParentTaskService.class)); |
| | | } |
| | | |
| | | private DeviceEntity ownedDevice() { |
| New file |
| | |
| | | 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")); |
| | | } |
| | | } |
| New file |
| | |
| | | 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())); |
| | | } |
| | | } |
| | |
| | | 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' |
| | |
| | | VITE_DELETE_CONSOLE = false |
| | | # 是否开启sourcemap |
| | | VITE_SHOW_SOURCEMAP = true |
| | | |
| | | # 与 manager-web(8001) 同一套本地 manager-api |
| | | VITE_SERVER_BASEURL = 'http://127.0.0.1:8002/xiaozhi' |
| | |
| | | 'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans' |
| | | 'h5': { |
| | | router: { |
| | | // base: VITE_APP_PUBLIC_BASE, |
| | | base: VITE_APP_PUBLIC_BASE, |
| | | }, |
| | | }, |
| | | /* 5+App特有相关 */ |
| | |
| | | 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 |
| | |
| | | alias?: string |
| | | agentId: string |
| | | appVersion: string |
| | | online?: boolean |
| | | sort: number |
| | | updater?: string |
| | | updateDate: string |
| New file |
| | |
| | | export * from './parent' |
| | | export * from './types' |
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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[] |
| | | } |
| | |
| | | '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', |
| | |
| | | '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', |
| | |
| | | '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', |
| | |
| | | '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.', |
| | | } |
| | |
| | | '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', |
| | |
| | | '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', |
| | |
| | | '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', |
| | |
| | | '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.', |
| | | } |
| | |
| | | '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', |
| | |
| | | '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', |
| | |
| | | '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', |
| | |
| | | '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.', |
| | | } |
| | |
| | | '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ý', |
| | |
| | | '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', |
| | |
| | | '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', |
| | |
| | | '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.', |
| | | } |
| | |
| | | 'home.daysAgo': '天前', |
| | | 'home.languageModel': '语言模型', |
| | | 'home.voiceModel': '音色模型', |
| | | 'home.noBoundDevice': '暂无绑定设备', |
| | | 'home.onlineCount': '{online}/{total} 台在线', |
| | | |
| | | // Agent页面 |
| | | 'agent.pageTitle': '智能体', |
| | |
| | | 'agent.deviceManagement': '设备管理', |
| | | 'agent.chatHistory': '聊天记录', |
| | | 'agent.voiceprintManagement': '声纹管理', |
| | | 'agent.parentTasks': '学习任务', |
| | | 'agent.editTitle': '编辑智能体', |
| | | 'agent.toolsTitle': '编辑功能', |
| | | 'agent.voiceActivityDetection': '语音活动检测', |
| | |
| | | 'device.deviceType': '设备类型', |
| | | 'device.loading': '加载中...', |
| | | 'device.neverConnected': '从未连接', |
| | | 'device.checkingOnline': '正在检测设备状态…', |
| | | 'device.online': '设备在线', |
| | | 'device.offline': '设备不在线', |
| | | 'device.onlineHint': '可以下发即时任务和计划任务。', |
| | | 'device.offlineHint': '无法下发任务,仍可查看聊天记录等。', |
| | | 'device.justNow': '刚刚', |
| | | 'device.minutesAgo': '{minutes}分钟前', |
| | | 'device.hoursAgo': '{hours}小时前', |
| | |
| | | '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': '这段时间没有留下对话。', |
| | | } |
| | |
| | | 'home.daysAgo': '天前', |
| | | 'home.languageModel': '語言模型', |
| | | 'home.voiceModel': '音色模型', |
| | | 'home.noBoundDevice': '暫無綁定裝置', |
| | | 'home.onlineCount': '{online}/{total} 台在線', |
| | | |
| | | // Agent頁面 |
| | | 'agent.pageTitle': '智能體', |
| | |
| | | 'agent.deviceManagement': '設備管理', |
| | | 'agent.chatHistory': '聊天記錄', |
| | | 'agent.voiceprintManagement': '聲紋管理', |
| | | 'agent.parentTasks': '學習任務', |
| | | 'agent.editTitle': '編輯智能體', |
| | | 'agent.toolsTitle': '編輯功能', |
| | | 'agent.voiceActivityDetection': '語音活動檢測', |
| | |
| | | 'device.deviceType': '設備類型', |
| | | 'device.loading': '加載中...', |
| | | 'device.neverConnected': '從未連接', |
| | | 'device.checkingOnline': '正在檢測裝置狀態…', |
| | | 'device.online': '裝置在線', |
| | | 'device.offline': '裝置不在線', |
| | | 'device.onlineHint': '可以下發即時任務和計畫任務。', |
| | | 'device.offlineHint': '無法下發任務,仍可查看聊天記錄等。', |
| | | 'device.justNow': '剛剛', |
| | | 'device.minutesAgo': '{minutes}分鐘前', |
| | | 'device.hoursAgo': '{hours}小時前', |
| | |
| | | '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': '這段時間沒有留下對話。', |
| | | } |
| | |
| | | }, |
| | | "vueVersion": "3", |
| | | "h5": { |
| | | "router": {} |
| | | "router": { |
| | | "base": "/mobile/" |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | }, |
| | | { |
| | | "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", |
| | |
| | | |
| | | const agentId = computed(() => props.agentId) |
| | | |
| | | function isUsableAgentId(id?: string) { |
| | | return Boolean(id && id !== 'default') |
| | | } |
| | | |
| | | // 表单数据 |
| | | const formData = ref<Partial<AgentDetail>>({ |
| | | agentName: '', |
| | |
| | | |
| | | // 加载智能体详情 |
| | | async function loadAgentDetail(targetAgentId = agentId.value) { |
| | | if (!targetAgentId) |
| | | if (!isUsableAgentId(targetAgentId)) |
| | | return false |
| | | |
| | | const requestId = ++agentDetailRequestSequence |
| | |
| | | |
| | | // 获取智能体标签 |
| | | async function loadAgentTags(targetAgentId = agentId.value) { |
| | | if (!targetAgentId) { |
| | | if (!isUsableAgentId(targetAgentId)) { |
| | | return false |
| | | } |
| | | const requestId = ++agentTagRequestSequence |
| | |
| | | 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([ |
| | |
| | | ]) |
| | | |
| | | // 然后加载智能体详情,这样可以正确映射显示名称 |
| | | if (agentId.value && !snapshotReloadBlocked.value) { |
| | | if (isUsableAgentId(agentId.value) && !snapshotReloadBlocked.value) { |
| | | await loadAgentDetail() |
| | | } |
| | | }) |
| | |
| | | </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' |
| | | |
| | |
| | | // #endif |
| | | |
| | | // 智能体ID |
| | | const currentAgentId = ref('default') |
| | | const currentAgentId = ref('') |
| | | |
| | | // 当前 tab |
| | | const currentTab = ref('agent-config') |
| | |
| | | const deviceRef = ref() |
| | | const chatRef = ref() |
| | | const voiceprintRef = ref() |
| | | const parentTaskRef = ref() |
| | | |
| | | // 更新刷新器状态 |
| | | function updateRefresherEnabled(value: boolean) { |
| | |
| | | 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', |
| | | }, |
| | | ] |
| | | |
| | |
| | | await voiceprintRef.value.refresh() |
| | | } |
| | | break |
| | | case 'parent-tasks': |
| | | if (parentTaskRef.value?.refresh) { |
| | | await parentTaskRef.value.refresh() |
| | | } |
| | | break |
| | | } |
| | | } |
| | | catch (error) { |
| | |
| | | if (options?.agentId) { |
| | | currentAgentId.value = options.agentId |
| | | console.log('接收到智能体ID:', options.agentId) |
| | | } |
| | | }) |
| | | |
| | | onShow(() => { |
| | | if (currentTab.value === 'parent-tasks' && parentTaskRef.value?.refresh) { |
| | | parentTaskRef.value.refresh() |
| | | } |
| | | }) |
| | | |
| | |
| | | <!-- Tab 内容 --> |
| | | <view class="flex-1"> |
| | | <AgentEdit |
| | | v-if="currentTab === 'agent-config'" |
| | | v-if="currentTab === 'agent-config' && currentAgentId" |
| | | :agent-id="currentAgentId" |
| | | /> |
| | | <DeviceManagement |
| | |
| | | :agent-id="currentAgentId" |
| | | @update-refresher-enabled="updateRefresherEnabled" |
| | | /> |
| | | <ParentTaskList |
| | | v-else-if="currentTab === 'parent-tasks'" |
| | | ref="parentTaskRef" |
| | | embedded |
| | | :agent-id="currentAgentId" |
| | | /> |
| | | </view> |
| | | </scroll-view> |
| | | </view> |
| | |
| | | } |
| | | |
| | | // 获取设备类型名称 |
| | | 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 |
| | |
| | | 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)}`, |
| | | }) |
| | | } |
| | | |
| | | // 打开绑定设备对话框 |
| | |
| | | <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> |
| | | |
| | |
| | | @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> |
| | |
| | | </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' |
| | | |
| | |
| | | console.log('z-paging获取智能体列表') |
| | | |
| | | const response = await getAgentList() |
| | | await attachDevices(response) |
| | | |
| | | // 更新本地列表 |
| | | agentList.value = response |
| | |
| | | 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') |
| | | } |
| | | |
| | | // 进入编辑页面 |
| | |
| | | <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"> |
| | |
| | | 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; |
| | | |
| | |
| | | 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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | 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 '' |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | 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 || '' |
| | | } |
| | |
| | | import smCrypto from 'sm-crypto' |
| | | import { pages, subPackages } from '@/pages.json' |
| | | |
| | | import { isMpWeixin } from './platform' |
| | | import { isH5, isMpWeixin } from './platform' |
| | | |
| | | /** |
| | | * 运行时服务端地址覆盖存储键 |
| | |
| | | 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 |
| | | |
| | |
| | | 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, |
| | |
| | | # 所以如果你使用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 |
| | | # 认证配置 |
| | |
| | | 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." |
| | |
| | | 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) |
| | | |
| New file |
| | |
| | | """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})" |
| | | ) |
| | |
| | | { |
| | | "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": "聊天", |
| | |
| | | "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": { |
| | |
| | | }, |
| | | "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.", |
| | |
| | | "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": [ |
| | | { |
| | |
| | | "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": [] |
| | | } |
| | |
| | | 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: |
| | |
| | | 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) |
| | |
| | | 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 |
| New file |
| | |
| | | { |
| | | "_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 |
| | | } |
| | | ] |
| | | } |
| New file |
| | |
| | | """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) |
| | | ) |
| New file |
| | |
| | | """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) |
| | |
| | | |
| | | 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() |
| | |
| | | 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: |
| | |
| | | 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: |
| | |
| | | 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 {} |
| | |
| | | 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( |
| | | { |
| | |
| | | |
| | | |
| | | 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 [] |
| | |
| | | |
| | | _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 |
| | |
| | | _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", |
| | |
| | | |
| | | |
| | | 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 |
| | | |
| | | |
| | |
| | | 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, |
| | | ) |
| | |
| | | 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 |
| | |
| | | "用户说退出中英对话、回到聊天时调用 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 = ( |
| | | "你是用户的语音伙伴。进入对话后可以先主动短句邀请聊天,例如「我们来聊天吧」。" |
| | | "用户开口后按正常对话回答,不要反复催促。" |
| | |
| | | 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: |
| | |
| | | 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 ""): |
| | |
| | | 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: |
| | |
| | | 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 |
| | | |
| | |
| | | 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") |
| | |
| | | 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): |
| | |
| | | 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"), |
| | |
| | | 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): |
| | |
| | | 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 |
| | | |
| | | |
| | |
| | | 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: |
| | |
| | | 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( |
| | |
| | | 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 |
| | | |
| | |
| | | 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): |
| | |
| | | ) |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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( |
| | |
| | | ) -> 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}") |
| | |
| | | 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})" |
| | | ) |
| | |
| | | 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") |
| | |
| | | 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" |
| | |
| | | _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 |
| | |
| | | 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) |
| | |
| | | ) |
| | | 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: |
| | |
| | | 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]: |
| | |
| | | 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 |
| | | |
| | |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import json |
| | | import os |
| | | import time |
| | | from typing import Any, Optional |
| | |
| | | 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: |
| | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | 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}") |
| | | |
| | |
| | | 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 |
| | |
| | | |
| | | 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": |
| | |
| | | return |
| | | |
| | | if mode == "PLAY_AUDIO": |
| | | conn.logger.bind(tag=TAG).info("复读机模式忽略上行对话") |
| | | conn.logger.bind(tag=TAG).info("泛听模式忽略上行对话") |
| | | return |
| | | |
| | | if mode == "SHADOWING": |
| | |
| | | |
| | | |
| | | 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: |
| | |
| | | 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 |
| | | # 执行异步上报 |
| | |
| | | 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": |
| | |
| | | |
| | | # 发送消息到客户端 |
| | | 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): |
| | |
| | | # 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}" |
| | | ) |
| | |
| | | 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): |
| | |
| | | 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 |
| | | |
| | |
| | | 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地址 |
| | |
| | | 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, |
| | | ), |
| | | ] |
| | | ) |
| | | |
| | |
| | | await runner.setup() |
| | | site = web.TCPSite(runner, host, port) |
| | | await site.start() |
| | | self.lesson_audio_handler.log_ready() |
| | | |
| | | # 保持服务运行 |
| | | while True: |
| | |
| | | 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) |
| | |
| | | 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]") |
| | | ) |
| | |
| | | "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", |
| | |
| | | return ActionResponse( |
| | | action=Action.RESPONSE, |
| | | result=result.result, |
| | | response=result.response or "无法开始复读", |
| | | response=result.response or "无法开始泛听", |
| | | ) |
| | | return ActionResponse( |
| | | action=Action.NONE, |
| | |
| | | "type": "function", |
| | | "function": { |
| | | "name": "start_shadowing", |
| | | "description": "开始跟读:设备用语音读一句英语,用户跟着读,录音会上报,但不会进入聊天。用户说退出跟读或回到聊天时结束。", |
| | | "description": "开始跟读:播放家长上次即时/计划任务下发的课文。设备读一句,用户跟着读,录音会上报,但不会进入聊天。还没有下发过跟读课文时会提示先在家长端选一课。用户说退出跟读或回到聊天时结束。", |
| | | "parameters": { |
| | | "type": "object", |
| | | "properties": {}, |
| | |
| | | "description": ( |
| | | "切换设备当前 App。" |
| | | "chat=对话;english_translation=翻译;english_conversation=中英对话;" |
| | | "english_learning 需配合 task_id:repeater=复读机,shadowing=跟读。" |
| | | "english_learning 需配合 task_id:repeater=泛听,shadowing=跟读。" |
| | | "语音切换泛听/跟读时执行家长上次下发的课文,没有上次课文则提示先选课。" |
| | | "退出当前模式、回到聊天时 app_id=chat。" |
| | | ), |
| | | "parameters": { |
| | |
| | | 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(): |
| | |
| | | _, 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 |
| | | |
| | |
| | | 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 |
| | |
| | | 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): |
| | |
| | | |
| | | 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" |
| | | ) |
| | | |
| | | |
| | |
| | | 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" |
| | |
| | | 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): |
| | |
| | | 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 |
| | | |
| | |
| | | |
| | | 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 |
| | | |
| | |
| | | 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(): |
| | |
| | | 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 |
| | | |
| | |
| | | '{"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 |
| | | |
| | |
| | | } |
| | | } |
| | | 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] |
| | |
| | | |
| | | |
| | | 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?" |
| | |
| | | |
| | | 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 |
| | |
| | | ) |
| | | ) |
| | | assert switched == [("chat", "conversation", False, False)] |
| | | assert spoken == ["复读结束。我们来聊天吧。"] |
| | | assert spoken == ["泛听结束。我们来聊天吧。"] |
| | | |
| | | |
| | | def test_late_asr_after_listen_window_still_scores_previous_sentence(tmp_path, monkeypatch): |
| | |
| | | |
| | | 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"] |
| | | |
| | |
| | | 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): |
| | |
| | | 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): |
| | |
| | | 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): |
| | |
| | | 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 |
| | | |
| | | |
| | |
| | | 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): |
| | |
| | | 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): |
| | |
| | | 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") == "" |
| New file |
| | |
| | | """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", |
| | | ] |
| | |
| | | |
| | | 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 ( |
| | |
| | | |
| | | 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(): |
| | |
| | | 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 |
| | |
| | | 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", |
| | |
| | | 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( |
| | |
| | | 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", |
| | |
| | | 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(): |
| | |
| | | _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, |
| | | }, |
| | |
| | | _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, |
| | |
| | | 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 |
| | |
| | | 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 |