Files
STM32F4-Base/.dsh/skills/serial-assistant/scripts/serial_tool.py
T
super_agent_bot dfd1528409
CI / 静态门禁 (pre-commit) (pull_request) Failing after 4m19s
CI / 主机侧单测 (MSVC) (pull_request) Successful in 44s
CI / 静态门禁 (pre-commit) (push) Failing after 4m51s
CI / 主机侧单测 (MSVC) (push) Failing after 43s
fix(serial-assistant): daemon 重启自动重开口 + 自动重连 + 日志缺口标记(#75)
按 Gitea issue #75 R1–R5 修采集层「daemon 死了采集静默停摆」族问题:
旧行为下 daemon 崩溃/被杀/读错丢口后,新实例起得来但口不开
(run() 先 _save_meta 把现场覆成 null,且无任何重开路径),
serial.log 缺口无边界,wait-idle 计满还良性返回 no_data,
把「采集器死」伪装成「板子静默」。

- R1 启动自动重开:meta.json 的 port 字段升格为「开口意图」现场
  (缺口期不清空,显式 close/stop 清除);run() 先读旧 meta 再覆写;
  重开在串口线程内执行(串口对象唯一持有者不变量),首次 = 出生 +1.5s。
  restart 特判:cmd_start(force) 在 stop 前抓 /status 现场、新 daemon
  就绪后 POST /open 开回来(stop 语义不变,结果进返回 JSON 的 reopen 键)。
- R2 失败自动重连:_port_error 后退避重试(REOPEN_SETTLE_S=1.5 起步、
  倍增、REOPEN_BACKOFF_MAX_S=30 封顶)直到成功或显式 close;
  逐次失败原文进 daemon.log(dlog=stderr),不刷 serial.log。
- R3 缺口标记:serial.log 落 [SYS] 行——daemon started(新旧 pid +
  重开现场)/ GAP-START(丢口与崩溃恢复两种,窗口起点下限声明)/
  GAP-END(窗口秒数 + 尝试次数 + 字节不可找回)/ GAP-RETRY(退避期
  仅首次 + 之后每分钟 ≤1 条,口径定死并写入设计说明 §6.1)。
- R4 状态可见:status 新增 auto_reopen{enabled,pending,attempts,
  last_error,next_retry_in_ms};port 新增 desired(意图)与 ready
  (实际)并列,缺口期 name/baud 显示待重开参数;wait-idle 入口加
  无口门:口未开秒回 ok:false + 现场,不再计满返回良性 no_data。
- R5:VERSION=1.2.0-skill;selftest 新增 T10–T15 共 22 项无头用例
  (重开意图判定 / 退避倍增封顶 / 缺口行格式 / 无口门 / auto_reopen
  三态 / restart 捕获),合计 45 项全绿;SKILL.md 与使用指南、
  设计说明 §6.1+§13 同步(机理只在设计说明详述一处)。

兼容性:JSON 只增字段、退出码形状不变;run_boot_test.py 正常路径
不受影响,其 wait-idle 撞无口时按采集错误 exit 2 归因(正是工单
要的区分)。

验证:selftest 45/45 PASS;COM3 台架三场景全过(崩溃恢复自动重开
+ GAP 标记、显式 close 后 restart 不重开、wait-idle 快速失败),
transcript 见工单汇报。
2026-11-04 11:10:04 +08:00

1873 lines
81 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 无头自检(行拆分/解码回退/截头算法,无需硬件)
daemon 自动重开(v1.2,issue #75):崩溃/机器重启后自动拉起的新 daemon 读旧 meta
现场自动重开口;读写出错按退避自动重连(1.5s 起步、倍增、封顶 30s);serial.log
落 GAP-START / GAP-END 缺口标记;显式 close/stop 清除该意图(不再重开)。
wait-idle 在口未开且重开未成时快速失败(ok:false),不再伪装成 no_data。
退出码: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.2.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 陷阱);R2 自动重连的起步间隔
REOPEN_BACKOFF_MAX_S = 30.0 # R2 自动重开退避封顶(1.5s 起步倍增:1.5/3/6/12/24/30…)
GAP_NOTE_INTERVAL_S = 60.0 # R3 退避期 serial.log 的 GAP-RETRY 节奏:首次必记 + 之后 ≤1 条/分
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
# ------------------------- 自动重开判定 / 退避 / 缺口标记行(R1–R3,纯函数,selftest 直测)
def plan_startup_reopen(meta, default_baud=115200):
"""R1 启动自动重开的意图判定:旧 meta.port 现场非空 = 上一任「开着口死的」。
显式 `close`/`stop` 已把该字段清成 null → None(不重开);崩溃、被 taskkill、
机器重启等现场仍在 → 返回按 meta 的重开参数。"""
if not isinstance(meta, dict):
return None
p = meta.get("port")
if not isinstance(p, dict) or not p.get("name"):
return None
try:
baud = int(p.get("baud") or default_baud)
except (TypeError, ValueError):
baud = default_baud
return {"port": str(p["name"]), "baud": baud,
"dtr": bool(p.get("dtr")), "rts": bool(p.get("rts"))}
def restart_reopen_plan(st):
"""R1 restart 专用:POST /stop **之前**从 /status 抓开口现场(stop 自身语义不变、
照清意图;CLI 在新 daemon 就绪后按这份现场显式 /open 回来)。旧 daemon 没口 /
已 close → None;重开缺口期(desired 未 ready)name 仍在 → 同样恢复。"""
if not isinstance(st, dict):
return None
p = st.get("port") or {}
if not p.get("name"):
return None
out = {"port": p["name"]}
try:
if p.get("baud"):
out["baud"] = int(p["baud"])
except (TypeError, ValueError):
pass
for k in ("dtr", "rts"):
if p.get(k) is not None:
out[k] = bool(p[k])
return out
def next_backoff(cur):
"""R2 自动重开退避序列:REOPEN_SETTLE_S 起步、倍增、REOPEN_BACKOFF_MAX_S 封顶。"""
return min(cur * 2.0, REOPEN_BACKOFF_MAX_S)
def sys_daemon_started(version, pid, prev_pid, pending):
"""R3① daemon 启动边界行:新 pid、上一任 pid、自动重开结论(pending 现场 / none)。"""
mode = ("pending %s@%s" % (pending["port"], pending["baud"]) if pending else "none")
return "daemon started v%s pid=%s prev_pid=%s auto-reopen %s" % (
version, pid, prev_pid if prev_pid is not None else "none", mode)
def sys_gap_start_lost(msg):
"""R3② 缺口起(运行中丢口):保留 serial error 原文,再落缺口声明。"""
return ("serial error: %s (GAP-START: port released; bytes in this window are "
"unrecoverable; auto-reopen retrying, backoff %.1fs doubling to %.0fs cap)"
% (msg, REOPEN_SETTLE_S, REOPEN_BACKOFF_MAX_S))
def sys_gap_start_recovery(prev_pid, port_name):
"""R3②' 缺口起(崩溃/重启恢复):真实缺口起点在「上一任死掉」与此刻之间、不可
确知 → 行内声明窗口是下限,别把恢复耗时当成全部丢失时长。"""
return ("GAP-START after daemon restart: previous daemon (pid=%s) died holding %s; "
"gap began no later than now (exact start unknown); bytes in this window "
"are unrecoverable; auto-reopen pending"
% (prev_pid if prev_pid is not None else "none", port_name))
def sys_gap_end(name, baud, gen, gap_s, attempts):
"""R3③ 缺口止(重开成功):含窗口秒数、尝试次数、gen 与不可找回声明。"""
return ("GAP-END reopened %s@%s (gen=%s) after %.1fs, %d attempt(s); bytes lost "
"inside the gap window are unrecoverable" % (name, baud, gen, gap_s, attempts))
def sys_gap_retry(attempt, err, next_s):
"""R3 退避期节奏行:首次必记、之后每分钟 ≤1 条;逐次失败原文只进 daemon.log。"""
return ("GAP-RETRY attempt %d failed: %s (next retry in %.1fs; while the gap is "
"open serial.log notes at most 1/min, full details in daemon.log)"
% (attempt, err, next_s))
def dlog(msg):
"""daemon 自身取证(自动重开逐次尝试原文等)→ stderr = state/daemon.log,
不刷 serial.log(R3 降噪口径)。"""
print("daemon %s: %s" % (VERSION, msg), file=sys.stderr, flush=True)
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
# ---- 自动重开/重连簿记(R1/R2/R4,issue #75)----
self.desired = False # 意图:该不该开着口(open 成功=置;显式 close/stop=清)
self._reopen_params = None # 待重开现场 {"port","baud","dtr","rts"}
self._reopen_backoff = REOPEN_SETTLE_S
self._reopen_next_at = 0.0 # 下次自动重开尝试时刻(0 = 未排期)
self._reopen_attempts = 0 # 本缺口期尝试次数(成功即清零)
self._reopen_last_error = None
self._prev_pid = None # 上一任 daemon pid(启动时读旧 meta 记入)
self._gap_start_ts = None # GAP-START 时刻(GAP-END 窗口秒数按它计)
self._gap_attempts = 0
self._last_gap_note_ts = 0.0 # GAP-RETRY 上一条 serial.log 时刻
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 _open_identity(self):
"""当前「应然开口现场」:实开的口 > 待重开参数 > None。meta 与 status 都由它
表达意图——重开缺口期现场必须留在 meta.port 里,否则下一次崩溃/重启后无人
知道「这台机器本来该开着 COMx」。"""
if self.port_name:
return {"name": self.port_name, "baud": self.port_baud,
"dtr": self.dtr, "rts": self.rts}
p = self._reopen_params
if self.desired and p and p.get("port"):
return {"name": p["port"], "baud": p.get("baud"),
"dtr": bool(p.get("dtr")), "rts": bool(p.get("rts"))}
return None
def _save_meta(self):
ident = self._open_identity()
save_meta({
"pid": os.getpid(),
"version": VERSION,
"gen": self.gen,
"port": ({"name": ident["name"], "baud": ident["baud"],
"dtr": ident["dtr"], "rts": ident["rts"]} if ident 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)
elif self.desired and time.time() >= self._reopen_next_at:
# R1/R2:「该开着口」却没开着(启动恢复 / 丢口重连)→ 在本线程按退避
# 自动重开(串口对象唯一持有者不变量不破;旧实现此处纯空转永不重连)
self._auto_reopen_attempt()
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):
gap = False
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()
if self.desired and self.port_name:
# R2:丢口不再躺平等手工 'open'——快照现场,串口线程按退避自动重开
# (USB 转串口掉线/重枚举是台架常态,旧实现 CH340 几秒后回来也不重连)
self._reopen_params = {"port": self.port_name, "baud": self.port_baud,
"dtr": self.dtr, "rts": self.rts}
self._reopen_backoff = REOPEN_SETTLE_S
self._reopen_next_at = self.last_released + REOPEN_SETTLE_S
self._reopen_attempts = 0
self._reopen_last_error = None
self._gap_start_ts = self.last_released
self._gap_attempts = 0
self._last_gap_note_ts = 0.0
gap = True
self.emit(self.sys_line(sys_gap_start_lost(msg) if gap
else "serial error: %s (port released)" % msg))
def _reset_reopen_state_locked(self):
"""重开簿记清零(意图 desired 本身由调用方负责置/清)。"""
self._reopen_params = None
self._reopen_backoff = REOPEN_SETTLE_S
self._reopen_next_at = 0.0
self._reopen_attempts = 0
self._reopen_last_error = None
self._gap_start_ts = None
self._gap_attempts = 0
self._last_gap_note_ts = 0.0
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.desired = False # 显式 close/stop = 清除重开意图(R1:此后不再自动重开)
self._reset_reopen_state_locked()
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
ser, err = self._open_port(name, baud, dtr, rts)
if ser is None:
cmd.finish({"ok": False, "error": err})
return
with self.lock:
gap = self._gap_start_ts is not None
gap_s = time.time() - self._gap_start_ts if gap else 0.0
gap_att = self._gap_attempts
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 # 新会话:丢掉上一段会话遗留的残行
# R1:显式 open 成功 = 写入重开意图(此后再崩溃,新 daemon 认这份现场)
self.desired = True
self._reset_reopen_state_locked()
self._reopen_params = {"port": name, "baud": baud, "dtr": dtr, "rts": rts}
self.emit(self.sys_line("port opened %s %d (gen=%d)" % (name, baud, self.gen)))
if gap:
# 手工 open 抢在退避重试前把口拉回来了 → 缺口窗口同样要收口(R3③)
self.emit(self.sys_line(sys_gap_end(name, baud, self.gen, gap_s, gap_att)))
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:
had_intent = self.desired
was_open = self.ser is not None
if was_open:
self._close_port_locked()
else:
# 「重开缺口中已释放」或「从未开过」:显式 close 同样清除重开意图
# (否则缺口期 close 后一旦崩溃/机器重启,新 daemon 还会自作主张重开)
self.desired = False
self._reset_reopen_state_locked()
if was_open:
self.emit(self.sys_line("port closed"))
elif had_intent:
self.emit(self.sys_line("port not open; auto-reopen intent cleared"))
self._save_meta()
cmd.finish({"ok": True} if was_open else {"ok": True, "note": "port not open"})
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:
if self.desired:
ar = self.auto_reopen_status()
cmd.finish({"ok": False,
"error": "port not open; auto-reopen pending (attempts=%s last_error=%s)"
% (ar["attempts"], ar["last_error"])})
else:
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 []
# -- 自动重开/重连(R1/R2;一律在串口线程执行,主/HTTP 线程不碰 Serial 对象) --
def _open_port(self, name, baud, dtr, rts):
"""开串口原语(仅串口线程调用)→ (ser|None, err|None)。
先构造后 open:dtr/rts 状态写入 DCB 初始值(避免开端口瞬间的复位脉冲)。"""
try:
ser = serial.Serial()
ser.port = name
ser.baudrate = baud
ser.timeout = 0
ser.dtr = dtr
ser.rts = rts
ser.open()
return ser, None
except serial.SerialException as e:
return None, self._classify_open_error(name, str(e))
@staticmethod
def _classify_open_error(name, msg):
"""pyserial 异常原文 → 可操作错误文本(不存在 → 被占 → 其它),手工 open 与自动重开共用。"""
names = sorted(Daemon._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)
return err
def _auto_reopen_attempt(self):
"""自动重开退避循环的一次尝试(串口线程):成功 = 写现场 + GAP-END 收口;
失败 = 倍增退避(R2)。逐次失败原文进 daemon.log;serial.log 只按
「首次 + 之后每分钟 ≤1 条」记 GAP-RETRY(R3 口径)。"""
if self.ser is not None:
return
p = self._reopen_params or {}
name = p.get("port")
if not name:
self.desired = False
self._reopen_next_at = 0.0
return
baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200)
ser, err = self._open_port(name, baud, bool(p.get("dtr")), bool(p.get("rts")))
now = time.time()
if ser is not None:
with self.lock:
gap = self._gap_start_ts is not None
gap_s = now - self._gap_start_ts if gap else 0.0
gap_att = max(self._gap_attempts, self._reopen_attempts, 1)
self.ser = ser
self.port_name = name
self.port_baud = baud
self.dtr = bool(p.get("dtr"))
self.rts = bool(p.get("rts"))
self.port_since = now_str()
self.state = "active"
self.error = None
self.last_data_at = now
self.splitter_gen += 1 # 新会话:残行归零
self._reset_reopen_state_locked()
self._reopen_params = {"port": name, "baud": baud,
"dtr": self.dtr, "rts": self.rts}
self.emit(self.sys_line(sys_gap_end(name, baud, self.gen, gap_s, gap_att)))
self._save_meta()
dlog("auto-reopen OK %s@%d: %d attempt(s), window %.1fs"
% (name, baud, gap_att, gap_s))
return
with self.lock:
self._reopen_attempts += 1
self._gap_attempts += 1
self._reopen_last_error = err
self._reopen_backoff = next_backoff(self._reopen_backoff)
self._reopen_next_at = now + self._reopen_backoff
att = self._reopen_attempts
note_due = att == 1 or (now - self._last_gap_note_ts) >= GAP_NOTE_INTERVAL_S
if note_due:
self._last_gap_note_ts = now
next_s = self._reopen_backoff
dlog("auto-reopen attempt %d failed (next in %.1fs): %s" % (att, next_s, err))
if note_due:
self.emit(self.sys_line(sys_gap_retry(att, err, next_s)))
# -- 查询 --
def auto_reopen_status(self):
"""R4:自动重开/重连的对外现场(status() 持锁调用,本函数自身不加锁)。"""
ready = self.ser is not None and self.state != "error"
pending = bool(self.desired) and not ready
nxt = None
if pending and self._reopen_next_at > 0:
nxt = int(max(0.0, (self._reopen_next_at - time.time()) * 1000))
return {"enabled": bool(self.desired), "pending": pending,
"attempts": int(self._reopen_attempts),
"last_error": self._reopen_last_error,
"next_retry_in_ms": nxt}
def status(self):
with self.lock:
ident = self._open_identity()
port = {"ready": self.ser is not None and self.state != "error",
# R4:desired = 意图(该不该开着),ready = 实际;缺口期 desired=true / ready=false
"desired": self.desired,
"name": ident["name"] if ident else None,
"baud": ident["baud"] if ident else None,
"dtr": ident["dtr"] if ident else self.dtr,
"rts": ident["rts"] if ident else 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
ar = self.auto_reopen_status()
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, "auto_reopen": ar, "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 毫无意义)。
v1.2(issue #75 根因 4):口未开且自动重开未成时**快速失败** ok:false(error
带 auto-reopen 现场),不再计满返回 no_data——no_data 从此只表示「口开着且
本次零字节」。
"""
# R4 无口门:先判「有没有口可采」,再谈静默判据
if not (self.ser is not None and self.state != "error"):
pending = bool(getattr(self, "desired", False))
now = time.time()
na = getattr(self, "_reopen_next_at", 0.0)
nxt = int((na - now) * 1000) if (pending and na > now) else None
return {"ok": False,
"error": "port not open; auto-reopen %s (attempts=%s last_error=%s "
"next_retry_in_ms=%s) -- no_data requires an open port"
% ("pending" if pending else "disabled",
getattr(self, "_reopen_attempts", 0),
getattr(self, "_reopen_last_error", None), nxt)}
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)
# R1(issue #75 根因 1):先读旧 meta **再**覆写。旧实现 run() 第一件事就是
# _save_meta(),把上一任「开着口死的」现场覆成 port:null——重启瞬间抹掉现场,
# 且启动流程无任何重开动作。
prev = load_meta()
self._prev_pid = prev.get("pid")
params = plan_startup_reopen(prev, self.cfg.get("baudRate") or 115200)
if params:
self.desired = True
self._reopen_params = params
self._reopen_backoff = REOPEN_SETTLE_S
# 开口动作落在串口线程(唯一持有者不变量):born+1.5s 后首次尝试,
# 沿用 CH340「释放后立即重开」的 REOPEN_SETTLE_S 退避口径
self._reopen_next_at = self.born + REOPEN_SETTLE_S
self._gap_start_ts = time.time() # 窗口下限 = 此刻;真实起点见 GAP-START 文字
self._save_meta()
self.emit(self.sys_line(sys_daemon_started(VERSION, os.getpid(),
self._prev_pid, params)))
if params:
self.emit(self.sys_line(sys_gap_start_recovery(self._prev_pid, params["port"])))
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")}
reopen_after = None
if alive:
# R1(restart 特判,issue #75):stop 语义不变——关端口 + 清重开意图;但
# 「重启」不该把采集现场弄丢 → stop **之前**先从 /status 抓开口现场,
# 新 daemon 就绪后再显式 /open 回来(命令仍走串口线程执行)。
try:
reopen_after = restart_reopen_plan(http_request("GET", "/status", timeout=2))
except (urllib.error.URLError, OSError, ValueError):
pass
# 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)
result = {"ok": True, "already_running": False,
"pid": st.get("daemon", {}).get("pid")}
except (urllib.error.URLError, OSError):
result = {"ok": True, "started": True}
if reopen_after:
# 把 restart 前记住的开口现场开回来。失败不把 restart 翻成 ok:false
# (旧进程确实被干净换掉了),现场进 result["reopen"] 由调用方判读。
try:
ro = http_request("POST", "/open", reopen_after, timeout=10)
except (urllib.error.URLError, OSError) as e:
ro = {"ok": False, "error": str(e)}
result["reopen"] = {"requested": reopen_after, "result": ro}
return result
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
# v1.2 起 wait-idle 先过「无口门」(R4):假对象要伪装成「口正常开着」
self.ser = object()
self.state = "active"
self.desired = True
self._reopen_attempts = 0
self._reopen_last_error = None
self._reopen_next_at = 0.0
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)
# T10 R1 启动重开意图判定:崩溃现场→重开;显式 close/stop、首启→不重开
m10 = {"pid": 11, "port": {"name": "COM3", "baud": 921600, "dtr": True, "rts": False},
"since": "2026-11-04T00:00:00.000+08:00"}
check("T10a 崩溃现场(meta.port 非空)→ 重开参数",
plan_startup_reopen(m10, 115200)
== {"port": "COM3", "baud": 921600, "dtr": True, "rts": False},
repr(plan_startup_reopen(m10, 115200)))
check("T10b 显式 close/stop 后(port:null)不重开",
plan_startup_reopen({"port": None}, 115200) is None)
check("T10c 首次启动(无 meta)不重开",
plan_startup_reopen({}, 115200) is None and plan_startup_reopen(None, 115200) is None)
check("T10d baud 缺失/坏值 → 按配置回退",
plan_startup_reopen({"port": {"name": "COM4"}}, 460800)
== {"port": "COM4", "baud": 460800, "dtr": False, "rts": False}
and plan_startup_reopen({"port": {"name": "COM4", "baud": "x"}}, 460800)["baud"] == 460800)
check("T10e port 记录残缺(无 name / 非 dict)不重开",
plan_startup_reopen({"port": {"baud": 1}}, 115200) is None
and plan_startup_reopen({"port": "COM3"}, 115200) is None)
# T11 R2 退避计划:1.5s 起步、倍增、封顶 30s
seq11 = []
b11 = REOPEN_SETTLE_S
for _ in range(7):
seq11.append(b11)
b11 = next_backoff(b11)
check("T11a 退避倍增封顶(1.5→3→6→12→24→30→30)",
seq11 == [1.5, 3.0, 6.0, 12.0, 24.0, 30.0, 30.0], repr(seq11))
check("T11b 封顶边界不越界",
next_backoff(24.0) == 30.0 and next_backoff(16.0) == 30.0 and next_backoff(30.0) == 30.0,
"16→%s" % next_backoff(16.0))
# T12 R3 缺口标记行格式(口径详述 = 设计说明 §6「自动重开与日志缺口标记」)
t12a = sys_daemon_started("1.2.0-skill", 100, 99, {"port": "COM3", "baud": 115200})
check("T12a 启动边界行:新旧 pid + 自动重开中",
"pid=100" in t12a and "prev_pid=99" in t12a and "auto-reopen pending COM3@115200" in t12a,
t12a)
t12b = sys_daemon_started("v", 1, None, None)
check("T12b 边界行:无需重开 + prev_pid 未知",
"prev_pid=none" in t12b and "auto-reopen none" in t12b, t12b)
t12c = sys_gap_start_lost("read: boom")
check("T12c 缺口起(丢口):错误原文 + GAP-START + 不可找回",
t12c.startswith("serial error: read: boom") and "GAP-START" in t12c
and "unrecoverable" in t12c, t12c)
t12d = sys_gap_start_recovery(99, "COM3")
check("T12d 缺口起(崩溃恢复):prev pid + 窗口下限声明",
"GAP-START" in t12d and "pid=99" in t12d and "COM3" in t12d
and "exact start unknown" in t12d and "unrecoverable" in t12d, t12d)
t12e = sys_gap_end("COM3", 115200, 7, 12.34, 5)
check("T12e 缺口止:窗口秒数 + 尝试次数 + gen + 不可找回",
"GAP-END" in t12e and "COM3@115200" in t12e and "gen=7" in t12e
and "12.3s" in t12e and "5 attempt(s)" in t12e and "unrecoverable" in t12e, t12e)
t12f = sys_gap_retry(2, "port busy: COM3 (held by another process)", 3.0)
check("T12f GAP-RETRY:尝试数 + 下次间隔 + 1/min 口径 + daemon.log 指针",
"attempt 2" in t12f and "3.0s" in t12f and "1/min" in t12f and "daemon.log" in t12f,
t12f)
t12g = line_at(1000.0, "SYS", t12e)
check("T12g 缺口行仍走 [SYS] 前缀(行格式不变量)",
t12g.startswith("[SYS] ") and "GAP-END" in t12g, t12g[:48])
# T13 R4 wait-idle 无口门:快速失败,不再计满返回 no_data
class _NoPort:
ser = None
state = "stopped"
t13_0 = time.time()
r13 = Daemon.wait_idle(_NoPort(), 0.2, 1.0, 0.3)
fast13 = (time.time() - t13_0) < 0.2
check("T13a 无口快速失败(ok:false、非 no_data、秒回)",
r13["ok"] is False and r13.get("reason") is None and fast13
and "port not open" in r13["error"] and "auto-reopen disabled" in r13["error"],
repr(r13))
class _Pending(_NoPort):
desired = True
_reopen_attempts = 3
_reopen_last_error = "port busy: COM3 (held by another process)"
_reopen_next_at = time.time() + 2.0
r13b = Daemon.wait_idle(_Pending(), 0.2, 1.0, 0.3)
check("T13b 失败文本带 auto-reopen 现场",
"pending" in r13b["error"] and "attempts=3" in r13b["error"]
and "port busy" in r13b["error"] and "next_retry_in_ms=" in r13b["error"],
repr(r13b))
# T14 R4 status.auto_reopen 现场(正常在开 / 缺口进行 / 显式关闭后)
class _AROk:
ser = object()
state = "active"
desired = True
_reopen_attempts = 0
_reopen_last_error = None
_reopen_next_at = 0.0
s14a = Daemon.auto_reopen_status(_AROk())
check("T14a 正常在开:enabled true / pending false",
s14a["enabled"] and not s14a["pending"] and s14a["attempts"] == 0
and s14a["next_retry_in_ms"] is None, repr(s14a))
class _ARGap(_AROk):
ser = None
state = "error"
_reopen_attempts = 2
_reopen_last_error = "port not found: COM3 (available: (none))"
_reopen_next_at = time.time() + 1.5
s14b = Daemon.auto_reopen_status(_ARGap())
check("T14b 缺口中:pending + 倒计时 + last_error",
s14b["enabled"] and s14b["pending"] and s14b["attempts"] == 2
and 0 < s14b["next_retry_in_ms"] <= 1600 and "port not found" in s14b["last_error"],
repr(s14b))
class _AROff:
ser = None
state = "stopped"
desired = False
_reopen_attempts = 0
_reopen_last_error = None
_reopen_next_at = 0.0
s14c = Daemon.auto_reopen_status(_AROff())
check("T14c 显式 close 后:enabled false(台架场景 2 断言点)",
s14c["enabled"] is False and s14c["pending"] is False, repr(s14c))
# T15 R1 restart 现场捕获(cmd_start force 路径的纯判定部分)
check("T15a restart 捕获:开着的口 → 记全现场",
restart_reopen_plan({"port": {"name": "COM3", "baud": 115200,
"dtr": False, "rts": True}})
== {"port": "COM3", "baud": 115200, "dtr": False, "rts": True})
check("T15b restart 捕获:重开缺口期(name 在/baud 缺)→ 仍恢复",
restart_reopen_plan({"port": {"name": "COM3", "baud": None, "desired": True}})
== {"port": "COM3"})
check("T15c restart 捕获:已 close / 旧 daemon 不可达 → 不重开",
restart_reopen_plan({"port": {"name": None}}) is None
and restart_reopen_plan({}) is None and restart_reopen_plan(None) 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())