#!/usr/bin/env python3 # -*- coding: utf-8 -*- """serial_tool — Python 串口助手(常驻 daemon + 薄 CLI + 日志文件即接口)。 设计文档:docs/测试说明/测试工具/串口助手设计说明.md 架构: CLI(短命进程,硬超时) --HTTP JSON--> daemon(detached 单实例,127.0.0.1) daemon = HTTP 服务 + 串口读线程(唯一串口 I/O 者) + 日志写线程(唯一文件写者) 历史 = state/serial.log(纯文本、只含完整行,Agent 直接读文件即是历史) 用法(**项目根目录**下运行,python 需装有 pyserial): python /scripts/serial_tool.py <命令> [选项] 命令: ports 枚举 COM 口(CLI 本地执行,不依赖 daemon) open / close 开(幂等)/ 关串口 send | --hex 发送 TX(进日志);--nl 追加 CRLF status 全状态(单条 JSON) log --tail N --grep P 读日志(先过滤后截尾;-i 忽略大小写 / --regex 正则) clear 清空日志(原子截断 + 代际递增) wait-idle 等 RX 静默(/serial-boot-test 用;--settle-ms / --max-ms / --first-timeout-ms) 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 import json import os import queue import re import signal import socket import subprocess import sys import tempfile import threading import time import urllib.error import urllib.request from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import serial import serial.tools.list_ports VERSION = "1.2.0-skill" SCRIPT = Path(__file__).resolve() TOOL_DIR = SCRIPT.parent STATE_DIR = TOOL_DIR / "state" # 全局单 daemon、单日志(一台机一个串口采集面) LOG_FILE = STATE_DIR / "serial.log" META_FILE = STATE_DIR / "meta.json" DAEMON_LOG = STATE_DIR / "daemon.log" def find_config_file(): """串口配置源解析顺序(可移植版;cwd = 项目根目录): 1. $DSH_SERIAL_CONFIG 环境变量(显式文件) 2. /.serialtest/config.json (本 skill 约定) 3. /.opencode/serial_config.json (旧位置,兼容既有仓库如 STM32F4-Base) 都不存在时返回第 1 候选(错误信息照它报)。""" env = os.environ.get("DSH_SERIAL_CONFIG") if env: return Path(env) cwd = Path.cwd() candidates = [cwd / ".serialtest" / "config.json", cwd / ".opencode" / "serial_config.json"] for c in candidates: if c.is_file(): return c return candidates[0] CONFIG_FILE = find_config_file() 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 陷阱);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 # ---------------------------------------------------------------- 配置 / 元数据 def load_config(): """读串口配置源(find_config_file 解析出的那个文件)。 编码用 utf-8-sig(与 run_boot_test.py 同口径):纯 utf-8 遇 BOM 会让 json.load 抛错,旧实现再把它静默吞掉 → 端口/波特率全回退默认 → 报 "no port specified", 把配置问题误导成端口问题(2026-10-09 修)。解析失败不再静默:错误文本放进 configError,由 status / open / ports 显式回显。 """ cfg = {"portName": "", "baudRate": 115200, "httpPort": DEFAULT_HTTP_PORT, "configError": ""} try: with open(CONFIG_FILE, "r", encoding="utf-8-sig") as f: raw = json.load(f) except FileNotFoundError: cfg["configError"] = "config file not found: %s" % CONFIG_FILE return cfg except OSError as e: cfg["configError"] = "cannot read config %s: %s" % (CONFIG_FILE, e) return cfg except ValueError as e: cfg["configError"] = "bad JSON in %s: %s" % (CONFIG_FILE, e) return cfg if not isinstance(raw, dict): cfg["configError"] = "config root is not an object: %s" % CONFIG_FILE return cfg cfg["portName"] = str(raw.get("portName", "")) try: cfg["baudRate"] = int(raw.get("baudRate", 115200)) cfg["httpPort"] = int(raw.get("httpPort", DEFAULT_HTTP_PORT)) except (TypeError, ValueError) as e: cfg["configError"] = "bad baudRate/httpPort in %s: %s" % (CONFIG_FILE, e) 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 # ------------------------- 自动重开判定 / 退避 / 缺口标记行(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: return json.load(f) except (OSError, ValueError): return {} def save_meta(meta): tmp = META_FILE.with_name(META_FILE.name + ".tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) os.replace(tmp, META_FILE) def now_str(): return datetime.now().astimezone().isoformat(timespec="milliseconds") def line_at(ts_epoch, direction, text): # Windows CRT 仅支持 1980+ 时间戳,越界时回退当前时间(不致整行丢失) try: ts = datetime.fromtimestamp(ts_epoch).astimezone().isoformat(timespec="milliseconds") except (OSError, OverflowError, ValueError): ts = now_str() return "[%s] %s %s" % (direction, ts, text) # ---------------------------------------------------------------- 行拆分 / 解码 / 截头 class LineSplitter: """字节流 → 完整行。残行留内存(保证日志文件只含完整行)。""" def __init__(self): self.buf = b"" self.buf_ts = 0.0 def feed(self, data, now): out = [] if not data: return out if not self.buf: self.buf_ts = now self.buf += data while True: i = self.buf.find(b"\n") if i < 0: break raw = self.buf[:i].rstrip(b"\r") self.buf = self.buf[i + 1:] 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 [] out = [(self.buf_ts or time.time(), self.buf.rstrip(b"\r"))] self.buf = b"" self.buf_ts = 0.0 return out def decode_line(raw): """UTF-8 → GBK → HEX 转义(MCU 流 = ASCII 为主 + UTF-8 中文,前两级足够)。""" for enc in ("utf-8", "gbk"): try: return raw.decode(enc) except UnicodeDecodeError: continue return "HEX:" + raw.hex() 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) return True # ---------------------------------------------------------------- daemon 核心 class Cmd: """提交给串口线程的命令(带完成事件与结果盒)。""" def __init__(self, action, **params): self.action = action self.params = params self.done = threading.Event() self.result = {"ok": False, "error": "not executed"} def finish(self, result): self.result = result self.done.set() class Daemon: def __init__(self): self.cfg = load_config() self.log_q = queue.Queue() # ("line", str) / ("clear", Event|None) → 写线程 self.cmd_q = queue.Queue() # Cmd → 串口线程 self.lock = threading.Lock() self.server = None self.state = "stopped" # stopped / active / error self.error = None self.port_name = None self.port_baud = None self.dtr = False 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() self.rx_bytes = 0 self.tx_bytes = 0 self.rx_lines = 0 self.tx_lines = 0 self.errors = 0 self.last_rx_ts = None 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() # -- 日志(一律经写线程,保序 + 单写者) -- def emit(self, full_line): self.log_q.put(("line", full_line)) def emit_clear(self, event=None): self.log_q.put(("clear", event)) 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": ident["name"], "baud": ident["baud"], "dtr": ident["dtr"], "rts": ident["rts"]} if ident else None), "since": self.port_since, }) # -- 写线程(日志文件唯一写者) -- def writer_loop(self): # a+b:追加写 + 可读(截头需从同一句柄读尾部;"ab" 在 Windows 禁读会崩) f = open(LOG_FILE, "a+b") try: while not self._stop.is_set(): try: kind, payload = self.log_q.get(timeout=0.2) except queue.Empty: continue if kind == "line": f.write(payload.encode("utf-8") + b"\n") f.flush() elif kind == "clear": self.gen += 1 f.seek(0) os.ftruncate(f.fileno(), 0) f.write(self.sys_line("log cleared (gen=%d)" % self.gen).encode("utf-8") + b"\n") f.flush() self._save_meta() if payload is not None: payload.set() size = f.tell() if size > CAP_BYTES: cap_truncate(f, size) f.write(self.sys_line("log truncated to %dMB (cap=%dMB)" % (KEEP_BYTES // 1048576, CAP_BYTES // 1048576) ).encode("utf-8") + b"\n") f.flush() finally: f.close() # -- 串口线程(Serial 对象唯一持有者) -- def serial_loop(self): 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: n = ser.in_waiting if n: data = ser.read(min(n, 4096)) if data: t = time.time() self.rx_bytes += len(data) self.last_data_at = t for ts, raw in splitter.feed(data, t): self._rx_line(ts, raw) except (serial.SerialException, OSError) as e: self._port_error("read: %s" % e) ser = None if ser is not None and splitter.buf: 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: cmd = None if cmd is not None: self._run_cmd(cmd) else: time.sleep(0.01) def _rx_line(self, ts, raw): if not raw: return text = decode_line(raw) self.rx_lines += 1 self.last_rx_ts = ts self.last_rx_line = text self.last_rx_at = time.time() self.emit(line_at(ts, "RX", text)) def _port_error(self, msg): gap = False with self.lock: if self.ser is not None: try: self.ser.close() except (serial.SerialException, OSError): pass self.ser = None self.state = "error" self.error = msg self.errors += 1 self.last_released = time.time() 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: try: self.ser.close() except (serial.SerialException, OSError): pass self.ser = None self.port_name = None self.port_baud = None self.port_since = None 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 # -- 命令执行(均在串口线程) -- def _run_cmd(self, cmd): try: if cmd.action == "open": self._cmd_open(cmd) elif cmd.action == "close": self._cmd_close(cmd) elif cmd.action == "send": self._cmd_send(cmd) else: cmd.finish({"ok": False, "error": "unknown action %s" % cmd.action}) except Exception as e: # 命令失败不得杀死串口线程 cmd.finish({"ok": False, "error": "cmd %s failed: %s" % (cmd.action, e)}) def _cmd_open(self, cmd): p = cmd.params name = p.get("port") or self.cfg.get("portName") or "" if not name: err = "no port specified (and no portName in config)" if self.cfg.get("configError"): err += " [config: %s]" % self.cfg["configError"] cmd.finish({"ok": False, "error": err}) return baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200) # 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, "dtr": self.dtr, "rts": self.rts, "since": self.port_since, "note": note}) return self._close_port_locked() # 重开退避:距上次释放 <1.5s 或 daemon 启动 <1.5s(CH340 特性) for since_ts in (self.last_released, self.born): if since_ts: wait = REOPEN_SETTLE_S - (time.time() - since_ts) if wait > 0: time.sleep(wait) break 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 self.dtr = dtr self.rts = rts self.port_since = now_str() self.state = "active" 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: 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} if was_open else {"ok": True, "note": "port not open"}) def _cmd_send(self, cmd): p = cmd.params if p.get("hex"): try: data = bytes.fromhex(p["hex"]) except ValueError: cmd.finish({"ok": False, "error": "bad --hex value"}) return else: data = (p.get("text") or "").encode("utf-8") if p.get("nl"): data += b"\r\n" with self.lock: ser = self.ser if ser is None: 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) except (serial.SerialException, OSError) as e: self._port_error("write: %s" % e) cmd.finish({"ok": False, "error": "write failed: %s" % e}) return self.tx_bytes += len(data) self.tx_lines += 1 text = data.decode("utf-8", "replace").replace("\r", "\\r").replace("\n", "\\n") self.emit(line_at(time.time(), "TX", text if text else "HEX:" + data.hex())) cmd.finish({"ok": True, "bytes": len(data)}) @staticmethod def _list_port_names(): try: return [p.device for p in serial.tools.list_ports.comports()] 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", # 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() .isoformat(timespec="milliseconds"), "line": self.last_rx_line, "age_ms": int(max(0, time.time() - self.last_rx_at) * 1000)} size = LOG_FILE.stat().st_size if LOG_FILE.exists() else 0 return {"ok": True, "daemon": {"pid": os.getpid(), "version": VERSION, "uptime_s": int(time.time() - self.born), "exclusive_bind": getattr(self.server, "exclusive_bound", False)}, "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, "log": {"path": str(LOG_FILE), "size": size, "gen": gen}, "state": state, "error": err, "config_error": self.cfg.get("configError") or None} def wait_idle(self, settle_s, max_s, first_timeout_s=None): """等 RX 静默,返回 reason ∈ {idle, no_data, timeout}。 2026-10-09 修三处(旧实现会让 /serial-boot-test 在"板子全程没输出"时误判收尾): 1. no_data 判据:旧实现比的是"进入函数时 daemon 累计 rx_bytes 是否为 0"。 daemon 常驻(本机实测 rx_bytes=1.2MB)→ 永远 >0 → 板子一个字节都不发 也返回 idle:true,/serial-boot-test 的 no_data 拦截形同虚设。现按**本次增量**判。 2. 首字节门:旧实现从进入即开始计静默时间,板子第一行比 settle_ms 来得 晚就提前收尾("开机日志缺尾巴"的另一半成因)。现首字节到达前不启动 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 last_change = 0.0 # 0.0 = 本次尚未收到任何字节 first_deadline = t0 + (first_timeout_s if first_timeout_s is not None else max_s) reason = "timeout" while True: now = time.time() if self.rx_bytes != last: last = self.rx_bytes last_change = now if last_change: if now - last_change >= settle_s: reason = "idle" break elif now >= first_deadline: reason = "no_data" break if now - t0 >= max_s: reason = "timeout" break time.sleep(0.1) return {"ok": True, "rx_bytes": last - initial, "idle": reason == "idle", "waited_ms": int((time.time() - t0) * 1000), "reason": reason} # -- 生命周期 -- 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"] try: self.server = _ExclusiveHTTPServer(("127.0.0.1", port), _Handler) except OSError as e: print("serial_tool: HTTP port %d busy (another instance running?): %s" % (port, e), file=sys.stderr) sys.exit(3) self.server.daemon_threads = True _Handler.daemon = self print("serial_tool daemon %s listening on 127.0.0.1:%d (pid %d)" % (VERSION, port, os.getpid()), flush=True) try: self.server.serve_forever() except KeyboardInterrupt: pass finally: self._stop.set() self.server.server_close() class _ExclusiveHTTPServer(ThreadingHTTPServer): """强制独占端口绑定。http.server 默认 SO_REUSEADDR 在 Windows 允许多个活跃 socket 绑同一端口(Linux 不会),会致多 daemon 并存、请求随机分发,必须禁用。""" allow_reuse_address = False exclusive_bound = False def server_bind(self): if os.name == "nt": # 真名是 SO_EXCLUSIVEADDRUSE(值 -5 = ~SO_REUSEADDR)。旧代码写成 # "SO_EXCLUSIVEADDRPLACE" 并用数值 0x0040 兜底:属性不存在 → 取到 0x40 # → setsockopt 抛 WinError 10042(无效选项)→ 被 except 静默吞掉 # → 注释承诺的"独占绑定"从未真正生效(2026-10-09 实测复现)。 excl = getattr(socket, "SO_EXCLUSIVEADDRUSE", -5) try: self.socket.setsockopt(socket.SOL_SOCKET, excl, 1) _ExclusiveHTTPServer.exclusive_bound = True except OSError as e: print("serial_tool: SO_EXCLUSIVEADDRUSE failed (%s); falling back " "to allow_reuse_address=False" % e, file=sys.stderr, flush=True) super().server_bind() def handle_error(self, request, client_address): """CLI 硬超时先退出会掐断连接 → socketserver 默认往 daemon.log 打整段 traceback(本机实测 16 次 ConnectionAbortedError)。客户端已断开不是 daemon 故障,静音处理。""" exc = sys.exc_info()[1] if isinstance(exc, ConnectionError): return super().handle_error(request, client_address) class _Handler(BaseHTTPRequestHandler): daemon = None # 由 Daemon.run 注入 def log_message(self, fmt, *args): pass # 静默(daemon 输出已重定向到 state/daemon.log) def _send(self, code, obj): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _body(self): n = int(self.headers.get("Content-Length") or 0) if n <= 0: return {} try: raw = json.loads(self.rfile.read(n).decode("utf-8")) return raw if isinstance(raw, dict) else {} except (ValueError, UnicodeDecodeError): return {} def _run_cmd(self, cmd, wait_s=20): self.daemon.cmd_q.put(cmd) if not cmd.done.wait(wait_s): self._send(504, {"ok": False, "error": "daemon busy (cmd not finished in %ds)" % wait_s}) return None code = 200 if cmd.result.get("ok") else 400 self._send(code, cmd.result) return cmd.result def do_GET(self): d = self.daemon if self.path == "/ping": self._send(200, {"ok": True, "version": VERSION, "pid": os.getpid()}) elif self.path == "/status": self._send(200, d.status()) elif self.path == "/ports": ports = [{"name": p.device, "description": p.description, "hwid": p.hwid} for p in serial.tools.list_ports.comports()] self._send(200, {"ok": True, "ports": ports, "configured": d.cfg.get("portName", "")}) else: self._send(404, {"ok": False, "error": "no such endpoint: %s" % self.path}) def do_POST(self): d = self.daemon if self.path == "/open": body = self._body() params = {k: body[k] for k in ("port", "baud", "dtr", "rts") if k in body} self._run_cmd(Cmd("open", **params)) elif self.path == "/close": self._run_cmd(Cmd("close")) elif self.path == "/send": body = self._body() 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): self._send(500, {"ok": False, "error": "clear timeout"}) return self._send(200, {"ok": True, "gen": d.gen}) elif self.path == "/wait-idle": body = self._body() settle = max(0.5, float(body.get("settle_ms", 8000)) / 1000.0) mx = max(1.0, float(body.get("max_ms", 30000)) / 1000.0) ft = body.get("first_timeout_ms") ft = None if ft is None else max(1.0, float(ft) / 1000.0) self._send(200, d.wait_idle(settle, mx, ft)) elif self.path == "/stop": cmd = Cmd("close") d.cmd_q.put(cmd) cmd.done.wait(5) d._stop.set() if d.server is not None: threading.Timer(0.3, d.server.shutdown).start() self._send(200, {"ok": True, "stopped": True}) else: self._send(404, {"ok": False, "error": "no such endpoint: %s" % self.path}) # ---------------------------------------------------------------- CLI 侧 class ToolError(Exception): def __init__(self, msg, exit_code): super().__init__(msg) self.exit_code = exit_code def http_port(): return load_config().get("httpPort") or DEFAULT_HTTP_PORT def http_request(method, path, body=None, timeout=DEFAULT_TIMEOUT_S): url = "http://127.0.0.1:%d%s" % (http_port(), path) data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: # 4xx/5xx 也是 daemon 的正常应答(领域错误 JSON),解析返回而非当作不可达 try: return json.loads(e.read().decode("utf-8")) except (ValueError, UnicodeDecodeError, OSError): raise ToolError("daemon HTTP %d (non-JSON body)" % e.code, 2) def _rotate_daemon_log(): """daemon.log 超 DAEMON_LOG_MAX → 原地只保尾部 DAEMON_LOG_KEEP。 不 rename(Windows 下老 daemon 可能还持有句柄,rename 会让它继续往旧 inode 写); 只在拉起新实例前做,写入口径不变。旧实现无上限:daemon 反复重启 + 断连 traceback 会把它无限撑大。 """ try: size = DAEMON_LOG.stat().st_size if size <= DAEMON_LOG_MAX: return with open(DAEMON_LOG, "r+b") as f: f.seek(size - DAEMON_LOG_KEEP) tail = f.read() f.seek(0) os.ftruncate(f.fileno(), 0) f.write(b"[daemon.log rotated: kept last %dKB]\n" % (DAEMON_LOG_KEEP // 1024)) f.write(tail) except OSError: pass def spawn_daemon(): STATE_DIR.mkdir(parents=True, exist_ok=True) _rotate_daemon_log() log_fh = open(DAEMON_LOG, "ab") kwargs = {} if os.name == "nt": kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW subprocess.Popen([sys.executable, str(SCRIPT), "--serve"], stdout=log_fh, stderr=subprocess.STDOUT, cwd=str(Path.cwd()), **kwargs) # daemon 继承调用方项目根 → 配置源按项目解析 log_fh.close() def wait_ready(timeout=DEFAULT_TIMEOUT_S): t0 = time.time() while time.time() - t0 < timeout: try: http_request("GET", "/ping", timeout=0.5) return True except (urllib.error.URLError, OSError, ValueError): time.sleep(0.1) return False def _try_kill(pid): try: os.kill(int(pid), signal.SIGTERM) # Windows 下 = TerminateProcess return True except (OSError, ValueError): return False def _is_timeout_exc(exc): """超时判定:urlopen 连接阶段超时会被包成 URLError(reason=socket.timeout), 读响应阶段超时则直接抛 socket.timeout(3.10+ 即 TimeoutError)。""" if isinstance(exc, (socket.timeout, TimeoutError)): return True return isinstance(getattr(exc, "reason", None), (socket.timeout, TimeoutError)) def call_daemon(args, method, path, body=None): """调 daemon;**连接被拒/不可达**时自动拉起并重试一次。 2026-10-10 修:超时不再当"daemon 不可达"。旧实现把超时和拒连塞进同一个 except → 任何耗时超过 --timeout 的命令(典型:wait-idle)都会误判 daemon 已死、 额外拉起一个 daemon(新实例绑不上 HTTP 口 → 打 "HTTP port busy" 后退出; daemon.log 里 36 条 listening 大半是这么攒出来的),再把原命令撞一次超时。 现在超时直接快速失败并提示调大 --timeout;只有真·不可达才走自动拉起。 """ timeout = getattr(args, "timeout", DEFAULT_TIMEOUT_S) try: return http_request(method, path, body, timeout) except (socket.timeout, TimeoutError) as e: raise ToolError("daemon did not answer within %.1fs (busy? raise --timeout): %s" % (timeout, e), 2) except (urllib.error.URLError, OSError) as e: if _is_timeout_exc(e): raise ToolError("daemon did not answer within %.1fs (busy? raise --timeout): %s" % (timeout, getattr(e, "reason", e)), 2) if args.command == "stop": killed = False pid = load_meta().get("pid") if pid: killed = _try_kill(pid) return {"ok": True, "stopped": True, "note": "daemon not running", "killed_zombie": killed} if args.command == "start": raise ToolError("daemon not running: %s" % e, 2) spawn_daemon() if not wait_ready(timeout): raise ToolError("daemon start timeout (no response on 127.0.0.1:%d within %.0fs)" % (http_port(), timeout), 2) try: return http_request(method, path, body, timeout) except (urllib.error.URLError, OSError) as e2: raise ToolError("daemon unresponsive after auto-start: %s" % e2, 2) # -- 各命令 -- 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() 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} def cmd_open(args): body = {} if args.port: body["port"] = args.port if args.baud: body["baud"] = args.baud if args.dtr is not None: body["dtr"] = args.dtr if args.rts is not None: body["rts"] = args.rts return call_daemon(args, "POST", "/open", body) def cmd_close(args): return call_daemon(args, "POST", "/close", {}) def cmd_send(args): body = {} if args.hex: body["hex"] = args.hex else: body["text"] = args.text if args.nl: body["nl"] = True return call_daemon(args, "POST", "/send", body) def cmd_status(args): return call_daemon(args, "GET", "/status") def resolve_log_paths(): """日志/代际的**权威路径**解析:优先问在跑的 daemon(status.log.path), daemon 不可达或未报告时回退本地路径。 一台机单 daemon,但 serial_tool 可以有多份副本(仓库版 / skill 版)——日志由 **daemon 那份副本**的 state 目录落盘,CLI 若只读自己目录下的文件会读到空 (2026-10-10 实测:skill 版 CLI 对仓库版 daemon,wait-idle 有增量、log 却 0 行)。 """ try: st = http_request("GET", "/status", timeout=2.0) log = st.get("log") or {} p = str(log.get("path") or "") if p and Path(p).is_file(): return Path(p), int(log.get("gen", 0)) except (urllib.error.URLError, OSError, ValueError): pass return LOG_FILE, int(load_meta().get("gen", 0)) def cmd_log(args): """读日志:**先过滤、后截尾**。 旧实现先 tail 后 grep → `log --grep "App init OK"`(--tail 默认 50)只在末 50 行 里找,开机标记必然查无此 marker——这就是 AGENTS.md 里"log --grep 实测不可信"的 根因(2026-10-09 实测复现并修复)。返回带 scanned/matched 计数,便于判定。 """ log_path, gen = resolve_log_paths() if not log_path.exists(): return {"ok": True, "gen": gen, "lines": [], "note": "log file not created yet: %s" % log_path} with open(log_path, "r", encoding="utf-8", errors="replace") as f: lines = f.read().splitlines() scanned = len(lines) matched = scanned if args.grep: if args.regex: try: pat = re.compile(args.grep, re.IGNORECASE if args.ignore_case else 0) except re.error as e: raise ToolError("bad --grep regex %r: %s" % (args.grep, e), 3) lines = [l for l in lines if pat.search(l)] elif args.ignore_case: low = args.grep.lower() lines = [l for l in lines if low in l.lower()] else: lines = [l for l in lines if args.grep in l] matched = len(lines) if args.tail: lines = lines[-args.tail:] result = {"ok": True, "gen": gen, "lines": lines, "path": str(log_path), "scanned": scanned, "matched": matched} if args.grep and args.tail and matched > len(lines): result["note"] = "grep matched %d line(s), showing last %d (tail)" % (matched, len(lines)) return result def cmd_clear(args): return call_daemon(args, "POST", "/clear", {}) def cmd_wait_idle(args): body = {"settle_ms": args.settle_ms, "max_ms": args.max_ms} if args.first_timeout_ms: body["first_timeout_ms"] = args.first_timeout_ms if not args.timeout_explicit: # wait-idle 是同步阻塞的长操作:CLI 超时按等待上限自动推导。旧实现固定 5s # 硬超时会在 daemon 还在等的时候先掐断 → CLI 报 exit 2、daemon 侧 # ConnectionAbortedError traceback(本机 daemon.log 实测 16 次)。 # AGENTS.md 抓开机日志铁律②"必须带 --timeout"就是这个坑的手工补丁。 args.timeout = args.max_ms / 1000.0 + 5.0 return call_daemon(args, "POST", "/wait-idle", body) def cmd_start(args, force=False): """拉起 daemon。force=True(restart 用):先确保旧实例彻底退出再拉新实例, 保证加载的是当前代码(独占绑定保证任何时刻只有一个 daemon)。""" alive = False try: http_request("GET", "/ping", timeout=1) alive = True except (urllib.error.URLError, OSError): alive = False if alive and not force: 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) except (urllib.error.URLError, OSError): pass for attempt in (1, 2): deadline = time.time() + 3 while time.time() < deadline: try: http_request("GET", "/ping", timeout=0.5) time.sleep(0.2) except (urllib.error.URLError, OSError): break else: if attempt == 1: pid = load_meta().get("pid") if pid: _try_kill(pid) continue raise ToolError("旧 daemon 仍在响应(stop/kill 均无效),放弃启动", 2) break spawn_daemon() if not wait_ready(args.timeout): raise ToolError("daemon start timeout (no response within %.0fs)" % args.timeout, 2) try: st = http_request("GET", "/status", timeout=1) result = {"ok": True, "already_running": False, "pid": st.get("daemon", {}).get("pid")} except (urllib.error.URLError, OSError): 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): try: http_request("POST", "/stop", timeout=args.timeout) except (urllib.error.URLError, OSError): killed = False pid = load_meta().get("pid") if pid: killed = _try_kill(pid) return {"ok": True, "stopped": True, "note": "daemon not responding", "killed_zombie": killed} deadline = time.time() + 5 while time.time() < deadline: try: http_request("GET", "/ping", timeout=0.5) time.sleep(0.2) except (urllib.error.URLError, OSError): return {"ok": True, "stopped": True} pid = load_meta().get("pid") if pid: _try_kill(pid) return {"ok": True, "stopped": True, "note": "forced kill (pid %s)" % pid} # ---------------------------------------------------------------- 无头自检 def run_selftest(): results = [] def check(name, cond, detail=""): results.append((name, bool(cond), detail)) # 行拆分:CRLF / 多行 / 残行冲刷 sp = LineSplitter() got = [l for _, l in sp.feed(b"abc\r\n", 1000.0)] check("行拆分:CRLF 行", got == [b"abc"], repr(got)) got = [l for _, l in sp.feed(b"de\nf\n", 1001.0)] check("行拆分:多行", got == [b"de", b"f"], repr(got)) check("行拆分:残行不入文件", sp.feed(b"ghi", 1002.0) == [] and sp.buf == b"ghi") got = [l for _, l in sp.flush_partial()] check("行拆分:残行冲刷", got == [b"ghi"], repr(got)) got = sp.feed(b"\n\n", 1003.0) check("行拆分:空行忽略", all(not raw for _, raw in got) and sp.buf == b"", repr(got)) # 解码回退链 check("T2a UTF-8", decode_line("中文ok".encode("utf-8")) == "中文ok") check("T2b GBK", decode_line("中文".encode("gbk")) == "中文") check("T2c HEX 兜底", decode_line(b"\xff\xfe\x01") == "HEX:fffe01") # 时间戳格式 s = line_at(time.time(), "RX", "x") check("时间戳行格式", s.startswith("[RX] ") and "T" in s.split(" ")[1], s) # T4 截头算法(保留尾部 + 截后继续追加) pat = b"0123456789abcdef" size = 1_100_000 keep = 512 * 1024 fd, path = tempfile.mkstemp(prefix="serial_tool_selftest_") os.close(fd) try: with open(path, "wb") as f: f.write((pat * (size // len(pat) + 1))[:size]) with open(path, "r+b") as f: cap_truncate(f, size, keep=keep, cap=keep // 2) f.write(b"TAIL") f.seek(0) data = f.read() expect = (pat * (size // len(pat) + 1))[:size][-keep:] + b"TAIL" check("T4a 截头保尾", len(data) == keep + 4 and data == expect, "len=%d expect=%d" % (len(data), len(expect))) finally: try: os.remove(path) except OSError: pass # T5 wait_idle:no_data 判据 / 首字节门 / 增量字节(2026-10-09 回归锁) 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 and not r["idle"], repr(r)) p5 = _Cnt(100) threading.Thread(target=lambda: (time.sleep(0.35), setattr(p5, "rx_bytes", 200)), daemon=True).start() r = Daemon.wait_idle(p5, 0.2, 2.0, 1.0) # 第一行比 settle 晚到 check("T5b 首字节门(晚到的第一行仍收全)", r["reason"] == "idle" and r["rx_bytes"] == 100 and r["waited_ms"] >= 500, repr(r)) p6 = _Cnt(0) stop6 = threading.Event() def _spam(): while not stop6.is_set(): p6.rx_bytes += 10 time.sleep(0.05) threading.Thread(target=_spam, daemon=True).start() r = Daemon.wait_idle(p6, 0.3, 0.8) stop6.set() check("T5c 持续吐数判 timeout + 返回增量字节", r["reason"] == "timeout" and r["rx_bytes"] > 0, repr(r)) # T6 log 命令:先 grep 后 tail(旧顺序即 AGENTS.md"log --grep 不可信"的根因) saved_log, saved_meta = LOG_FILE, load_meta saved_resolve = globals()["resolve_log_paths"] fd2, lpath2 = tempfile.mkstemp(prefix="serial_tool_selftest_log_", suffix=".log") os.close(fd2) try: body = "[RX] 2026-10-09T00:00:00.000+08:00 App init OK\n" + "\n".join( "[RX] 2026-10-09T00:00:01.000+08:00 noise %d" % i for i in range(60)) Path(lpath2).write_text(body, encoding="utf-8") globals()["LOG_FILE"] = Path(lpath2) globals()["load_meta"] = lambda: {"gen": 7} # resolve_log_paths 会先问在跑的 daemon——selftest 必须连它一起桩掉, # 否则多 daemon 共存环境下 T6 读到真 daemon 的日志而不是这份临时文件 globals()["resolve_log_paths"] = lambda: (Path(lpath2), 7) class _G: tail = 50 grep = "App init OK" regex = False ignore_case = False r = cmd_log(_G()) check("T6a grep 先于 tail(末 50 行也查到开机标记)", r["matched"] == 1 and len(r["lines"]) == 1 and "App init OK" in r["lines"][0], "matched=%s lines=%d" % (r.get("matched"), len(r["lines"]))) class _R: tail = 0 grep = r"App init|noise 5[0-9]" regex = True ignore_case = False r = cmd_log(_R()) check("T6b --regex 交替匹配", r["matched"] == 11, "matched=%s" % r.get("matched")) class _I: tail = 5 grep = "NOISE" regex = False ignore_case = True r = cmd_log(_I()) check("T6c -i 忽略大小写 + tail 在过滤之后", r["scanned"] == 61 and r["matched"] == 60 and len(r["lines"]) == 5, "scanned=%s matched=%s lines=%d" % (r.get("scanned"), r.get("matched"), len(r["lines"]))) finally: globals()["LOG_FILE"], globals()["load_meta"] = saved_log, saved_meta globals()["resolve_log_paths"] = saved_resolve try: os.remove(lpath2) except OSError: pass # T7 CLI 超时/拒连区分(旧实现把超时也当"daemon 不可达"→ 误拉起新实例) check("T7a 超时识别(裸抛 + URLError 包裹)", _is_timeout_exc(socket.timeout("t")) and _is_timeout_exc(urllib.error.URLError(socket.timeout("t")))) 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 # 端口建议:换 USB 口后 COM 号变了,唯一候选直接给(多候选不猜) check("端口建议:唯一候选才给建议", port_hint("COM7", ["COM6"]) == "COM6" 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: all_ok &= ok print("[%s] %s %s" % ("PASS" if ok else "FAIL", name, detail)) print("selftest: %d/%d passed" % (sum(1 for _, ok, _ in results if ok), len(results))) print("SELFTEST " + ("PASS" if all_ok else "FAIL")) return 0 if all_ok else 1 # ---------------------------------------------------------------- 入口 def build_parser(): common = argparse.ArgumentParser(add_help=False) common.add_argument("--timeout", type=float, default=None, help="CLI->daemon 硬超时秒数(默认 %.0f;wait-idle 未显式给时按 --max-ms 自动推导)" % DEFAULT_TIMEOUT_S) parser = argparse.ArgumentParser( prog="serial_tool", description="Python 串口助手:常驻 daemon + 薄 CLI + 日志文件即接口") parser.add_argument("--serve", action="store_true", help=argparse.SUPPRESS) sub = parser.add_subparsers(dest="command") sub.add_parser("ports", parents=[common], help="枚举 COM 口(本地执行,不依赖 daemon)") p = sub.add_parser("open", parents=[common], help="开串口(幂等;daemon 未运行会自动拉起)") p.add_argument("--port", help="COM 口(默认取串口配置源,见 find_config_file)") p.add_argument("--baud", type=int, help="波特率(默认取配置)") p.add_argument("--dtr", type=int, choices=[0, 1], help="显式置 DTR(默认不翻转)") p.add_argument("--rts", type=int, choices=[0, 1], help="显式置 RTS(默认不翻转)") sub.add_parser("close", parents=[common], help="关串口") p = sub.add_parser("send", parents=[common], help="发送 TX 字节(进日志)") p.add_argument("text", nargs="?", help="文本(UTF-8 编码发送)") p.add_argument("--hex", help="改发 hex 字节,如 0D0A(与 text 二选一)") p.add_argument("--nl", action="store_true", help="末尾追加 CRLF") sub.add_parser("status", parents=[common], help="全状态 JSON") p = sub.add_parser("log", parents=[common], help="读日志(直接读文件,daemon 死也能用)") p.add_argument("--tail", type=int, default=50, help="过滤后取末 N 行(默认 50;0 = 全部)") p.add_argument("--grep", help="只留匹配行(默认子串匹配;先过滤后 tail)") p.add_argument("-i", "--ignore-case", action="store_true", help="grep 忽略大小写") p.add_argument("--regex", action="store_true", help="grep 按 Python re 正则") sub.add_parser("clear", parents=[common], help="清空日志(代际 +1)") p = sub.add_parser("wait-idle", parents=[common], help="等 RX 静默(/serial-boot-test 用)") p.add_argument("--settle-ms", type=int, default=8000, help="静默判据毫秒(默认 8000:boot 有 3~4s 自然静默间隙)") p.add_argument("--max-ms", type=int, default=30000, help="最长等待毫秒(默认 30000)") p.add_argument("--first-timeout-ms", type=int, default=0, help="首字节超时毫秒(0 = 同 --max-ms):期间零字节即判 no_data") sub.add_parser("start", parents=[common], help="拉起 daemon(已在跑则直接返回)") sub.add_parser("stop", parents=[common], help="停止 daemon(关端口后退出)") sub.add_parser("restart", parents=[common], help="stop + start") sub.add_parser("selftest", parents=[common], help="无头自检(无需硬件)") return parser def main(argv=None): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") parser = build_parser() args = parser.parse_args(argv) if args.serve: Daemon().run() return 0 if not args.command: parser.print_help() return 3 # --timeout 缺省解析:显式与否要留给 cmd_wait_idle(它按 --max-ms 自动推导) args.timeout_explicit = args.timeout is not None if args.timeout is None: args.timeout = DEFAULT_TIMEOUT_S try: if args.command == "selftest": return run_selftest() handlers = { "ports": cmd_ports, "open": cmd_open, "close": cmd_close, "send": cmd_send, "status": cmd_status, "log": cmd_log, "clear": cmd_clear, "wait-idle": cmd_wait_idle, "start": cmd_start, "stop": cmd_stop, } if args.command == "send" and not args.text and not args.hex: raise ToolError("send 需要 text 或 --hex", 3) if args.command == "restart": result = cmd_start(args, force=True) else: result = handlers[args.command](args) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if result.get("ok", False) else 1 except ToolError as e: print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False, indent=2)) return e.exit_code except urllib.error.URLError as e: print(json.dumps({"ok": False, "error": "daemon unreachable: %s" % e.reason}, ensure_ascii=False, indent=2)) return 2 except KeyboardInterrupt: return 130 if __name__ == "__main__": sys.exit(main())