fix(serial-assistant): daemon 重启自动重开口 + 自动重连 + 日志缺口标记(#75)
CI / 静态门禁 (pre-commit) (pull_request) Failing after 4m19s
CI / 主机侧单测 (MSVC) (pull_request) Successful in 44s
CI / 静态门禁 (pre-commit) (push) Failing after 4m51s
CI / 主机侧单测 (MSVC) (push) Failing after 43s

按 Gitea issue #75 R1–R5 修采集层「daemon 死了采集静默停摆」族问题:
旧行为下 daemon 崩溃/被杀/读错丢口后,新实例起得来但口不开
(run() 先 _save_meta 把现场覆成 null,且无任何重开路径),
serial.log 缺口无边界,wait-idle 计满还良性返回 no_data,
把「采集器死」伪装成「板子静默」。

- R1 启动自动重开:meta.json 的 port 字段升格为「开口意图」现场
  (缺口期不清空,显式 close/stop 清除);run() 先读旧 meta 再覆写;
  重开在串口线程内执行(串口对象唯一持有者不变量),首次 = 出生 +1.5s。
  restart 特判:cmd_start(force) 在 stop 前抓 /status 现场、新 daemon
  就绪后 POST /open 开回来(stop 语义不变,结果进返回 JSON 的 reopen 键)。
- R2 失败自动重连:_port_error 后退避重试(REOPEN_SETTLE_S=1.5 起步、
  倍增、REOPEN_BACKOFF_MAX_S=30 封顶)直到成功或显式 close;
  逐次失败原文进 daemon.log(dlog=stderr),不刷 serial.log。
- R3 缺口标记:serial.log 落 [SYS] 行——daemon started(新旧 pid +
  重开现场)/ GAP-START(丢口与崩溃恢复两种,窗口起点下限声明)/
  GAP-END(窗口秒数 + 尝试次数 + 字节不可找回)/ GAP-RETRY(退避期
  仅首次 + 之后每分钟 ≤1 条,口径定死并写入设计说明 §6.1)。
- R4 状态可见:status 新增 auto_reopen{enabled,pending,attempts,
  last_error,next_retry_in_ms};port 新增 desired(意图)与 ready
  (实际)并列,缺口期 name/baud 显示待重开参数;wait-idle 入口加
  无口门:口未开秒回 ok:false + 现场,不再计满返回良性 no_data。
- R5:VERSION=1.2.0-skill;selftest 新增 T10–T15 共 22 项无头用例
  (重开意图判定 / 退避倍增封顶 / 缺口行格式 / 无口门 / auto_reopen
  三态 / restart 捕获),合计 45 项全绿;SKILL.md 与使用指南、
  设计说明 §6.1+§13 同步(机理只在设计说明详述一处)。

兼容性:JSON 只增字段、退出码形状不变;run_boot_test.py 正常路径
不受影响,其 wait-idle 撞无口时按采集错误 exit 2 归因(正是工单
要的区分)。

验证:selftest 45/45 PASS;COM3 台架三场景全过(崩溃恢复自动重开
+ GAP 标记、显式 close 后 restart 不重开、wait-idle 快速失败),
transcript 见工单汇报。
This commit is contained in:
2026-11-04 11:10:04 +08:00
parent f8c9296392
commit dfd1528409
4 changed files with 572 additions and 71 deletions
@@ -2,7 +2,7 @@
> **定位**:面向使用者的操作手册——命令怎么敲、输出怎么看、常见场景怎么组合、出问题怎么排查。
> **关联文档**:设计决策与实现细节见 `串口助手设计说明.md`;/serial-boot-test 自动化闭环见 `自动化测试流程.md`;`serial-assistant` skill 有一页速查(用到自动加载,AGENTS.md 不再重复收录)。
> **版本**:v1.1(与 `.dsh/skills/serial-assistant/scripts/serial_tool.py` 当前实现一致,2026-09-15)
> **版本**:v1.2(与 `.dsh/skills/serial-assistant/scripts/serial_tool.py` 当前实现一致,2026-11-04;新增 daemon 死后串口自动重开与日志缺口标记——机理见 `docs/测试说明/测试工具/串口助手设计说明.md` §6.1,本文只讲行为)
## 1. 它是什么(60 秒版)
@@ -10,7 +10,7 @@
| 组成 | 是什么 | 你要知道的 |
|---|---|---|
| **常驻 daemon** | 后台 Python 进程,**串口唯一持有者**,把 RX/TX 持续写进日志文件 | 不用管它——任何命令发现它没在跑会**自动拉起** |
| **常驻 daemon** | 后台 Python 进程,**串口唯一持有者**,把 RX/TX 持续写进日志文件 | 不用管它——任何命令发现它没在跑会**自动拉起**;v1.2 起它死的时候口若还开着(且你没显式 close/stop),新实例还会**自动把口重开回来** |
| **薄 CLI** | `serial_tool.py <命令>`,每条命令都是带 5s 硬超时的短操作 | 你唯一直接打交道的界面 |
| **日志文件** | `.dsh/skills/serial-assistant/scripts/state/serial.log`,**全量历史就在这里** | 想回看任何时刻的数据 → 直接读这个文件 |
@@ -51,7 +51,7 @@
| `open` | 开串口(**幂等**:同口已开 = 直接成功;不带 `--dtr/--rts` = **保持现状**,显式给则现场应用并在 `note` 里回显) | 是(未运行自动拉起) |
| `close` | 关串口 | 是 |
| `send` | 发 TX 字节(进日志) | 是 |
| `status` | 全状态一条 JSON(含 `state`、`rx_age_ms` = 距最后一个 RX 字节的毫秒数,`null` = 这个 daemon 还没收过字节) | 是 |
| `status` | 全状态一条 JSON(含 `state`、`rx_age_ms` = 距最后一个 RX 字节的毫秒数,`null` = 这个 daemon 还没收过字节;v1.2 起含 `port.desired` / `auto_reopen`) | 是 |
| `log` | 读日志文件(`--tail N` / `--grep P`) | **否**(直接读文件,daemon 死了也能用) |
| `clear` | 清空日志 + 代际 +1 | 是 |
| `wait-idle` | 等 RX 静默(/serial-boot-test 的收尾判据) | 是 |
@@ -101,7 +101,7 @@
... serial_tool.py close # → {"ok":true}
```
未开时返回 `{"ok":true,"note":"port not open"}`(不算错误)。daemon 继续运行。
未开时返回 `{"ok":true,"note":"port not open"}`(不算错误)。daemon 继续运行。**close 同时清除自动重开意图**(v1.2):此后 daemon 无论怎么死灰重生,都**不会**再自动重开这个口,直到你再次显式 `open`。
### 3.4 `send` — 发数据
@@ -127,11 +127,13 @@
{
"ok": true,
"daemon": {"pid": 16816, "version": "1.0.0", "uptime_s": 31},
"port": {"ready": true, "name": "COM1", "baud": 115200, "dtr": false, "rts": false,
"since": "2026-09-15T02:30:57.739+08:00"},
"port": {"ready": true, "desired": true, "name": "COM1", "baud": 115200, "dtr": false,
"rts": false, "since": "2026-09-15T02:30:57.739+08:00"},
"counters": {"rx_bytes": 1834, "tx_bytes": 22, "rx_lines": 57, "tx_lines": 3, "errors": 0},
"last_rx": {"ts": "…", "line": "UI menu init: OK", "age_ms": 320},
"log": {"path": "…\\tools\\serial_tool\\state\\serial.log", "size": 4523, "gen": 7},
"auto_reopen": {"enabled": true, "pending": false, "attempts": 0, "last_error": null,
"next_retry_in_ms": null},
"state": "active",
"error": null
}
@@ -139,8 +141,10 @@
| 字段 | 说明 |
|---|---|
| `state` | `active`(正常收数据)/ `stopped`(口未开)/ `error`(读错/拔线,`error` 字段有详情;重开 `open` 即恢复) |
| `port.ready` | 已开 && 非 error |
| `state` | `active`(正常收数据)/ `stopped`(口未开)/ `error`(读错/拔线,`error` 字段有详情;v1.2 起自动重连,无需手工 `open`) |
| `port.ready` | 已开 && 非 error(**实际**状态) |
| `port.desired` | 开口**意图**(v1.2):该不该开着口——显式 `open`/自动重开置位,`close`/`stop` 清除;`desired=true` 而 `ready=false` = 采集缺口进行中 |
| `auto_reopen` | 自动重开现场(v1.2):`enabled`(意图在不在)/ `pending`(正在退避等重试)/ `attempts`(本次缺口已试次数)/ `last_error`(最近一次失败原文)/ `next_retry_in_ms`(下次重试倒计时) |
| `counters` | 本 daemon 生命周期累计(daemon 重启清零) |
| `last_rx.age_ms` | 距最后一行 RX 的毫秒数——判断"还在刷/已静默" |
| `log.gen` | 当前日志代际(`clear` 会 +1) |
@@ -191,9 +195,10 @@ Select-String -Path '.dsh\skills\serial-assistant\scripts\state\serial.log' -Pat
| `reason` | 含义 | `idle` |
|---|---|---|
| `idle` | 收到过数据,且已连续静默 ≥ `--settle-ms`(**正常收尾**) | true |
| `no_data` | **本次等待期间**一个字节都没收到(板子没输出/口不对/没烧进去) | false |
| `no_data` | **本次等待期间**一个字节都没收到(板子没输出/波特率不对/没烧进去)——**v1.2 起仅当口开着**才可能返回:口未开已改秒回 `ok:false`(见下) | false |
| `timeout` | `--max-ms` 到了仍在持续吐数据 | false |
- **v1.2 行为变化(issue #75)**:口未开时调用 → **立即返回** `{"ok":false,"error":"port not open; auto-reopen pending (attempts=… last_error=… next_retry_in_ms=…)"}`(exit 1,不占等待时间)。旧行为是干等满 `--max-ms` 再良性返回 `no_data`,把「采集器根本没在采」伪装成「板子静默」——现在 `no_data` 严格 = 口开着且板子真静默,可放心作为判定依据。
- **`--settle-ms` 默认 8000 不是随便定的**:本工程 boot 任务间有 3~4s 自然静默间隙,判据小于它会把开机日志截断。
- **首字节门(2026-10-10 加)**:首字节到达**之前**不启动静默计时——旧实现从进入即计时,板子第一行比 `--settle-ms` 来得晚就提前收尾("开机日志缺尾巴"的另一半成因)。`--first-timeout-ms`(0 = 同 `--max-ms`)到点仍零字节 → `no_data` 快速失败。
- **`rx_bytes` 是本次增量**(旧实现回的是 daemon 累计值,常驻 daemon 下动辄百万,无判读价值)。
@@ -210,7 +215,8 @@ Select-String -Path '.dsh\skills\serial-assistant\scripts\state\serial.log' -Pat
- 日常**不需要** start(任何命令都会自动拉起)。
- **改了 `serial_tool.py` 之后**要 `restart`,否则运行中的还是旧代码。
- `stop` 后 COM 端口立即释放,可交给其他程序;daemon 死掉/被杀同理(下次命令自动拉起,但**串口不会自动重开**——需要时再 `open`)。
- `stop` 后 COM 端口立即释放,可交给其他程序。`stop` 与 `close` 都会**清除自动重开意图**——之后被拉起的 daemon 不会自动重开口(`auto_reopen.enabled=false`)。
- **v1.2**:daemon 崩溃/被强杀/拔线丢口 → **自动重开**(退避 1.5s→封顶 30s,现场看 `status.auto_reopen`);`restart` 时若重启前口开着,新 daemon 就绪后自动 `open` 回来(`stop` 语义本身不变;返回 JSON 里 `reopen` 键记录这次恢复)。
### 3.10 `selftest` — 无头自检
@@ -243,11 +249,15 @@ Select-String -Path '.dsh\skills\serial-assistant\scripts\state\serial.log' -Pat
[TX] 2026-09-15T02:31:23.986+08:00 hello_from_tool\r\n
[SYS] 2026-09-15T02:31:24.245+08:00 log cleared (gen=1)
[SYS] 2026-09-15T02:39:54.598+08:00 log truncated to 10MB (cap=20MB)
[SYS] 2026-11-04T09:00:00.000+08:00 daemon started v1.2.0-skill pid=4321 prev_pid=16816 auto-reopen pending COM3@115200
[SYS] 2026-11-04T09:00:00.000+08:00 GAP-START after daemon restart: previous daemon (pid=16816) died holding COM3; …
[SYS] 2026-11-04T09:00:01.520+08:00 GAP-END reopened COM3@115200 (gen=7) after 1.5s, 1 attempt(s); bytes lost inside the gap window are unrecoverable
```
| 规则 | 说明 |
|---|---|
| 方向标记 | `[RX]` 串口收到 / `[TX]` 你发出 / `[SYS]` 工具事件(open/close/error/cleared/truncated) |
| 方向标记 | `[RX]` 串口收到 / `[TX]` 你发出 / `[SYS]` 工具事件(open/close/error/cleared/truncated + v1.2 缺口标记) |
| **缺口标记(v1.2)** | `GAP-START`(丢口/崩溃恢复,缺口起)… `GAP-END`(重开成功,缺口止)之间的时间窗内**没有任何 [RX],字节不可找回**;`GAP-RETRY` 是退避期提示(首次 + 之后每分钟 ≤1 条)。有 `GAP-START` 无 `GAP-END` = 采集器还没恢复;口径全文见 `docs/测试说明/测试工具/串口助手设计说明.md` §6.1 |
| 时间戳 | 本地 ISO8601 含毫秒与偏移;= **该行首字节到达时刻** |
| **只含完整行** | 残行留 daemon 内存,200ms 无后续数据才冲刷成行 → 你**永远读不到半行** |
| 解码回退 | UTF-8 → GBK → 都不行则该文本域写 `HEX:ab12…`(纯二进制流时整行转 hex) |
@@ -332,9 +342,10 @@ Get-Content '.dsh\skills\serial-assistant\scripts\state\daemon.log' -Tail 30 #
| `port busy: COMx (held by another process)` | 别的程序占着口 | `Get-Process` 找占用者关掉;或 `… serial_tool.py close` 释放本工具占的口 |
| `daemon start timeout` / `daemon unreachable` | daemon 起不来(端口冲突/解释器问题) | 看 `state/daemon.log`;`… serial_tool.py restart`;确认 18765 未被占(改 `httpPort`) |
| `daemon did not answer within Ns (busy? raise --timeout)` | 命令耗时超过 `--timeout`——daemon 活着、只是还在等(典型:显式给 `wait-idle` 带了小 `--timeout`) | 调大 `--timeout`,或不显式给(`wait-idle` 按 `--max-ms` 自动推导)。2026-10-10 起这种情况**不再**顺手拉起第二个 daemon |
| `wait-idle` 返回 `no_data` | 全程无 RX | 核对 COM 号与波特率;板子供电;烧录是否成功;`status` 看 `port.ready` |
| `wait-idle` 返回 `no_data` | 全程无 RX(**口开着**才算数;v1.2 起口未开是秒回 `ok:false`,不再是 `no_data`) | 核对 COM 号与波特率;板子供电;烧录是否成功;`status` 看 `port.ready` |
| 日志里出现 `HEX:…` 行 | 该段字节不是 UTF-8/GBK 文本(二进制流/乱码) | 正常回退行为,按 hex 对照协议解析 |
| `state: error` + `error: read: …` | 读串口出错/拔线 | daemon 仍活着;重插后 `open` 即恢复(自动 1.5s 退避) |
| `state: error` + `error: read: …` | 读串口出错/拔线 | daemon 仍活着;**v1.2 起自动重连**(退避 1.5s→封顶 30s),插回线缆即自动恢复——进度看 `status.auto_reopen`,也可手工 `open` 立刻重试 |
| `GAP-START` 迟迟没有 `GAP-END` | 自动重开还没成功:口被别的程序占着 / 适配器还没回来 | 看 `status.auto_reopen.last_error` + `state/daemon.log`;腾出口后下一轮退避自动恢复;不想要了 `close` 清意图 |
| 改了 `serial_tool.py` 行为没变 | 运行中的 daemon 还是旧代码 | `… serial_tool.py restart` |
| 开机日志"缺尾巴"(如缺 `Network Init`) | ① `--settle-ms` 太小,在 boot 自然静默间隙就收尾;② 首字节晚于 `--settle-ms` 到达,静默计时提前触发 | 用默认 8000(`run_boot_test.py` 已内置);2026-10-10 起首字节到达前不启动静默计时,必要时调 `--first-timeout-ms` |
| `open` 报 `no port specified … [config: …]` | `.serialtest/config.json` 缺失 / JSON 语法错 / `baudRate`·`httpPort` 非整数(现按 `utf-8-sig` 读并**显式回显**错误,不再静默回退成"没配端口") | 按 `config_error` 文本修文件;`status` 与 `ports` 也回显 `config_error` |
@@ -343,7 +354,7 @@ Get-Content '.dsh\skills\serial-assistant\scripts\state\daemon.log' -Tail 30 #
## 8. 常见问题
- **daemon 会一直占着内存吗?** 一个 Python 进程约 20MB;不需要时 `stop` 即释放。常驻与否都不影响正确性。
- **daemon 被强杀(taskkill)会怎样?** 端口由系统自动释放;下次任何命令自动拉起新 daemon。**串口不会自动重开**,需要时再 `open`(这是设计:避免在设备异常时盲目重连)。
- **daemon 被强杀(taskkill)会怎样?** 端口由系统自动释放;下次任何命令自动拉起新 daemon;**v1.2 起串口还会自动重开**——条件是上一任死时口开着且没被显式 close/stop。之后口一直不存在或被别的程序占着,就按 1.5s→30s 倍增退避一直重试,直到成功或你显式 `close`(现场看 `status.auto_reopen`)。
- **能同时开两个串口吗?** v1 单口(一个 daemon 管一个口)。多口需求见 `docs/测试说明/测试工具/串口助手设计说明.md` §8 v2 候选。
- **能直接删 `serial.log` 吗?** 可以,但代际 `gen` 不会变、写句柄不变——建议用 `clear`(原子 + 代际 +1 + 标记行)。
- **为什么 TX 日志里是字面 `\r\n` 而不是真换行?** 日志按行存储;真换行会把一条 TX 劈成两行,转义后一条 TX 恒一行。
@@ -363,7 +374,7 @@ daemon 在 `127.0.0.1:<httpPort>`(默认 18765)提供 JSON API,与 CLI 一
| `POST /close` | — | 同 `close` |
| `POST /send` | `{"text":"hi","nl":true}` 或 `{"hex":"0D0A"}` | 同 `send` |
| `POST /clear` | — | 同 `clear` |
| `POST /wait-idle` | `{"settle_ms":8000,"max_ms":30000,"first_timeout_ms":12000}`(均可省) | **同步阻塞**至静默/首字节超时/整体超时才返回 |
| `POST /wait-idle` | `{"settle_ms":8000,"max_ms":30000,"first_timeout_ms":12000}`(均可省) | **同步阻塞**至静默/首字节超时/整体超时才返回;口未开 → **立即**返回 `ok:false`(v1.2 快速失败) |
| `POST /stop` | — | 关口后 daemon 退出(响应先于退出发出) |
响应码:`200` 成功 / `400` 领域错误(`ok:false`+`error`)/ `504` daemon 忙(命令 20s 未完成)/ `404` 未知端点。
@@ -1,7 +1,7 @@
# Python 串口助手(serial_tool)设计说明
> **版本**:v1.1(需求 + 设计 + 实施记录)
> **日期**:2026-09-15
> **版本**:v1.2(需求 + 设计 + 实施记录;版本记录见 §13)
> **日期**:2026-09-15(v1.0/v1.1)· 2026-11-04(v1.2:daemon 死后自动重开,Gitea issue #75)
> **状态**:**已实施**(步骤 1–6 完成;本机无硬件自检项全部通过;CH340 已接入 = COM3,「拔线容错」与「烧录+抓开机日志全流程」两条正路径的硬件前提已具备,后者已由 `/serial-boot-test` 台架持续跑通)
> **关联文档**:`串口助手使用指南.md`(命令用法/工作流/排障) · `自动化测试流程.md` · `serial-assistant` skill(命令速查/陷阱) · `.serialtest/config.json`(端口单一配置源,2026-10-10 由 `.opencode/serial_config.json` 迁入)
> **取代**:`serial-mcp-server_串口调试.md`(实施时删除,工具历史并入本文 §12)
@@ -84,7 +84,7 @@ flowchart LR
| `send` | 发 TX 字节(进日志);默认**精确字节**不加换行 | 位置参数文本(UTF-8)或 `--hex 0D0A`;`--nl` 追加 CRLF | `{"ok":true,"bytes":12}` |
| `status` | 全状态(见 §4.2) | — | 见 §4.2 |
| `log` | 读日志(**先过滤后截尾**,2026-10-10) | `--tail N`(默认 50;`0` = 全部)`--grep P` `-i` `--regex` | `{"ok":true,"gen":8,"lines":["…"],"scanned":N,"matched":M}` |
| `wait-idle` | 等 RX 静默(/serial-boot-test 收尾判据) | `--settle-ms`(默认 8000)`--max-ms`(默认 30000)`--first-timeout-ms`(默认同 max) | `{"ok":true,"rx_bytes":本次增量,"idle":bool,"waited_ms":N,"reason":"idle\|no_data\|timeout"}` |
| `wait-idle` | 等 RX 静默(/serial-boot-test 收尾判据) | `--settle-ms`(默认 8000)`--max-ms`(默认 30000)`--first-timeout-ms`(默认同 max) | `{"ok":true,"rx_bytes":本次增量,"idle":bool,"waited_ms":N,"reason":"idle\|no_data\|timeout"}`;口未开 → **秒回** `{"ok":false,"error":"port not open; auto-reopen …"}`(v1.2 快速失败,不再计满返回 `no_data`) |
| `clear` | 清空日志:原地截断 + 写 `[SYS] log cleared (gen=N+1)` | — | `{"ok":true,"gen":8}` |
| `start` / `stop` / `restart` | daemon 生命周期 | — | `{"ok":true,"pid":…}` / stop 后 `{"ok":true,"stopped":true}` |
| `selftest` | 无头自检(行拆分器/解码回退/截头算法 单测回环,无需硬件) | — | 0=PASS |
@@ -95,17 +95,19 @@ flowchart LR
{
"ok": true,
"daemon": {"pid": 1234, "version": "1.0.0", "uptime_s": 61},
"port": {"ready": true, "name": "COM6", "baud": 115200, "dtr": false, "rts": false,
"since": "2026-09-15T10:29:58.112+08:00"},
"port": {"ready": true, "desired": true, "name": "COM6", "baud": 115200, "dtr": false,
"rts": false, "since": "2026-09-15T10:29:58.112+08:00"},
"counters": {"rx_bytes": 1834, "tx_bytes": 12, "rx_lines": 57, "errors": 0},
"last_rx": {"ts": "2026-09-15T10:31:02.441+08:00", "line": "UI menu init: OK", "age_ms": 320},
"log": {"path": "D:\\…\\tools\\serial_tool\\state\\serial.log", "size": 4523, "gen": 7},
"auto_reopen": {"enabled": true, "pending": false, "attempts": 0, "last_error": null,
"next_retry_in_ms": null},
"state": "active",
"error": null
}
```
`state ∈ {stopped, active, error}`;`port.ready = 已开 && state≠error`。Agent 轮询等待只用本命令。
`state ∈ {stopped, active, error}`;`port.ready = 已开 && state≠error`(实际);`port.desired` = 开口意图(v1.2,该不该开着,见 §6.1);顶层 `auto_reopen` = 自动重开循环现场(v1.2)。Agent 轮询等待只用本命令。
### 4.3 CLI 退出码契约
@@ -129,7 +131,7 @@ flowchart LR
| 文件 | 用途 |
|---|---|
| `.dsh/skills/serial-assistant/scripts/state/serial.log` | RX/TX/SYS 全量日志(Agent 直接读) |
| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / 当前 gen) |
| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / 当前 gen / v1.2 起含开口意图现场 `port`——重启自动重开的依据,§6.1) |
| `.dsh/skills/serial-assistant/scripts/state/daemon.log` | daemon 自身 stderr(排障用) |
### 5.2 行格式
@@ -171,14 +173,35 @@ flowchart LR
| 拉起方式 | CLI detached 子进程(`CREATE_NO_WINDOW`),解释器 = `sys.executable`(保证同一 .venv) |
| `open` 语义 | 幂等(同端口已开 = 成功返回,**不重开**);端口不存在 → ≤1s 报错并附可用端口列表;被占用 → ≤1s 报错(Windows 共享冲突即时返回)并提示持有者 |
| DTR/RTS | 默认**不翻转**(`dtr=False, rts=False`,防 STM32 自动复位电路误复位目标板);`--dtr/--rts` 可显式置位 |
| 读线程容错 | 串口读异常/设备拔出 → 记 `[SYS] … serial error: …`,`state=error`(`status.error` 非空),**daemon 不退出**;重插后 `open` 重开恢复 |
| 重开退避 | 错误恢复路径重开端口前 ≥1.5s 退避(CH340 特性);正常流程不重开 |
| `stop` | 关端口(flush 尾行)→ 写 `[SYS] port closed` → 退出 |
| 进程被强杀 | Windows 进程退出自动释放 COM 端口;下次命令自动拉起新实例(`open` 幂等路径) |
| 读线程容错 | 串口读异常/设备拔出 → 记 `[SYS] … serial error: …(GAP-START…)`,`state=error`(`status.error` 非空),**daemon 不退出**;v1.2 起自动进入重开重试循环(§6.1),插回/让位后自动恢复,也可手工 `open` |
| 重开退避 | 手工 `open` 与自动重开的首次尝试均距上次关口/daemon 启动 ≥1.5s(CH340 特性);自动重开失败按 1.5s 起步倍增、封顶 30s 持续重试(v1.2,§6.1) |
| `stop` | 关端口(flush 尾行)→ 写 `[SYS] port closed` → 退出;同时清除自动重开意图(v1.2) |
| 进程被强杀 | Windows 进程退出自动释放 COM 端口;下次命令自动拉起新实例(`open` 幂等路径);v1.2 起若上一任死时口开着(`meta.json` 的 `port` 现场),新实例**自动重开该口**(§6.1) |
| `wait-idle` 判据(2026-10-10 修) | `no_data` = **本次等待期间零字节**(旧实现比的是 daemon 累计 `rx_bytes` 是否为 0 → 常驻 daemon 下永不触发,板子全程静默也误报 `idle:true`,/serial-boot-test 的 no_data 拦截形同虚设);**首字节到达前不启动 settle 计时**(`first_timeout_ms` 兜底,默认 = `max_ms`);返回 `rx_bytes` = 本次增量 |
| CLI 超时与断连噪声(2026-10-10) | `wait-idle` 未显式带 `--timeout` 时按 `max_ms/1000 + 5` 自动推导(旧版固定 5s 会在 daemon 还在等时掐断连接);handler 侧 `ConnectionError`(客户端已断开)不再往 daemon.log 打 traceback(本机曾累积 16 次);`daemon.log` 超 1MB 在拉起新实例前原地保尾 256KB |
| 配置读取(2026-10-10) | 按 `utf-8-sig` 读(容忍 BOM,与 `run_boot_test.py` 同口径);文件缺失 / JSON 语法错 / `baudRate`·`httpPort` 非整数不再静默回退成"没配端口",错误文本进 `configError`,由 `status` / `ports` / `open` 显式回显 |
### 6.1 自动重开与日志缺口标记(v1.2,issue #75 R1–R4)
**动机**:daemon 是采集层,进程崩溃/被杀/读错丢口后,旧行为 = 「新实例起得来但口不开、serial.log 静默追加无边界」——读日志方无法区分「板子安静」与「采集器死」。v1.2 把「该不该开着口」升格为持久意图,并把采集缺口显式钉进日志。
- **意图现场(R1 载体)**:`meta.json` 的 `port` 字段 = 「上一任死时该不该开着口」。`_save_meta` 写 `_open_identity()`(实开口 > 待重开参数),**重开缺口期间不清空**(缺口正是最该留现场的时候);显式 `close` / `POST /stop` 写 `port:null` 清除意图。新 daemon `run()` **先读旧 meta**(记下旧 `pid` 供边界行引用,**再** `_save_meta` 覆写——旧实现覆写在前,现场即被抹成 null,这就是 R1 的根因)→ `plan_startup_reopen()` 判定:有现场 = 开着口死的 → `desired=True`。
- **重开在串口线程内执行**:「daemon 内串口对象唯一持有者」不变量(§3.1)不因自动重开而破坏——主线程只置标志,`serial_loop` 在 `ser is None 且 desired 且已到重试时刻` 时调 `_auto_reopen_attempt()`;首次尝试 = 线程出生 +1.5s(CH340 释放退避照旧适用于自动路径)。
- **失败自动重连(R2)**:`_port_error`(读错/丢适配器)不再只提示手工 `open`——快照现场后退避循环重试,直到成功或显式 close。退避:`REOPEN_SETTLE_S=1.5` 起步、`next_backoff()` 倍增、`REOPEN_BACKOFF_MAX_S=30` 封顶。每次失败的**错误原文进 daemon.log**(`dlog` = stderr),不刷 serial.log。
- **restart 特判**:`cmd_start(force=True)` 走 `POST /stop`,而 stop 会清现场——「重启」不该弄丢采集。CLI 在 stop **之前**用 `restart_reopen_plan(GET /status)` 抓开口现场,新 daemon 就绪后再 `POST /open` 开回来(open 命令本就经串口线程执行);`stop` 自身语义不变。
- **缺口标记(R3)**——serial.log 的 `[SYS]` 行共四种,构造函数 = serial_tool.py 内同名纯函数(selftest T12 锁格式),行式仍是 §5.2 的 `[SYS] <ISO8601> <文本>`:
| 时机 | 行文本(要点) |
|---|---|
| daemon 启动边界 | `daemon started v<版本> pid=<新> prev_pid=<旧\|none> auto-reopen pending <COMx@baud\|none>` |
| 缺口起(运行中丢口) | `serial error: <原文> (GAP-START: port released; bytes in this window are unrecoverable; auto-reopen retrying, backoff 1.5s doubling to 30s cap)` |
| 缺口起(崩溃恢复) | `GAP-START after daemon restart: previous daemon (pid=B) died holding COMx; gap began no later than now (exact start unknown); …`——崩溃时刻无从得知,只能声明**窗口起点下限** |
| 缺口止(重开成功) | `GAP-END reopened COMx@baud (gen=G) after Ss, N attempt(s); bytes lost inside the gap window are unrecoverable` |
**退避期刷屏口径(定死)**:`GAP-RETRY attempt N failed: <原文> (…)` 行仅**首次失败**(attempts=1)落 serial.log,此后**每 60s 最多一条**(`GAP_NOTE_INTERVAL_S`);逐次重试细节全在 daemon.log。**窗口内字节不可找回是硬事实**:GAP-START 与 GAP-END 之间到达 UART 的字节没有任何进程读过,任何标记/重试都救不回——标记行的价值是把「不可找回窗口」在时间轴上钉出来(读方见 `GAP-START` 无 `GAP-END` = 采集器未恢复,见 `no_data` = 口开着且板子真静默)。
- **状态可见(R4)**:`status.auto_reopen = {enabled(意图在不在), pending(正在退避等重试), attempts, last_error, next_retry_in_ms}`;`port.desired`(意图)与 `port.ready`(实际)并列,缺口期 `port.name/baud` 显示**待重开参数**(回退 `_open_identity`),消费方不用自己拼 meta。`wait-idle` 入口新增**无口门**:口未开 → 秒回 `ok:false` + auto-reopen 现场,**不再**计满返回良性 `no_data`。
- **兼容性**:JSON 只增字段;退出码形状不变(§4.3)。采集层消费者 `run_boot_test.py` 总是 `open→clear→wait-idle`:正常路径不受影响;它的 wait-idle 若撞上无口,`ok:false`(exit 1)经 `_tool_cmd` 归为**采集错误 exit 2**——把「采集器没在采」报成采集错误而非「板子静默」判读失败,正是 issue #75 要的归因。
## 7. 集成与迁移(/serial-boot-test 切换)
### 7.1 新 /serial-boot-test 流程(`run_boot_test.py` 采集步改写,退出码约定不变:0/1/2/3)
@@ -218,7 +241,7 @@ flowchart LR
### 9.1 无头自检(`selftest`,0=PASS)
行拆分器(CRLF/残行冲刷/纯二进制窗口)、解码回退链(UTF-8/GBK/HEX)、截头算法(合成大文件 → ftruncate 语义)——纯进程内,无需硬件。
行拆分器(CRLF/残行冲刷/纯二进制窗口)、解码回退链(UTF-8/GBK/HEX)、截头算法(合成大文件 → ftruncate 语义);v1.2 增补:重开意图判定(T10,含「显式 close 后不重开」「崩溃现场重开」)、退避倍增封顶(T11)、缺口标记行格式(T12)、wait-idle 无口门(T13)、`auto_reopen` 三态(T14)、restart 现场捕获(T15)——共 45 项,纯进程内,无需硬件。
### 9.2 端到端验收(逐项过)
@@ -264,3 +287,11 @@ flowchart LR
| 1 | Serial Studio MCP(GUI 依赖、导出文件定位问题) | 已清理(2026-09 前) |
| 2 | Adancurusul/serial-mcp-server(Rust,MCP+CLI) | **被本工具取代**:二进制绑定用户档案(本机缺失)+ 无历史缓冲(采集窗口仪式) |
| 3 | **serial_tool(本文,Python 仓库内)** | 当前方案 |
## 13. 版本记录
| 版本 | 日期 | 内容 |
|---|---|---|
| v1.0 | 2026-09-15 | 初版实施(§10 步骤 1–6) |
| v1.1 | 2026-10-10 | 修批(各行内「2026-10-10」标注):`log` 先过滤后截尾、wait-idle `no_data` 判据/增量/首字节门、CLI 超时推导与断连噪声、`SO_EXCLUSIVEADDRUSE` 真独占、残行 64KB 封顶与会话边界、截头按行边界、配置 `utf-8-sig` + `configError`;VERSION = `1.1.0-skill` |
| v1.2 | 2026-11-04 | daemon 死后自动重开 + 失败自动重连 + 日志缺口标记 + `status.auto_reopen`/`port.desired` + wait-idle 无口快速失败(机理 §6.1;Gitea issue #75 R1–R5);VERSION = `1.2.0-skill`,selftest 45 项 |