串口助手补残行封顶与会话边界复位、截头按行对齐、open 保持 dtr/rts;判定加命中行/顺序/耗时/JSON 与产物清理

This commit is contained in:
2026-10-10 03:17:16 +08:00
parent fccf99bc90
commit 61a6061950
8 changed files with 410 additions and 46 deletions
+136 -6
View File
@@ -61,6 +61,7 @@ DEFAULT_HTTP_PORT = 18765
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 陷阱)
DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒;wait-idle 未显式给时按 --max-ms 推导)
DAEMON_LOG_MAX = 1024 * 1024 # daemon.log 超此体积 → 拉起新实例前只保尾部
@@ -103,6 +104,17 @@ def load_config():
return cfg
def port_hint(configured, names):
"""配置里的端口不在位、而系统只有**一个**候选时,给出可照抄的建议。
换 USB 口 / 换机器 COM 号就变(本机 COM7→COM6 实测)。旧实现只报
`port not found: COM7 (available: COM6)`,还得人自己判断改哪里;现单一候选
直接给 hint(多候选不猜,避免把蓝牙/虚拟口当目标口)。
"""
cands = [n for n in sorted(set(names)) if n != configured]
return cands[0] if len(cands) == 1 else None
def load_meta():
try:
with open(META_FILE, "r", encoding="utf-8") as f:
@@ -156,8 +168,23 @@ class LineSplitter:
ts = self.buf_ts
self.buf_ts = now if self.buf else 0.0
out.append((ts, raw))
# 残行封顶(2026-10-10):板子跑飞 / 烧录期吐二进制垃圾时,没有 \n 的字节会
# 一直留在内存(旧实现无上限,长会话能涨到几十 MB),而且后续每一条正常行
# 都被拼在这坨垃圾尾巴上,变成一条读不出来的超长行。超 PARTIAL_MAX_BYTES
# 就当一行落地(decode_line 会转成 HEX:),内存封顶 + 自动重新对齐换行。
if len(self.buf) >= PARTIAL_MAX_BYTES:
out.extend(self.flush_partial())
return out
def reset(self):
"""丢弃残行:open / close / clear 的会话边界。
旧实现残行跨会话留存——上一段会话末尾的半行(复位瞬间被截断的那行)会在
下次 open 后被拼进新会话的第一行,开机日志的头一行因此不可读
(2026-10-10 修)。"""
self.buf = b""
self.buf_ts = 0.0
def flush_partial(self):
if not self.buf:
return []
@@ -180,11 +207,19 @@ def decode_line(raw):
def cap_truncate(f, size, keep=KEEP_BYTES, cap=CAP_BYTES):
"""原地截头:保留尾部 keep 字节。
Windows 无"丢弃头部"的单系统调用(ftruncate/SetEndOfFile 只截尾部),
故在同一句柄上:读尾部 → 截空 → 写回。单写者线程内执行,无并发风险。"""
故在同一句柄上:读尾部 → 截空 → 写回。单写者线程内执行,无并发风险。
按行边界对齐(2026-10-10):旧实现按字节偏移取尾,截出来的**第一行是半行**
(前半截已被丢掉)——"日志文件只含完整行"的契约被破坏,Grep / verify_log
读到的首行是垃圾,人读同样误导。现丢掉首个换行之前的残片(无换行则原样保留)。
"""
if size <= cap:
return False
f.seek(size - keep)
tail = f.read(keep)
i = tail.find(b"\n")
if 0 <= i < len(tail) - 1:
tail = tail[i + 1:]
f.seek(0)
os.ftruncate(f.fileno(), 0)
f.write(tail)
@@ -235,6 +270,10 @@ class Daemon:
self.last_rx_line = None
self.last_rx_at = 0.0
self.gen = int(load_meta().get("gen", 0))
self.splitter = LineSplitter()
# 会话边界计数:open / close / clear 递增,串口线程下一轮循环丢弃残行
# (跨线程只碰一个 int,避免直接改 splitter.buf 与 feed() 竞态)
self.splitter_gen = 0
self._stop = threading.Event()
# -- 日志(一律经写线程,保序 + 单写者) --
@@ -294,8 +333,13 @@ class Daemon:
# -- 串口线程(Serial 对象唯一持有者) --
def serial_loop(self):
splitter = LineSplitter()
splitter = self.splitter
my_gen = self.splitter_gen
while not self._stop.is_set():
if self.splitter_gen != my_gen:
# open / close / clear:上一段会话的半行不得粘进新会话首行
splitter.reset()
my_gen = self.splitter_gen
ser = self.ser
if ser is not None:
try:
@@ -361,6 +405,7 @@ class Daemon:
self.state = "stopped"
self.error = None
self.last_released = time.time()
self.splitter_gen += 1
# -- 命令执行(均在串口线程) --
@@ -387,14 +432,37 @@ class Daemon:
cmd.finish({"ok": False, "error": err})
return
baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200)
dtr = bool(p.get("dtr", False))
rts = bool(p.get("rts", False))
# dtr/rts:**未显式给 = 保持现状**。旧实现把"没给"当 False,于是每次不带
# --dtr/--rts 的重开都会静默把 DTR/RTS 拉低(对走 DTR/RTS 复位电路的板子
# 等于莫名多了一次复位脉冲,2026-10-10 修)
dtr = bool(p["dtr"]) if p.get("dtr") is not None else self.dtr
rts = bool(p["rts"]) if p.get("rts") is not None else self.rts
with self.lock:
if self.ser is not None:
if self.port_name == name and self.port_baud == baud:
# 幂等重开。旧实现直接回 "already open",**显式给的 --dtr/--rts
# 被静默忽略**(第二次起 `open --dtr 1` 毫无作用,2026-10-10 修):
# 现按需现场应用并回显改了什么。
note = "already open (idempotent)"
applied = []
for key, attr in (("dtr", "dtr"), ("rts", "rts")):
if p.get(key) is None:
continue
want = bool(p[key])
if want == getattr(self, attr):
continue
try:
setattr(self.ser, attr, want)
setattr(self, attr, want)
applied.append("%s=%d" % (key, int(want)))
except (serial.SerialException, OSError) as e:
note += "; %s apply failed: %s" % (key, e)
if applied:
note += "; applied " + ", ".join(applied)
cmd.finish({"ok": True, "port": name, "baud": baud, "gen": self.gen,
"since": self.port_since, "note": "already open (idempotent)"})
"dtr": self.dtr, "rts": self.rts,
"since": self.port_since, "note": note})
return
self._close_port_locked()
@@ -422,6 +490,9 @@ class Daemon:
# 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:
@@ -439,6 +510,7 @@ class Daemon:
self.state = "active"
self.error = None
self.last_data_at = time.time()
self.splitter_gen += 1 # 新会话:丢掉上一段会话遗留的残行
self.emit(self.sys_line("port opened %s %d (gen=%d)" % (name, baud, self.gen)))
self._save_meta()
cmd.finish({"ok": True, "port": name, "baud": baud, "dtr": dtr, "rts": rts,
@@ -514,6 +586,9 @@ class Daemon:
"uptime_s": int(time.time() - self.born),
"exclusive_bind": getattr(self.server, "exclusive_bound", False)},
"port": port, "counters": counters, "last_rx": last,
# rx_age_ms:距最后一个 RX 字节的毫秒数(null = 这个 daemon 还没收过
# 任何字节)。判"板子是不是根本没在说话"不必再自己减时间戳(2026-10-10)
"rx_age_ms": last["age_ms"] if last else None,
"log": {"path": str(LOG_FILE), "size": size, "gen": gen},
"state": state, "error": err,
"config_error": self.cfg.get("configError") or None}
@@ -675,6 +750,7 @@ class _Handler(BaseHTTPRequestHandler):
params = {k: body[k] for k in ("text", "hex", "nl") if k in body}
self._run_cmd(Cmd("send", **params))
elif self.path == "/clear":
d.splitter_gen += 1 # 清日志 = 会话边界:残行一并丢弃
ev = threading.Event()
d.emit_clear(ev)
if not ev.wait(5):
@@ -834,7 +910,15 @@ def cmd_ports(args):
ports = [{"name": p.device, "description": p.description, "hwid": p.hwid}
for p in serial.tools.list_ports.comports()]
cfg = load_config()
return {"ok": True, "ports": ports, "configured": cfg.get("portName", ""),
configured = cfg.get("portName", "")
names = [p["name"] for p in ports]
hint = None
if configured and configured not in names:
hint = port_hint(configured, names) # 配置里的口不在位:唯一候选直接给
elif not configured and len(names) == 1:
hint = names[0] # 压根没配端口:唯一候选直接给
return {"ok": True, "ports": ports, "configured": configured,
"configured_present": configured in names, "hint": hint,
"config_error": cfg.get("configError") or None}
@@ -1130,6 +1214,52 @@ def run_selftest():
check("T7b 拒连不当成超时(仍走自动拉起)",
not _is_timeout_exc(urllib.error.URLError(ConnectionRefusedError("refused"))))
# T8 残行封顶 + 会话边界复位(2026-10-10):二进制垃圾不再无限占内存,
# 也不再把自己拼进下一段会话的第一行
sp8 = LineSplitter()
junk = b"\x00\x01" * 40000 # 80 KB 无换行垃圾
got = sp8.feed(junk, 2000.0)
check("T8a 无换行垃圾封顶成一行(内存不无限涨)",
len(got) == 1 and got[0][1] == junk and sp8.buf == b"",
"lines=%d buf=%d" % (len(got), len(sp8.buf)))
got2 = sp8.feed(b"App init OK\n", 2001.0)
check("T8b 封顶后重新对齐换行", len(got2) == 1 and got2[0][1] == b"App init OK", repr(got2))
sp9 = LineSplitter()
sp9.feed(b"half-a-li", 3000.0) # 复位瞬间被截断的半行
sp9.reset()
got3 = sp9.feed(b"ne boot: syslog init OK\n", 3001.0)
check("T8c 会话边界丢弃残行(新会话首行不被污染)",
len(got3) == 1 and got3[0][1] == b"ne boot: syslog init OK", repr(got3))
# T9 截头按行对齐(旧实现按字节切 → 截出来的首行是半行,Grep/verify_log 读到垃圾)
fd9, path9 = tempfile.mkstemp(prefix="serial_tool_selftest_trunc_")
os.close(fd9)
try:
with open(path9, "wb") as f:
for i in range(2000):
f.write(b"[RX] line %04d %s\n" % (i, b"x" * 40))
size9 = os.path.getsize(path9)
with open(path9, "r+b") as f:
cap_truncate(f, size9, keep=8000, cap=1000)
f.seek(0)
data9 = f.read()
first9 = data9.split(b"\n", 1)[0]
check("T9 截头后首行完整(按行边界对齐)",
first9.startswith(b"[RX] line ") and data9.endswith(b"\n"),
"first=%r" % first9[:24])
finally:
try:
os.remove(path9)
except OSError:
pass
# T10 端口建议:换 USB 口后 COM 号变了,唯一候选直接给(多候选不猜)
check("T10 port_hint 唯一候选才给建议",
port_hint("COM7", ["COM6"]) == "COM6"
and port_hint("COM7", ["COM6", "COM9"]) is None
and port_hint("COM6", ["COM6"]) is None)
width = 46
all_ok = True
for name, ok, detail in results: