整理 App 与 test 目录:按职责分子目录并同步全库引用

This commit is contained in:
2026-09-26 12:51:07 +08:00
parent 39bd06adea
commit 22559365b9
90 changed files with 2650 additions and 2487 deletions
+52
View File
@@ -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
+90
View File
@@ -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)
+256
View File
@@ -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()
+91
View File
@@ -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())
+258
View File
@@ -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()
+208
View File
@@ -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()
+279
View File
@@ -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()
+836
View File
@@ -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())
+218
View File
@@ -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())
+79
View File
@@ -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] 已停止")
+134
View File
@@ -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())