整理 App 与 test 目录:按职责分子目录并同步全库引用
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# test/host/ — 主机端测试脚本
|
||||
|
||||
> PC 侧的测试驱动与判读脚本(Python,跑在开发机上,经串口/网络与目标板交互)。
|
||||
|
||||
## 文件一览
|
||||
|
||||
### 流程与判读
|
||||
|
||||
| 脚本 | 作用 |
|
||||
|---|---|
|
||||
| `run_boot_test.py` | **`@test` 一键闭环**:开串口+清日志 → 烧录 → wait-idle → `verify_log` 校验(退出码 0=PASS) |
|
||||
| `verify_log.py` | **日志标记校验**:Required / Forbidden 标记 → PASS / FAIL |
|
||||
| `e02_probe.py` | E-02 专项探针(错误码传播审计) |
|
||||
|
||||
### 网络 / FTP
|
||||
|
||||
| 脚本 | 作用 |
|
||||
|---|---|
|
||||
| `ch395f_socket_test.py` | Socket 通信测试(回显服务器 / 文件收发) |
|
||||
| `ftp_test.py` | FTP 基本功能测试 |
|
||||
| `ftp_up_dl_test.py` | FTP 上传下载测试 |
|
||||
| `ftp_speed_test.py` | FTP 传输速度测试 |
|
||||
| `ftp_capture.py` | FTP 流量抓取 |
|
||||
| `ftp_regression.py` | FTP 回归套件 |
|
||||
|
||||
### 时间 / 时延
|
||||
|
||||
| 脚本 | 作用 |
|
||||
|---|---|
|
||||
| `latency_peer.py` | 链路时延测试的 PC 对端(peer) |
|
||||
| `time_sync_server.py` | 时间同步推送服务(对时源) |
|
||||
|
||||
## 运行方式
|
||||
|
||||
```bash
|
||||
# @test 一键闭环(推荐)
|
||||
& '.venv\Scripts\python.exe' test/host/run_boot_test.py
|
||||
```
|
||||
|
||||
> 串口端口配置在 `.opencode/serial_config.json`(单一配置源,当前 COM6/115200)。
|
||||
|
||||
## ⚠️ 注意
|
||||
|
||||
- **PYTHONPATH/路径敏感**:脚本按仓库根相对路径读写,**从仓库根执行**
|
||||
- 本目录**被 pre-commit 门禁排除**
|
||||
- 台架探针族(TCP 复连 / FTP 中止等)在 `MDK-ARM/*.py`,与本目录分工不同
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `docs/测试说明/自动化测试流程.md` — `@test` 细节与失败模式
|
||||
- `docs/测试说明/测试开发标准流程.md` — 测试分层与门禁
|
||||
- `docs/测试说明/CH395F_Test_Guide.md`、`FTP_Test_Guide.md`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""E-02 头阻塞专项台架探针(timeSync 侧,A/B 对照用)。
|
||||
|
||||
场景(错误码传播审计.md E-02 的 timeSync 触发形态):
|
||||
1. 一个"挂起"的对时客户端连上 :8888 后不发任何数据(空闲/故障客户端,
|
||||
常见工况)——旧固件在此触发 30s 阻塞 net_recv 独占 netTask,
|
||||
全体消费者的队列消息停摆最长 30s;
|
||||
2. 2s 后从 PC 发起 FTP(:21) 连接,测 banner 到达时延 = 队列响应性:
|
||||
修复前 ≈ 25~30s(队头冻结),修复后 ≈ 数百 ms 内;
|
||||
3. 34s 时 30s 总预算应已耗尽:设备拆除该连接(修复版落
|
||||
"time_sync: no TIME packet in 30s, drop conn" 日志,旧版静默)、
|
||||
监听重建,:8888 应能再接受新连接;
|
||||
4. 新连接发完整 8 字节 TIME 包 → 设备应落 "wall clock updated"。
|
||||
|
||||
用法: python e02_probe.py <run-tag>
|
||||
退出码: 0 = 队列响应性 PASS;1 = FAIL;2 = 探针自身异常
|
||||
"""
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
DEV = "192.168.1.100"
|
||||
tag = sys.argv[1] if len(sys.argv) > 1 else "run"
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(msg):
|
||||
print("[%s] %9.3f %s" % (tag, time.time() - t0, msg), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
# 1. 挂起对时客户端:连接后不发任何数据
|
||||
ts = socket.create_connection((DEV, 8888), timeout=5)
|
||||
log("time-sync conn open (hung client: sends nothing)")
|
||||
|
||||
# 2. 2s 后测 FTP banner 时延(队列响应性)
|
||||
time.sleep(2.0)
|
||||
t1 = time.time()
|
||||
banner = None
|
||||
try:
|
||||
ftp = socket.create_connection((DEV, 21), timeout=35)
|
||||
banner = ftp.recv(256)
|
||||
dt = time.time() - t1
|
||||
log("FTP banner after %8.1f ms: %r" % (dt * 1000, banner[:40]))
|
||||
ftp.close()
|
||||
except Exception as e:
|
||||
dt = time.time() - t1
|
||||
log("FTP connect FAIL after %8.1f ms: %r" % (dt * 1000, e))
|
||||
dt = None
|
||||
queue_ok = (banner is not None) and (dt is not None) and (dt < 3.0)
|
||||
log("queue responsiveness: %s (banner %s ms)"
|
||||
% ("PASS" if queue_ok else "FAIL",
|
||||
("%.0f" % (dt * 1000)) if dt is not None else "n/a"))
|
||||
|
||||
# 3. 等 30s 总预算耗尽(设备应拆除挂起连接、重建监听)
|
||||
remain = 34.0 - (time.time() - t0)
|
||||
if remain > 0:
|
||||
time.sleep(remain)
|
||||
|
||||
# 4. 监听应已重建:新连接可接入,且正常对时路径工作
|
||||
t2 = time.time()
|
||||
listener_ok = False
|
||||
try:
|
||||
ts2 = socket.create_connection((DEV, 8888), timeout=5)
|
||||
dt2 = time.time() - t2
|
||||
epoch = int(time.time())
|
||||
log("new time-sync conn in %6.0f ms, sent full TIME packet (epoch=%d)"
|
||||
% (dt2 * 1000, epoch))
|
||||
ts2.sendall(b"TIME" + epoch.to_bytes(4, "big"))
|
||||
time.sleep(1.5) # 留时间给设备处理并打日志
|
||||
ts2.close()
|
||||
listener_ok = True
|
||||
except Exception as e:
|
||||
log("second time-sync conn FAIL: %r" % (e,))
|
||||
try:
|
||||
ts.close()
|
||||
except OSError:
|
||||
pass # 设备已先拆连,本地 close 报 ECONNRESET 属预期
|
||||
log("OVERALL: queue=%s listener=%s"
|
||||
% ("PASS" if queue_ok else "FAIL", "PASS" if listener_ok else "FAIL"))
|
||||
return 0 if (queue_ok and listener_ok) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
print("[%s] PROBE ERROR: %r" % (tag, e), flush=True)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FTP 协议抓取工具 — 模拟 FTP Server,打印客户端每一条命令和交互细节
|
||||
用法:python ftp_capture.py [--port 21]
|
||||
需要管理员权限(Linux 无需,Windows 需要管理员权限绑定 21 端口)
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import struct
|
||||
import re
|
||||
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 21
|
||||
DATA_DIR = "ftp_capture_data"
|
||||
|
||||
def log(fmt, *args):
|
||||
ts = time.strftime("%H:%M:%S", time.localtime())
|
||||
msg = fmt % args if args else fmt
|
||||
print(f"[{ts}] {msg}")
|
||||
|
||||
def recv_line(sock):
|
||||
data = b""
|
||||
while True:
|
||||
ch = sock.recv(1)
|
||||
if not ch:
|
||||
return None
|
||||
data += ch
|
||||
if data.endswith(b"\r\n"):
|
||||
return data
|
||||
|
||||
def send_line(sock, line):
|
||||
sock.sendall((line + "\r\n").encode())
|
||||
log(">>> %s", line)
|
||||
|
||||
class FtpHandler(threading.Thread):
|
||||
def __init__(self, ctrl_sock, addr):
|
||||
super().__init__(daemon=True)
|
||||
self.ctrl = ctrl_sock
|
||||
self.addr = addr
|
||||
self.cwd = "/"
|
||||
self.data_listener = None
|
||||
self.data_port = 0
|
||||
self.pasv_socket = None
|
||||
|
||||
def run(self):
|
||||
log("=== New connection from %s:%d ===", *self.addr)
|
||||
send_line(self.ctrl, "220 Service ready for new user.")
|
||||
|
||||
while True:
|
||||
raw = recv_line(self.ctrl)
|
||||
if raw is None:
|
||||
log("=== Connection closed ===")
|
||||
break
|
||||
|
||||
line = raw.decode(errors="replace").rstrip()
|
||||
log("<<< %s", line)
|
||||
|
||||
parts = line.split()
|
||||
cmd = parts[0].upper()
|
||||
arg = parts[1] if len(parts) > 1 else None
|
||||
try:
|
||||
self.handle(cmd, arg)
|
||||
except Exception as e:
|
||||
log("!!! ERROR: %s", e)
|
||||
send_line(self.ctrl, "550 Internal error.")
|
||||
|
||||
self.cleanup()
|
||||
|
||||
def handle(self, cmd, arg):
|
||||
handler = getattr(self, f"cmd_{cmd}", None)
|
||||
if handler:
|
||||
handler(arg)
|
||||
else:
|
||||
send_line(self.ctrl, f"502 Command not implemented.")
|
||||
|
||||
def cmd_USER(self, arg):
|
||||
send_line(self.ctrl, "230 User logged in, proceed.")
|
||||
|
||||
def cmd_PASS(self, arg):
|
||||
send_line(self.ctrl, "230 User logged in, proceed.")
|
||||
|
||||
def cmd_FEAT(self, arg):
|
||||
send_line(self.ctrl, "211-System status, or system help reply.")
|
||||
send_line(self.ctrl, " EPSV")
|
||||
send_line(self.ctrl, " PASV")
|
||||
send_line(self.ctrl, " SIZE")
|
||||
send_line(self.ctrl, " NLST")
|
||||
send_line(self.ctrl, "211 System status, or system help reply.")
|
||||
|
||||
def cmd_PWD(self, arg):
|
||||
send_line(self.ctrl, f'257 "{self.cwd}"')
|
||||
|
||||
def cmd_CWD(self, arg):
|
||||
if arg == "." or arg == "/":
|
||||
self.cwd = "/"
|
||||
send_line(self.ctrl, "250 Requested file action okay, completed.")
|
||||
else:
|
||||
send_line(self.ctrl, "550 Requested action not taken.")
|
||||
|
||||
def cmd_TYPE(self, arg):
|
||||
send_line(self.ctrl, "200 Command okay.")
|
||||
|
||||
def cmd_SYST(self, arg):
|
||||
send_line(self.ctrl, "215 UNIX Type: L8")
|
||||
|
||||
def cmd_NOOP(self, arg):
|
||||
send_line(self.ctrl, "200 Command okay.")
|
||||
|
||||
def cmd_QUIT(self, arg):
|
||||
send_line(self.ctrl, "221 Service closing control connection.")
|
||||
self.ctrl.close()
|
||||
|
||||
def cmd_SIZE(self, arg):
|
||||
send_line(self.ctrl, "550 File unavailable.")
|
||||
|
||||
def cmd_PASV(self, arg):
|
||||
"""创建 PASV 数据监听"""
|
||||
self.cleanup_data()
|
||||
pasv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
pasv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
pasv.bind(("0.0.0.0", 0))
|
||||
pasv.listen(1)
|
||||
self.pasv_socket = pasv
|
||||
|
||||
port = pasv.getsockname()[1]
|
||||
p1, p2 = port >> 8, port & 0xFF
|
||||
# 使用 127.0.0.1 本地环回避免防火墙问题
|
||||
send_line(self.ctrl,
|
||||
f"227 Entering Passive Mode (127,0,0,1,{p1},{p2}).")
|
||||
log(" PASV listener on port %d (127.0.0.1:port)", port)
|
||||
|
||||
def cmd_EPSV(self, arg):
|
||||
self.cleanup_data()
|
||||
pasv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
pasv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
pasv.bind(("0.0.0.0", 0))
|
||||
pasv.listen(1)
|
||||
self.pasv_socket = pasv
|
||||
port = pasv.getsockname()[1]
|
||||
send_line(self.ctrl, f"229 Entering Extended Passive Mode (|||{port}|)")
|
||||
log(" EPSV listener on port %d", port)
|
||||
|
||||
def cmd_LIST(self, arg):
|
||||
send_line(self.ctrl, "150 File status okay; about to open data connection.")
|
||||
conn = self.accept_data()
|
||||
if conn:
|
||||
log(" LIST: sending directory listing (empty)")
|
||||
conn.sendall(b"-rw-rw-rw- 1 owner group 123 Jan 01 1970 test.txt\r\n")
|
||||
conn.close()
|
||||
log(" LIST: data closed")
|
||||
send_line(self.ctrl, "226 Closing data connection.")
|
||||
|
||||
def cmd_NLST(self, arg):
|
||||
self.cmd_LIST(arg)
|
||||
|
||||
def cmd_STOR(self, arg):
|
||||
"""接收文件上传"""
|
||||
size = 0
|
||||
send_line(self.ctrl, "150 File status okay; about to open data connection.")
|
||||
conn = self.accept_data()
|
||||
if conn:
|
||||
log(" STOR: receiving file '%s'...", arg)
|
||||
data = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
size += len(chunk)
|
||||
except socket.timeout:
|
||||
break
|
||||
conn.close()
|
||||
log(" STOR: received %d bytes, data closed", size)
|
||||
else:
|
||||
log(" STOR: data connection failed!")
|
||||
send_line(self.ctrl, f"226 Closing data connection.")
|
||||
|
||||
def cmd_RETR(self, arg):
|
||||
send_line(self.ctrl, "550 File unavailable.")
|
||||
|
||||
def cmd_DELE(self, arg):
|
||||
send_line(self.ctrl, "250 Requested file action okay, completed.")
|
||||
|
||||
def accept_data(self):
|
||||
"""接受数据连接(5秒超时)"""
|
||||
if self.pasv_socket is None:
|
||||
log(" ERROR: no PASV listener!")
|
||||
return None
|
||||
try:
|
||||
self.pasv_socket.settimeout(5)
|
||||
conn, addr = self.pasv_socket.accept()
|
||||
log(" Data connection from %s:%d", *addr)
|
||||
return conn
|
||||
except socket.timeout:
|
||||
log(" Data accept TIMEOUT (5s)")
|
||||
return None
|
||||
except Exception as e:
|
||||
log(" Data accept ERROR: %s", e)
|
||||
return None
|
||||
|
||||
def cleanup_data(self):
|
||||
if self.pasv_socket:
|
||||
try:
|
||||
self.pasv_socket.close()
|
||||
except:
|
||||
pass
|
||||
self.pasv_socket = None
|
||||
|
||||
def cleanup(self):
|
||||
self.cleanup_data()
|
||||
try:
|
||||
self.ctrl.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="FTP Protocol Capture")
|
||||
parser.add_argument("--port", type=int, default=PORT,
|
||||
help=f"FTP port (default {PORT}, may need admin)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Windows 下低端口需要管理员权限
|
||||
if sys.platform == "win32" and args.port < 1024:
|
||||
import ctypes
|
||||
if not ctypes.windll.shell32.IsUserAnAdmin():
|
||||
log("WARNING: Port %d may need admin rights on Windows", args.port)
|
||||
log(" Run as Administrator or use --port 2121")
|
||||
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((HOST, args.port))
|
||||
server.listen(5)
|
||||
log("FTP capture server on port %d", args.port)
|
||||
log("Connect with MobaXterm/FileZilla and try operations")
|
||||
log("=" * 50)
|
||||
|
||||
try:
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
FtpHandler(conn, addr).start()
|
||||
except KeyboardInterrupt:
|
||||
log("Stopped")
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FTP 数据连接回归测试(D20 验证 + 日后回归)
|
||||
|
||||
背景(2026-09-22 台架实测 D20):同一 FTP 会话内**第二个数据连接必失败**
|
||||
(425 Can't open data connection)。根因是单连接 Socket 复用时的"陈旧 DISCONNECT"
|
||||
误判:上一条数据连接的 FIN/ACK 在 Socket 已复用为新连接之后才上报,新连接被
|
||||
net 层当作"刚建立就断开"释放。修复见 net_socket.c handle_disconnect_event 的
|
||||
陈旧事件甄别(回读芯片 TCP 状态,仍 ESTABLISHED 则丢弃该中断)。
|
||||
|
||||
本脚本在一个会话里连续做多次数据连接(LIST/NLST/STOR/RETR),并做大文件往返
|
||||
内容校验;任一环节失败即退出码 1。
|
||||
|
||||
用法:
|
||||
& '.venv\\Scripts\\python.exe' test/host/ftp_regression.py --host 192.168.1.100
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
import time
|
||||
from ftplib import FTP
|
||||
|
||||
SMALL = b"D20-small-" + b"A" * 1017 # 1024B
|
||||
BIG = bytes(((i * 7 + 3) & 0xFF) for i in range(32768)) # 32KB 模式数据
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="FTP data-connection regression (D20)")
|
||||
ap.add_argument("--host", default="192.168.1.100", help="device IP")
|
||||
ap.add_argument("--port", type=int, default=21, help="FTP control port")
|
||||
ap.add_argument("--timeout", type=int, default=20, help="socket timeout (s)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
results = []
|
||||
|
||||
def step(name, fn):
|
||||
"""执行一步并记录结果;不抛异常,便于一次跑完全部步骤。"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
extra = fn()
|
||||
dt = time.time() - t0
|
||||
results.append((name, True, dt))
|
||||
print(" %-16s OK %6.2fs %s" % (name, dt, extra if extra is not None else ""))
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001
|
||||
dt = time.time() - t0
|
||||
results.append((name, False, dt))
|
||||
print(" %-16s FAIL %6.2fs %s %s" % (name, dt, type(e).__name__, e))
|
||||
return False
|
||||
|
||||
f = FTP()
|
||||
print("[ftp] connecting %s:%d ..." % (args.host, args.port))
|
||||
f.connect(args.host, args.port, timeout=args.timeout)
|
||||
f.login("anonymous", "x@x")
|
||||
print("[ftp] login ok: %s" % f.getwelcome())
|
||||
|
||||
# 同一会话内连续 8 次数据连接(含两次大文件)—— D20 的失败点是第 2 次
|
||||
step("LIST #1", lambda: "%d lines" % len(f.nlst()))
|
||||
step("STOR #2 1KB", lambda: f.storbinary("STOR d20s.tmp", io.BytesIO(SMALL)) or "226")
|
||||
|
||||
got = bytearray()
|
||||
step("RETR #3 1KB", lambda: (f.retrbinary("RETR d20s.tmp", got.extend), "226")[1])
|
||||
if bytes(got) != SMALL:
|
||||
print(" !! RETR #3 content mismatch (%d bytes)" % len(got))
|
||||
results.append(("RETR#3-content", False, 0.0))
|
||||
else:
|
||||
results.append(("RETR#3-content", True, 0.0))
|
||||
|
||||
step("LIST #4", lambda: "%d lines" % len(f.nlst()))
|
||||
step("STOR #5 32KB", lambda: f.storbinary("STOR d20b.tmp", io.BytesIO(BIG)) or "226")
|
||||
|
||||
big = bytearray()
|
||||
step("RETR #6 32KB", lambda: (f.retrbinary("RETR d20b.tmp", big.extend), "226")[1])
|
||||
if bytes(big) != BIG:
|
||||
print(" !! RETR #6 content mismatch (%d/%d bytes)" % (len(big), len(BIG)))
|
||||
results.append(("RETR#6-content", False, 0.0))
|
||||
else:
|
||||
results.append(("RETR#6-content", True, 0.0))
|
||||
|
||||
step("LIST #7", lambda: "%d lines" % len(f.nlst()))
|
||||
step("cleanup", lambda: (f.delete("d20s.tmp"), f.delete("d20b.tmp"), "deleted")[2])
|
||||
step("QUIT", lambda: f.quit() or "221")
|
||||
|
||||
ok = sum(1 for _, good, _ in results if good)
|
||||
print("[ftp] %d/%d steps passed" % (ok, len(results)))
|
||||
return 0 if ok == len(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FTP 速度测试 — 上传/下载大文件,测量传输速度
|
||||
用法:python ftp_speed_test.py --host 192.168.1.100 [--size 512] [--port 21]
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import re
|
||||
import argparse
|
||||
import os
|
||||
|
||||
HOST = "192.168.1.100"
|
||||
PORT = 21
|
||||
FILE_SIZE_KB = 512 # 默认 512KB 测试文件
|
||||
|
||||
def log(msg, *args):
|
||||
print(f" {msg % args if args else msg}")
|
||||
|
||||
def recv_line(sock, timeout=60.0):
|
||||
data = b""
|
||||
sock.settimeout(timeout)
|
||||
while True:
|
||||
try:
|
||||
ch = sock.recv(1)
|
||||
except socket.timeout:
|
||||
return None
|
||||
if not ch:
|
||||
return data if data else None
|
||||
data += ch
|
||||
if data.endswith(b"\r\n"):
|
||||
return data
|
||||
|
||||
def send_cmd(sock, cmd):
|
||||
sock.sendall((cmd + "\r\n").encode())
|
||||
time.sleep(0.1)
|
||||
return recv_line(sock)
|
||||
|
||||
def connect_with_retry(host, port, timeout=15.0, attempts=6, delay=3.0):
|
||||
"""连接控制通道(带重试)。
|
||||
|
||||
CH395F 单客户端复用:上一会话未 QUIT 就断开时,服务端可能仍在处理旧连接,
|
||||
新连接会被 RST(WinError 10061);实测约 30s 后服务端丢弃旧会话并重新 listen。
|
||||
注意不要用裸 TCP 探测端口(探测会占掉唯一会话)。
|
||||
"""
|
||||
last_err = None
|
||||
for i in range(1, attempts + 1):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return s, None
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
if i < attempts:
|
||||
print(f" connect failed ({e}); retry {i}/{attempts - 1} in {delay:.0f}s "
|
||||
f"(single-client: stale session blocks new connections)")
|
||||
time.sleep(delay)
|
||||
return None, last_err
|
||||
|
||||
|
||||
def parse_pasv(resp):
|
||||
m = re.search(r"(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)", resp.decode())
|
||||
if m:
|
||||
a, b, c, d, e, f = [int(x) for x in m.groups()]
|
||||
return f"{a}.{b}.{c}.{d}", e * 256 + f
|
||||
return None, None
|
||||
|
||||
def test_speed(host, port, size_kb):
|
||||
total_bytes = size_kb * 1024
|
||||
test_data = os.urandom(total_bytes)
|
||||
test_name = "SPEEDTST.DAT" # 8.3 format
|
||||
|
||||
print(f"\n=== FTP Speed Test: {host}:{port}, {size_kb}KB file ===")
|
||||
print(f" Test data: {total_bytes:,} bytes generated\n")
|
||||
|
||||
checks = []
|
||||
|
||||
def check(label, ok, detail=""):
|
||||
checks.append((label, ok, detail))
|
||||
mark = "PASS" if ok else "FAIL"
|
||||
print(f" [{mark}] {label}{(' - ' + detail) if detail else ''}")
|
||||
|
||||
def finish(sock):
|
||||
passed = all(ok for _, ok, _ in checks)
|
||||
print("\n" + "=" * 40)
|
||||
print("FTP Speed Test Summary:")
|
||||
for label, ok, _ in checks:
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {label}")
|
||||
print("=" * 40)
|
||||
if passed:
|
||||
print(">>> TEST PASSED <<<")
|
||||
else:
|
||||
print(">>> TEST FAILED <<<")
|
||||
print("=== Test Complete ===")
|
||||
if sock:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
return passed
|
||||
|
||||
# ---- Connect + Login ----
|
||||
print("[Connect] Connecting...")
|
||||
sock, err = connect_with_retry(host, port, timeout=60.0)
|
||||
if sock is None:
|
||||
print(f" CONNECT FAILED: {err}")
|
||||
check("Connect", False, str(err))
|
||||
return finish(None)
|
||||
recv_line(sock) # 220
|
||||
resp = send_cmd(sock, "USER anonymous")
|
||||
if not (resp and resp.startswith(b"230")):
|
||||
check("Login USER 230", False,
|
||||
resp.decode(errors='replace').rstrip() if resp else "no resp")
|
||||
return finish(sock)
|
||||
resp = send_cmd(sock, "PASS test@test.com")
|
||||
if not (resp and resp.startswith(b"230")):
|
||||
check("Login PASS 230", False,
|
||||
resp.decode(errors='replace').rstrip() if resp else "no resp")
|
||||
return finish(sock)
|
||||
check("Login (USER/PASS 230)", True)
|
||||
send_cmd(sock, "TYPE I")
|
||||
|
||||
# ---- Upload ----
|
||||
print("[UPLOAD] Connecting...")
|
||||
resp = send_cmd(sock, "PASV")
|
||||
if not resp:
|
||||
check("PASV", False, "no response")
|
||||
return finish(sock)
|
||||
pasv_ip, pasv_port = parse_pasv(resp)
|
||||
if pasv_port is None:
|
||||
check("PASV parse", False, resp.decode())
|
||||
return finish(sock)
|
||||
check("PASV parse", True, f"{pasv_ip}:{pasv_port}")
|
||||
|
||||
data = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
data.settimeout(60.0)
|
||||
data.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 关 Nagle,防 FIN 跑在数据前面
|
||||
try:
|
||||
data.connect((pasv_ip, pasv_port))
|
||||
except Exception as e:
|
||||
print(f" Data connect failed: {e}")
|
||||
check("PASV data connect", False, str(e))
|
||||
return finish(sock)
|
||||
check("PASV data connect", True)
|
||||
|
||||
t0 = time.time()
|
||||
send_cmd(sock, f"STOR {test_name}")
|
||||
data.sendall(test_data)
|
||||
data.shutdown(socket.SHUT_WR)
|
||||
data.close()
|
||||
|
||||
resp = recv_line(sock, timeout=120.0)
|
||||
t1 = time.time()
|
||||
upload_time = t1 - t0
|
||||
upload_speed = total_bytes / upload_time / 1024 if upload_time > 0 else 0
|
||||
stor_code = resp[:3].decode() if resp else "???"
|
||||
|
||||
print(f" Sent: {total_bytes:,} / {total_bytes:,} bytes")
|
||||
print(f" Time: {upload_time:.1f}s")
|
||||
print(f" Speed: {upload_speed:.1f} KB/s")
|
||||
print(f" Response: {resp.decode().rstrip() if resp else 'NONE'}")
|
||||
|
||||
upload_ok = (stor_code == "226")
|
||||
check("STOR 226", upload_ok, f"{stor_code} up={upload_speed:.1f}KB/s")
|
||||
|
||||
if not upload_ok:
|
||||
send_cmd(sock, "QUIT")
|
||||
return finish(sock)
|
||||
|
||||
# ---- Download ----
|
||||
time.sleep(3.0)
|
||||
print("[DOWNLOAD] Connecting...")
|
||||
resp = send_cmd(sock, "PASV")
|
||||
if not resp:
|
||||
check("RETR PASV", False, "no response")
|
||||
return finish(sock)
|
||||
pasv_ip, pasv_port = parse_pasv(resp)
|
||||
if pasv_port is None:
|
||||
check("RETR PASV parse", False, resp.decode())
|
||||
return finish(sock)
|
||||
data = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
data.settimeout(60.0)
|
||||
try:
|
||||
data.connect((pasv_ip, pasv_port))
|
||||
except Exception as e:
|
||||
print(f" Data connect failed: {e}")
|
||||
check("RETR data connect", False, str(e))
|
||||
return finish(sock)
|
||||
check("RETR data connect", True)
|
||||
|
||||
t0 = time.time()
|
||||
send_cmd(sock, f"RETR {test_name}")
|
||||
dl_data = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = data.recv(8192)
|
||||
if not chunk:
|
||||
break
|
||||
dl_data += chunk
|
||||
except socket.timeout:
|
||||
break
|
||||
data.close()
|
||||
resp = recv_line(sock, timeout=120.0)
|
||||
t1 = time.time()
|
||||
download_time = t1 - t0
|
||||
download_speed = len(dl_data) / download_time / 1024 if download_time > 0 else 0
|
||||
|
||||
print(f" Received: {len(dl_data):,} / {total_bytes:,} bytes")
|
||||
print(f" Time: {download_time:.1f}s")
|
||||
print(f" Speed: {download_speed:.1f} KB/s")
|
||||
print(f" Response: {resp.decode().rstrip() if resp else 'NONE'}")
|
||||
|
||||
retr_ok = bool(resp) and resp.startswith(b"226")
|
||||
check("RETR 226", retr_ok,
|
||||
f"{resp[:3].decode() if resp else '???'} dl={download_speed:.1f}KB/s")
|
||||
|
||||
# Compare
|
||||
print(f"\n[VERIFY]")
|
||||
match = (len(dl_data) == total_bytes and dl_data == test_data)
|
||||
if match:
|
||||
print(f" *** MATCH! {total_bytes:,} bytes identical. ***")
|
||||
else:
|
||||
print(f" MISMATCH: sent={total_bytes}, got={len(dl_data)}")
|
||||
if len(dl_data) != total_bytes:
|
||||
print(f" Size mismatch!")
|
||||
else:
|
||||
for i in range(total_bytes):
|
||||
if dl_data[i] != test_data[i]:
|
||||
print(f" First diff at byte {i}")
|
||||
break
|
||||
check("Upload/Download MATCH", match,
|
||||
f"up={total_bytes:,} dl={len(dl_data):,} up={upload_speed:.1f}KB/s dl={download_speed:.1f}KB/s")
|
||||
|
||||
# Cleanup
|
||||
send_cmd(sock, f"DELE {test_name}")
|
||||
send_cmd(sock, "QUIT")
|
||||
|
||||
return finish(sock)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="FTP Speed Test")
|
||||
parser.add_argument("--host", default=HOST)
|
||||
parser.add_argument("--port", type=int, default=PORT)
|
||||
parser.add_argument("--size", type=int, default=FILE_SIZE_KB,
|
||||
help=f"File size in KB (default {FILE_SIZE_KB})")
|
||||
args = parser.parse_args()
|
||||
success = test_speed(args.host, args.port, args.size)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
FTP Server 测试脚本 — 连接 STM32F4 上的 lftpd 服务器
|
||||
用法:python ftp_test.py [--host 192.168.1.100] [--port 21]
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import argparse
|
||||
|
||||
HOST = "192.168.1.100"
|
||||
PORT = 21
|
||||
TIMEOUT = 10.0
|
||||
|
||||
def recv_line(sock):
|
||||
"""读取 FTP 响应行(以 \r\n 结尾)"""
|
||||
data = b""
|
||||
sock.settimeout(TIMEOUT)
|
||||
while True:
|
||||
try:
|
||||
ch = sock.recv(1)
|
||||
except socket.timeout:
|
||||
print(f"[TIMEOUT] after receiving: {data!r}")
|
||||
return None
|
||||
if not ch:
|
||||
return data if data else None
|
||||
data += ch
|
||||
if data.endswith(b"\r\n"):
|
||||
return data
|
||||
|
||||
def send_cmd(sock, cmd):
|
||||
"""发送 FTP 命令并读取响应"""
|
||||
print(f">>> {cmd}")
|
||||
sock.sendall((cmd + "\r\n").encode())
|
||||
time.sleep(0.1)
|
||||
response = recv_line(sock)
|
||||
if response:
|
||||
print(f"<<< {response.decode(errors='replace').rstrip()}")
|
||||
else:
|
||||
print(f"<<< [NO RESPONSE / CONNECTION CLOSED]")
|
||||
return response
|
||||
|
||||
def parse_pasv(response):
|
||||
"""解析 PASV 227 响应,返回 (ip, port)"""
|
||||
import re
|
||||
m = re.search(r"(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)", response.decode())
|
||||
if m:
|
||||
a, b, c, d, e, f = [int(x) for x in m.groups()]
|
||||
ip = f"{a}.{b}.{c}.{d}"
|
||||
port = e * 256 + f
|
||||
return ip, port
|
||||
return None, None
|
||||
|
||||
def test_ftp(host, port):
|
||||
print(f"=== FTP Test: {host}:{port} ===\n")
|
||||
checks = [] # (label, ok, detail)
|
||||
|
||||
def check(label, ok, detail=""):
|
||||
checks.append((label, ok, detail))
|
||||
mark = "PASS" if ok else "FAIL"
|
||||
print(f" [{mark}] {label}{(' - ' + detail) if detail else ''}")
|
||||
|
||||
# 1. Connect
|
||||
print("[1] Connecting to control channel...")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(TIMEOUT)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
print(f" Connected to {host}:{port}")
|
||||
check("Connect", True)
|
||||
except Exception as e:
|
||||
print(f" CONNECT FAILED: {e}")
|
||||
check("Connect", False, str(e))
|
||||
return False
|
||||
|
||||
# 2. Read welcome
|
||||
print("\n[2] Reading welcome banner...")
|
||||
resp = recv_line(sock)
|
||||
if resp:
|
||||
print(f" {resp.decode(errors='replace').rstrip()}")
|
||||
check("Welcome 220", resp.startswith(b"220"),
|
||||
resp.decode(errors='replace').rstrip())
|
||||
else:
|
||||
print(" NO WELCOME BANNER")
|
||||
check("Welcome 220", False, "no banner")
|
||||
sock.close()
|
||||
return False
|
||||
|
||||
# 3. Login
|
||||
print("\n[3] Login...")
|
||||
resp = send_cmd(sock, "USER anonymous")
|
||||
if not resp:
|
||||
sock.close()
|
||||
check("Login USER", False, "no response")
|
||||
return False
|
||||
|
||||
resp = send_cmd(sock, "PASS test@test.com")
|
||||
if not resp:
|
||||
sock.close()
|
||||
check("Login PASS", False, "no response")
|
||||
return False
|
||||
login_ok = b"230" in resp
|
||||
check("Login (USER/PASS 230)", login_ok,
|
||||
resp.decode(errors='replace').rstrip())
|
||||
|
||||
# 4. PWD
|
||||
print("\n[4] PWD...")
|
||||
resp = send_cmd(sock, "PWD")
|
||||
pwd_ok = bool(resp) and resp.startswith(b"257")
|
||||
check("PWD 257", pwd_ok,
|
||||
resp.decode(errors='replace').rstrip() if resp else "no response")
|
||||
|
||||
# 5. PASV mode
|
||||
print("\n[5] PASV...")
|
||||
resp = send_cmd(sock, "PASV")
|
||||
pasv_ip, pasv_port = (None, None)
|
||||
if resp:
|
||||
pasv_ip, pasv_port = parse_pasv(resp)
|
||||
if pasv_port is None and resp and b"227" in resp:
|
||||
# 227 响应但解析端口失败
|
||||
pasv_port = None
|
||||
if pasv_port is None:
|
||||
# Try EPSV
|
||||
print("\n[5b] Trying EPSV...")
|
||||
resp = send_cmd(sock, "EPSV")
|
||||
if resp and b"229" in resp:
|
||||
import re
|
||||
m = re.search(r"\(\|\|\|(\d+)\|\)", resp.decode())
|
||||
if m:
|
||||
pasv_port = int(m.group(1))
|
||||
pasv_ip = host
|
||||
print(f" EPSV: {pasv_ip}:{pasv_port}")
|
||||
pasv_ok = pasv_port is not None
|
||||
check("PASV/EPSV data port", pasv_ok,
|
||||
f"{pasv_ip}:{pasv_port}" if pasv_ok else "no port")
|
||||
|
||||
# 6. LIST (correct PASV flow: connect data first, then send LIST)
|
||||
if pasv_ok:
|
||||
print(f" Data channel: {pasv_ip}:{pasv_port}")
|
||||
print("\n[6] LIST...")
|
||||
data_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
data_sock.settimeout(5.0)
|
||||
try:
|
||||
data_sock.connect((pasv_ip, pasv_port))
|
||||
print(f" Data connected to {pasv_ip}:{pasv_port}")
|
||||
resp = send_cmd(sock, "LIST")
|
||||
list_open = bool(resp) and resp.startswith(b"150")
|
||||
data = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = data_sock.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except socket.timeout:
|
||||
break
|
||||
print(f" LIST result ({len(data)} bytes):")
|
||||
print(data.decode(errors='replace'))
|
||||
data_sock.close()
|
||||
resp = recv_line(sock)
|
||||
list_close = bool(resp) and resp.startswith(b"226")
|
||||
print(f" LIST response: "
|
||||
f"{resp.decode(errors='replace').rstrip() if resp else 'none'}")
|
||||
list_ok = list_open and list_close
|
||||
check("LIST 150->226", list_ok,
|
||||
f"open={list_open} close={list_close} bytes={len(data)}")
|
||||
except Exception as e:
|
||||
print(f" LIST FAILED: {e}")
|
||||
data_sock.close()
|
||||
check("LIST 150->226", False, str(e))
|
||||
else:
|
||||
print(" Skipping LIST (no data port)")
|
||||
|
||||
# 7. QUIT
|
||||
print("\n[7] QUIT...")
|
||||
resp = send_cmd(sock, "QUIT")
|
||||
quit_ok = bool(resp) and resp.startswith(b"221")
|
||||
check("QUIT 221", quit_ok,
|
||||
resp.decode(errors='replace').rstrip() if resp else "no response")
|
||||
sock.close()
|
||||
|
||||
# Summary
|
||||
passed = all(ok for _, ok, _ in checks)
|
||||
print("\n" + "=" * 40)
|
||||
print("FTP Test Summary:")
|
||||
for label, ok, _ in checks:
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {label}")
|
||||
print("=" * 40)
|
||||
if passed:
|
||||
print(">>> TEST PASSED <<<")
|
||||
else:
|
||||
print(">>> TEST FAILED <<<")
|
||||
print("=== Test Complete ===")
|
||||
return passed
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="FTP Server Test")
|
||||
parser.add_argument("--host", default=HOST, help=f"FTP server IP (default: {HOST})")
|
||||
parser.add_argument("--port", type=int, default=PORT, help=f"FTP server port (default: {PORT})")
|
||||
args = parser.parse_args()
|
||||
|
||||
success = test_ftp(args.host, args.port)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
FTP 上传+下载测试脚本
|
||||
用法:python ftp_up_dl_test.py --host 192.168.1.100
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import re
|
||||
import argparse
|
||||
import os
|
||||
|
||||
HOST = "192.168.1.100"
|
||||
PORT = 21
|
||||
TIMEOUT = 15.0
|
||||
|
||||
TEST_DATA = b"Hello from Python FTP test!\r\nThis is line 2.\r\nLine 3: " + b"ABCDEFGH" * 50 + b"\r\n"
|
||||
|
||||
def recv_line(sock, timeout=TIMEOUT):
|
||||
data = b""
|
||||
sock.settimeout(timeout)
|
||||
while True:
|
||||
try:
|
||||
ch = sock.recv(1)
|
||||
except socket.timeout:
|
||||
print(f" [TIMEOUT] partial: {data[:80]!r}")
|
||||
return None
|
||||
if not ch:
|
||||
return data if data else None
|
||||
data += ch
|
||||
if data.endswith(b"\r\n"):
|
||||
return data
|
||||
|
||||
def send_cmd(sock, cmd, expect_code=None):
|
||||
print(f" > {cmd}")
|
||||
sock.sendall((cmd + "\r\n").encode())
|
||||
time.sleep(0.15)
|
||||
response = recv_line(sock)
|
||||
if response:
|
||||
text = response.decode(errors='replace').rstrip()
|
||||
print(f" < {text}")
|
||||
if expect_code and not text.startswith(str(expect_code)):
|
||||
print(f" *** Expected {expect_code}, got: {text}")
|
||||
return None
|
||||
return response
|
||||
print(" < [NO RESPONSE]")
|
||||
return None
|
||||
|
||||
def connect_with_retry(host, port, timeout=TIMEOUT, attempts=6, delay=3.0):
|
||||
"""连接控制通道(带重试)。
|
||||
|
||||
CH395F 是单客户端复用:上一会话未 QUIT 就断开时,服务端仍在处理旧连接,
|
||||
新连接会被 RST(WinError 10061)。实测旧会话约 30s 后被服务端丢弃并重新
|
||||
listen,故这里等一会儿重试即可,不要用裸 TCP 探测端口(探测本身会占掉
|
||||
唯一会话)。
|
||||
"""
|
||||
last_err = None
|
||||
for i in range(1, attempts + 1):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return s, None
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
if i < attempts:
|
||||
print(f" connect failed ({e}); retry {i}/{attempts - 1} in {delay:.0f}s "
|
||||
f"(single-client: stale session blocks new connections)")
|
||||
time.sleep(delay)
|
||||
return None, last_err
|
||||
|
||||
|
||||
def parse_pasv(resp):
|
||||
m = re.search(r"(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)", resp.decode())
|
||||
if m:
|
||||
a, b, c, d, e, f = [int(x) for x in m.groups()]
|
||||
return f"{a}.{b}.{c}.{d}", e * 256 + f
|
||||
return None, None
|
||||
|
||||
def test_upload_download(host, port):
|
||||
print(f"=== FTP Upload/Download Test: {host}:{port} ===\n")
|
||||
checks = []
|
||||
|
||||
def check(label, ok, detail=""):
|
||||
checks.append((label, ok, detail))
|
||||
mark = "PASS" if ok else "FAIL"
|
||||
print(f" [{mark}] {label}{(' - ' + detail) if detail else ''}")
|
||||
|
||||
def finish(sock):
|
||||
passed = all(ok for _, ok, _ in checks)
|
||||
print("\n" + "=" * 40)
|
||||
print("FTP Upload/Download Summary:")
|
||||
for label, ok, _ in checks:
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {label}")
|
||||
print("=" * 40)
|
||||
if passed:
|
||||
print(">>> TEST PASSED <<<")
|
||||
else:
|
||||
print(">>> TEST FAILED <<<")
|
||||
print("=== Test Complete ===")
|
||||
if sock:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
return passed
|
||||
|
||||
# 1. Connect + Login
|
||||
print("[1] Connect + Login")
|
||||
sock, err = connect_with_retry(host, port)
|
||||
if sock is None:
|
||||
print(f" CONNECT FAILED: {err}")
|
||||
check("Connect", False, str(err))
|
||||
return finish(None)
|
||||
recv_line(sock) # 220
|
||||
|
||||
if not send_cmd(sock, "USER anonymous", 230):
|
||||
check("Login USER 230", False)
|
||||
return finish(sock)
|
||||
if not send_cmd(sock, "PASS test@test.com", 230):
|
||||
check("Login PASS 230", False)
|
||||
return finish(sock)
|
||||
check("Login (USER/PASS 230)", True)
|
||||
|
||||
# 2. TYPE I (binary mode)
|
||||
print("\n[2] Set binary mode")
|
||||
resp = send_cmd(sock, "TYPE I")
|
||||
type_ok = bool(resp) and resp.startswith(b"200")
|
||||
check("TYPE I 200", type_ok)
|
||||
|
||||
# 3. PASV + STOR (upload)
|
||||
print("\n[3] Upload: STOR up_test.dat")
|
||||
resp = send_cmd(sock, "PASV")
|
||||
if not resp:
|
||||
check("PASV", False, "no response")
|
||||
return finish(sock)
|
||||
pasv_ip, pasv_port = parse_pasv(resp)
|
||||
print(f" PASV: {pasv_ip}:{pasv_port}")
|
||||
|
||||
data_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
data_sock.settimeout(10.0)
|
||||
try:
|
||||
data_sock.connect((pasv_ip, pasv_port))
|
||||
print(f" Data connected.")
|
||||
except Exception as e:
|
||||
print(f" Data connect FAILED: {e}")
|
||||
check("PASV data connect", False, str(e))
|
||||
return finish(sock)
|
||||
check("PASV data connect", True, f"{pasv_ip}:{pasv_port}")
|
||||
|
||||
send_cmd(sock, "STOR up_test.dat")
|
||||
total = 0
|
||||
while total < len(TEST_DATA):
|
||||
try:
|
||||
n = data_sock.send(TEST_DATA[total:])
|
||||
if n <= 0:
|
||||
break
|
||||
total += n
|
||||
except Exception as e:
|
||||
print(f" Send error: {e}")
|
||||
break
|
||||
print(f" Uploaded {total}/{len(TEST_DATA)} bytes.")
|
||||
data_sock.shutdown(socket.SHUT_WR)
|
||||
data_sock.close()
|
||||
|
||||
resp = recv_line(sock)
|
||||
stor_ok = bool(resp) and resp.startswith(b"226") and total == len(TEST_DATA)
|
||||
check("STOR 226 + bytes", stor_ok,
|
||||
f"uploaded={total}/{len(TEST_DATA)} resp={resp.decode().rstrip() if resp else 'none'}")
|
||||
|
||||
# 4. PASV + LIST (verify)
|
||||
print("\n[4] Verify: LIST")
|
||||
resp = send_cmd(sock, "PASV")
|
||||
if not resp:
|
||||
check("LIST PASV", False, "no response")
|
||||
return finish(sock)
|
||||
pasv_ip, pasv_port = parse_pasv(resp)
|
||||
|
||||
data_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
data_sock.settimeout(10.0)
|
||||
data_sock.connect((pasv_ip, pasv_port))
|
||||
|
||||
send_cmd(sock, "LIST")
|
||||
data = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = data_sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except socket.timeout:
|
||||
break
|
||||
print(f" LIST: {data.decode(errors='replace').rstrip() or '(empty directory)'}")
|
||||
data_sock.close()
|
||||
|
||||
resp = recv_line(sock)
|
||||
list_ok = bool(data) and b"UP_TEST.DAT" in data.upper()
|
||||
check("LIST shows UP_TEST.DAT", list_ok,
|
||||
resp.decode().rstrip() if resp else "no resp")
|
||||
|
||||
# 5. PASV + RETR (download)
|
||||
time.sleep(1.0)
|
||||
print("\n[5] Download: RETR up_test.dat")
|
||||
resp = send_cmd(sock, "PASV")
|
||||
if not resp:
|
||||
check("RETR PASV", False, "no response")
|
||||
return finish(sock)
|
||||
pasv_ip, pasv_port = parse_pasv(resp)
|
||||
|
||||
data_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
data_sock.settimeout(10.0)
|
||||
try:
|
||||
data_sock.connect((pasv_ip, pasv_port))
|
||||
except Exception as e:
|
||||
print(f" Data connect FAILED: {e}")
|
||||
check("RETR data connect", False, str(e))
|
||||
return finish(sock)
|
||||
check("RETR data connect", True, f"{pasv_ip}:{pasv_port}")
|
||||
|
||||
send_cmd(sock, "RETR up_test.dat")
|
||||
dl_data = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = data_sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
dl_data += chunk
|
||||
except socket.timeout:
|
||||
break
|
||||
data_sock.close()
|
||||
resp = recv_line(sock)
|
||||
retr_ok = bool(resp) and resp.startswith(b"226")
|
||||
check("RETR 226", retr_ok, resp.decode().rstrip() if resp else "no resp")
|
||||
|
||||
# Compare
|
||||
print(f"\n[6] Compare")
|
||||
print(f" Uploaded: {len(TEST_DATA)} bytes")
|
||||
print(f" Downloaded: {len(dl_data)} bytes")
|
||||
match = (dl_data == TEST_DATA)
|
||||
if match:
|
||||
print(f" *** MATCH! Upload/Download success. ***")
|
||||
else:
|
||||
print(f" *** MISMATCH! ***")
|
||||
if dl_data:
|
||||
print(f" First 80 bytes uploaded: {TEST_DATA[:80]!r}")
|
||||
print(f" First 80 bytes downloaded: {dl_data[:80]!r}")
|
||||
check("Upload/Download MATCH", match, f"up={len(TEST_DATA)} dl={len(dl_data)}")
|
||||
|
||||
# 7. DELE (delete test file)
|
||||
print("\n[7] Cleanup: DELE up_test.dat")
|
||||
resp = send_cmd(sock, "DELE up_test.dat")
|
||||
dele_ok = bool(resp) and resp.startswith(b"250")
|
||||
check("DELE 250", dele_ok, resp.decode().rstrip() if resp else "no resp")
|
||||
|
||||
# 8. QUIT
|
||||
print("\n[8] QUIT")
|
||||
resp = send_cmd(sock, "QUIT")
|
||||
quit_ok = bool(resp) and resp.startswith(b"221")
|
||||
check("QUIT 221", quit_ok, resp.decode().rstrip() if resp else "no resp")
|
||||
|
||||
return finish(sock)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="FTP Upload/Download Test")
|
||||
parser.add_argument("--host", default=HOST)
|
||||
parser.add_argument("--port", type=int, default=PORT)
|
||||
args = parser.parse_args()
|
||||
success = test_upload_download(args.host, args.port)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,836 @@
|
||||
#!/usr/bin/env python3
|
||||
"""latency_peer.py - Link latency test peer for Phase 13 (M2).
|
||||
|
||||
Server-driven protocol: the MCU (testTask, port 8080) sends one operation
|
||||
byte; the peer executes the corresponding traffic pattern. See
|
||||
docs/测试说明/链路延迟测试说明.md §4.5 for the full protocol spec.
|
||||
|
||||
Ops (MCU -> peer):
|
||||
0x01 L5 recv flood : peer sends 50 x 100B (5ms apart)
|
||||
0x02 L5 send drain : peer drains 50 x 100B
|
||||
0x03 L6 : peer sends 10B request, expects 10B echo (timed)
|
||||
0x04 L7 : peer sends 9B LCDMEM request, expects 3209B frame
|
||||
0x05 L3 send drain : peer drains 32 x 4096B
|
||||
0xFF done : session complete
|
||||
|
||||
The peer records host-side (L4-side) timing: first byte / frame total /
|
||||
per-batch arrival curve / >=500ms spikes (TCP retransmission fingerprint).
|
||||
Both the serial log and this JSON carry HOST timestamps -> direct correlation.
|
||||
|
||||
Usage:
|
||||
python latency_peer.py --host 192.168.1.100 --port 8080 --json m2_net.json
|
||||
python latency_peer.py --mode net --ftp-stress ... (E-02 head-of-line repro)
|
||||
|
||||
Pure stdlib (socket/json/time/argparse/threading). ASCII output only.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
# --- Protocol constants (mirror test/board/net_latency_test.c Phase 13 defines) ---
|
||||
HELLO = b"\x5a\xa5\x58\x01"
|
||||
OP_L5_RECV = 0x01
|
||||
OP_L5_SEND = 0x02
|
||||
OP_L6 = 0x03
|
||||
OP_L7 = 0x04
|
||||
OP_L3 = 0x05
|
||||
OP_DONE = 0xFF
|
||||
|
||||
L5_CHUNKS = 20 # 2026-09-22 run1: 50 chunks saturated the chip RX buffer
|
||||
# (window close -> retransmit re-delivery -> RST); 20 @ 50ms
|
||||
# = 1KB/s quiet baseline (see 链路延迟测试说明.md §6.2)
|
||||
L5_LEN = 100
|
||||
L6_SIZE = 10
|
||||
L7_REQ_LEN = 9
|
||||
L7_FRAME_LEN = 3209
|
||||
L3_SENDS = 16 # mirror firmware LAT_P13_L3_SENDS (run3: 32->16, 64 chunk samples)
|
||||
L3_LEN = 4096
|
||||
|
||||
SPIKE_MS = 500.0 # retransmission granularity fingerprint (D1)
|
||||
|
||||
# --- Production RemoDispBus constants (mirror drivers/BSP/UI/ui_net_sink.h) ---
|
||||
REMO_TAG_CLIENT = 0xAA
|
||||
REMO_TAG_DEVICE = 0xBB
|
||||
REMO_CMD_KEEP = 0
|
||||
REMO_CMD_INIT = 1
|
||||
REMO_CMD_KEY = 2
|
||||
REMO_CMD_LCDMEM = 3
|
||||
REMO_MEMSIZE = 3200 # 160x160 1bpp
|
||||
REMO_LCDMEM_FRAME = 4 + 4 + REMO_MEMSIZE + 1 # 3209 (header + offset + bitmap + CRC)
|
||||
REMO_CYCLE_DEADLINE_S = 45.0 # per-cycle recv deadline (E-02 stress: up to 30s)
|
||||
|
||||
|
||||
def now_ns():
|
||||
return time.perf_counter_ns()
|
||||
|
||||
|
||||
def iso_now():
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def recv_exact(sock, n, deadline_ns):
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
left = deadline_ns - now_ns()
|
||||
if left <= 0:
|
||||
raise TimeoutError("recv_exact deadline (%d/%d bytes)" % (len(buf), n))
|
||||
try:
|
||||
sock.settimeout(max(left, 1) / 1e9 / 5.0)
|
||||
d = sock.recv(n - len(buf))
|
||||
except socket.timeout:
|
||||
continue
|
||||
if not d:
|
||||
raise ConnectionError("peer connection closed")
|
||||
buf.extend(d)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def l7_request(seq=0):
|
||||
"""Production-shaped 9B LCDMEM request (ui_net_sink.c remo_handle):
|
||||
[TAG 0xAA][CMD 0x03][LEN=4 BE][offset u32 BE = 0][CRC = XOR of the 4 offset bytes].
|
||||
LEN is the DATA-segment length (4), NOT the requested bitmap size; the offset
|
||||
field must be 0 (device only serves full-frame requests)."""
|
||||
data = bytes([0, 0, 0, 0])
|
||||
crc = 0
|
||||
for b in data:
|
||||
crc ^= b
|
||||
return bytes([0xAA, 0x03, 0x00, 0x04]) + data + bytes([crc])
|
||||
|
||||
|
||||
def l7_frame_ok(data):
|
||||
"""Validate the 3209B production LCDMEM response (ui_net_sink.c remo_send):
|
||||
[TAG 0xBB][CMD 0x03][LEN=3204 BE][offset u32 = 0][1bpp bitmap 3200B]
|
||||
[CRC = XOR of the 3204 data bytes] -> 4 + 3204 + 1 = 3209B."""
|
||||
if len(data) != L7_FRAME_LEN:
|
||||
return False
|
||||
if data[0] != 0xBB or data[1] != 0x03 or data[2] != 0x0C or data[3] != 0x84:
|
||||
return False
|
||||
payload = data[4:4 + 3204]
|
||||
crc = 0
|
||||
for b in payload:
|
||||
crc ^= b
|
||||
if data[4 + 3204] != crc:
|
||||
return False
|
||||
if payload[:4] != bytes(4):
|
||||
return False
|
||||
for i in range(3200):
|
||||
if payload[4 + i] != (i & 0xFF):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def pattern(n, seed=0):
|
||||
return bytes((seed + i) & 0xFF for i in range(n))
|
||||
|
||||
|
||||
# ================= RemoDispBus production client (M3, :7003) =================
|
||||
|
||||
def xor_bytes(data):
|
||||
crc = 0
|
||||
for b in data:
|
||||
crc ^= b
|
||||
return crc
|
||||
|
||||
|
||||
def remo_request(cmd, data=b""):
|
||||
"""Client->device frame: [0xAA][CMD][LEN_H][LEN_L][DATA][CRC = XOR(DATA)]."""
|
||||
body = bytes(data)
|
||||
return (bytes([REMO_TAG_CLIENT, cmd, (len(body) >> 8) & 0xFF, len(body) & 0xFF])
|
||||
+ body + bytes([xor_bytes(body)]))
|
||||
|
||||
|
||||
def remo_lcdmem_request():
|
||||
"""9B full-frame LCDMEM request (offset 0) - what the production client sends."""
|
||||
return remo_request(REMO_CMD_LCDMEM, bytes(4))
|
||||
|
||||
|
||||
def recv_upto(sock, want, deadline_ns):
|
||||
"""Bounded read: never consumes more than `want` bytes (no over-read of a
|
||||
following frame/op byte - the run10 lesson)."""
|
||||
left = deadline_ns - now_ns()
|
||||
if left <= 0:
|
||||
raise TimeoutError("frame deadline")
|
||||
try:
|
||||
sock.settimeout(max(left, 1) / 1e9 / 5.0)
|
||||
return sock.recv(want)
|
||||
except socket.timeout:
|
||||
return b""
|
||||
|
||||
|
||||
def read_remo_frame(sock, deadline_ns, ack_probe=False):
|
||||
"""Read exactly one device frame; returns tag/cmd/data/CRC + arrival curve.
|
||||
|
||||
The first read is bounded to 8B so the segment-arrival curve stays
|
||||
fine-grained; subsequent reads are bounded by the remaining frame length
|
||||
(device never pipelines: one response per request).
|
||||
|
||||
ack_probe=True: as soon as the frame is known to be incomplete, send one
|
||||
byte the device parser discards (0x00, not TAG 0xAA). The resulting TCP
|
||||
segment carries a piggybacked ACK for the data already received, which
|
||||
discriminates "sender holds the last partial segment waiting for an ACK
|
||||
(Nagle-style)" from "chip-internal fixed timer" - without any packet capture."""
|
||||
buf = bytearray()
|
||||
batches = []
|
||||
first_ns = None
|
||||
total = None
|
||||
while True:
|
||||
want = (8 - len(buf)) if total is None else (total - len(buf))
|
||||
if want <= 0:
|
||||
break
|
||||
d = recv_upto(sock, min(want, 4096), deadline_ns)
|
||||
if not d:
|
||||
if total is not None:
|
||||
raise ConnectionError("frame truncated (%d/%d bytes)" % (len(buf), total))
|
||||
continue # header still incomplete: wait until deadline
|
||||
t = now_ns()
|
||||
if first_ns is None:
|
||||
first_ns = t
|
||||
buf.extend(d)
|
||||
batches.append([t, len(d)])
|
||||
if total is None and len(buf) >= 4:
|
||||
total = 4 + (((buf[2] << 8) | buf[3])) + 1
|
||||
if ack_probe and total is not None and len(buf) < total:
|
||||
try:
|
||||
sock.sendall(b"\x00") # parser-safe: discarded while waiting for TAG 0xAA
|
||||
except OSError:
|
||||
pass
|
||||
data = bytes(buf[4:total - 1])
|
||||
crc = buf[total - 1]
|
||||
return {"tag": buf[0], "cmd": buf[1], "data": data, "total": total,
|
||||
"crc_ok": crc == xor_bytes(data), "first_ns": first_ns, "batches": batches}
|
||||
|
||||
|
||||
def enable_immediate_ack(sock):
|
||||
"""Windows: disable delayed ACK on this socket (SIO_TCP_SET_ACK_FREQUENCY=1).
|
||||
|
||||
Root-cause fix for the ~50ms tail: the CH395F (MSS=1024) holds the final
|
||||
sub-MSS segment of a 3209B frame per Nagle until the peer ACKs; Windows'
|
||||
delayed ACK defers that ACK ~50ms. Setting ACK frequency=1 makes every
|
||||
segment ACKed immediately -> tail 52ms -> ~4ms (measured). Returns True on
|
||||
success. Other platforms: no-op (Linux uses TCP_QUICKACK per recv)."""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
ws2 = ctypes.WinDLL("ws2_32", use_last_error=True)
|
||||
WSAIoctl = ws2.WSAIoctl
|
||||
WSAIoctl.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_uint32,
|
||||
ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32),
|
||||
ctypes.c_void_p, ctypes.c_void_p]
|
||||
WSAIoctl.restype = ctypes.c_int
|
||||
val = ctypes.c_uint32(1) # 1 = ACK every segment
|
||||
ret = ctypes.c_uint32(0)
|
||||
rc = WSAIoctl(ctypes.c_void_p(sock.fileno()), 0x98000017,
|
||||
ctypes.byref(val), 4, None, 0, ctypes.byref(ret), None, None)
|
||||
return rc == 0
|
||||
except (OSError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def remo_bitmap_nontrivial(payload):
|
||||
"""Same 'non-trivial frame' criterion as simulator/tests/check_remo_sink.py:
|
||||
the 1bpp bitmap must not be a single repeated byte (blank/broken frame)."""
|
||||
bmp = payload[4:4 + REMO_MEMSIZE]
|
||||
return len(set(bmp)) > 2
|
||||
|
||||
|
||||
class RemoPeer:
|
||||
"""Production-path peer (M3): drives L8/L9 against :7003 (device) or the
|
||||
simulator, measuring the full client-observed frame cycle."""
|
||||
|
||||
def __init__(self, host, port, wait_s, json_path, samples, cadence_ms,
|
||||
stress_from, nodelay, reconnect_rounds=1, ack_probe=False, ack_fast=False):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.wait_s = wait_s
|
||||
self.json_path = json_path
|
||||
self.samples = samples
|
||||
self.cadence_s = cadence_ms / 1000.0
|
||||
self.stress_from = stress_from # cycle index where FTP stress starts
|
||||
self.nodelay = nodelay
|
||||
self.reconnect_rounds = max(1, reconnect_rounds)
|
||||
self.ack_probe = ack_probe # in-band ACK-release experiment (tail diagnosis)
|
||||
self.ack_fast = ack_fast # Windows immediate-ACK socket option
|
||||
self.ack_fast_ok = None
|
||||
self.sock = None
|
||||
self.t0_wall = None
|
||||
self.t0_ns = 0
|
||||
self.connect_ms = None
|
||||
self.reconnect_ms = None
|
||||
self.reconnect_list = []
|
||||
self.init_rtt_ms = None
|
||||
self.geom = {}
|
||||
self.frames = []
|
||||
|
||||
# --- connection ---
|
||||
def connect_once(self):
|
||||
t = now_ns()
|
||||
s = socket.create_connection((self.host, self.port), timeout=5.0)
|
||||
if self.nodelay:
|
||||
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
if self.ack_fast:
|
||||
self.ack_fast_ok = enable_immediate_ack(s)
|
||||
print("[peer] immediate-ACK (SIO_TCP_SET_ACK_FREQUENCY=1): %s"
|
||||
% ("applied" if self.ack_fast_ok else "not available"))
|
||||
self.sock = s
|
||||
return (now_ns() - t) / 1e6
|
||||
|
||||
def connect_with_retry(self):
|
||||
t_end = time.time() + self.wait_s
|
||||
last = None
|
||||
while time.time() < t_end:
|
||||
try:
|
||||
return self.connect_once()
|
||||
except OSError as e:
|
||||
last = e
|
||||
time.sleep(1.0)
|
||||
raise ConnectionError("cannot connect to %s:%d within %ds (%s)" %
|
||||
(self.host, self.port, self.wait_s, last))
|
||||
|
||||
def init_roundtrip(self, deadline_s=5.0):
|
||||
"""INIT request -> geometry reply; returns round-trip ms."""
|
||||
t = now_ns()
|
||||
self.sock.sendall(remo_request(REMO_CMD_INIT))
|
||||
f = read_remo_frame(self.sock, t + int(deadline_s * 1e9))
|
||||
ms = (now_ns() - t) / 1e6
|
||||
if f["cmd"] == REMO_CMD_INIT and len(f["data"]) >= 6:
|
||||
# 8B (fixed): [W u16][H u16][MEMSIZE u32 BE]; 6B = 旧固件(u32 被截半 → 0)
|
||||
self.geom = {"w": (f["data"][0] << 8) | f["data"][1],
|
||||
"h": (f["data"][2] << 8) | f["data"][3],
|
||||
"framebytes": int.from_bytes(f["data"][4:8], "big")
|
||||
if len(f["data"]) >= 8
|
||||
else ((f["data"][4] << 8) | f["data"][5]),
|
||||
"payload_len": len(f["data"])}
|
||||
return ms
|
||||
|
||||
# --- FTP head-of-line stress (E-02) ---
|
||||
def ftp_stress_worker(self, start_evt, stop_evt, hold_s):
|
||||
"""Wait for the stress window to open, then hold an idle FTP login:
|
||||
lftpd read_line -> blocking net_recv(0) inside netTask (NET_RECV_TIMEOUT_MS)
|
||||
-> every queued message (incl. the LCDMEM response) waits behind it (E-02)."""
|
||||
if not start_evt.wait(120.0):
|
||||
sys.stdout.write("[peer] ftp stress: window never opened\n")
|
||||
return
|
||||
try:
|
||||
s = socket.create_connection((self.host, 21), timeout=10.0)
|
||||
s.settimeout(10.0)
|
||||
s.sendall(b"USER latency\r\n")
|
||||
reply = b""
|
||||
while b"\r\n" not in reply:
|
||||
d = s.recv(64)
|
||||
if not d:
|
||||
break
|
||||
reply += d
|
||||
sys.stdout.write("[peer] ftp stress armed (331: %r)\n" % reply[:40])
|
||||
stop_evt.wait(hold_s)
|
||||
try:
|
||||
s.sendall(b"QUIT\r\n")
|
||||
except OSError:
|
||||
pass
|
||||
s.close()
|
||||
sys.stdout.write("[peer] ftp stress released\n")
|
||||
except OSError as e:
|
||||
sys.stdout.write("[peer] ftp stress FAILED: %s\n" % e)
|
||||
|
||||
# --- session ---
|
||||
def one_session(self):
|
||||
self.t0_wall = iso_now()
|
||||
self.t0_ns = now_ns()
|
||||
|
||||
self.connect_ms = self.connect_with_retry()
|
||||
print("[peer] connected %s:%d in %.1f ms" % (self.host, self.port, self.connect_ms))
|
||||
self.init_rtt_ms = self.init_roundtrip()
|
||||
print("[peer] INIT rtt=%.1f ms geom=%s" % (self.init_rtt_ms, self.geom))
|
||||
|
||||
stress_start = threading.Event()
|
||||
stress_stop = threading.Event()
|
||||
stress_thread = None
|
||||
if self.stress_from is not None:
|
||||
# hold long enough to cover the remaining cycles (+15s margin)
|
||||
hold_s = self.cadence_s * max(1, self.samples - self.stress_from) + 15.0
|
||||
stress_thread = threading.Thread(
|
||||
target=self.ftp_stress_worker,
|
||||
args=(stress_start, stress_stop, hold_s),
|
||||
daemon=True)
|
||||
stress_thread.start()
|
||||
|
||||
for i in range(self.samples):
|
||||
if self.stress_from is not None and i == self.stress_from:
|
||||
print("[peer] === FTP stress ON (E-02 head-of-line) at cycle %d ===" % i)
|
||||
stress_start.set()
|
||||
time.sleep(0.5) # let the FTP half-login block netTask
|
||||
|
||||
stressed = (self.stress_from is not None and i >= self.stress_from)
|
||||
t = now_ns()
|
||||
self.sock.sendall(remo_lcdmem_request())
|
||||
f = read_remo_frame(self.sock, t + int(REMO_CYCLE_DEADLINE_S * 1e9),
|
||||
ack_probe=self.ack_probe)
|
||||
t_end = now_ns()
|
||||
|
||||
first_ms = (f["first_ns"] - t) / 1e6
|
||||
total_ms = (t_end - t) / 1e6
|
||||
tail_ms = (f["batches"][-1][0] - f["batches"][0][0]) / 1e6 if len(f["batches"]) > 1 else 0.0
|
||||
rec = {
|
||||
"cycle": i,
|
||||
"stress": stressed,
|
||||
"first_ms": round(first_ms, 3),
|
||||
"total_ms": round(total_ms, 3),
|
||||
"tail_lag_ms": round(tail_ms, 3),
|
||||
"batches": [[round((b[0] - t) / 1e6, 3), b[1]] for b in f["batches"]],
|
||||
"bytes": f["total"],
|
||||
"tag": f["tag"],
|
||||
"cmd": f["cmd"],
|
||||
"crc_ok": bool(f["crc_ok"]),
|
||||
"nontrivial": bool(f["cmd"] == REMO_CMD_LCDMEM
|
||||
and len(f["data"]) == 4 + REMO_MEMSIZE
|
||||
and remo_bitmap_nontrivial(f["data"])),
|
||||
}
|
||||
self.frames.append(rec)
|
||||
if (i % 10) == 0 or stressed:
|
||||
print("[peer] cycle %3d %-7s first=%7.1f total=%8.1f ms batches=%d %s"
|
||||
% (i, "STRESS" if stressed else "quiet", first_ms, total_ms,
|
||||
len(f["batches"]),
|
||||
"OK" if (rec["crc_ok"] and rec["nontrivial"]) else "BAD"))
|
||||
if i + 1 < self.samples:
|
||||
time.sleep(self.cadence_s)
|
||||
|
||||
if stress_thread is not None:
|
||||
stress_stop.set()
|
||||
|
||||
self.dump_json()
|
||||
|
||||
# --- L9: close + reconnect rounds (each close drives the device auto-relisten
|
||||
# probe; each reconnect measures connect + INIT round trip) ---
|
||||
for r in range(self.reconnect_rounds):
|
||||
self.sock.close()
|
||||
print("[peer] round %d/%d: closed; reconnect in 1.5s (device relisten window) ..."
|
||||
% (r + 1, self.reconnect_rounds))
|
||||
time.sleep(1.5)
|
||||
t = now_ns()
|
||||
self.connect_once()
|
||||
self.init_rtt_ms = self.init_roundtrip()
|
||||
ms = (now_ns() - t) / 1e6
|
||||
self.reconnect_list.append(round(ms, 3))
|
||||
self.reconnect_ms = ms
|
||||
print("[peer] round %d: reconnect + INIT = %.1f ms" % (r + 1, ms))
|
||||
|
||||
rep = self.dump_json()
|
||||
self.print_summary(rep)
|
||||
self.sock.close()
|
||||
|
||||
# --- reporting ---
|
||||
def _split(self, key, stressed):
|
||||
return [f[key] for f in self.frames if f["stress"] is stressed]
|
||||
|
||||
def _spikes(self, vals):
|
||||
return sum(1 for x in vals if x >= SPIKE_MS)
|
||||
|
||||
def dump_json(self, error=None):
|
||||
all_total = [f["total_ms"] for f in self.frames]
|
||||
all_first = [f["first_ms"] for f in self.frames]
|
||||
all_tail = [f["tail_lag_ms"] for f in self.frames]
|
||||
quiet_total = self._split("total_ms", False)
|
||||
stress_total = self._split("total_ms", True)
|
||||
rep = {
|
||||
"mode": "remo",
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"ts_start": self.t0_wall,
|
||||
"ts_end": iso_now(),
|
||||
"duration_s": round((now_ns() - self.t0_ns) / 1e9, 2),
|
||||
"samples": self.samples,
|
||||
"cadence_ms": round(self.cadence_s * 1000.0, 1),
|
||||
"tcp_nodelay": bool(self.nodelay),
|
||||
"immediate_ack": (None if not self.ack_fast else bool(self.ack_fast_ok)),
|
||||
"in_band_ack_probe": bool(self.ack_probe),
|
||||
"stress_from": self.stress_from,
|
||||
"connect_ms": None if self.connect_ms is None else round(self.connect_ms, 3),
|
||||
"reconnect_ms": None if self.reconnect_ms is None else round(self.reconnect_ms, 3),
|
||||
"reconnect_list_ms": self.reconnect_list,
|
||||
"reconnect_stat": stat_block(self.reconnect_list),
|
||||
"init": {"rtt_ms": None if self.init_rtt_ms is None else round(self.init_rtt_ms, 3),
|
||||
"geom": self.geom},
|
||||
"cycle_all": stat_block(all_total, self._spikes(all_total)),
|
||||
"cycle_quiet": stat_block(quiet_total, self._spikes(quiet_total)),
|
||||
"cycle_stress": stat_block(stress_total, self._spikes(stress_total)),
|
||||
"first_byte_all": stat_block(all_first, self._spikes(all_first)),
|
||||
"tail_lag_all": stat_block(all_tail, self._spikes(all_tail)),
|
||||
"crc": {"ok": sum(1 for f in self.frames if f["crc_ok"]),
|
||||
"fail": sum(1 for f in self.frames if not f["crc_ok"])},
|
||||
"bitmap": {"nontrivial_ok": sum(1 for f in self.frames if f["nontrivial"]),
|
||||
"nontrivial_fail": sum(1 for f in self.frames if not f["nontrivial"])},
|
||||
"frames": self.frames,
|
||||
}
|
||||
if error:
|
||||
rep["error"] = error
|
||||
if self.json_path:
|
||||
with open(self.json_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(rep, fh, ensure_ascii=False, indent=1)
|
||||
print("[peer] JSON report -> %s%s" % (self.json_path,
|
||||
" (PARTIAL - error)" if error else ""))
|
||||
return rep
|
||||
|
||||
def print_summary(self, rep):
|
||||
print("=== latency_peer summary (M3 / Phase 14, production RemoDispBus) ===")
|
||||
print("host=%s:%d samples=%d cadence=%.0fms nodelay=%d duration=%.1fs"
|
||||
% (self.host, self.port, rep["samples"], rep["cadence_ms"],
|
||||
int(rep["tcp_nodelay"]), rep["duration_s"]))
|
||||
print("connect=%s ms reconnect+INIT=%s ms geom=%s"
|
||||
% (rep["connect_ms"], rep["reconnect_ms"], rep["init"]["geom"]))
|
||||
for key in ("cycle_all", "cycle_quiet", "cycle_stress",
|
||||
"first_byte_all", "tail_lag_all"):
|
||||
b = rep[key]
|
||||
if b.get("n"):
|
||||
print("%-15s n=%-4d min=%9.1f med=%9.1f p95=%9.1f max=%9.1f ms spikes=%d"
|
||||
% (key, b["n"], b["min_ms"], b["med_ms"], b["p95_ms"], b["max_ms"],
|
||||
b.get("spikes500", 0)))
|
||||
else:
|
||||
print("%-15s (no samples)" % key)
|
||||
print("crc ok/fail = %d/%d bitmap nontrivial ok/fail = %d/%d"
|
||||
% (rep["crc"]["ok"], rep["crc"]["fail"],
|
||||
rep["bitmap"]["nontrivial_ok"], rep["bitmap"]["nontrivial_fail"]))
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.one_session()
|
||||
except (ConnectionError, TimeoutError, RuntimeError, OSError) as e:
|
||||
self.dump_json(error="%s: %s" % (type(e).__name__, e))
|
||||
raise
|
||||
|
||||
|
||||
def percentile(sorted_vals, pct):
|
||||
if not sorted_vals:
|
||||
return None
|
||||
idx = (len(sorted_vals) * pct) // 100
|
||||
if idx >= len(sorted_vals):
|
||||
idx = len(sorted_vals) - 1
|
||||
return sorted_vals[idx]
|
||||
|
||||
|
||||
def stat_block(ms_vals, spikes=0):
|
||||
if not ms_vals:
|
||||
return {"n": 0}
|
||||
s = sorted(ms_vals)
|
||||
return {
|
||||
"n": len(s),
|
||||
"min_ms": round(s[0], 3),
|
||||
"med_ms": round(s[len(s) // 2], 3),
|
||||
"p95_ms": round(percentile(s, 95), 3),
|
||||
"max_ms": round(s[-1], 3),
|
||||
"spikes500": spikes,
|
||||
}
|
||||
|
||||
|
||||
def fresh_reports():
|
||||
"""Per-session measurement containers (reset on each retry attempt)."""
|
||||
return {
|
||||
"l6": {"samples": [], "echo_ok": 0, "echo_fail": 0},
|
||||
"l7": {"frames": [], "first_ms": [], "total_ms": [], "spikes500": 0,
|
||||
"crc_ok": 0, "crc_fail": 0, "batches_per_frame": []},
|
||||
"l5_recv": {"bytes": 0, "window_ms": None},
|
||||
"l5_send": {"bytes": 0, "window_ms": None},
|
||||
"l3": {"bytes": 0, "window_ms": None},
|
||||
}
|
||||
|
||||
|
||||
class Peer:
|
||||
def __init__(self, host, port, wait_s, json_path, ftp_stress):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.wait_s = wait_s
|
||||
self.connect_wait_s = wait_s # per-attempt connect budget (retries shrink it)
|
||||
self.json_path = json_path
|
||||
self.ftp_stress = ftp_stress
|
||||
self.sock = None
|
||||
self.t0_wall = None
|
||||
self.t0_ns = 0
|
||||
self.n_ops = 0
|
||||
self.reports = fresh_reports()
|
||||
|
||||
def connect_with_retry(self):
|
||||
t_end = time.time() + self.connect_wait_s
|
||||
while time.time() < t_end:
|
||||
try:
|
||||
s = socket.create_connection((self.host, self.port), timeout=5.0)
|
||||
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
self.sock = s
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(1.0)
|
||||
raise ConnectionError("cannot connect to %s:%d within %ds" %
|
||||
(self.host, self.port, self.wait_s))
|
||||
|
||||
def ftp_stress_worker(self, start_evt, stop_evt):
|
||||
"""Hold an idle FTP login: lftpd read_line -> blocking net_recv(0) in
|
||||
netTask (30s timeout) -> all queued messages wait behind it (E-02)."""
|
||||
try:
|
||||
s = socket.create_connection((self.host, 21), timeout=10.0)
|
||||
s.settimeout(10.0)
|
||||
s.sendall(b"USER latency\r\n")
|
||||
reply = b""
|
||||
while b"\r\n" not in reply:
|
||||
d = s.recv(64)
|
||||
if not d:
|
||||
break
|
||||
reply += d
|
||||
start_evt.set() # L5 window starts under stress
|
||||
sys.stdout.write("[peer] ftp stress armed (331: %r)\n" % reply[:40])
|
||||
stop_evt.wait(35.0) # > 30s: netTask read times out (full freeze)
|
||||
try:
|
||||
s.sendall(b"QUIT\r\n")
|
||||
except OSError:
|
||||
pass
|
||||
s.close()
|
||||
sys.stdout.write("[peer] ftp stress released\n")
|
||||
except OSError as e:
|
||||
sys.stdout.write("[peer] ftp stress FAILED: %s\n" % e)
|
||||
start_evt.set()
|
||||
|
||||
def dump_json(self, error=None):
|
||||
"""Build the report dict and write it (idempotent; error => partial)."""
|
||||
rep = {
|
||||
"mode": "net",
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"ts_start": self.t0_wall,
|
||||
"ts_end": iso_now(),
|
||||
"duration_s": round((now_ns() - self.t0_ns) / 1e9, 2),
|
||||
"ops_seen": self.n_ops,
|
||||
"ftp_stress": self.ftp_stress,
|
||||
"seg_l6": stat_block(self.reports["l6"]["samples"], 0)
|
||||
| {"echo_ok": self.reports["l6"]["echo_ok"], "echo_fail": self.reports["l6"]["echo_fail"]},
|
||||
"seg_l7_first_byte": stat_block(self.reports["l7"]["first_ms"]),
|
||||
"seg_l7_total": stat_block(self.reports["l7"]["total_ms"], self.reports["l7"]["spikes500"]),
|
||||
"l7_crc": {"ok": self.reports["l7"]["crc_ok"], "fail": self.reports["l7"]["crc_fail"],
|
||||
"batches_per_frame": sorted(set(self.reports["l7"]["batches_per_frame"]))},
|
||||
"l5_recv": self.reports["l5_recv"],
|
||||
"l5_send": self.reports["l5_send"],
|
||||
"l3_drain": self.reports["l3"],
|
||||
"frames_l7": self.reports["l7"]["frames"],
|
||||
}
|
||||
if error:
|
||||
rep["error"] = error
|
||||
if self.json_path:
|
||||
with open(self.json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(rep, f, indent=1, ensure_ascii=True)
|
||||
print("[peer] JSON report -> %s%s" % (self.json_path,
|
||||
" (PARTIAL - error)" if error else ""))
|
||||
return rep
|
||||
|
||||
def run(self, max_attempts=3):
|
||||
"""整会话重试:芯片对空闲连接自行 RST(MCU 栈慢于芯片握手完成时,连接
|
||||
建立后 15~30s 无流量即被芯片超时断开,2026-09-22 台架实证)→ 重连 +
|
||||
MCU 端 re-HELLO 重启 = 闭环。重试时缩短 connect 预算(设备已在运行)。"""
|
||||
last_err = None
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
self.t0_wall = iso_now()
|
||||
self.t0_ns = now_ns()
|
||||
self.n_ops = 0
|
||||
self.reports = fresh_reports()
|
||||
self.connect_wait_s = self.wait_s if attempt == 1 else min(self.wait_s, 60)
|
||||
try:
|
||||
self.one_session()
|
||||
return
|
||||
except (ConnectionError, TimeoutError, RuntimeError, OSError) as e:
|
||||
last_err = e
|
||||
self.close_quiet()
|
||||
if attempt < max_attempts:
|
||||
print("[peer] session attempt %d/%d failed: %s" % (attempt, max_attempts, e))
|
||||
print("[peer] reconnect in 2s (MCU auto-relistens; re-HELLO restarts) ...")
|
||||
time.sleep(2.0)
|
||||
raise RuntimeError("all %d session attempts failed (last: %s)" % (max_attempts, last_err))
|
||||
|
||||
def close_quiet(self):
|
||||
try:
|
||||
if self.sock is not None:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.sock = None
|
||||
|
||||
def one_session(self):
|
||||
t0_wall = self.t0_wall
|
||||
t0_ns = self.t0_ns
|
||||
print("[peer] connecting %s:%d (wait up to %ds) ..." % (self.host, self.port, self.connect_wait_s))
|
||||
self.connect_with_retry()
|
||||
print("[peer] connected at %s" % iso_now())
|
||||
|
||||
l5_start_evt = threading.Event()
|
||||
l5_stop_evt = threading.Event()
|
||||
if self.ftp_stress:
|
||||
th = threading.Thread(target=self.ftp_stress_worker,
|
||||
args=(l5_start_evt, l5_stop_evt), daemon=True)
|
||||
th.start()
|
||||
|
||||
self.sock.sendall(HELLO)
|
||||
print("[peer] HELLO sent, awaiting MCU ops ...")
|
||||
|
||||
l5_window_open = False
|
||||
l5_t0 = 0
|
||||
l3_t0 = 0
|
||||
while True:
|
||||
op = recv_exact(self.sock, 1, t0_ns + 300 * 1000 * 1000000)
|
||||
op = op[0]
|
||||
self.n_ops += 1
|
||||
if op == OP_DONE:
|
||||
print("[peer] op=done after %d ops" % self.n_ops)
|
||||
break
|
||||
|
||||
if op == OP_L5_RECV:
|
||||
if self.ftp_stress:
|
||||
l5_start_evt.wait(60.0) # start window only under full stress
|
||||
l5_window_open = True
|
||||
l5_t0 = now_ns()
|
||||
for i in range(L5_CHUNKS):
|
||||
self.sock.sendall(pattern(L5_LEN, seed=i))
|
||||
time.sleep(0.05) # 50ms: keep 1KB/s below MCU drain rate
|
||||
self.reports["l5_recv"]["window_ms"] = (now_ns() - l5_t0) / 1e6
|
||||
self.reports["l5_recv"]["bytes"] = L5_CHUNKS * L5_LEN
|
||||
l5_stop_evt.set()
|
||||
|
||||
elif op == OP_L5_SEND:
|
||||
t = now_ns()
|
||||
data = recv_exact(self.sock, L5_CHUNKS * L5_LEN, t + 120 * 1000 * 1000000)
|
||||
self.reports["l5_send"]["bytes"] = len(data)
|
||||
self.reports["l5_send"]["window_ms"] = (now_ns() - t) / 1e6
|
||||
|
||||
elif op == OP_L6:
|
||||
t0 = now_ns()
|
||||
req = pattern(L6_SIZE, seed=self.n_ops)
|
||||
self.sock.sendall(req)
|
||||
resp = recv_exact(self.sock, L6_SIZE, t0 + 40 * 1000 * 1000000)
|
||||
ms = (now_ns() - t0) / 1e6
|
||||
self.reports["l6"]["samples"].append(ms)
|
||||
if resp == req:
|
||||
self.reports["l6"]["echo_ok"] += 1
|
||||
else:
|
||||
self.reports["l6"]["echo_fail"] += 1
|
||||
|
||||
elif op == OP_L7:
|
||||
t_send = now_ns()
|
||||
self.sock.sendall(l7_request(self.n_ops))
|
||||
got = bytearray()
|
||||
batches = []
|
||||
first_ns = None
|
||||
while len(got) < L7_FRAME_LEN:
|
||||
# bounded read: never consume the next op byte (ASCII 0x04) that
|
||||
# follows the frame in the stream (run10: unbounded recv ate it ->
|
||||
# 3210-byte "frame" -> length check failed -> peer closed)
|
||||
d = self.sock.recv(min(4096, L7_FRAME_LEN - len(got)))
|
||||
if not d:
|
||||
raise ConnectionError("L7 frame truncated (%d/%d)" %
|
||||
(len(got), L7_FRAME_LEN))
|
||||
if first_ns is None:
|
||||
first_ns = now_ns()
|
||||
got.extend(d)
|
||||
batches.append([round((now_ns() - t_send) / 1e6, 3), len(d)])
|
||||
t_end = now_ns()
|
||||
first_ms = (first_ns - t_send) / 1e6 if first_ns else None
|
||||
total_ms = (t_end - t_send) / 1e6
|
||||
# inter-batch arrival gaps
|
||||
spikes = 0
|
||||
for k in range(1, len(batches)):
|
||||
if batches[k][0] - batches[k - 1][0] >= SPIKE_MS:
|
||||
spikes += 1
|
||||
self.reports["l7"]["first_ms"].append(first_ms if first_ms is not None else 0.0)
|
||||
self.reports["l7"]["total_ms"].append(total_ms)
|
||||
self.reports["l7"]["spikes500"] += spikes
|
||||
self.reports["l7"]["batches_per_frame"].append(len(batches))
|
||||
if l7_frame_ok(bytes(got)):
|
||||
self.reports["l7"]["crc_ok"] += 1
|
||||
else:
|
||||
self.reports["l7"]["crc_fail"] += 1
|
||||
self.reports["l7"]["frames"].append(
|
||||
{"first_ms": round(first_ms, 3) if first_ms is not None else None,
|
||||
"total_ms": round(total_ms, 3), "batches": batches})
|
||||
|
||||
elif op == OP_L3:
|
||||
l3_t0 = now_ns()
|
||||
recv_exact(self.sock, L3_SENDS * L3_LEN, l3_t0 + 120 * 1000 * 1000000)
|
||||
self.reports["l3"]["bytes"] = L3_SENDS * L3_LEN
|
||||
self.reports["l3"]["window_ms"] = (now_ns() - l3_t0) / 1e6
|
||||
|
||||
else:
|
||||
raise RuntimeError("unknown op byte 0x%02X (protocol desync)" % op)
|
||||
|
||||
# --- JSON report (also used on the error path: partial data kept) ---
|
||||
rep = self.dump_json()
|
||||
|
||||
print("=== latency_peer summary (M2 / Phase 13) ===")
|
||||
print("ops=%d duration=%.1fs ftp_stress=%d" % (self.n_ops, rep["duration_s"], int(self.ftp_stress)))
|
||||
for key in ("seg_l6", "seg_l7_first_byte", "seg_l7_total"):
|
||||
b = rep[key]
|
||||
if b.get("n"):
|
||||
print("%-18s n=%-4d min=%9.1f med=%9.1f p95=%9.1f max=%9.1f ms spikes=%d"
|
||||
% (key, b["n"], b["min_ms"], b["med_ms"], b["p95_ms"], b["max_ms"],
|
||||
b.get("spikes500", 0)))
|
||||
else:
|
||||
print("%-18s (no samples)" % key)
|
||||
print("l7 crc ok/fail = %d/%d batches/frame = %s"
|
||||
% (rep["l7_crc"]["ok"], rep["l7_crc"]["fail"], rep["l7_crc"]["batches_per_frame"]))
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="link latency test peer - M2 Phase 13 (net) / M3 Phase 14 (remo)")
|
||||
ap.add_argument("--mode", default="net", choices=["net", "remo"],
|
||||
help="net = Phase 13 test protocol :8080; "
|
||||
"remo = production RemoDispBus client :7003 (Phase 14 / simulator)")
|
||||
ap.add_argument("--host", default="192.168.1.100", help="device IP")
|
||||
ap.add_argument("--port", type=int, default=None,
|
||||
help="listen port (default: 8080 for net, 7003 for remo)")
|
||||
ap.add_argument("--wait", type=int, default=180,
|
||||
help="max seconds to retry connect (device boots after flash)")
|
||||
ap.add_argument("--json", default=None, help="write JSON report to this path")
|
||||
ap.add_argument("--ftp-stress", action="store_true",
|
||||
help="hold idle FTP login (E-02 head-of-line repro)")
|
||||
ap.add_argument("--samples", type=int, default=50,
|
||||
help="remo mode: LCDMEM frame cycles to measure (default 50)")
|
||||
ap.add_argument("--cadence-ms", type=int, default=100,
|
||||
help="remo mode: client poll cadence in ms (production client = 100)")
|
||||
ap.add_argument("--stress-from", type=int, default=None,
|
||||
help="remo mode: cycle index where FTP stress starts "
|
||||
"(default with --ftp-stress: half of --samples)")
|
||||
ap.add_argument("--nodelay", action="store_true",
|
||||
help="remo mode: set TCP_NODELAY on the client socket")
|
||||
ap.add_argument("--reconnect-rounds", type=int, default=1,
|
||||
help="remo mode: close+reconnect rounds at the end (each one drives "
|
||||
"the device auto-relisten probe; M3 uses 3)")
|
||||
ap.add_argument("--ack-probe", action="store_true",
|
||||
help="remo mode: when a frame is still incomplete, send one parser-safe "
|
||||
"byte so the TCP segment piggybacks an ACK (diagnoses whether the "
|
||||
"~50ms tail waits for an ACK; no packet capture needed)")
|
||||
ap.add_argument("--ack-fast", action="store_true",
|
||||
help="remo mode: Windows only - disable delayed ACK on this socket "
|
||||
"(SIO_TCP_SET_ACK_FREQUENCY=1); removes the ~50ms frame tail")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.mode == "remo":
|
||||
port = args.port if args.port else 7003
|
||||
stress_from = args.stress_from
|
||||
if args.ftp_stress and stress_from is None:
|
||||
stress_from = max(1, args.samples // 2)
|
||||
peer = RemoPeer(args.host, port, args.wait, args.json, args.samples,
|
||||
args.cadence_ms, stress_from, args.nodelay,
|
||||
args.reconnect_rounds, args.ack_probe, args.ack_fast)
|
||||
else:
|
||||
peer = Peer(args.host, args.port or 8080, args.wait, args.json, args.ftp_stress)
|
||||
try:
|
||||
peer.run()
|
||||
return 0
|
||||
except (ConnectionError, TimeoutError, RuntimeError, OSError) as e:
|
||||
print("[peer] ERROR: %s" % e)
|
||||
try:
|
||||
peer.dump_json(error=str(e)) # partial report: keep measured data
|
||||
except OSError as e2:
|
||||
print("[peer] (JSON write failed: %s)" % e2)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库,2026-09-20 替代 run_boot_test.ps1)。
|
||||
|
||||
调用仓库内置串口助手(tools/serial_tool,常驻 daemon,子进程 CLI + JSON):
|
||||
1. open 串口 + clear 日志(本次运行独立代际)
|
||||
2. 烧录(默认 MDK-ARM\\download.bat:下载 + Reset & Run)
|
||||
3. wait-idle:RX 静默 >= settle-ms,或 max-wait-ms 超时
|
||||
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||||
5. verify_log.py 标记校验
|
||||
|
||||
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
|
||||
无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。
|
||||
|
||||
相对旧 PowerShell 版的加固(本机 Windows 环境实测教训,2026-09-20):
|
||||
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
|
||||
- serial_config.json 按 UTF-8(容忍 BOM)解析 —— 无 PS 5.1 按 GBK 读 JSON
|
||||
导致 "Unterminated string" 崩溃的问题
|
||||
- 不经 PowerShell —— 无执行策略(scripts disabled)拦截
|
||||
- 烧录带超时(默认 180s;旧版无超时,坏 SWD 连接会永久挂起)
|
||||
|
||||
用法:
|
||||
& '.venv\\Scripts\\python.exe' test/host/run_boot_test.py
|
||||
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
|
||||
[--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180]
|
||||
[--required "App init OK;Network Init;net init: OK"]
|
||||
[--forbidden "FAIL;ERROR;HardFault;[ERR]"]
|
||||
[--out-dir <目录>] [--show-tail-lines 15]
|
||||
|
||||
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源)
|
||||
|
||||
退出码(与旧版一致):
|
||||
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
||||
1 = FAIL(标记缺失/命中)
|
||||
2 = 采集或烧录错误
|
||||
3 = 用法/预检错误
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent
|
||||
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
|
||||
CFG_FILE = REPO_ROOT / ".opencode" / "serial_config.json"
|
||||
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
|
||||
|
||||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;[ERR]"
|
||||
|
||||
|
||||
def _cli(cmd_args, timeout):
|
||||
"""调用串口助手 CLI,返回 (rc, stdout_text, parsed_json_or_None)。
|
||||
|
||||
全程字节级处理(capture_output 取 bytes 后显式 UTF-8 解码),
|
||||
任何 Windows 控制台代码页都不参与数据路径。
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(cmd_args, capture_output=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return 2, "client timeout ({}s)".format(timeout), None
|
||||
text = proc.stdout.decode("utf-8", errors="replace")
|
||||
try:
|
||||
return proc.returncode, text, json.loads(text)
|
||||
except ValueError:
|
||||
return proc.returncode, text, None
|
||||
|
||||
|
||||
def _tool_cmd(py, cmd, *rest, timeout=15):
|
||||
rc, out, data = _cli([py, str(SERIAL_TOOL), cmd, *rest], timeout)
|
||||
if rc != 0 or data is None:
|
||||
return None, out
|
||||
return data, out
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
|
||||
ap.add_argument("--port", default="",
|
||||
help="serial port (default: portName from .opencode/serial_config.json)")
|
||||
ap.add_argument("--baud-rate", type=int, default=115200)
|
||||
ap.add_argument("--settle-ms", type=int, default=8000,
|
||||
help="RX silence that ends the capture")
|
||||
ap.add_argument("--max-wait-ms", type=int, default=30000,
|
||||
help="overall wait-idle timeout")
|
||||
ap.add_argument("--flash-cmd", default="",
|
||||
help="flash command (default: MDK-ARM/download.bat; pass reset.bat for reset-only)")
|
||||
ap.add_argument("--flash-timeout", type=int, default=180,
|
||||
help="flash subprocess timeout in seconds")
|
||||
ap.add_argument("--required", default=REQUIRED_DEFAULT,
|
||||
help="required markers, ';' separated")
|
||||
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
|
||||
help="forbidden patterns, ';' separated")
|
||||
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode"))
|
||||
ap.add_argument("--show-tail-lines", type=int, default=15)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
|
||||
py = sys.executable
|
||||
|
||||
# 端口缺省:单一配置源 .opencode/serial_config.json(UTF-8,容忍 BOM)
|
||||
port = args.port
|
||||
if not port and CFG_FILE.is_file():
|
||||
try:
|
||||
cfg = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
|
||||
port = str(cfg.get("portName") or "")
|
||||
except (OSError, ValueError) as e:
|
||||
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
|
||||
return 3
|
||||
if not port:
|
||||
print("RUN_TEST_ERROR: no --port given and no portName in {}".format(CFG_FILE))
|
||||
return 3
|
||||
|
||||
flash_cmd = Path(args.flash_cmd) if args.flash_cmd else DEFAULT_FLASH
|
||||
|
||||
# 预检(对应旧版 missing-file 检查)
|
||||
for p in (SERIAL_TOOL, SCRIPT_DIR / "verify_log.py", flash_cmd):
|
||||
if not p.is_file():
|
||||
print("RUN_TEST_ERROR: missing {}".format(p))
|
||||
return 3
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
|
||||
|
||||
def tool(cmd, *rest, timeout=15):
|
||||
return _tool_cmd(py, cmd, *rest, timeout=timeout)
|
||||
|
||||
print("=== Boot test {} ===".format(stamp))
|
||||
print("Port: {} @ {} settle={}ms max={}ms".format(
|
||||
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
|
||||
|
||||
# 1. open(幂等;未运行则自动拉起常驻 daemon)
|
||||
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: open failed: {}".format(out))
|
||||
return 2
|
||||
|
||||
# 2. clear(新代际;此后数据才属于本次运行)
|
||||
data, out = tool("clear", "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: clear failed: {}".format(out))
|
||||
return 2
|
||||
|
||||
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
|
||||
# 由采集 + 标记判定结果(与旧版一致)
|
||||
print("FLASH_START: {}".format(flash_cmd))
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(["cmd", "/c", str(flash_cmd)],
|
||||
capture_output=True, timeout=args.flash_timeout)
|
||||
flash_rc = proc.returncode
|
||||
flash_bytes = (proc.stdout or b"") + (proc.stderr or b"")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
flash_rc = -1
|
||||
flash_bytes = (e.stdout or b"") + (e.stderr or b"")
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
for line in flash_bytes.decode("utf-8", errors="replace").strip().splitlines()[-12:]:
|
||||
print(line)
|
||||
print("FLASH_RC: {} elapsed_ms={}".format(flash_rc, elapsed_ms))
|
||||
if flash_rc != 0:
|
||||
print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc))
|
||||
|
||||
# 4. wait-idle(RX 静默收尾或整体超时)
|
||||
wait_timeout_s = args.max_wait_ms // 1000 + 20
|
||||
data, out = tool("wait-idle",
|
||||
"--settle-ms", str(args.settle_ms),
|
||||
"--max-ms", str(args.max_wait_ms),
|
||||
"--timeout", str(wait_timeout_s),
|
||||
timeout=wait_timeout_s + 15)
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
|
||||
return 2
|
||||
print("WAIT: reason={} rx_bytes={} waited_ms={}".format(
|
||||
data.get("reason"), data.get("rx_bytes"), data.get("waited_ms")))
|
||||
if data.get("reason") == "no_data":
|
||||
print("RUN_TEST_ERROR: no RX data at all (check port / board power / flash)")
|
||||
return 2
|
||||
|
||||
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||||
data, out = tool("log", "--tail", "0", "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: log read failed: {}".format(out))
|
||||
return 2
|
||||
rx = []
|
||||
for l in data.get("lines", []):
|
||||
if isinstance(l, str) and l.startswith("[RX] "):
|
||||
rest = l[5:]
|
||||
sp = rest.find(" ")
|
||||
rx.append(rest[sp + 1:] if sp >= 0 else rest)
|
||||
if not rx:
|
||||
print("RUN_TEST_ERROR: no [RX] lines in log (device silent?)")
|
||||
return 2
|
||||
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
|
||||
print("Boot log: {} (lines={} gen={})".format(boot_txt, len(rx), data.get("gen")))
|
||||
|
||||
# 6. 标记校验
|
||||
from verify_log import verify
|
||||
rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines)
|
||||
print("RUN_TEST_RC={}".format(rc))
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
本地时间同步服务(PC -> MCU 板子)
|
||||
|
||||
功能:
|
||||
周期性把本机(PC)的本地时间,通过 TCP 推送到 STM32 板子。
|
||||
板子端在 timeSyncTask 中以 TCP Server 方式监听端口,收到后调用
|
||||
sys_clock_set_unix() 更新系统墙钟并写回 SD2506 RTC,使板子时间 == 你电脑的时间。
|
||||
|
||||
协议(板子端实现见 App/sync/time_sync.c 的 time_sync_handle_conn):
|
||||
字节 0..3 : 魔术字 "TIME" (0x54 0x49 0x4D 0x45)
|
||||
字节 4..7 : 本地 Unix 时间戳,uint32,大端(主机字节序转换后由板子分解)
|
||||
|
||||
用法:
|
||||
python time_sync_server.py # 默认推送到 192.168.1.100:8888
|
||||
python time_sync_server.py 192.168.1.100 # 指定板子 IP
|
||||
python time_sync_server.py 192.168.1.100 9000 3 # 指定端口与间隔(秒)
|
||||
|
||||
按 Ctrl+C 停止。
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import calendar
|
||||
|
||||
DEFAULT_MCU_IP = "192.168.1.100"
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_INTERVAL = 5.0
|
||||
|
||||
|
||||
def main():
|
||||
mcu_ip = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_MCU_IP
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_PORT
|
||||
interval = float(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_INTERVAL
|
||||
|
||||
print("[time_sync] 本地时间推送服务已启动(TCP 客户端)")
|
||||
print(f" 目标板子 : {mcu_ip}:{port}")
|
||||
print(f" 推送间隔 : {interval}s")
|
||||
print(f" 协议帧 : 'TIME' + uint32(BE) 本地 Unix 时间戳")
|
||||
print("[time_sync] 按 Ctrl+C 停止")
|
||||
print("-" * 48)
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 本地 wall-clock 当作 UTC 算出的 epoch(与电脑显示一致)。
|
||||
# 注意:板子 RTC 存本地时间且 unix_to_sd2506 无时区处理,
|
||||
# 故必须发"本地墙钟当 UTC"的伪 epoch(calendar.timegm),
|
||||
# 不能用 time.mktime(返回真正 UTC epoch,会导致板子慢 8 小时)。
|
||||
local_epoch = int(calendar.timegm(time.localtime()))
|
||||
pkt = b"TIME" + struct.pack(">I", local_epoch)
|
||||
|
||||
with socket.create_connection((mcu_ip, port), timeout=5) as sock:
|
||||
sock.sendall(pkt)
|
||||
# 关键:发送后保持连接打开,等板子读完再关。
|
||||
# 若立即 close,CH395F 收到 FIN 会丢弃接收缓冲里尚未被板子读出的
|
||||
# 8 字节时间包,导致板子 net_recv 看到 CLOSED 而无数据、时间同步失败。
|
||||
# 这里 recv 直到板子主动关闭(EOF)或 2s 超时,确保板子先读到数据。
|
||||
sock.settimeout(2.0)
|
||||
try:
|
||||
sock.recv(64)
|
||||
except (socket.timeout, OSError):
|
||||
pass
|
||||
|
||||
now_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
print(f"[{time.strftime('%H:%M:%S')}] -> {now_str} (epoch={local_epoch})")
|
||||
except OSError as e:
|
||||
print(f"[{time.strftime('%H:%M:%S')}] 连接/发送失败: {e}")
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[time_sync] 已停止")
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
|
||||
|
||||
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
|
||||
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
|
||||
|
||||
用法:
|
||||
& '.venv\\Scripts\\python.exe' test/host/verify_log.py --log-path <file>
|
||||
[--required 'marker1;marker2']
|
||||
[--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]']
|
||||
[--show-tail-lines 40]
|
||||
|
||||
退出码(与旧版一致):
|
||||
0 = PASS(所有 Required 标记找到,Forbidden 零命中)
|
||||
1 = FAIL(缺 Required 或命中 Forbidden)
|
||||
2 = ERROR(日志文件不存在/不可读)
|
||||
|
||||
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
|
||||
按测试用例覆盖,例如:
|
||||
--required 'App init OK;net init: OK;PHY: 100M FULL'
|
||||
--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'
|
||||
|
||||
语义约定(与旧 PowerShell 版对齐):
|
||||
- Required:大小写敏感子串匹配(全文)
|
||||
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
|
||||
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
|
||||
- 固件错误级日志前缀是 `[ERR]`(`DBG_ERROR`,形如 `[时间][ERR][TAG] 消息`),
|
||||
与 `ERROR` 子串**不匹配**——默认 Forbidden 因此显式包含 `[ERR]`,驱动/模块
|
||||
错误行不再漏检(2026-09-21 修复)。测试套件中**预期**出现 `[ERR]` 的用例
|
||||
(如 GD5F DMA 守卫负路径、FatFs 非法名校验)须用 --forbidden 覆盖默认值。
|
||||
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
|
||||
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
|
||||
都只影响终端显示,不会导致本脚本崩溃
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import locale
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert;[ERR]"
|
||||
|
||||
|
||||
def _split_list(spec):
|
||||
"""';' 分隔列表 → 去空白、去空项。"""
|
||||
return [p.strip() for p in spec.split(";") if p.strip()]
|
||||
|
||||
|
||||
def _read_lines(path):
|
||||
raw = Path(path).read_bytes()
|
||||
for enc in ("utf-8", locale.getpreferredencoding(False)):
|
||||
try:
|
||||
return raw.decode(enc).splitlines()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace").splitlines()
|
||||
|
||||
|
||||
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
show_tail=40, out=print):
|
||||
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。"""
|
||||
p = Path(log_path)
|
||||
if not p.is_file():
|
||||
out("VERIFY_ERROR: log file not found: {}".format(log_path))
|
||||
return 2
|
||||
try:
|
||||
lines = _read_lines(log_path)
|
||||
except OSError:
|
||||
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
|
||||
return 2
|
||||
|
||||
full = "\n".join(lines)
|
||||
req_list = _split_list(required)
|
||||
forb_list = _split_list(forbidden)
|
||||
|
||||
out("=== Log verdict: {} ===".format(log_path))
|
||||
out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size))
|
||||
|
||||
missing = []
|
||||
for m in req_list:
|
||||
if m in full:
|
||||
out("[OK] required marker found: {}".format(m))
|
||||
else:
|
||||
out("[MISS] required marker NOT found: {}".format(m))
|
||||
missing.append(m)
|
||||
|
||||
hits = []
|
||||
for f in forb_list:
|
||||
fl = f.lower()
|
||||
n = sum(1 for ln in lines if fl in ln.lower())
|
||||
if n > 0:
|
||||
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, n))
|
||||
hits.append(f)
|
||||
else:
|
||||
out("[OK] forbidden pattern absent: {}".format(f))
|
||||
|
||||
if show_tail > 0 and lines:
|
||||
out("")
|
||||
out("=== Last {} line(s) of log ===".format(show_tail))
|
||||
for ln in lines[-show_tail:]:
|
||||
out(ln)
|
||||
|
||||
out("")
|
||||
if missing or hits:
|
||||
out("VERDICT: FAIL")
|
||||
return 1
|
||||
out("VERDICT: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description="MCU debug log marker check (pure stdlib)")
|
||||
ap.add_argument("--log-path", required=True, help="boot log file to check")
|
||||
ap.add_argument("--required", default=REQUIRED_DEFAULT,
|
||||
help="required markers, ';' separated (case-sensitive)")
|
||||
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
|
||||
help="forbidden patterns, ';' separated (case-insensitive)")
|
||||
ap.add_argument("--show-tail-lines", type=int, default=40,
|
||||
help="show last N log lines (0 = off)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
return verify(args.log_path, args.required, args.forbidden, args.show_tail_lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user