串口助手补残行封顶与会话边界复位、截头按行对齐、open 保持 dtr/rts;判定加命中行/顺序/耗时/JSON 与产物清理
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user