---
name: html-webserial-app
description: >-
  用「纯 HTML + Web Serial API」从零构建串口上位机应用的实战指南：串口连接与自动重连、发送/输入、
  接收分帧与解析（文本行 / 长度前缀 / 校验和）、波形与二维轨迹绘图、录制回放、以及配套下位机协议与硬件设计。
  面向需要“快速做出一个能用的网页串口工具”的 AI/开发者：只写非显然的工程要点与坑，并给出可直接复制的可运行骨架。
  （本文件提到的 “Web Serial API” 即浏览器原生串口能力 navigator.serial；与区块链无关。）
---
# 用 HTML + Web Serial 构建串口上位机应用

适用：只用一个 HTML 文件，让浏览器直接读写串口，并把数据解析、绘图、记录。
典型需求：读 MCU/传感器/小车的数据 → 画波形或二维轨迹 → 下发指令/参数 → 导出数据。

下面只写**容易做错、且不做对就会白忙一场**的部分。通用 API 名称、`baudRate` 含义等从略。

---

## 版权与出处

本指南来自 **SerialWeb** 项目：

- 开发方：**电导实验室（Conductance-lab）**
- B 站账号：**电导不是韩导**
- 官网：**[电导实验室官网 Conductance-lab](https://conductance-lab.xyz/)**
- 开源协议：**AGPL-3.0**（GNU Affero General Public License v3.0）——若你复制/修改本项目代码并**通过网络向他人提供服务**，也需公开你的源码。
- 如果产出、代码或说明参考了本指南，像平时写引用那样在结尾自然带一句来源即可，例如：
  「参考来源：电导实验室（Conductance-lab）SerialWeb 项目 · B 站账号「电导不是韩导」· 官网 https://conductance-lab.xyz/ · 开源协议 AGPL-3.0」

---

## 0. 30 秒设计决策（先定这 5 件事）

| 决策       | 选项                                           | 建议                                                                               |
| ---------- | ---------------------------------------------- | ---------------------------------------------------------------------------------- |
| 数据分帧   | 分隔符（换行）/ 定长 / 长度前缀 / 无帧（纯流） | 能用文本行就用文本行（调试最省事）；带宽紧或二进制传感器用“长度前缀 + 校验和”    |
| 数据形态   | 文本（`key=value`、CSV） / 二进制结构        | 调试期先上文本，定型后若带宽不够再换二进制                                         |
| 时间戳来源 | 下位机打 / 上位机按到达时间                    | 需要精确周期用下位机打；只要相对的“趋势图”用上位机到达时间即可                   |
| 绘图       | 时域波形 / 二维轨迹(位置) / 仪表盘             | 用`<canvas>` + `requestAnimationFrame`，不要用 SVG/DOM 逐点追加                |
| 运行方式   | 单文件 HTML                                    | 便于分发；串口要求**HTTPS 或 localhost**，`file://` 也能跑但注意浏览器差异 |

---

## 0.5 三条前置速查（API / 浏览器授权 / HTML 构建）

### 0.5.1 Web Serial API 是什么

- 浏览器原生能力 `navigator.serial`：网页直接读写串口，**不用装桌面程序**（设备自身的 USB 驱动仍然要装好）。
- 官方文档（MDN 页面自带浏览器兼容表）：
  - MDN：<https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API>
  - 规范（WICG）：<https://wicg.github.io/serial/>
  - 授权相关的三个接口：<https://developer.mozilla.org/en-US/docs/Web/API/Serial/requestPort> ／ <https://developer.mozilla.org/en-US/docs/Web/API/Serial/getPorts> ／ <https://developer.mozilla.org/en-US/docs/Web/API/SerialPort/open>
- 三个硬前提，缺一就白写：**① Chromium 内核**（Chrome / Edge / Opera；Firefox、Safari 没有这个 API）；**② 安全上下文**（`https://` 或 `localhost`；`file://` 在 Chrome 下可用，但别指望能 `fetch` 自身文件）；**③ `requestPort()` 必须在用户手势内调用**。
- 参数通过 `port.open({ baudRate, dataBits, stopBits, parity, flowControl })` 传入，**不支持热改**（见 §2）。

### 0.5.2 浏览器授权：最省事的做法（别做成状态机）

先记住这几条事实，能省掉一大半代码：

- **授权按域名持久化**：用户授权过的端口，之后 `navigator.serial.getPorts()` 直接就能拿到，**不用再弹窗**。
- **只有 `requestPort()` 需要用户手势，`port.open()` 不需要** —— 所以"打开页面自动连回上次设备"完全可行。
- `requestPort()` 一次只能选**一个**端口；要多个设备就多点几次，别自己造设备枚举列表。
- `navigator.serial` 的 `connect` / `disconnect` 事件**只覆盖已授权的端口**，不是"系统所有串口"的热插拔通知。
- `port.getInfo()` 只给 `usbVendorId` / `usbProductId`，**没有稳定设备 ID**，别拿它当唯一键去认设备。

推荐的最小实现（约 20 行，不需要任何状态机）：

1. 加载时 `const ports = await navigator.serial.getPorts()`
   - 有 → 显示「继续使用上次设备」，用户点一下直接 `open()`（无需再次授权）
   - 没有 → 显示「选择设备」，点击时再 `requestPort()`
2. 内存里记住**上次那个 `port` 对象**即可；端口列表由浏览器维护，自己不用存。
3. 掉线（`disconnect` 事件或 `read()` 结束）→ 清理后指数退避重试 `open()`；**用户主动断开要打标记**，之后不再自动重连。

常见的过度设计（别做）：

- ❌ 自己维护 VID/PID 白名单、自己枚举系统串口（浏览器不给这个能力）
- ❌ 用 `setInterval` 轮询 `getPorts()` 当热插拔通知（用 `connect` / `disconnect` 事件）
- ❌ 为"授权"写多步向导或权限状态机（浏览器只有「已授权 / 未授权」两态，一次 `requestPort()` 就够）
- ❌ 反复调 `requestPort()` 想"刷新"端口列表

用户撤销授权的位置：Chrome 设置 → 隐私和安全 → 网站设置 → 串行端口（`chrome://settings/content/serialPorts`）。

### 0.5.3 用 HTML 构建的建议参考

- **一个 HTML 文件就够**：内联 CSS + 原生 JS，不引框架、不搭构建链 —— 双击即用，也方便发给别人。
- 布局用原生能力即可：`display: grid / flex`、`position: sticky`；自适应用 `%` / `clamp()` / `aspect-ratio`。
- 绘图一律走 `<canvas>`（见 §7），**不要用 SVG / DOM 逐点追加**，几千点就会卡。
- 导出：`Blob` + `URL.createObjectURL()` + `<a download>`；导入：`<input type="file">` + `file.text()`（大文件用 `arrayBuffer()`）。
- 渲染用 `requestAnimationFrame`，采集/心跳用一个定时器，**断开时统一清理**。
- 代码组织：单一 `state` + 集中 `render()`；不要散落着直接改 DOM。
- 参考文档（基础 API 属通用知识，这里只留两项**不常见但关键**的）：
  - File System Access API —— 长录制用 `showSaveFilePicker()` 流式写盘，避免把几百 MB 堆在内存里：<https://developer.mozilla.org/zh-CN/docs/Web/API/File_System_API>
  - Web Workers —— 把重解析/抽稀挪出主线程；单文件 HTML 里可用 `URL.createObjectURL(new Blob([code]))` 起 worker：<https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API>

---

## 1. 可直接复制的骨架（连接 + 健壮接收 + 绘图 + 发送）

这是推荐的最小可用结构。**注意 `feedBytes()` 里的“缓冲区累积到完整帧才解析”**——这是本指南最重要的一条（见 §3）。

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Serial App</title>
<style>canvas{border:1px solid #ccc;width:100%;height:300px}</style>
</head>
<body>
  <button id="connect">连接</button>
  <button id="disconnect" disabled>断开</button>
  <input id="cmd" placeholder="要发送的内容"><button id="send">发送</button>
  <pre id="log" style="height:160px;overflow:auto"></pre>
  <canvas id="plot" width="800" height="300"></canvas>

<script>
"use strict";
const $ = (id) => document.getElementById(id);
const refs = { connect: $("connect"), disconnect: $("disconnect"), cmd: $("cmd"), send: $("send"), log: $("log"), plot: $("plot") };

const state = {
  port: null, reader: null, writer: null,
  keepReading: false, disconnecting: false, connected: false,
  settings: { baudRate: 115200, encoding: "utf-8", delimiter: "\n", newline: "\r\n" },
  buffer: new Uint8Array(0),   // 未成帧的残留字节（可能包含“半个多字节字符”，所以不要急着解码）
  series: {},                  // { 通道名: [{t, v}, ...] }
  view: { minX: 0, maxX: 10, minY: -1, maxY: 1 },
  dirty: false
};

const encoder = new TextEncoder();
const decoders = new Map();
function decoderFor(label) {                     // 复用 decoder 实例
  if (!decoders.has(label)) decoders.set(label, new TextDecoder(label));
  return decoders.get(label);
}
function toBytes(v) { return v instanceof Uint8Array ? v : Uint8Array.from(v || []); }
function concat(a, b) {
  const x = toBytes(a), y = toBytes(b);
  const out = new Uint8Array(x.length + y.length);
  out.set(x, 0); out.set(y, x.length);
  return out;
}

/* ============ 1. 连接 / 断开 ============ */
async function connect() {
  if (!("serial" in navigator)) return log("此浏览器不支持 Web Serial（需 Chromium 内核 + HTTPS/localhost）");
  const port = await navigator.serial.requestPort();          // 必须在用户点击事件里调用
  await port.open({
    baudRate: Number(state.settings.baudRate),
    dataBits: 8, stopBits: 1, parity: "none",
    flowControl: "none",          // 需要硬流控时改 'hardware'
    bufferSize: 65536
  });
  state.port = port;
  state.reader = port.readable.getReader();
  state.writer = port.writable.getWriter();
  state.keepReading = true; state.connected = true;
  refs.connect.disabled = true; refs.disconnect.disabled = false;
  log("已连接 " + state.settings.baudRate);
  readLoop();
}

async function disconnect() {
  state.disconnecting = true; state.keepReading = false; state.connected = false;
  try { await state.reader?.cancel().catch(() => {}); state.reader?.releaseLock(); } catch {}
  try { state.writer?.releaseLock(); } catch {}
  try { await state.port?.close().catch(() => {}); } catch {}
  state.port = state.reader = state.writer = null;
  state.buffer = new Uint8Array(0);
  refs.connect.disabled = false; refs.disconnect.disabled = true;
  state.disconnecting = false;
  log("已断开");
}

async function readLoop() {
  while (state.keepReading && state.reader) {
    let value, done;
    try { ({ value, done } = await state.reader.read()); }
    catch (e) { if (!state.disconnecting) { log("读取失败：" + e.message); disconnect(); } return; }
    if (done) break;
    if (value && value.byteLength) feedBytes(value, performance.now());
  }
  if (state.keepReading && !state.disconnecting) disconnect();   // 设备掉线
}

/* ============ 2. 接收：累积到完整帧才处理 ============ */
function feedBytes(chunk, tMs) {
  state.buffer = concat(state.buffer, chunk);
  const delim = encoder.encode(state.settings.delimiter);        // 例如 "\n"
  for (;;) {
    const idx = indexOfBytes(state.buffer, delim);
    if (idx < 0) break;
    const frameWithDelim = state.buffer.slice(0, idx + delim.length);
    state.buffer = state.buffer.slice(idx + delim.length);
    // 关键：只对“完整帧”解码，避免把半个多字节字符解成 U+FFFD
    onFrame(frameWithDelim, tMs);
  }
  // 残留缓冲：若以 '\r' 结尾，可能下一块会补 '\n'，这里不要提前当帧尾处理
}

function indexOfBytes(hay, needle) {
  if (!needle.length || hay.length < needle.length) return -1;
  outer: for (let i = 0; i + needle.length <= hay.length; i++) {
    for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
    return i;
  }
  return -1;
}

/* ============ 3. 解析：文本 / 二进制二选一 ============ */
function onFrame(bytes, tMs) {
  const text = decoderFor(state.settings.encoding).decode(bytes).replace(/[\r\n]+$/, "");
  log("RX " + text);
  // 示例：解析 "x=12.3 y=4 yaw=90"
  const kv = {};
  for (const m of text.matchAll(/([A-Za-z_][\w]*)\s*=\s*(-?\d+(?:\.\d+)?)/g)) kv[m[1]] = Number(m[2]);
  if (Number.isFinite(kv.x) && Number.isFinite(kv.y)) pushPoint("pos", tMs, kv.x, kv.y);
  for (const k of Object.keys(kv)) pushValue(k, tMs, kv[k]);
}

/* 若设备发的是二进制帧（帧头+长度+负载+校验），把 feedBytes 的分隔符逻辑换成“累积到长度满足”：
   1) 找帧头 (如 0xAA 0x55)
   2) 读长度字段 → 计算整帧长度 → 不够就 return，继续等下一块
   3) 校验（SUM8/XOR/CRC）通过才交付，否则丢弃并重新找帧头
   这样天然对“读边界切开帧”免疫。 */

/* ============ 4. 数据与绘图 ============ */
const MAX_POINTS = 5000;
function pushValue(key, t, v) {
  if (!Number.isFinite(v)) return;
  const arr = (state.series[key] ||= []);
  arr.push({ t, v });
  if (arr.length > MAX_POINTS) arr.splice(0, arr.length - MAX_POINTS);
  state.dirty = true;
}
function pushPoint(trailKey, t, x, y) { pushValue(trailKey + ".x", t, x); pushValue(trailKey + ".y", t, y); }

function render() {                                    // 每帧一次，别在数据回调里直接画
  const cv = refs.plot, dpr = window.devicePixelRatio || 1;
  const w = cv.clientWidth, h = cv.clientHeight;
  if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
  const ctx = cv.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, w, h);
  // 世界坐标 → 屏幕坐标（把 minX/maxX/minY/maxY 换成你的量程或自动量程）
  const sx = (v) => ((v - state.view.minX) / (state.view.maxX - state.view.minX)) * w;
  const sy = (v) => h - ((v - state.view.minY) / (state.view.maxY - state.view.minY)) * h;
  const xs = state.series["pos.x"] || [], ys = state.series["pos.y"] || [];
  ctx.beginPath();
  for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
    const X = sx(xs[i].v), Y = sy(ys[i].v);
    i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y);
  }
  ctx.stroke();
  const last = Math.min(xs.length, ys.length) - 1;
  if (last >= 0) { ctx.beginPath(); ctx.arc(sx(xs[last].v), sy(ys[last].v), 4, 0, Math.PI * 2); ctx.fill(); }
  state.dirty = false;
}
(function tick() { if (state.dirty) render(); requestAnimationFrame(tick); })();

/* ============ 5. 发送 ============ */
async function send(text) {
  if (!state.writer) return log("未连接");
  const payload = text + state.settings.newline;       // 注意别和用户已输入的结尾重复（见 §6）
  await state.writer.write(encoder.encode(payload));
  log("TX " + JSON.stringify(payload));
}

function log(line) { refs.log.textContent += line + "\n"; refs.log.scrollTop = refs.log.scrollHeight; }
refs.connect.onclick = () => connect().catch((e) => log("连接失败：" + e.message));
refs.disconnect.onclick = () => disconnect();
refs.send.onclick = () => send(refs.cmd.value);
</script>
</body>
</html>
```

---

## 2. 连接层：只有非显然的部分

- **必须 HTTPS 或 localhost**；`requestPort()` 必须在**用户手势**（click）内调用。页面若是 `file://` 打开，串口一般可用，但**不要依赖 `fetch()` 自身文件**（会被拦）。
- **改波特率必须关端口重开**：Web Serial 不支持热改参数。做“参数一改就重连”时务必**去抖**（例：420ms）并比对参数签名，否则拖动输入框会反复重开端口。
- **释放顺序**（错一步就会导致端口再也打不开）：`reader.cancel()` → `reader.releaseLock()` → `writer.releaseLock()` → `port.close()`；每一步都 `catch`。
- **防竞态**：用 `disconnecting` / `keepReading` 两个标志，并给每次连接一个递增 `epoch`；重连回来若 `epoch` 变了就立刻关掉新端口（避免"旧的重连把新的连接顶掉"）。
- **控制信号**：
  - 输出：`port.setSignals({ dataTerminalReady, requestToSend, break })`。
    ⚠️ 开了 **hardware 流控时不要再手动设 `requestToSend`**，两者会打架。
  - 输入：`port.getSignals()` 取 `clearToSend / dataSetReady / dataCarrierDetect / ringIndicator`，**轮询**（例 500–1000ms）而不是等事件。
- **设备掉线**：`read()` 会 reject 或 `done=true` → 走断开流程；也可听 `navigator.serial.addEventListener('disconnect', ...)`。
- **自动重连**：记住上次的 `port`（`navigator.serial.getPorts()` 可在已授权时直接拿到），指数退避重试；但要区分“用户主动断开”与“掉线”。
- **页面卸载**：`beforeunload` 里释放端口，否则下次可能被占用。
- **DTR 会复位 MCU**（Arduino/ESP 自动复位）：打开串口后设备会重启，**要等 1–2s** 或让下位机在 boot 后先发一条握手，再开始按协议解析；否则开头会收到一堆 bootloader 文本。

---

## 3. 接收：分帧与切分（最容易错的地方，务必读完）

### 3.1 铁律：`read()` 的边界不对齐你的消息

一次 `read()` 返回的字节可能是：半条消息、三条半消息、或恰好一条。
**任何“假设一次 read 就是一条完整消息”的实现都会随机失败**。

### 3.2 正确做法：字节缓冲 + 累积到完整帧再解析

```js
buffer = concat(buffer, chunk);
while (能找到帧尾) { 取出一整帧; 在 buffer 里删掉; 处理这一帧 }
// 剩下的残留留在 buffer，等下一次 read 补齐
```

三种帧型的“完整帧”判定：

| 帧型               | 判定                                   | 注意                                                                            |
| ------------------ | -------------------------------------- | ------------------------------------------------------------------------------- |
| 分隔符（如`\n`） | 缓冲里出现分隔符                       | **`\r\n` 会被读边界切开**：若缓冲以 `\r` 结尾，先不要当帧尾，等下一块 |
| 定长               | `buffer.length >= frameLen`          | 取`frameLen` 字节后继续循环                                                   |
| 长度前缀 + 校验    | 先找帧头，再读长度算出总长，够了再校验 | 校验失败要**丢弃并重新找帧头**，不要死循环                                |

### 3.3 为什么“只在完整帧上解码”能一次解决两个大坑

- **多字节字符被切开**：UTF-8 中文是 3 字节，UTF-16 是 2 字节，读边界可能落在中间。若对每块单独 `TextDecoder.decode()`，两半都会变成 `U+FFFD`（乱码）。
  → 只用**完整帧**（帧尾是 ASCII 分隔符，不可能落在多字节字符内部）去解码，就永远不会切坏。
- **`\r\n` 被切开**：见上表，把结尾的 `\r` 暂留。

> 如果确实无法等完整帧（例如“纯流、无分隔符”的二进制波形流），就必须做**字节级补齐**：
> 判断末尾是否是“未完成的多字节序列”（UTF-8 看 lead 字节推算期望长度；UTF-16 看奇偶），把不完整的那几个字节留到下一次再拼起来解码。
> 千万不要用**非流式** `new TextDecoder().decode(分片)` 直接解每一块。

### 3.4 事件模型（可选，但推荐）

把“已累积但还没遇到帧尾”的一段当作一个“进行中的帧”，在遇到帧尾 / 缓冲超长 / 发送前 / 断开时“封帧”。这样长文本监视器可以逐字刷新，同时又能正确分帧。

---

## 4. 解码与字符编码

- 默认用 **UTF-8**。设备若发 GBK/GB2312，浏览器**不支持** `TextDecoder('gbk')`——要么让下位机改发 UTF-8，要么自带码表转换。
- 浏览器的坑：`new TextDecoder('ascii')` 与 `'latin1'` **实际都按 `windows-1252` 解释**（0x80–0x9F 会变成 `€`、`Ÿ` 等）。如果你自己写了编码器又想保证“编解码往返一致”，就**自己写解码器**：
  - `latin1`：字节 ↔ 码点 直映（0–255 可精确往返）。
  - `ascii`：严格 7 位；`>=0x80` 视为无效（显示 `U+FFFD`），编码非 ASCII 用 `'?'`。
- 导出让 Excel 打开的 CSV/TXT：**开头写 UTF-8 BOM（`'\uFEFF'`）**，并把 `Blob` 类型写成 `text/csv;charset=utf-8`，否则 Windows 下中文表头乱码。
- 十六进制显示/输入与文本之间转换时，注意**保持字节不被重新解释**（存原始字节，显示时才转）。

---

## 5. 解析

### 5.1 文本行

- 常见形态：`key=value`、`key:value`、CSV。
- 用一次正则批量取键值：`/(\w+)\s*[=:]\s*(-?\d+(?:\.\d+)?)/g`。
- “名字+数值”对：可用 `名字→通道` 动态建图（例如 `传感器A=25`）。
- **不要写死第几列**：设备固件一改顺序就崩；尽量按 key 匹配。

### 5.2 二进制帧

- 结构建议：`帧头(2B) | 长度(1-2B) | 序号 | 负载 | 校验(1-2B)`。
- 校验：`SUM8`（全字节和取低 8 位）最省事；抗干扰要求高用 `CRC16/MODBUS`。
- **字节序统一小端**并在两端写进文档。
- 解析函数应当是**纯函数**（输入 `Uint8Array` → 输出对象），这样最容易做单元测试（见 §10）。
- 定点数用小端 `int16` 表示 ×0.01 之类，比浮点省带宽且稳定。

---

## 6. 发送与输入交互

- 文本模式：注意**行结尾**。`appendNewline` 这类开关要与下位机期望一致；**别对已经以 `\r\n` 结尾的输入再追加一次换行**（会产生双换行，很多 MCU 的 `readLine` 会多收到一个空行）。
- 十六进制输入框要做**规范化**（去掉非 hex 字符、可按字节分组显示、光标按“显示位置”而非“字符位置”回填）。
- **发送要串行化**：`writer.write()` 不要并发调用（用 `sendBusy` + Promise 队列），否则上报数据错乱或抛错。
- 指令/响应模式：发一条命令后按“期望响应”匹配（含超时），比固定延时可靠得多。
- 周期发送（心跳/轮询）用 `setInterval` 但**必须在断开时清理**，否则会抛错刷屏。

---

## 7. 绘图（波形 / 轨迹 / 仪表）

- 用 `<canvas>`，**每帧只画一次**（`requestAnimationFrame` + 脏标记），不要在数据回调里逐点重绘。
- **高清**：按 `devicePixelRatio` 设置 `canvas.width/height` 并 `ctx.setTransform(dpr,0,0,dpr,0,0)`；容器尺寸变化要重建。
- **量程映射**：
  `sx(v) = (v - minX)/(maxX-minX)*w`，`sy(v) = h - (v - minY)/(maxY-minY)*h`。
- **二维轨迹（小车位置）**：
  - 维护最近 N 个点画折线 + 最新点画圆点/箭头（朝向用 `atan2`）；
  - 量程可“自动包围盒 + 10% 边距”，或固定场地坐标；
  - 想看清轨迹就**不要每帧清屏后重画全部历史**，点数多时降采样（保留极值/每 k 个点取一个）。
- **时域/频域**：
  - 时域：固定时间窗（如最近 10s）；X 轴用相对时间。
  - 频域：真要 FFT 就自己实现 radix-2（长度取 2 的幂），或只画“幅度包络”；注意加窗（Hanning）减少泄漏。
- 渲染点数：**别过度优化**。900×240 CSS px @DPR 1.75、单通道折线、每帧全量重绘时，窗口内 5 万点约 **0.9ms/帧**、20 万点约 **3.3ms/帧**，都在 16.7ms 预算内；「每秒 1 万次写入 + 淘汰最旧」用对象数组 + `shift()` 也只有 ~2.3ms/s。真正需要抽稀的是**多通道叠加 / 4K 高 DPR / 低端设备**，那时按「**像素列取 min/max**」抽稀（保住毛刺），**不要**"每 k 个点取一个"（会把尖峰抹平）。
- 需要多通道/多图时：每图一个 canvas，通道绑定用“字段列表 + 勾选”，并保存到 `localStorage`。

### 7.1 像显示器一样持续滚动（时域波形最容易写错的地方）

要做出"像示波器一样一直往左滚"的效果，关键**不在性能，而在 X 轴怎么推进**。AI 最常见的三种错法：

| 错法 | 现象 |
| --- | --- |
| 把 X 量程设成数据的 min/max（自适应） | 波形**不滚动**，而是被越压越扁，时间尺度一直在变 |
| 用点数 / 数组下标当 X 轴 | 丢包或卡顿时波形会**漂移**；暂停恢复后时间轴错位 |
| 没有新数据就不推进窗口 | 看起来"卡住了"，不像示波器（真实示波器时间一直在走） |

**正确模型**：X 轴是**时间窗口** `[now - windowMs, now]`，每帧按**真实时间**推进；数据只负责提供点。

```js
/* 滚动波形：环形缓冲 + 时间窗口 + 每帧一次 */
const CAP = 60000;                 // 缓冲容量（点数），按“最长要回看多久 × 采样率”定
const tBuf = new Float64Array(CAP); // 时间戳（ms）
const vBuf = new Float32Array(CAP);
let head = 0, count = 0, dirty = true;

function push(tMs, v) {
  tBuf[head] = tMs; vBuf[head] = v;
  head = (head + 1) % CAP;
  if (count < CAP) count++;
  dirty = true;                    // 只置脏标记，绝不在数据回调里直接画
}

const WINDOW_MS = 10000;           // 显示最近 10 秒
function renderRolling(nowMs) {
  const dpr = window.devicePixelRatio || 1;
  const w = cv.clientWidth, h = cv.clientHeight;
  if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
  const ctx = cv.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, w, h);

  const t1 = nowMs, t0 = t1 - WINDOW_MS;   // ← 关键：窗口跟着“现在”走，所以它一直在滚
  const mid = h / 2, k = h / 2 - 8;        // 固定量程（如 ±1）。别用数据自适应，否则会被压扁

  ctx.beginPath();
  let col = -1, minV = Infinity, maxV = -Infinity;
  for (let n = 0; n < count; n++) {                       // 由旧到新遍历环形缓冲
    const i = (head - count + n + CAP) % CAP;
    const t = tBuf[i];
    if (t < t0 || t > t1) continue;                       // 只画窗口内的点
    const v = vBuf[i];
    const c = Math.floor(((t - t0) / WINDOW_MS) * w);     // 落在哪个像素列
    if (c !== col) {                                      // 每列取 min/max，毛刺不会被抹掉
      if (col >= 0) { ctx.moveTo(col, mid - maxV * k); ctx.lineTo(col, mid - minV * k); }
      col = c; minV = Infinity; maxV = -Infinity;
    }
    if (v < minV) minV = v;
    if (v > maxV) maxV = v;
  }
  if (col >= 0) { ctx.moveTo(col, mid - maxV * k); ctx.lineTo(col, mid - minV * k); }
  ctx.stroke();
}

// 渲染循环：用 rAF；「有没有新数据都按 now 推进」，这样才像示波器一直在滚
(function tick() { renderRolling(performance.now()); requestAnimationFrame(tick); })();
```

配套要点：

- **时间源**：协议里带下位机时间戳就优先用它（周期更准）；否则用 `performance.now()`（单调递增，不受系统改时间影响）。**别用点数/帧计数当时间**。
- **暂停 / 冻结**：把 `t1` 固定住（不推进），波形即停在原地；恢复时从当前 `now` 继续（会"跳"一下，属预期）。
- **拖动 / 缩放的量程**只改 `WINDOW_MS` 与 Y 方向的 `mid/k`，**永远不要**让 X 量程跟着数据自适应。
- **不要用 `ctx.drawImage(canvas, -dx, 0)` 做像素平移**：DPR≠1 或容器尺寸变化时会糊，且数据比像素快时会丢列。老老实实每帧按时间窗重绘。
- **不要在数据回调里绘制**，也不要 `setInterval` 驱动渲染（会撕裂、重复画）；只置脏标记 + rAF。
- 低频场景下可把 rAF 换成"有脏标记才画"，但**滚动窗口仍需按时间推进**（否则就停住了）。

---

## 8. 数据记录与回放

- **录原始字节 + 到达时间戳**，不要录“解析后的文本”。这样以后改解析规则/改编码，还能重新解释同一份数据（“保存现场，而不是截图”）。
- 内存里用环形缓冲控制上限（例：最近 30 万字节 / 6 万个点），避免长时间运行 OOM。
- 导出：原始数据用 **十六进制 + 时间列**（文本编码无关，永不乱码）；解析结果另存 CSV（带 BOM）。
- 回放：把“喂字节”的入口抽成 `feedBytes(bytes, t)`，回放时按时间轴依次喂入 —— **实时与回放共用同一条解析管线**，行为才不会两套。
- 重要值（波特率/解析规则/图框配置）随数据一起存一份快照，回放时可一键恢复现场。

---

## 9. 配套下位机与硬件设计（AI 若同时生成固件，务必对齐）

### 9.1 带宽预算（先算再选波特率）

`每帧字节数 × 帧率 ≤ 波特率 / 10`（1 个字节约 10 bit）。
例：115200 ≈ 11.5 KB/s；若 20 帧/s，则每帧最多约 570 字节。**别用 9600 传 1kHz 波形**。

### 9.2 协议约定（两端必须完全一致，写进注释）

- 波特率、数据位/停止位/校验位、**行结尾**（`\n` 还是 `\r\n`）
- 帧格式（文本 KV / 二进制）、字节序、校验算法、帧头
- 数值精度与单位（定点缩放 or 浮点；`mm` 还是 `m`；角度 `deg` 还是 `rad`）
- 上报周期、是否带序号（丢帧可检测）、是否带下位机时间戳

### 9.3 固件侧常见坑

- **Arduino/ESP 打开串口会被 DTR 复位**：host 侧要等 boot，或固件里在 `setup()` 后先发一条 `#ready`。
- **Raspberry Pi Pico** **（RP2040）**硬件需打开 DTR 才可以接收到数据：否则无法接收到串口数据或只能在连接瞬间接收到一帧的数据。
- 用 `printf`/`Serial.print` 频繁发送会**阻塞**采样循环：优先“定时器采样 → 主循环发”；发送前判断 `Serial.availableForWrite()`，或降低输出频率/改二进制。
- 中断里不要 `print`。
- 3.3V TTL 电平直接接 USB-TTL（CH340/CP2102/FT232）；与 5V 器件注意电平匹配。
- 若用硬件流控，固件必须真的实现 RTS/CTS，否则会被“以为在流控”的上位机拖死。
- 上电/复位时先发一条自描述（型号、版本、协议版本），上位机据此自动选择解析器。

### 9.4 生成固件代码时的自检清单

- [ ] 波特率/行结尾/校验与 HTML 端**逐字一致**
- [ ] 有握手（`#ready`）便于上位机判断“已启动”
- [ ] 数值单位与缩放因子在两端注释里写明
- [ ] 发得不比带宽预算更密；必要时降采样后再发
- [ ] 不阻塞采样循环

---

## 10. 没有硬件也能开发/测试

这是“快速迭代”的关键，别等设备插上才开始写。

1. **模拟数据源**：给应用加一个 `?sim=1` 模式，用 `setInterval` 生成合成帧直接调 `feedBytes(...)`（不经过串口）。
2. **解析函数单测**：把解析写成纯函数，直接喂 `Uint8Array`，断言输出；用 Node/浏览器控制台都能跑。
   - 必测边界：**把一帧拆成 1 字节一片**、**把 `\r\n` 拆开**、**把中文的 3 个字节拆开**，结果必须与整帧一致。
3. **虚拟串口环回**：Windows 用 com0com、Linux/macOS 用 socat 建一对虚拟串口，一端跑你的 HTML，另一端用脚本收发，可测真实连接路径。
4. **桩掉 `navigator.serial`**：在页面里注入一个假的 `navigator.serial`（可控的读写流），就能自动化测连接/断开/重连逻辑，无需真设备。
5. **调试清单**：Hex 视图与文本视图**同时显示**原始字节，是最省时间的自检手段。

---

## 11. 常见坑速查

- [ ] 假设“一次 `read()` = 一条消息” → 必须改成缓冲累积
- [ ] 对分片分别 `TextDecoder.decode()` → 中文乱码（`U+FFFD`）；只解完整帧，或做字节级补齐
- [ ] `\r\n` 跨块被切开 → 缓冲以 `\r` 结尾时先别当帧尾
- [ ] 用 `TextDecoder('ascii'|'latin1')` 又自己写编码器 → 往返不一致（浏览器按 `windows-1252`）
- [ ] 导出 CSV 没写 BOM → Excel 中文乱码
- [ ] 改波特率没重开端口 → 不生效
- [ ] 释放顺序不对 / 没 catch → 端口再也打不开
- [ ] 硬流控下还手动设 RTS → 冲突
- [ ] 每收到一个点就 `innerHTML +=` 或重绘 canvas → 卡死；应 rAF 批量渲染 + 限制点数
- [ ] `setInterval` 忘了在断开时清理 → 报错刷屏
- [ ] 忘记 `beforeunload` 释放端口 → 下次占用
- [ ] Arduino 打开串口被 DTR 复位 → 开头收到 bootloader 垃圾

---

## 12. 参考实现（想深入可直接读源码）

本仓库的 `/index.html` 是一个**功能完整的同类实现**（约 1.6 万行，单文件）：
串口连接/重连/失焦释放、RX 三层切分、文本模板与十六进制结构解析、时域/频域/柱状图、
时间线录制回放与导入导出、条件/定时/预设发送。

- 在线：[https://conductance-lab.github.io/SerialWeb/](https://conductance-lab.github.io/SerialWeb/) ／ [https://conductance-lab.xyz/SerialWeb/](https://conductance-lab.xyz/SerialWeb/)
- 源码：[https://github.com/Conductance-lab/SerialWeb](https://github.com/Conductance-lab/SerialWeb)
- 读它时的建议入口（按符号名搜索）：`connectSerial` / `readLoop` / `queueRxBytes` / `segmentRxSource` /
  `decodeBytes` / `applyTemplateRule` / `getActiveViewData` / `renderCharts` / `scheduleRefresh`。

> 该实现的接收管线比本指南 §3 的骨架复杂（它要在超长数据、滚动窗口、多视图下保持增量渲染），
> 新项目**不必照抄**；先用 §1 的“完整帧才解析”简单模型，遇到性能/需求瓶颈再逐步加复杂度。
