14 KiB
按键触发云端抽签上下行流程
1. 目的与原则
本文说明用户短按 SW2 后,设备如何通过 FreeRTOS 队列传递按键事件,如何使用 RequestToRespond 向云端发送“我要抽签”,以及云端返回的设备指令和 TTS 音频如何分别驱动 LCD UI 与扬声器。
当前实现遵循以下原则:
- SW2 单击不再在设备本地随机抽签;
- 抽签结果由云端生成;
- UI 与语音来自同一轮云端响应;
- 云端会话不可用时不执行本地抽签,只记录日志;
- 一轮按键抽签处理期间忽略重复请求。
2. 总体流程
SW2 电平变化
→ GPIO ISR 投递原始中断事件
→ Key Input Task 消抖并识别 SHORT_PRESS
→ 投递 CONTROL_PLANE_EVENT_FAMILY_KEY 系统事件
→ System Event Task 取出事件
→ control_plane_request_cloud_chouqian()
→ RequestToRespond(prompt, "我要抽签")
→ WebSocket 上行到云端
→ 云端生成抽签结果、回复文本和 TTS
→ WebSocket 下行
├→ RespondingContent 文本回调
│ → 解析抽签设备指令
│ → 投递模型命令队列
│ → Model Command Task 请求抽签 UI
│ → LCD Render Task 绘制动画和结果
└→ Binary Audio 下行回调
→ 音频播放管线
→ 扬声器播放 TTS
→ RespondingEnded
→ 清除按键抽签处理中状态
3. 模块职责
| 模块 | 主要职责 |
|---|---|
control_plane_key_input.c |
GPIO 中断接收、消抖、按下/释放和单双击识别 |
control_plane_system_events.c |
系统事件队列和按键业务分发 |
control_plane_multimodal_runtime.c |
云端抽签请求编排、回调桥接、抽签状态管理 |
control_plane_multimodal_session_service.c |
会话状态、协议 JSON 组装和 WebSocket 消息解析 |
control_plane_model_command_extractor.c |
从云端回复提取抽签设备指令和结果索引 |
control_plane_runtime_workers.c |
模型命令队列消费和抽签 UI 请求 |
domain_runtime_adapters.c |
抽签索引到 LCD 资源的映射 |
| LCD Platform 实现 | 串行读取资源并绘制 LCD |
4. 上行:从按键到云端
4.1 GPIO ISR 投递原始事件
SW2 对应 GPIO 发生电平变化后,CPU 进入 GPIO ISR。中断函数不执行云端请求或 UI 绘制,只采集 GPIO 编号和电平,并使用 ISR 安全的队列接口投递到 s_gpio_irq_queue:
GPIO ISR
→ control_plane_gpio_irq_event_t
→ s_gpio_irq_queue
→ 立即退出 ISR
这样可以避免在中断上下文执行耗时、阻塞或不允许调用的操作。
4.2 Key Input Task 识别短按
control_plane_key_input_task() 从 s_gpio_irq_queue 接收原始电平事件,负责消抖、按下/释放判断、按压时长计算以及单双击识别。
当 SW2 被识别为单击时,得到的语义事件主要字段为:
key_id = CONTROL_PLANE_KEY_ID_SW2
type = CONTROL_PLANE_KEY_EVENT_SHORT_PRESS
gpio_num = 13
click_count = 1
4.3 投递系统事件
按键模块调用 control_plane_emit_key_event() 构造 control_plane_system_event_t:
event.family = CONTROL_PLANE_EVENT_FAMILY_KEY;
event.data.key.key_id = CONTROL_PLANE_KEY_ID_SW2;
event.data.key.type = CONTROL_PLANE_KEY_EVENT_SHORT_PRESS;
然后调用:
control_plane_post_system_event(&event, 0);
内部使用 FreeRTOS xQueueSend() 将整个事件结构体复制到 s_system_event_queue。超时参数为 0,因此队列有空位时立即成功,队列已满时不等待并返回错误。
Queue 保存的是结构体副本,而不是局部变量地址,所以投递函数返回后,原始局部变量失效不会影响队列中的事件。
4.4 System Event Task 分发事件
control_plane_system_event_task() 使用:
xQueueReceive(s_system_event_queue, &event, pdMS_TO_TICKS(200));
等待系统事件。队列为空时任务进入 FreeRTOS Blocked 状态,不会持续占用 CPU。收到事件后依次调用:
control_plane_dispatch_system_event()
→ 根据 family 识别为 KEY
→ control_plane_dispatch_key_event()
→ 根据 key_id 识别为 SW2
→ 根据 type 识别为 SHORT_PRESS
→ control_plane_request_cloud_chouqian()
SW2 双击仍走原有 DOMAIN_EXPRESSION_WUXING 流程,不会触发云端抽签。
4.5 防止重复请求
control_plane_request_cloud_chouqian() 在 s_multimodal_runtime_lock 保护下检查 s_cloud_chouqian_pending:
false:当前没有 SW2 发起的云端抽签请求
true:请求已经发出,本轮云端响应尚未结束
状态变化为:
IDLE(false)
→ SW2 单击并开始发送
→ PENDING(true)
→ RespondingEnded 或异常
→ IDLE(false)
如果已经是 true,新的 SW2 单击不会再次发送,避免多轮 TTS 和 UI 重叠。
4.6 重置模型指令去重状态
发送按键抽签请求前还会执行:
s_model_command_dispatched = false;
RespondingContent 是流式全量文本,同一设备指令可能随文本更新重复出现。该状态保证一轮响应只派发一次设备指令:
false:本轮还没有派发设备指令
true:已经派发,后续重复文本忽略
语音上行通常在 SpeechStarted 时复位该状态。按键 Prompt 没有 SpeechStarted,所以必须在发送前主动复位。
4.7 构造 Prompt
按键抽签请求为:
const control_plane_multimodal_respond_request_t request = {
.type = CONTROL_PLANE_MULTIMODAL_RESPOND_TYPE_PROMPT,
.text = "我要抽签",
.parameters_json = NULL,
};
PROMPT 表示把文本交给大模型理解并生成回答,不是直接朗读“我要抽签”。
调用链如下:
control_plane_request_cloud_chouqian()
→ control_plane_multimodal_send_request_to_respond()
→ send_request_to_respond_directive()
→ domain_runtime_multimodal_transport_send_text()
→ WebSocket
4.8 会话状态检查
发送接口要求:
- WebSocket transport 可用;
task_id非空;dialog_id非空;s_server_listening == true。
任何条件不满足都会返回 ESP_ERR_INVALID_STATE。按键入口只记录日志,不执行本地抽签。
4.9 上行协议
设备发送的核心 JSON 为:
{
"header": {
"action": "continue-task",
"task_id": "<当前任务ID>",
"streaming": "duplex"
},
"payload": {
"input": {
"directive": "RequestToRespond",
"dialog_id": "<当前对话ID>",
"type": "prompt",
"text": "我要抽签"
}
}
}
发送成功后记录:
Cloud chouqian prompt sent
5. 下行:从云端回复到语音和 UI
5.1 回调注册
多模态客户端启动时注册生命周期事件回调:
control_plane_multimodal_register_event_callback(
control_plane_multimodal_event_bridge,
NULL
);
同时注册下行音频回调:
control_plane_multimodal_register_downstream_audio_callback(
control_plane_downstream_audio_bridge,
NULL
);
模型命令工作任务启动时注册模型命令回调:
control_plane_register_model_command_callback(
control_plane_on_model_command,
NULL
);
该注册过程又把 control_plane_responding_text_bridge() 注册为 RespondingText 回调。
| 回调 | 职责 |
|---|---|
control_plane_multimodal_event_bridge() |
处理连接、任务、Listening、Responding 等生命周期事件 |
control_plane_downstream_audio_bridge() |
接收并播放云端 TTS 音频 |
control_plane_responding_text_bridge() |
从回复文本提取设备指令 |
control_plane_on_model_command() |
把已解析的设备指令投递到模型命令队列 |
5.2 RespondingStarted
云端开始回答后,Session Service 更新远端响应状态并通知 CONTROL_PLANE_MULTIMODAL_EVENT_RESPONDING_STARTED。事件桥接函数会停止仍在运行的麦克风上行,避免 TTS 播放期间继续上传语音。
5.3 TTS 音频链路
云端 TTS 通过 WebSocket 二进制消息流式下发:
WebSocket binary
→ control_plane_downstream_audio_bridge(data, len)
→ control_plane_multimodal_play_downstream_audio(data, len)
→ 音频解码与播放管线
→ 扬声器
按键抽签完全复用原有云端 TTS 播放链路。
5.4 文本与设备指令链路
云端流式回复 RespondingContent 时,Session Service 读取 output.text 和 output.spoken,然后调用 notify_responding_text(),最终进入:
control_plane_responding_text_bridge();
假设云端回复包含:
/*{"抽签":"上上签"}*/
文本桥接函数调用 control_plane_extract_model_command(),得到:
command_type = CONTROL_PLANE_MODEL_COMMAND_CHOUQIAN
value = "上上签"
value_index = 2
value_index_valid = true
解析成功后再次在锁内检查 s_model_command_dispatched,避免两个流式回调同时派发相同指令。
5.5 模型命令回调和队列
文本桥接函数调用已注册的:
control_plane_on_model_command(...);
该回调不直接操作 LCD,而是构造 control_plane_model_command_t 并通过 xQueueSend() 投递到模型命令队列。这样 WebSocket/Session 回调可以快速返回,不会被 UI 绘制阻塞。
5.6 Model Command Task 请求 UI
control_plane_model_command_task() 从队列收到 CONTROL_PLANE_MODEL_COMMAND_CHOUQIAN 后调用:
control_plane_show_chouqian_expression(
cmd.value_index,
cmd.value_index_valid
);
随后构造 Domain 请求:
domain_expression_request_t request = {
.id = DOMAIN_EXPRESSION_CHOUQIAN,
.chouqian_result_index = result_index,
.chouqian_result_index_valid = result_index_valid,
};
5.7 结果到 LCD 资源的映射
| 索引 | 签型 | LCD 资源目录 |
|---|---|---|
| 0 | 卜运签 | chouqian_buyun |
| 1 | 和平签 | chouqian_heping |
| 2 | 上上签 | chouqian_shangshang |
| 3 | 小吉签 | chouqian_xiaoji |
| 4 | 中上签 | chouqian_zhongshang |
当 chouqian_result_index_valid == true 时,Domain 使用云端结果,不进行本地随机。
LCD 播放顺序为:
chouqian_qianzhi frame 0
→ chouqian_qianzhi frame 1
→ chouqian_qianzhi frame 2
→ 结果资源 frame 0
→ 结果资源 frame 1
→ 结果资源 frame 2
6. 响应结束与状态清理
云端回答结束时发送 RespondingEnded。Session Service 结束下行播放并通知 CONTROL_PLANE_MULTIMODAL_EVENT_RESPONDING_ENDED。事件桥接函数随后调用:
control_plane_clear_cloud_chouqian_pending("response ended");
将 s_cloud_chouqian_pending 恢复为 false,允许下一次 SW2 抽签。
以下异常事件也会清除该状态:
TRANSPORT_DISCONNECTED;TRANSPORT_ERROR;TASK_FAILED;OUTPUT_ERROR;- 请求发送失败;
- 多模态客户端停止。
7. 任务、队列和回调关系
GPIO ISR
│ xQueueSendFromISR
▼
GPIO IRQ Queue
│ xQueueReceive
▼
Key Input Task
│ control_plane_post_system_event / xQueueSend
▼
System Event Queue
│ xQueueReceive
▼
System Event Task
│ RequestToRespond / WebSocket send
▼
Cloud
│
├─ RespondingContent ─→ RespondingText Callback
│ │ Model Command Callback
│ ▼
│ Model Command Queue
│ │ xQueueReceive
│ ▼
│ Model Command Task
│ │ Domain expression request
│ ▼
│ LCD Request / Render Task
│
└─ Binary Audio ─────→ Downstream Audio Callback
│
▼
Audio Playback Pipeline
Queue 用于跨任务复制和传递数据;回调在事件产生者的当前任务上下文中执行,所以回调只做快速解析或桥接,较重的 UI 操作继续通过队列交给专用任务。
8. 主要状态变量
s_cloud_chouqian_pending
本次功能新增的请求状态:
false:允许 SW2 发起云端抽签
true:已有 SW2 云端抽签尚未结束,忽略重复请求
由 s_multimodal_runtime_lock 保护。
s_model_command_dispatched
原有的模型设备指令去重状态,本次按键抽签复用:
false:当前一轮回复尚未派发设备指令
true:已经派发,忽略流式重复文本中的同一指令
语音请求在 SpeechStarted 时复位;按键 Prompt 在发送前主动复位。
Session Service 原有状态
| 状态 | 含义 |
|---|---|
s_task_id |
当前云端多模态任务 ID |
s_dialog_id |
当前对话 ID |
s_server_listening |
云端是否接受新输入 |
s_remote_responding |
云端是否正在回答 |
s_request_to_speak_pending |
是否存在语音打断请求 |
这些状态由 Session Service 统一维护,上层不额外复制会话状态。
9. 关键日志
cp_system_event: key event key=SW2 type=SHORT_PRESS ...
control_plane_mm_rt: Cloud chouqian prompt sent
control_plane_mm_rt: Cloud chouqian result received: 上上签
control_plane_mm_rt: Cloud chouqian request completed: response ended
会话不可用或请求仍在处理中:
cp_system_event: Cloud chouqian request skipped: multimodal session unavailable or request pending
10. 当前注意事项
为了严格保证 UI 与语音一致,云端应返回带具体签型的设备指令,例如:
/*{"抽签":"上上签"}*/
如果只返回包含“签文:”的普通文本而没有具体设备指令,现有兼容逻辑可能生成无有效索引的抽签命令,Domain 随后会本地随机 UI。因此云端输出格式应保持稳定,或在后续版本中限制按键云端抽签只接受带有效结果索引的设备指令。