fix(serial-assistant): daemon 重启自动重开口 + 自动重连 + 日志缺口标记(#75) #76
@@ -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:
|
||||
|
||||
+26
-15
@@ -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` 未知端点。
|
||||
|
||||
+43
-12
@@ -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 项 |
|
||||
|
||||
Reference in New Issue
Block a user