From dfd152840906f02a0423389f6853cc6aca4b5e28 Mon Sep 17 00:00:00 2001 From: super_agent_bot Date: Wed, 4 Nov 2026 11:10:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(serial-assistant):=20daemon=20=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E8=87=AA=E5=8A=A8=E9=87=8D=E5=BC=80=E5=8F=A3=20+=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E8=BF=9E=20+=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=E6=A0=87=E8=AE=B0=EF=BC=88#75=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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 见工单汇报。 --- .dsh/skills/serial-assistant/SKILL.md | 13 +- .../serial-assistant/scripts/serial_tool.py | 534 ++++++++++++++++-- docs/测试说明/测试工具/串口助手使用指南.md | 41 +- docs/测试说明/测试工具/串口助手设计说明.md | 55 +- 4 files changed, 572 insertions(+), 71 deletions(-) diff --git a/.dsh/skills/serial-assistant/SKILL.md b/.dsh/skills/serial-assistant/SKILL.md index e537cf9c..0e2b68a1 100644 --- a/.dsh/skills/serial-assistant/SKILL.md +++ b/.dsh/skills/serial-assistant/SKILL.md @@ -33,7 +33,7 @@ python -m venv ".dsh\skills\serial-assistant\venv" & $PY $TOOL log --tail 40 # 读日志;--grep P [-i] [--regex](先过滤后截尾) & $PY $TOOL clear # 清空日志(代际 +1,会话边界) & $PY $TOOL wait-idle # 等 RX 静默:--settle-ms 8000 --max-ms 30000 --first-timeout-ms 12000 -& $PY $TOOL status # 全状态 JSON(daemon 版本/端口/计数/rx_age_ms/log.path) +& $PY $TOOL status # 全状态 JSON(daemon 版本/端口 desired+ready/计数/rx_age_ms/log.path/auto_reopen) & $PY $TOOL start|stop|restart # daemon 生命周期 & $PY $TOOL selftest # 无头自检(行拆分/解码/截头/wait 判据,无需硬件) ``` @@ -52,10 +52,17 @@ python -m venv ".dsh\skills\serial-assistant\venv" - **一台机只有一个 daemon**(HTTP 口 `SO_EXCLUSIVEADDRUSE` 独占绑定)。多个项目的 serial_tool 副本协议兼容、可互连:`status` 的 `daemon.version` 辨归属 - (本 skill 版 = `1.1.0-skill`),`log.path` 辨日志落点。 + (本 skill 版 = `1.2.0-skill`),`log.path` 辨日志落点。 - **`open` 报"拒绝访问/端口被占用"**:先 `status` 查是不是残留旧 daemon 占着口 → `stop`(或 `taskkill /F /PID`)→ 重新 `open`。 -- **`wait-idle` 判据**:`no_data` = 本次等待期间零字节;首字节到达前不启动静默计时; +- **`wait-idle` 判据**:`no_data` = 本次等待期间零字节且**口开着**;首字节到达前不启动静默计时; 返回的 `rx_bytes` 是本次增量。`settle-ms` 必须大于固件的自然静默间隙(常见 3~4s)。 + **v1.2:口未开 → 秒回 `ok:false`(exit 1,带 auto-reopen 现场),不再计满返回 `no_data`**。 +- **自动重开(v1.2,issue #75)**:daemon 崩溃/被杀/读错丢口 → 新实例按 `meta.json` 现场**自动重开** + (串口线程内执行;退避 1.5s 倍增封顶 30s;`status` 的 `auto_reopen={enabled,pending,attempts, + last_error,next_retry_in_ms}` 可观测,`port.desired`=意图、`ready`=实际)。显式 `close`/`stop` + =清意图(重启后不再自动开);`restart` 免操心(stop 前抓现场、就绪后自动开回,结果在返回 JSON + 的 `reopen` 键)。serial.log 缺口边界有 `[SYS] … GAP-START …` / `GAP-END …` 行:**窗口内字节不可 + 找回**;`GAP-START` 无 `GAP-END` = 采集器未恢复(查 `auto_reopen`/daemon.log),别当成板子静默。 - **`open` 不带 `--dtr/--rts` = 保持当前线路状态**;显式给才翻转。 - 日志 20MB 上限自动按行边界截头保尾 10MB;无换行残行 64KB 封顶。 diff --git a/.dsh/skills/serial-assistant/scripts/serial_tool.py b/.dsh/skills/serial-assistant/scripts/serial_tool.py index c953c0e3..a1d44745 100644 --- a/.dsh/skills/serial-assistant/scripts/serial_tool.py +++ b/.dsh/skills/serial-assistant/scripts/serial_tool.py @@ -23,6 +23,11 @@ start / stop / restart daemon 生命周期(其余命令会自动拉起 daemon) selftest 无头自检(行拆分/解码回退/截头算法,无需硬件) +daemon 自动重开(v1.2,issue #75):崩溃/机器重启后自动拉起的新 daemon 读旧 meta +现场自动重开口;读写出错按退避自动重连(1.5s 起步、倍增、封顶 30s);serial.log +落 GAP-START / GAP-END 缺口标记;显式 close/stop 清除该意图(不再重开)。 +wait-idle 在口未开且重开未成时快速失败(ok:false),不再伪装成 no_data。 + 退出码:0=成功 1=领域错误 2=daemon 不可达/超时 3=用法错误 """ import argparse @@ -46,7 +51,7 @@ from pathlib import Path import serial import serial.tools.list_ports -VERSION = "1.1.0-skill" +VERSION = "1.2.0-skill" SCRIPT = Path(__file__).resolve() TOOL_DIR = SCRIPT.parent @@ -81,7 +86,9 @@ CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限 KEEP_BYTES = 10 * 1024 * 1024 # 超限后保留的尾部 PARTIAL_FLUSH_S = 0.2 # 残行冲刷窗口(秒) PARTIAL_MAX_BYTES = 64 * 1024 # 无换行残行的内存封顶(超限即当一行落地,见 LineSplitter.feed) -REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱) +REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱);R2 自动重连的起步间隔 +REOPEN_BACKOFF_MAX_S = 30.0 # R2 自动重开退避封顶(1.5s 起步倍增:1.5/3/6/12/24/30…) +GAP_NOTE_INTERVAL_S = 60.0 # R3 退避期 serial.log 的 GAP-RETRY 节奏:首次必记 + 之后 ≤1 条/分 DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒;wait-idle 未显式给时按 --max-ms 推导) DAEMON_LOG_MAX = 1024 * 1024 # daemon.log 超此体积 → 拉起新实例前只保尾部 DAEMON_LOG_KEEP = 256 * 1024 @@ -134,6 +141,93 @@ def port_hint(configured, names): return cands[0] if len(cands) == 1 else None +# ------------------------- 自动重开判定 / 退避 / 缺口标记行(R1–R3,纯函数,selftest 直测) + +def plan_startup_reopen(meta, default_baud=115200): + """R1 启动自动重开的意图判定:旧 meta.port 现场非空 = 上一任「开着口死的」。 + 显式 `close`/`stop` 已把该字段清成 null → None(不重开);崩溃、被 taskkill、 + 机器重启等现场仍在 → 返回按 meta 的重开参数。""" + if not isinstance(meta, dict): + return None + p = meta.get("port") + if not isinstance(p, dict) or not p.get("name"): + return None + try: + baud = int(p.get("baud") or default_baud) + except (TypeError, ValueError): + baud = default_baud + return {"port": str(p["name"]), "baud": baud, + "dtr": bool(p.get("dtr")), "rts": bool(p.get("rts"))} + + +def restart_reopen_plan(st): + """R1 restart 专用:POST /stop **之前**从 /status 抓开口现场(stop 自身语义不变、 + 照清意图;CLI 在新 daemon 就绪后按这份现场显式 /open 回来)。旧 daemon 没口 / + 已 close → None;重开缺口期(desired 未 ready)name 仍在 → 同样恢复。""" + if not isinstance(st, dict): + return None + p = st.get("port") or {} + if not p.get("name"): + return None + out = {"port": p["name"]} + try: + if p.get("baud"): + out["baud"] = int(p["baud"]) + except (TypeError, ValueError): + pass + for k in ("dtr", "rts"): + if p.get(k) is not None: + out[k] = bool(p[k]) + return out + + +def next_backoff(cur): + """R2 自动重开退避序列:REOPEN_SETTLE_S 起步、倍增、REOPEN_BACKOFF_MAX_S 封顶。""" + return min(cur * 2.0, REOPEN_BACKOFF_MAX_S) + + +def sys_daemon_started(version, pid, prev_pid, pending): + """R3① daemon 启动边界行:新 pid、上一任 pid、自动重开结论(pending 现场 / none)。""" + mode = ("pending %s@%s" % (pending["port"], pending["baud"]) if pending else "none") + return "daemon started v%s pid=%s prev_pid=%s auto-reopen %s" % ( + version, pid, prev_pid if prev_pid is not None else "none", mode) + + +def sys_gap_start_lost(msg): + """R3② 缺口起(运行中丢口):保留 serial error 原文,再落缺口声明。""" + return ("serial error: %s (GAP-START: port released; bytes in this window are " + "unrecoverable; auto-reopen retrying, backoff %.1fs doubling to %.0fs cap)" + % (msg, REOPEN_SETTLE_S, REOPEN_BACKOFF_MAX_S)) + + +def sys_gap_start_recovery(prev_pid, port_name): + """R3②' 缺口起(崩溃/重启恢复):真实缺口起点在「上一任死掉」与此刻之间、不可 + 确知 → 行内声明窗口是下限,别把恢复耗时当成全部丢失时长。""" + return ("GAP-START after daemon restart: previous daemon (pid=%s) died holding %s; " + "gap began no later than now (exact start unknown); bytes in this window " + "are unrecoverable; auto-reopen pending" + % (prev_pid if prev_pid is not None else "none", port_name)) + + +def sys_gap_end(name, baud, gen, gap_s, attempts): + """R3③ 缺口止(重开成功):含窗口秒数、尝试次数、gen 与不可找回声明。""" + return ("GAP-END reopened %s@%s (gen=%s) after %.1fs, %d attempt(s); bytes lost " + "inside the gap window are unrecoverable" % (name, baud, gen, gap_s, attempts)) + + +def sys_gap_retry(attempt, err, next_s): + """R3 退避期节奏行:首次必记、之后每分钟 ≤1 条;逐次失败原文只进 daemon.log。""" + return ("GAP-RETRY attempt %d failed: %s (next retry in %.1fs; while the gap is " + "open serial.log notes at most 1/min, full details in daemon.log)" + % (attempt, err, next_s)) + + +def dlog(msg): + """daemon 自身取证(自动重开逐次尝试原文等)→ stderr = state/daemon.log, + 不刷 serial.log(R3 降噪口径)。""" + print("daemon %s: %s" % (VERSION, msg), file=sys.stderr, flush=True) + + def load_meta(): try: with open(META_FILE, "r", encoding="utf-8") as f: @@ -277,6 +371,17 @@ class Daemon: self.rts = False self.port_since = None self.ser = None + # ---- 自动重开/重连簿记(R1/R2/R4,issue #75)---- + self.desired = False # 意图:该不该开着口(open 成功=置;显式 close/stop=清) + self._reopen_params = None # 待重开现场 {"port","baud","dtr","rts"} + self._reopen_backoff = REOPEN_SETTLE_S + self._reopen_next_at = 0.0 # 下次自动重开尝试时刻(0 = 未排期) + self._reopen_attempts = 0 # 本缺口期尝试次数(成功即清零) + self._reopen_last_error = None + self._prev_pid = None # 上一任 daemon pid(启动时读旧 meta 记入) + self._gap_start_ts = None # GAP-START 时刻(GAP-END 窗口秒数按它计) + self._gap_attempts = 0 + self._last_gap_note_ts = 0.0 # GAP-RETRY 上一条 serial.log 时刻 self.last_released = 0.0 self.last_data_at = 0.0 self.born = time.time() @@ -306,13 +411,27 @@ class Daemon: def sys_line(self, text): return line_at(time.time(), "SYS", text) + def _open_identity(self): + """当前「应然开口现场」:实开的口 > 待重开参数 > None。meta 与 status 都由它 + 表达意图——重开缺口期现场必须留在 meta.port 里,否则下一次崩溃/重启后无人 + 知道「这台机器本来该开着 COMx」。""" + if self.port_name: + return {"name": self.port_name, "baud": self.port_baud, + "dtr": self.dtr, "rts": self.rts} + p = self._reopen_params + if self.desired and p and p.get("port"): + return {"name": p["port"], "baud": p.get("baud"), + "dtr": bool(p.get("dtr")), "rts": bool(p.get("rts"))} + return None + def _save_meta(self): + ident = self._open_identity() save_meta({ "pid": os.getpid(), "version": VERSION, "gen": self.gen, - "port": ({"name": self.port_name, "baud": self.port_baud, - "dtr": self.dtr, "rts": self.rts} if self.port_name else None), + "port": ({"name": ident["name"], "baud": ident["baud"], + "dtr": ident["dtr"], "rts": ident["rts"]} if ident else None), "since": self.port_since, }) @@ -378,6 +497,10 @@ class Daemon: if time.time() - self.last_data_at > PARTIAL_FLUSH_S: for ts, raw in splitter.flush_partial(): self._rx_line(ts, raw) + elif self.desired and time.time() >= self._reopen_next_at: + # R1/R2:「该开着口」却没开着(启动恢复 / 丢口重连)→ 在本线程按退避 + # 自动重开(串口对象唯一持有者不变量不破;旧实现此处纯空转永不重连) + self._auto_reopen_attempt() try: cmd = self.cmd_q.get_nowait() except queue.Empty: @@ -398,6 +521,7 @@ class Daemon: self.emit(line_at(ts, "RX", text)) def _port_error(self, msg): + gap = False with self.lock: if self.ser is not None: try: @@ -409,7 +533,32 @@ class Daemon: self.error = msg self.errors += 1 self.last_released = time.time() - self.emit(self.sys_line("serial error: %s (port released, reopen with 'open')" % msg)) + if self.desired and self.port_name: + # R2:丢口不再躺平等手工 'open'——快照现场,串口线程按退避自动重开 + # (USB 转串口掉线/重枚举是台架常态,旧实现 CH340 几秒后回来也不重连) + self._reopen_params = {"port": self.port_name, "baud": self.port_baud, + "dtr": self.dtr, "rts": self.rts} + self._reopen_backoff = REOPEN_SETTLE_S + self._reopen_next_at = self.last_released + REOPEN_SETTLE_S + self._reopen_attempts = 0 + self._reopen_last_error = None + self._gap_start_ts = self.last_released + self._gap_attempts = 0 + self._last_gap_note_ts = 0.0 + gap = True + self.emit(self.sys_line(sys_gap_start_lost(msg) if gap + else "serial error: %s (port released)" % msg)) + + def _reset_reopen_state_locked(self): + """重开簿记清零(意图 desired 本身由调用方负责置/清)。""" + self._reopen_params = None + self._reopen_backoff = REOPEN_SETTLE_S + self._reopen_next_at = 0.0 + self._reopen_attempts = 0 + self._reopen_last_error = None + self._gap_start_ts = None + self._gap_attempts = 0 + self._last_gap_note_ts = 0.0 def _close_port_locked(self): if self.ser is not None: @@ -424,6 +573,8 @@ class Daemon: self.state = "stopped" self.error = None self.last_released = time.time() + self.desired = False # 显式 close/stop = 清除重开意图(R1:此后不再自动重开) + self._reset_reopen_state_locked() self.splitter_gen += 1 # -- 命令执行(均在串口线程) -- @@ -493,33 +644,15 @@ class Daemon: time.sleep(wait) break - try: - # 先构造后 open:dtr/rts 状态写入 DCB 初始值(避免开端口瞬间的复位脉冲) - ser = serial.Serial() - ser.port = name - ser.baudrate = baud - ser.timeout = 0 - ser.dtr = dtr - ser.rts = rts - ser.open() - except serial.SerialException as e: - msg = str(e) - names = sorted(self._list_port_names()) - if name not in names: - # Windows 对不存在的 COM 口常报 Access Denied(而非 FileNotFound), - # 故以端口枚举结果为准,避免把"不存在"误报成"被占用" - err = "port not found: %s (available: %s)" % (name, ", ".join(names) or "(none)") - hint = port_hint(name, names) - if hint: - err += " [hint: only candidate is %s -> set portName in %s]" % (hint, CONFIG_FILE) - elif "Access is denied" in msg or "busy" in msg.lower(): - err = "port busy: %s (held by another process)" % name - else: - err = "port open failed: %s (%s)" % (name, msg) + ser, err = self._open_port(name, baud, dtr, rts) + if ser is None: cmd.finish({"ok": False, "error": err}) return with self.lock: + gap = self._gap_start_ts is not None + gap_s = time.time() - self._gap_start_ts if gap else 0.0 + gap_att = self._gap_attempts self.ser = ser self.port_name = name self.port_baud = baud @@ -530,20 +663,35 @@ class Daemon: self.error = None self.last_data_at = time.time() self.splitter_gen += 1 # 新会话:丢掉上一段会话遗留的残行 + # R1:显式 open 成功 = 写入重开意图(此后再崩溃,新 daemon 认这份现场) + self.desired = True + self._reset_reopen_state_locked() + self._reopen_params = {"port": name, "baud": baud, "dtr": dtr, "rts": rts} self.emit(self.sys_line("port opened %s %d (gen=%d)" % (name, baud, self.gen))) + if gap: + # 手工 open 抢在退避重试前把口拉回来了 → 缺口窗口同样要收口(R3③) + self.emit(self.sys_line(sys_gap_end(name, baud, self.gen, gap_s, gap_att))) self._save_meta() cmd.finish({"ok": True, "port": name, "baud": baud, "dtr": dtr, "rts": rts, "gen": self.gen, "since": self.port_since}) def _cmd_close(self, cmd): with self.lock: - if self.ser is None: - cmd.finish({"ok": True, "note": "port not open"}) - return - self._close_port_locked() - self.emit(self.sys_line("port closed")) + had_intent = self.desired + was_open = self.ser is not None + if was_open: + self._close_port_locked() + else: + # 「重开缺口中已释放」或「从未开过」:显式 close 同样清除重开意图 + # (否则缺口期 close 后一旦崩溃/机器重启,新 daemon 还会自作主张重开) + self.desired = False + self._reset_reopen_state_locked() + if was_open: + self.emit(self.sys_line("port closed")) + elif had_intent: + self.emit(self.sys_line("port not open; auto-reopen intent cleared")) self._save_meta() - cmd.finish({"ok": True}) + cmd.finish({"ok": True} if was_open else {"ok": True, "note": "port not open"}) def _cmd_send(self, cmd): p = cmd.params @@ -560,7 +708,13 @@ class Daemon: with self.lock: ser = self.ser if ser is None: - cmd.finish({"ok": False, "error": "port not open (run 'open' first)"}) + if self.desired: + ar = self.auto_reopen_status() + cmd.finish({"ok": False, + "error": "port not open; auto-reopen pending (attempts=%s last_error=%s)" + % (ar["attempts"], ar["last_error"])}) + else: + cmd.finish({"ok": False, "error": "port not open (run 'open' first)"}) return try: ser.write(data) @@ -581,18 +735,124 @@ class Daemon: except Exception: return [] + # -- 自动重开/重连(R1/R2;一律在串口线程执行,主/HTTP 线程不碰 Serial 对象) -- + + def _open_port(self, name, baud, dtr, rts): + """开串口原语(仅串口线程调用)→ (ser|None, err|None)。 + 先构造后 open:dtr/rts 状态写入 DCB 初始值(避免开端口瞬间的复位脉冲)。""" + try: + ser = serial.Serial() + ser.port = name + ser.baudrate = baud + ser.timeout = 0 + ser.dtr = dtr + ser.rts = rts + ser.open() + return ser, None + except serial.SerialException as e: + return None, self._classify_open_error(name, str(e)) + + @staticmethod + def _classify_open_error(name, msg): + """pyserial 异常原文 → 可操作错误文本(不存在 → 被占 → 其它),手工 open 与自动重开共用。""" + names = sorted(Daemon._list_port_names()) + if name not in names: + # Windows 对不存在的 COM 口常报 Access Denied(而非 FileNotFound), + # 故以端口枚举结果为准,避免把"不存在"误报成"被占用" + err = "port not found: %s (available: %s)" % (name, ", ".join(names) or "(none)") + hint = port_hint(name, names) + if hint: + err += " [hint: only candidate is %s -> set portName in %s]" % (hint, CONFIG_FILE) + elif "Access is denied" in msg or "busy" in msg.lower(): + err = "port busy: %s (held by another process)" % name + else: + err = "port open failed: %s (%s)" % (name, msg) + return err + + def _auto_reopen_attempt(self): + """自动重开退避循环的一次尝试(串口线程):成功 = 写现场 + GAP-END 收口; + 失败 = 倍增退避(R2)。逐次失败原文进 daemon.log;serial.log 只按 + 「首次 + 之后每分钟 ≤1 条」记 GAP-RETRY(R3 口径)。""" + if self.ser is not None: + return + p = self._reopen_params or {} + name = p.get("port") + if not name: + self.desired = False + self._reopen_next_at = 0.0 + return + baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200) + ser, err = self._open_port(name, baud, bool(p.get("dtr")), bool(p.get("rts"))) + now = time.time() + if ser is not None: + with self.lock: + gap = self._gap_start_ts is not None + gap_s = now - self._gap_start_ts if gap else 0.0 + gap_att = max(self._gap_attempts, self._reopen_attempts, 1) + self.ser = ser + self.port_name = name + self.port_baud = baud + self.dtr = bool(p.get("dtr")) + self.rts = bool(p.get("rts")) + self.port_since = now_str() + self.state = "active" + self.error = None + self.last_data_at = now + self.splitter_gen += 1 # 新会话:残行归零 + self._reset_reopen_state_locked() + self._reopen_params = {"port": name, "baud": baud, + "dtr": self.dtr, "rts": self.rts} + self.emit(self.sys_line(sys_gap_end(name, baud, self.gen, gap_s, gap_att))) + self._save_meta() + dlog("auto-reopen OK %s@%d: %d attempt(s), window %.1fs" + % (name, baud, gap_att, gap_s)) + return + with self.lock: + self._reopen_attempts += 1 + self._gap_attempts += 1 + self._reopen_last_error = err + self._reopen_backoff = next_backoff(self._reopen_backoff) + self._reopen_next_at = now + self._reopen_backoff + att = self._reopen_attempts + note_due = att == 1 or (now - self._last_gap_note_ts) >= GAP_NOTE_INTERVAL_S + if note_due: + self._last_gap_note_ts = now + next_s = self._reopen_backoff + dlog("auto-reopen attempt %d failed (next in %.1fs): %s" % (att, next_s, err)) + if note_due: + self.emit(self.sys_line(sys_gap_retry(att, err, next_s))) + # -- 查询 -- + def auto_reopen_status(self): + """R4:自动重开/重连的对外现场(status() 持锁调用,本函数自身不加锁)。""" + ready = self.ser is not None and self.state != "error" + pending = bool(self.desired) and not ready + nxt = None + if pending and self._reopen_next_at > 0: + nxt = int(max(0.0, (self._reopen_next_at - time.time()) * 1000)) + return {"enabled": bool(self.desired), "pending": pending, + "attempts": int(self._reopen_attempts), + "last_error": self._reopen_last_error, + "next_retry_in_ms": nxt} + def status(self): with self.lock: + ident = self._open_identity() port = {"ready": self.ser is not None and self.state != "error", - "name": self.port_name, "baud": self.port_baud, - "dtr": self.dtr, "rts": self.rts, "since": self.port_since} + # R4:desired = 意图(该不该开着),ready = 实际;缺口期 desired=true / ready=false + "desired": self.desired, + "name": ident["name"] if ident else None, + "baud": ident["baud"] if ident else None, + "dtr": ident["dtr"] if ident else self.dtr, + "rts": ident["rts"] if ident else self.rts, + "since": self.port_since} counters = {"rx_bytes": self.rx_bytes, "tx_bytes": self.tx_bytes, "rx_lines": self.rx_lines, "tx_lines": self.tx_lines, "errors": self.errors} state, err = self.state, self.error gen = self.gen + ar = self.auto_reopen_status() last = None if self.last_rx_at: last = {"ts": datetime.fromtimestamp(self.last_rx_ts).astimezone() @@ -604,7 +864,7 @@ class Daemon: "daemon": {"pid": os.getpid(), "version": VERSION, "uptime_s": int(time.time() - self.born), "exclusive_bind": getattr(self.server, "exclusive_bound", False)}, - "port": port, "counters": counters, "last_rx": last, + "port": port, "auto_reopen": ar, "counters": counters, "last_rx": last, # rx_age_ms:距最后一个 RX 字节的毫秒数(null = 这个 daemon 还没收过 # 任何字节)。判"板子是不是根本没在说话"不必再自己减时间戳(2026-10-10) "rx_age_ms": last["age_ms"] if last else None, @@ -624,7 +884,22 @@ class Daemon: settle 计时,改由 first_timeout_s 兜底(默认 = max_s)。 3. 返回的 rx_bytes 改为**本次等待期间的增量**(旧实现回的是 daemon 累计 值,调用方打出来的 WAIT: rx_bytes=1246761 毫无意义)。 + v1.2(issue #75 根因 4):口未开且自动重开未成时**快速失败** ok:false(error + 带 auto-reopen 现场),不再计满返回 no_data——no_data 从此只表示「口开着且 + 本次零字节」。 """ + # R4 无口门:先判「有没有口可采」,再谈静默判据 + if not (self.ser is not None and self.state != "error"): + pending = bool(getattr(self, "desired", False)) + now = time.time() + na = getattr(self, "_reopen_next_at", 0.0) + nxt = int((na - now) * 1000) if (pending and na > now) else None + return {"ok": False, + "error": "port not open; auto-reopen %s (attempts=%s last_error=%s " + "next_retry_in_ms=%s) -- no_data requires an open port" + % ("pending" if pending else "disabled", + getattr(self, "_reopen_attempts", 0), + getattr(self, "_reopen_last_error", None), nxt)} t0 = time.time() initial = self.rx_bytes last = initial @@ -654,7 +929,25 @@ class Daemon: def run(self): STATE_DIR.mkdir(parents=True, exist_ok=True) + # R1(issue #75 根因 1):先读旧 meta **再**覆写。旧实现 run() 第一件事就是 + # _save_meta(),把上一任「开着口死的」现场覆成 port:null——重启瞬间抹掉现场, + # 且启动流程无任何重开动作。 + prev = load_meta() + self._prev_pid = prev.get("pid") + params = plan_startup_reopen(prev, self.cfg.get("baudRate") or 115200) + if params: + self.desired = True + self._reopen_params = params + self._reopen_backoff = REOPEN_SETTLE_S + # 开口动作落在串口线程(唯一持有者不变量):born+1.5s 后首次尝试, + # 沿用 CH340「释放后立即重开」的 REOPEN_SETTLE_S 退避口径 + self._reopen_next_at = self.born + REOPEN_SETTLE_S + self._gap_start_ts = time.time() # 窗口下限 = 此刻;真实起点见 GAP-START 文字 self._save_meta() + self.emit(self.sys_line(sys_daemon_started(VERSION, os.getpid(), + self._prev_pid, params))) + if params: + self.emit(self.sys_line(sys_gap_start_recovery(self._prev_pid, params["port"]))) threading.Thread(target=self.writer_loop, name="writer", daemon=True).start() threading.Thread(target=self.serial_loop, name="serial", daemon=True).start() port = self.cfg["httpPort"] @@ -1059,7 +1352,15 @@ def cmd_start(args, force=False): st = http_request("GET", "/status", timeout=1) return {"ok": True, "already_running": True, "pid": st.get("daemon", {}).get("pid")} + reopen_after = None if alive: + # R1(restart 特判,issue #75):stop 语义不变——关端口 + 清重开意图;但 + # 「重启」不该把采集现场弄丢 → stop **之前**先从 /status 抓开口现场, + # 新 daemon 就绪后再显式 /open 回来(命令仍走串口线程执行)。 + try: + reopen_after = restart_reopen_plan(http_request("GET", "/status", timeout=2)) + except (urllib.error.URLError, OSError, ValueError): + pass # restart:先优雅停(关端口),再强杀,确认真正退出 try: http_request("POST", "/stop", timeout=5) @@ -1086,10 +1387,19 @@ def cmd_start(args, force=False): raise ToolError("daemon start timeout (no response within %.0fs)" % args.timeout, 2) try: st = http_request("GET", "/status", timeout=1) - return {"ok": True, "already_running": False, - "pid": st.get("daemon", {}).get("pid")} + result = {"ok": True, "already_running": False, + "pid": st.get("daemon", {}).get("pid")} except (urllib.error.URLError, OSError): - return {"ok": True, "started": True} + result = {"ok": True, "started": True} + if reopen_after: + # 把 restart 前记住的开口现场开回来。失败不把 restart 翻成 ok:false + # (旧进程确实被干净换掉了),现场进 result["reopen"] 由调用方判读。 + try: + ro = http_request("POST", "/open", reopen_after, timeout=10) + except (urllib.error.URLError, OSError) as e: + ro = {"ok": False, "error": str(e)} + result["reopen"] = {"requested": reopen_after, "result": ro} + return result def cmd_stop(args): @@ -1171,6 +1481,13 @@ def run_selftest(): class _Cnt: def __init__(self, n): self.rx_bytes = n + # v1.2 起 wait-idle 先过「无口门」(R4):假对象要伪装成「口正常开着」 + self.ser = object() + self.state = "active" + self.desired = True + self._reopen_attempts = 0 + self._reopen_last_error = None + self._reopen_next_at = 0.0 r = Daemon.wait_idle(_Cnt(500000), 0.2, 0.6, 0.3) # 常驻 daemon:累计值非 0 check("T5a 零字节判 no_data", r["reason"] == "no_data" and r["rx_bytes"] == 0 @@ -1304,6 +1621,141 @@ def run_selftest(): and port_hint("COM7", ["COM6", "COM9"]) is None and port_hint("COM6", ["COM6"]) is None) + # T10 R1 启动重开意图判定:崩溃现场→重开;显式 close/stop、首启→不重开 + m10 = {"pid": 11, "port": {"name": "COM3", "baud": 921600, "dtr": True, "rts": False}, + "since": "2026-11-04T00:00:00.000+08:00"} + check("T10a 崩溃现场(meta.port 非空)→ 重开参数", + plan_startup_reopen(m10, 115200) + == {"port": "COM3", "baud": 921600, "dtr": True, "rts": False}, + repr(plan_startup_reopen(m10, 115200))) + check("T10b 显式 close/stop 后(port:null)不重开", + plan_startup_reopen({"port": None}, 115200) is None) + check("T10c 首次启动(无 meta)不重开", + plan_startup_reopen({}, 115200) is None and plan_startup_reopen(None, 115200) is None) + check("T10d baud 缺失/坏值 → 按配置回退", + plan_startup_reopen({"port": {"name": "COM4"}}, 460800) + == {"port": "COM4", "baud": 460800, "dtr": False, "rts": False} + and plan_startup_reopen({"port": {"name": "COM4", "baud": "x"}}, 460800)["baud"] == 460800) + check("T10e port 记录残缺(无 name / 非 dict)不重开", + plan_startup_reopen({"port": {"baud": 1}}, 115200) is None + and plan_startup_reopen({"port": "COM3"}, 115200) is None) + + # T11 R2 退避计划:1.5s 起步、倍增、封顶 30s + seq11 = [] + b11 = REOPEN_SETTLE_S + for _ in range(7): + seq11.append(b11) + b11 = next_backoff(b11) + check("T11a 退避倍增封顶(1.5→3→6→12→24→30→30)", + seq11 == [1.5, 3.0, 6.0, 12.0, 24.0, 30.0, 30.0], repr(seq11)) + check("T11b 封顶边界不越界", + next_backoff(24.0) == 30.0 and next_backoff(16.0) == 30.0 and next_backoff(30.0) == 30.0, + "16→%s" % next_backoff(16.0)) + + # T12 R3 缺口标记行格式(口径详述 = 设计说明 §6「自动重开与日志缺口标记」) + t12a = sys_daemon_started("1.2.0-skill", 100, 99, {"port": "COM3", "baud": 115200}) + check("T12a 启动边界行:新旧 pid + 自动重开中", + "pid=100" in t12a and "prev_pid=99" in t12a and "auto-reopen pending COM3@115200" in t12a, + t12a) + t12b = sys_daemon_started("v", 1, None, None) + check("T12b 边界行:无需重开 + prev_pid 未知", + "prev_pid=none" in t12b and "auto-reopen none" in t12b, t12b) + t12c = sys_gap_start_lost("read: boom") + check("T12c 缺口起(丢口):错误原文 + GAP-START + 不可找回", + t12c.startswith("serial error: read: boom") and "GAP-START" in t12c + and "unrecoverable" in t12c, t12c) + t12d = sys_gap_start_recovery(99, "COM3") + check("T12d 缺口起(崩溃恢复):prev pid + 窗口下限声明", + "GAP-START" in t12d and "pid=99" in t12d and "COM3" in t12d + and "exact start unknown" in t12d and "unrecoverable" in t12d, t12d) + t12e = sys_gap_end("COM3", 115200, 7, 12.34, 5) + check("T12e 缺口止:窗口秒数 + 尝试次数 + gen + 不可找回", + "GAP-END" in t12e and "COM3@115200" in t12e and "gen=7" in t12e + and "12.3s" in t12e and "5 attempt(s)" in t12e and "unrecoverable" in t12e, t12e) + t12f = sys_gap_retry(2, "port busy: COM3 (held by another process)", 3.0) + check("T12f GAP-RETRY:尝试数 + 下次间隔 + 1/min 口径 + daemon.log 指针", + "attempt 2" in t12f and "3.0s" in t12f and "1/min" in t12f and "daemon.log" in t12f, + t12f) + t12g = line_at(1000.0, "SYS", t12e) + check("T12g 缺口行仍走 [SYS] 前缀(行格式不变量)", + t12g.startswith("[SYS] ") and "GAP-END" in t12g, t12g[:48]) + + # T13 R4 wait-idle 无口门:快速失败,不再计满返回 no_data + class _NoPort: + ser = None + state = "stopped" + + t13_0 = time.time() + r13 = Daemon.wait_idle(_NoPort(), 0.2, 1.0, 0.3) + fast13 = (time.time() - t13_0) < 0.2 + check("T13a 无口快速失败(ok:false、非 no_data、秒回)", + r13["ok"] is False and r13.get("reason") is None and fast13 + and "port not open" in r13["error"] and "auto-reopen disabled" in r13["error"], + repr(r13)) + + class _Pending(_NoPort): + desired = True + _reopen_attempts = 3 + _reopen_last_error = "port busy: COM3 (held by another process)" + _reopen_next_at = time.time() + 2.0 + + r13b = Daemon.wait_idle(_Pending(), 0.2, 1.0, 0.3) + check("T13b 失败文本带 auto-reopen 现场", + "pending" in r13b["error"] and "attempts=3" in r13b["error"] + and "port busy" in r13b["error"] and "next_retry_in_ms=" in r13b["error"], + repr(r13b)) + + # T14 R4 status.auto_reopen 现场(正常在开 / 缺口进行 / 显式关闭后) + class _AROk: + ser = object() + state = "active" + desired = True + _reopen_attempts = 0 + _reopen_last_error = None + _reopen_next_at = 0.0 + + s14a = Daemon.auto_reopen_status(_AROk()) + check("T14a 正常在开:enabled true / pending false", + s14a["enabled"] and not s14a["pending"] and s14a["attempts"] == 0 + and s14a["next_retry_in_ms"] is None, repr(s14a)) + + class _ARGap(_AROk): + ser = None + state = "error" + _reopen_attempts = 2 + _reopen_last_error = "port not found: COM3 (available: (none))" + _reopen_next_at = time.time() + 1.5 + + s14b = Daemon.auto_reopen_status(_ARGap()) + check("T14b 缺口中:pending + 倒计时 + last_error", + s14b["enabled"] and s14b["pending"] and s14b["attempts"] == 2 + and 0 < s14b["next_retry_in_ms"] <= 1600 and "port not found" in s14b["last_error"], + repr(s14b)) + + class _AROff: + ser = None + state = "stopped" + desired = False + _reopen_attempts = 0 + _reopen_last_error = None + _reopen_next_at = 0.0 + + s14c = Daemon.auto_reopen_status(_AROff()) + check("T14c 显式 close 后:enabled false(台架场景 2 断言点)", + s14c["enabled"] is False and s14c["pending"] is False, repr(s14c)) + + # T15 R1 restart 现场捕获(cmd_start force 路径的纯判定部分) + check("T15a restart 捕获:开着的口 → 记全现场", + restart_reopen_plan({"port": {"name": "COM3", "baud": 115200, + "dtr": False, "rts": True}}) + == {"port": "COM3", "baud": 115200, "dtr": False, "rts": True}) + check("T15b restart 捕获:重开缺口期(name 在/baud 缺)→ 仍恢复", + restart_reopen_plan({"port": {"name": "COM3", "baud": None, "desired": True}}) + == {"port": "COM3"}) + check("T15c restart 捕获:已 close / 旧 daemon 不可达 → 不重开", + restart_reopen_plan({"port": {"name": None}}) is None + and restart_reopen_plan({}) is None and restart_reopen_plan(None) is None) + width = 46 all_ok = True for name, ok, detail in results: diff --git a/docs/测试说明/测试工具/串口助手使用指南.md b/docs/测试说明/测试工具/串口助手使用指南.md index 34908804..ddedb327 100644 --- a/docs/测试说明/测试工具/串口助手使用指南.md +++ b/docs/测试说明/测试工具/串口助手使用指南.md @@ -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:`(默认 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` 未知端点。 diff --git a/docs/测试说明/测试工具/串口助手设计说明.md b/docs/测试说明/测试工具/串口助手设计说明.md index da16c006..2bdeb560 100644 --- a/docs/测试说明/测试工具/串口助手设计说明.md +++ b/docs/测试说明/测试工具/串口助手设计说明.md @@ -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] <文本>`: + +| 时机 | 行文本(要点) | +|---|---| +| daemon 启动边界 | `daemon started v<版本> pid=<新> prev_pid=<旧\|none> auto-reopen pending ` | +| 缺口起(运行中丢口) | `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 项 |