first commit

This commit is contained in:
lennlouisgeek
2026-03-30 02:59:56 +08:00
commit eec9927ae6
60 changed files with 15953 additions and 0 deletions

554
tauri-event.md Normal file
View 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 的 APIcommand 本身就应该写成 `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 负责启动和停止
这样结构通常都会比较干净。