# 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 回归。