1010 lines
37 KiB
Python
1010 lines
37 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 直接读文件即是历史)
|
||
|
||
用法(仓库根目录,统一入口 .venv):
|
||
& '.venv\\Scripts\\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [选项]
|
||
|
||
命令:
|
||
ports 枚举 COM 口(CLI 本地执行,不依赖 daemon)
|
||
open / close 开(幂等)/ 关串口
|
||
send <text> | --hex <hh> 发送 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())
|