# 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 负责启动和停止

这样结构通常都会比较干净。
