first commit
This commit is contained in:
554
tauri-event.md
Normal file
554
tauri-event.md
Normal file
@@ -0,0 +1,554 @@
|
||||
# Tauri Event Demo
|
||||
|
||||
这份笔记对应当前工程里的“串口后台任务 -> Tauri event -> 前端折线图”这条链路。
|
||||
|
||||
目标是把一类很常见的需求拆清楚:
|
||||
|
||||
- 前端点一次按钮,调用 Rust command
|
||||
- Rust 启动一个长期运行的后台任务
|
||||
- 后台任务持续读取数据
|
||||
- 每拿到一帧数据,就通过 Tauri event 推给前端
|
||||
- 前端监听 event,直接刷新图表
|
||||
|
||||
## 0. 2026-03-25 需求更新(WebGL 点阵改用真实数据)
|
||||
|
||||
这次改动的目标是:
|
||||
|
||||
- WebGL dot matrix 不再使用虚拟 demo 数据
|
||||
- 直接消费串口解析后的真实通道值
|
||||
- 默认矩阵大小改为 `row=12, col=7`
|
||||
|
||||
### 是否需要新建 event?
|
||||
|
||||
不需要。继续使用现有 `hud_stream` 即可,只扩展 payload 字段:
|
||||
|
||||
- 原来:`{ ts, panels, summary }`
|
||||
- 现在:`{ ts, panels, summary, pressureMatrix }`
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
- 前后端事件通道不变,兼容原有监听逻辑
|
||||
- 折线图和点阵图保持同一时间基准
|
||||
- 只需在 `HudPacket` 上增量扩展,不引入额外生命周期管理
|
||||
|
||||
### 本次代码落地点
|
||||
|
||||
- 后端 `HudPacket` 新增 `pressure_matrix`(序列化为 `pressureMatrix`):
|
||||
- `src-tauri/src/serial_core/model.rs`
|
||||
- 串口读循环在拿到解析值后写入 matrix 状态:
|
||||
- `src-tauri/src/serial_core/serial.rs`
|
||||
- 前端类型 `HudPacket` 新增 `pressureMatrix: number[] | null`:
|
||||
- `src/lib/types/hud.ts`
|
||||
- 页面把 `pressureMatrix` 透传到 `CenterStage -> PressureMatrixViewer`:
|
||||
- `src/routes/+page.svelte`
|
||||
- `PressureMatrixViewer` 去除 demo 生成逻辑;无真实数据时显示全 0:
|
||||
- `src/lib/components/PressureMatrixViewer.svelte`
|
||||
- 默认矩阵改为 `12 x 7`(含 reset 默认值):
|
||||
- `src/routes/+page.svelte`
|
||||
- `src/lib/components/CenterStage.svelte`
|
||||
- `src/lib/components/ConfigPanel.svelte`
|
||||
|
||||
## 1. 什么时候用 command,什么时候用 event
|
||||
|
||||
先记一个最实用的判断:
|
||||
|
||||
- `command` 适合“请求一次,返回一次”
|
||||
- `event` 适合“后台持续推送”
|
||||
|
||||
在这个项目里:
|
||||
|
||||
- `serial_enum` 是 command,因为它是“一次查询串口列表”
|
||||
- `serial_connect` 是 command,因为它是“一次启动连接”
|
||||
- `serial_disconnect` 是 command,因为它是“一次停止连接”
|
||||
- `hud_stream` 是 event,因为图表数据会持续不断地到来
|
||||
|
||||
如果你用 command 去不停轮询图表数据,也能做,但会有这些问题:
|
||||
|
||||
- 前后端耦合更重
|
||||
- 轮询频率不好拿捏
|
||||
- 串口数据到了也不能立刻推到前端
|
||||
- 后面做 TCP、日志流、状态流时会越来越别扭
|
||||
|
||||
所以像串口采样、TCP telemetry、日志输出、设备状态广播,这类都更适合 event。
|
||||
|
||||
## 2. 当前工程里各文件的职责
|
||||
|
||||
### Rust
|
||||
|
||||
- `src-tauri/src/lib.rs`
|
||||
- 注册 command
|
||||
- 注册全局状态 `SerialConnectionState`
|
||||
|
||||
- `src-tauri/src/commands/serial.rs`
|
||||
- 给前端暴露 command
|
||||
- 管理串口后台任务的生命周期
|
||||
- 这里尽量保持“薄”
|
||||
|
||||
- `src-tauri/src/serial_core/serial.rs`
|
||||
- 真正的串口读循环
|
||||
- 解码 frame
|
||||
- 发出 `hud_stream`
|
||||
|
||||
- `src-tauri/src/serial_core/model.rs`
|
||||
- 定义发给前端的结构
|
||||
- 把原始 frame 转成前端折线图直接能吃的 `HudPacket`
|
||||
|
||||
- `src-tauri/src/serial_core/codecs/test.rs`
|
||||
- 协议解码
|
||||
|
||||
### Frontend
|
||||
|
||||
- `src/routes/+page.svelte`
|
||||
- 调用 `serial_connect` / `serial_disconnect`
|
||||
- 监听 `hud_stream`
|
||||
- 拿到 `HudPacket` 后刷新页面状态
|
||||
|
||||
- `src/lib/types/hud.ts`
|
||||
- 前端使用的 HUD 类型定义
|
||||
|
||||
## 3. 完整数据流
|
||||
|
||||
当前链路可以按下面理解:
|
||||
|
||||
### 第一步:前端发起连接
|
||||
|
||||
前端点击按钮后:
|
||||
|
||||
```ts
|
||||
await invoke("serial_connect", { port })
|
||||
```
|
||||
|
||||
这一步只负责“启动”,不要指望它直接把流式数据返回给前端。
|
||||
|
||||
### 第二步:Rust command 启动后台任务
|
||||
|
||||
`serial_connect` 做的事应该很少:
|
||||
|
||||
1. 校验参数
|
||||
2. 打开串口
|
||||
3. 创建取消信号
|
||||
4. `spawn` 后台任务
|
||||
5. 把任务句柄保存到全局状态
|
||||
6. 立刻返回前端
|
||||
|
||||
也就是说,command 只是“开机按钮”。
|
||||
|
||||
### 第三步:后台任务持续读串口
|
||||
|
||||
后台任务跑在 `serial_core/serial.rs` 里:
|
||||
|
||||
```rust
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
read_result = port.read(&mut buffer) => {
|
||||
let n = read_result?;
|
||||
let frames = codec.decode(&buffer[..n])?;
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里有两个关键点:
|
||||
|
||||
- 必须只解码 `&buffer[..n]`
|
||||
- 长循环不要放在 command 里 `await` 到结束
|
||||
|
||||
第一点是为了避免把缓冲区后面没读到的 `0` 一起喂给解码器。
|
||||
第二点是为了避免前端永远等不到 command 返回。
|
||||
|
||||
### 第四步:frame 转成前端友好的 packet
|
||||
|
||||
后台拿到 frame 后,不要把原始协议直接丢给前端,先整理成业务结构:
|
||||
|
||||
```rust
|
||||
let packet = chart_state.apply_frame(&frame);
|
||||
```
|
||||
|
||||
这里输出的是 `HudPacket`,它的结构是前端图表直接能渲染的形状。
|
||||
|
||||
### 第五步:Rust 发 event
|
||||
|
||||
```rust
|
||||
app.emit("hud_stream", packet)?;
|
||||
```
|
||||
|
||||
这一步就是把数据从 Rust 主动推给前端。
|
||||
|
||||
### 第六步:前端监听 event
|
||||
|
||||
前端在页面挂载时注册监听:
|
||||
|
||||
```ts
|
||||
const unlisten = await listen<HudPacket>("hud_stream", (event) => {
|
||||
applyPacket(event.payload);
|
||||
});
|
||||
```
|
||||
|
||||
拿到 payload 后直接更新 `signalPanels`,图表就会刷新。
|
||||
|
||||
## 4. 为什么要保存后台任务句柄
|
||||
|
||||
因为连接不是瞬时动作,而是一个持续运行的任务。
|
||||
|
||||
如果你只做:
|
||||
|
||||
```rust
|
||||
tauri::async_runtime::spawn(async move {
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
但不保存返回的 `JoinHandle`,那你后面其实不知道该停哪个任务。
|
||||
|
||||
所以当前项目里 `SerialConnectionState` 的职责是:
|
||||
|
||||
- 保存当前连接的端口
|
||||
- 保存取消信号 `CancellationToken`
|
||||
- 保存后台任务句柄 `JoinHandle`
|
||||
|
||||
断开时:
|
||||
|
||||
1. 从 state 里把当前 session 取出来
|
||||
2. `cancel.cancel()`
|
||||
3. 等任务退出
|
||||
4. 清空 session
|
||||
|
||||
这就是“连接生命周期管理”。
|
||||
|
||||
## 5. 为什么我这里用 CancellationToken,而不是只 abort
|
||||
|
||||
`abort()` 可以硬停,但更像“直接掐掉线程”。
|
||||
|
||||
`CancellationToken` 更适合做长期任务,因为它允许你在循环里优雅退出:
|
||||
|
||||
```rust
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
这样后面如果你需要:
|
||||
|
||||
- 退出前写日志
|
||||
- 退出前关闭资源
|
||||
- 退出前发送状态事件
|
||||
|
||||
都更自然。
|
||||
|
||||
当前代码里依然保留了 `JoinHandle`,因为它可以让你等待任务真正结束。
|
||||
|
||||
## 6. 这个 demo 里 model 层在做什么
|
||||
|
||||
`src-tauri/src/serial_core/model.rs` 里现在做了两件事:
|
||||
|
||||
1. 定义前端消费的数据结构
|
||||
2. 提供 `HudChartState`
|
||||
|
||||
`HudChartState` 的作用是把“一帧 frame”逐步积累成“可滚动折线图数据”。
|
||||
|
||||
你可以把它理解成一个很轻量的 view-model:
|
||||
|
||||
- 输入:`TestFrame`
|
||||
- 内部:维护每条折线的点数组
|
||||
- 输出:`HudPacket`
|
||||
|
||||
好处是前端不用知道:
|
||||
|
||||
- CRC
|
||||
- 包头包尾
|
||||
- payload 字节意义
|
||||
- 缓冲和滑动窗口怎么维护
|
||||
|
||||
前端只知道:我收到了一份新的图表数据。
|
||||
|
||||
## 7. 前端这里做了什么
|
||||
|
||||
前端现在分成两层:
|
||||
|
||||
### 控制层
|
||||
|
||||
通过 `invoke` 调 Rust command:
|
||||
|
||||
- `serial_enum`
|
||||
- `serial_connect`
|
||||
- `serial_disconnect`
|
||||
|
||||
### 数据层
|
||||
|
||||
通过 `listen("hud_stream")` 收 Rust 推送的数据。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 控制动作走 command
|
||||
- 连续数据走 event
|
||||
|
||||
这就是后面最好复用的模式。
|
||||
|
||||
## 8. 为什么页面里还保留了 mock feed
|
||||
|
||||
因为浏览器直接预览 UI 的时候,没有 Tauri runtime,也没有串口。
|
||||
|
||||
所以当前页面里做了两条分支:
|
||||
|
||||
- 在 Tauri 环境里:监听真实 `hud_stream`
|
||||
- 在普通浏览器里:启动本地 mock feed
|
||||
|
||||
这样你开发样式时不用每次都连设备,效率会高很多。
|
||||
|
||||
## 9. 后面照着扩展时怎么套
|
||||
|
||||
如果你接下来做 TCP、日志流、设备状态流,基本可以照这个模板:
|
||||
|
||||
### TCP telemetry
|
||||
|
||||
- `tcp_connect` command
|
||||
- `tcp_disconnect` command
|
||||
- `tcp_stream` event
|
||||
|
||||
### 设备状态
|
||||
|
||||
- `device_get_status` command
|
||||
- `device_status` event
|
||||
|
||||
### 日志输出
|
||||
|
||||
- `log_stream` event
|
||||
|
||||
只要记住一句话:
|
||||
|
||||
“一次性动作走 command,持续推送走 event。”
|
||||
|
||||
## 10. 一个最小可复用模板
|
||||
|
||||
### Rust command
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn start_task(app: AppHandle, state: State<'_, TaskState>) -> Result<(), TaskError> {
|
||||
let cancel = CancellationToken::new();
|
||||
let task_cancel = cancel.clone();
|
||||
let task_app = app.clone();
|
||||
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
let _ = run_task(task_app, task_cancel).await;
|
||||
});
|
||||
|
||||
*state.task.lock().unwrap() = Some(TaskSession { cancel, task });
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Rust 后台任务
|
||||
|
||||
```rust
|
||||
pub async fn run_task(app: AppHandle, cancel: CancellationToken) -> anyhow::Result<()> {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
data = next_data() => {
|
||||
let payload = build_payload(data?);
|
||||
app.emit("some_stream", payload)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 前端监听
|
||||
|
||||
```ts
|
||||
const unlisten = await listen<Payload>("some_stream", (event) => {
|
||||
applyPayload(event.payload);
|
||||
});
|
||||
```
|
||||
|
||||
## 11. 这套方式最容易踩的坑
|
||||
|
||||
### 1. 在同步 command 里调用 async-only API
|
||||
|
||||
像 `open_native_async()` 这种需要 runtime 的 API,command 本身就应该写成 `async fn`。
|
||||
|
||||
### 2. 把长期循环直接写在 command 里
|
||||
|
||||
这样前端 `invoke` 会一直挂着,按钮像卡死一样。
|
||||
|
||||
### 3. 解码时传整个 buffer,而不是 `&buffer[..n]`
|
||||
|
||||
这会把未读取区域的垃圾值一并喂给解码器。
|
||||
|
||||
### 4. 没有保存任务句柄
|
||||
|
||||
这样断开时你并不知道要停哪个任务。
|
||||
|
||||
### 5. 原始协议直接发前端
|
||||
|
||||
短期快,长期维护会很痛苦。最好让 Rust 先转换成业务数据。
|
||||
|
||||
## 12. 你后面可以继续优化的方向
|
||||
|
||||
- 增加 `status_stream`,当串口异常退出时主动通知前端
|
||||
- 把 `SerialConnectionState` 扩成 `HashMap<String, SerialSession>`,支持多串口
|
||||
- 把 `HudChartState` 抽成更明确的 telemetry service
|
||||
- 给不同协议定义不同 packet,而不是全部复用一个事件名
|
||||
|
||||
## 13. 现在这版如何支持动态 panel
|
||||
|
||||
这次我已经把 demo 从“固定四个 panel”改成了“按数据源 id 动态创建 panel”。
|
||||
|
||||
当前后端的行为是:
|
||||
|
||||
- 串口保持连接
|
||||
- 收到某个 source id 的数据时,如果这个 id 还没有 panel,就新建
|
||||
- 后面这个 id 持续有数据,就持续往它自己的折线里追加点
|
||||
- 如果某个 id 超过一段时间没有新数据,就自动移除
|
||||
- 移除后前端会走离场动画
|
||||
|
||||
### 当前 demo 约定的数据格式
|
||||
|
||||
现在 `model.rs` 里的 demo 约定是一帧 payload 可以写成多个 4 字节分组:
|
||||
|
||||
```text
|
||||
[source_id, value1, value2, value3, source_id, value1, value2, value3, ...]
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
[0x41, 180, 120, 60, 0x42, 170, 80, 30]
|
||||
```
|
||||
|
||||
这里会被解释成:
|
||||
|
||||
- `0x41` -> `A`
|
||||
- `0x42` -> `B`
|
||||
|
||||
也就是说这一帧会同时更新两个 panel:
|
||||
|
||||
- A panel
|
||||
- B panel
|
||||
|
||||
如果后续几帧只有 B 和 C,没有 A,那 A 超时之后就会自动被移除。
|
||||
|
||||
### source id 是怎么来的
|
||||
|
||||
当前 demo 里:
|
||||
|
||||
- 如果字节是字母或数字,比如 `A`、`B`、`3`
|
||||
- 就直接把它当 source id
|
||||
|
||||
否则会变成:
|
||||
|
||||
- `CH01`
|
||||
- `CH0A`
|
||||
- `CHFF`
|
||||
|
||||
这种形式。
|
||||
|
||||
### 超时移除在哪里控制
|
||||
|
||||
在 `src-tauri/src/serial_core/model.rs` 里有一个常量:
|
||||
|
||||
```rust
|
||||
const PANEL_STALE_AFTER: Duration = Duration::from_millis(2400);
|
||||
```
|
||||
|
||||
意思是某个 panel 2.4 秒没收到新数据,就认为它已经“消失”,会从当前 packet 里移除。
|
||||
|
||||
### 如果你的真实协议不是这个格式怎么办
|
||||
|
||||
那就只改 `expand_frame_updates(frame)` 这一层。
|
||||
|
||||
你真正要做的是把自己的协议,翻译成:
|
||||
|
||||
- source id
|
||||
- 这个 source 对应的 3 个数值
|
||||
|
||||
例如如果你的协议里一帧只表示一个源:
|
||||
|
||||
```rust
|
||||
fn expand_frame_updates(frame: &TestFrame) -> Vec<HudPanelUpdate> {
|
||||
vec![HudPanelUpdate {
|
||||
source_id: "A".to_string(),
|
||||
values: [12.3, 45.6, 78.9],
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
如果一帧里同时包含多个源:
|
||||
|
||||
```rust
|
||||
fn expand_frame_updates(frame: &TestFrame) -> Vec<HudPanelUpdate> {
|
||||
vec![
|
||||
HudPanelUpdate {
|
||||
source_id: "A".to_string(),
|
||||
values: [12.3, 45.6, 78.9],
|
||||
},
|
||||
HudPanelUpdate {
|
||||
source_id: "C".to_string(),
|
||||
values: [33.1, 29.8, 81.4],
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
后面的动态创建、更新、超时移除,`HudChartState` 都会自动帮你处理掉。
|
||||
|
||||
## 14. TODO 提示词
|
||||
|
||||
如果你后面把 `handle` / 协议解析逻辑写完了,想让我把 demo 逻辑替换成真实业务逻辑,直接把下面这段话发给我就行:
|
||||
|
||||
```md
|
||||
我已经把串口 frame 的业务含义梳理清楚了,请你把当前 tauri-demo 里的 demo 动态 panel 逻辑改成真实协议版本。
|
||||
|
||||
已知信息:
|
||||
- frame 类型:`TestFrame`
|
||||
- 数据来源文件:`src-tauri/src/serial_core/...`
|
||||
- 当前 demo 映射入口:`src-tauri/src/serial_core/model.rs` 里的 `expand_frame_updates`
|
||||
- 如果需要,也可以调整 `handler.on_frame(...)` 的职责
|
||||
|
||||
真实协议说明:
|
||||
- source_id 怎么取:
|
||||
- 一帧里有几路数据:
|
||||
- 每一路数据对应哪些字段:
|
||||
- 数值是 u8 / u16 / i16 / float:
|
||||
- 字节序是大端还是小端:
|
||||
- 缩放系数是多少:
|
||||
- 哪些情况下表示“该路数据消失”:
|
||||
- panel 标题 / 图例文案要如何显示:
|
||||
|
||||
请你帮我做这些事:
|
||||
1. 去掉当前 demo 的 4 字节分组映射逻辑
|
||||
2. 改成按真实协议生成动态 panel
|
||||
3. 保留“有数据就创建,超时没数据就移除”的行为
|
||||
4. 如有必要,重构 `model.rs` / `handler` / `serial.rs` 的职责边界
|
||||
5. 更新 `tauri-event.md`,说明新的真实数据流
|
||||
```
|
||||
|
||||
如果你到时候还没完全确定协议,也可以先用这个简化版提示词:
|
||||
|
||||
```md
|
||||
我已经知道 payload 里哪些字节对应哪几个通道了,请你帮我把 `src-tauri/src/serial_core/model.rs` 里的 demo 映射改成真实映射,并告诉我还缺哪些协议信息。
|
||||
```
|
||||
|
||||
### 到时候我最需要你提供的最小信息
|
||||
|
||||
- source id 怎么区分不同数据源
|
||||
- 每个 source 对应几条曲线
|
||||
- payload 各字段的字节位置
|
||||
- 数值类型和缩放系数
|
||||
- 多久没数据算“消失”
|
||||
|
||||
---
|
||||
|
||||
如果你后面要继续照这个模式扩展,最推荐的顺序是:
|
||||
|
||||
1. 先定义前端真正需要的 payload 结构
|
||||
2. 再写 Rust 的 model/service 层
|
||||
3. 最后只让 command 负责启动和停止
|
||||
|
||||
这样结构通常都会比较干净。
|
||||
Reference in New Issue
Block a user