fix(serial-assistant): daemon 重启自动重开口 + 自动重连 + 日志缺口标记(#75)
按 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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user