4a4ad6bc5f
现状口径:/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 全过;字节卫生零漂移。
1421 lines
58 KiB
Python
1421 lines
58 KiB
Python
#!/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())
|