Files
STM32F4-Base/.dsh/skills/serial-assistant/scripts/serial_tool.py
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

1421 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <skill>/scripts/serial_tool.py <命令> [选项]
命令:
ports 枚举 COM 口(CLI 本地执行,不依赖 daemon)
open / close 开(幂等)/ 关串口
send <text> | --hex <hh> 发送 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. <cwd>/.serialtest/config.json (本 skill 约定)
3. <cwd>/.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())