#!/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 直接读文件即是历史) 用法(仓库根目录,统一入口 .venv): & '.venv\\Scripts\\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [选项] 命令: ports 枚举 COM 口(CLI 本地执行,不依赖 daemon) open / close 开(幂等)/ 关串口 send | --hex 发送 TX(进日志);--nl 追加 CRLF status 全状态(单条 JSON) log --tail N --grep P 读日志(CLI 直接读文件,daemon 死了也能看历史) clear 清空日志(原子截断 + 代际递增) wait-idle 等 RX 静默(@test 用;--settle-ms / --max-ms) start / stop / restart daemon 生命周期(其余命令会自动拉起 daemon) selftest 无头自检(行拆分/解码回退/截头算法,无需硬件) 退出码:0=成功 1=领域错误 2=daemon 不可达/超时 3=用法错误 """ import argparse import json import os import queue import signal 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.0.0" SCRIPT = Path(__file__).resolve() TOOL_DIR = SCRIPT.parent REPO_ROOT = TOOL_DIR.parents[1] STATE_DIR = TOOL_DIR / "state" LOG_FILE = STATE_DIR / "serial.log" META_FILE = STATE_DIR / "meta.json" DAEMON_LOG = STATE_DIR / "daemon.log" CONFIG_FILE = REPO_ROOT / ".opencode" / "serial_config.json" DEFAULT_HTTP_PORT = 18765 CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限 KEEP_BYTES = 10 * 1024 * 1024 # 超限后保留的尾部 PARTIAL_FLUSH_S = 0.2 # 残行冲刷窗口(秒) REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱) DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒) # ---------------------------------------------------------------- 配置 / 元数据 def load_config(): cfg = {"portName": "", "baudRate": 115200, "httpPort": DEFAULT_HTTP_PORT} try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: raw = json.load(f) cfg["portName"] = str(raw.get("portName", "")) cfg["baudRate"] = int(raw.get("baudRate", 115200)) cfg["httpPort"] = int(raw.get("httpPort", DEFAULT_HTTP_PORT)) except (OSError, ValueError): pass return cfg 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)) return out 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 只截尾部), 故在同一句柄上:读尾部 → 截空 → 写回。单写者线程内执行,无并发风险。""" if size <= cap: return False f.seek(size - keep) tail = f.read(keep) 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._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 = LineSplitter() while not self._stop.is_set(): 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() # -- 命令执行(均在串口线程) -- 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: cmd.finish({"ok": False, "error": "no port specified (and no portName in config)"}) return baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200) dtr = bool(p.get("dtr", False)) rts = bool(p.get("rts", False)) with self.lock: if self.ser is not None: if self.port_name == name and self.port_baud == baud: cmd.finish({"ok": True, "port": name, "baud": baud, "gen": self.gen, "since": self.port_since, "note": "already open (idempotent)"}) 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)") 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.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)}, "port": port, "counters": counters, "last_rx": last, "log": {"path": str(LOG_FILE), "size": size, "gen": gen}, "state": state, "error": err} def wait_idle(self, settle_s, max_s): t0 = time.time() last = self.rx_bytes last_change = t0 while time.time() - t0 < max_s: time.sleep(0.1) if self.rx_bytes != last: last = self.rx_bytes last_change = time.time() elif time.time() - last_change >= settle_s: break if last == 0: reason = "no_data" elif time.time() - last_change >= settle_s: reason = "idle" else: reason = "timeout" return {"ok": True, "rx_bytes": last, "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 def server_bind(self): import socket as _socket if os.name == "nt": # SO_EXCLUSIVEADDRPLACE = 0x0040;部分发行版 socket 模块未导出,用数值兜底 excl = getattr(_socket, "SO_EXCLUSIVEADDRPLACE", 0x0040) try: self.socket.setsockopt(_socket.SOL_SOCKET, excl, 1) except (OSError, AttributeError): pass # 退回 allow_reuse_address=False(Windows 同样拒绝二次绑定) super().server_bind() 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": 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) self._send(200, d.wait_idle(settle, mx)) 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 spawn_daemon(): STATE_DIR.mkdir(parents=True, exist_ok=True) 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(REPO_ROOT), **kwargs) 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 call_daemon(args, method, path, body=None): """调 daemon;连接失败时自动拉起并重试一次。""" timeout = getattr(args, "timeout", DEFAULT_TIMEOUT_S) try: return http_request(method, path, body, timeout) except (urllib.error.URLError, OSError) as e: 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()] return {"ok": True, "ports": ports, "configured": load_config().get("portName", "")} 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 cmd_log(args): gen = int(load_meta().get("gen", 0)) if not LOG_FILE.exists(): return {"ok": True, "gen": gen, "lines": [], "note": "log file not created yet"} with open(LOG_FILE, "r", encoding="utf-8", errors="replace") as f: lines = f.read().splitlines() if args.tail: lines = lines[-args.tail:] if args.grep: lines = [l for l in lines if args.grep in l] return {"ok": True, "gen": gen, "lines": lines} def cmd_clear(args): return call_daemon(args, "POST", "/clear", {}) def cmd_wait_idle(args): return call_daemon(args, "POST", "/wait-idle", {"settle_ms": args.settle_ms, "max_ms": args.max_ms}) 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)) # T1 行拆分:CRLF / 多行 / 残行冲刷 sp = LineSplitter() got = [l for _, l in sp.feed(b"abc\r\n", 1000.0)] check("T1a CRLF 行", got == [b"abc"], repr(got)) got = [l for _, l in sp.feed(b"de\nf\n", 1001.0)] check("T1b 多行", got == [b"de", b"f"], repr(got)) check("T1c 残行不入文件", sp.feed(b"ghi", 1002.0) == [] and sp.buf == b"ghi") got = [l for _, l in sp.flush_partial()] check("T1d 残行冲刷", got == [b"ghi"], repr(got)) got = sp.feed(b"\n\n", 1003.0) check("T1e 空行忽略", all(not raw for _, raw in got) and sp.buf == b"", repr(got)) # T2 解码回退链 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") # T3 时间戳格式 s = line_at(time.time(), "RX", "x") check("T3 行格式", 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 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=DEFAULT_TIMEOUT_S, help="CLI->daemon 硬超时秒数(默认 %.0f)" % 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 口(默认取 .opencode/serial_config.json)") 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="仅含子串的行") sub.add_parser("clear", parents=[common], help="清空日志(代际 +1)") p = sub.add_parser("wait-idle", parents=[common], help="等 RX 静默(@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)") 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 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())