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
+10 -3
View File
@@ -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 封顶。
@@ -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:
@@ -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 项 |