#!/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 无头自检(行拆分/解码回退/截头算法,无需硬件) 退出码: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.1.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 陷阱) 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 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 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 _save_meta(self): 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), "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) 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): 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() self.emit(self.sys_line("serial error: %s (port released, reopen with 'open')" % msg)) 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.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 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) cmd.finish({"ok": False, "error": err}) return with self.lock: 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 # 新会话:丢掉上一段会话遗留的残行 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, "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")) self._save_meta() cmd.finish({"ok": True}) 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: 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 [] # -- 查询 -- def status(self): with self.lock: 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} 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 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, "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 毫无意义)。 """ 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) self._save_meta() 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")} if alive: # 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) return {"ok": True, "already_running": False, "pid": st.get("daemon", {}).get("pid")} except (urllib.error.URLError, OSError): return {"ok": True, "started": True} 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 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) 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())