#!/usr/bin/env python3 # -*- coding: utf-8 -*- """屏板链路台架激励:用 PC + USB 转 TTL **顶替屏板**,按屏板的召唤节拍发 6 B 帧 A。 做什么: 1. **发**:在一个 TTL 串口上按屏板的节拍(默认 280 ms)发帧 A,模拟屏板的周期召唤; 可穿插按键帧(`--key N`)。 2. **收**(`--reply`):每发一帧就收主机回的 4208 B 帧 B,校验长度 / CRC / 998 B 填充,量"召唤末位 → 应答首位"的间隔与整帧耗时,可选把点阵 dump 成 PBM。 主机侧的统计由固件自己打(`[SBLINK] rx=... tx=...`),**收侧观测面是串口日志**; 本脚本的 `--reply` 是另一路观测(应答本身对不对),两者互补,不能互相替代。 为什么不用串口助手 GUI: 1-3 的判据含"连续 10 min 无丢帧",手点是做不到的;而且帧内容必须逐字节对 (CRC 低字节在前,写错一位就是 100 % CRC 错帧,与"波特率错"同形)。 发送侧判据含"整帧 59.6 ms ±0.5 ms、≤200 ms 内结束",更是只能靠脚本量。 ⚠ **USB 转 TTL 适配器量不到"帧内无间隙"**:USB 帧是 1 ms 量级,适配器把字节成批交上来, 所以 `--reply` 报的 `span` 只能证明"整帧在 60 ms 量级内发完",不能证明"没有 ≥28 µs 的间隙"(那是屏板断帧的硬约束,只能上逻辑分析仪或接真屏板时看)。别把 span 通过 当成那条判据通过。 接法(docs/子系统设计/屏板链路设计说明.md §3.3): 适配器 TXD -> U5.B10(网络 ST_RX0,PA10) 适配器 RXD -> U5.A10(网络 ST_TX0,PA9) 适配器 GND -> 板地 ⚠ 必须交叉接(TX 对 RX);USART1 的 TTL 只从 U5 出去,CN1 上没有 USART1 端子。 串口参数 700000 8N1,与固件 `SB_LINK_BAUD` 一致。 与日志口的关系: 日志走 RS-232 侧(默认 COM3,由 serial-assistant daemon 占用),本脚本占用的是 **另一路 TTL 口**(台架上通常是 COM6)——两者互不相干,可同时跑。 用法(仓库根目录): & '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --seconds 30 & '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --seconds 600 # 10 min 判据 & '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --reply --dump lcd.pbm --key N 每 N 个周期把召唤帧换成按键帧(键值在 1/2/3/4/7/8/9 里轮换;0 = 不发按键帧) --reply 每帧收应答并校验(长度 / CRC / 填充一致) --dump P 把最后一帧应答的点阵存成 PBM(P4,160x160,可直接看) 退出码:0 = 按时发完且(`--reply` 时)所有应答都对;1 = 串口打不开 / 发送失败; 2 = 用法错误;3 = 有应答不合格。 设计依据:docs/子系统设计/屏板链路设计说明.md §2(接收判据)、§4(发送口径)、§3.3(台架接法)。 创建日期:2026-10-29 """ import argparse import sys import time try: import serial except ImportError: # pragma: no cover - 环境缺失时给人话 print("[ERR] 缺少 pyserial:请用项目 .venv 的 python 运行本脚本") sys.exit(2) # 屏板固件里的 KEY_TYPE:7 个物理键可达 1/2/3/4/7/8/9(无 5/6,9 = RESET) KEY_VALUES = (1, 2, 3, 4, 7, 8, 9) CMD_KEY = 0x00 CMD_RT = 0x01 ADDR = 0x01 FUNC = 0x03 def crc16_modbus(data): """标准 CRC-16/MODBUS(与 sb_proto.c 同算法、同口径:写进帧里低字节在前)。""" crc = 0xFFFF for byte in data: crc ^= byte for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc & 0xFFFF def build_frame(cmd, key=0): """组一帧帧 A:01 03 ,CRC 只覆盖前 4 字节。""" body = bytes((ADDR, FUNC, cmd, key)) crc = crc16_modbus(body) return body + bytes((crc & 0xFF, (crc >> 8) & 0xFF)) # ---- 帧 B(主机 -> 屏板,4208 B):应答校验 ---- FRAME_B_LEN = 4208 FRAME_B_BMP = 3200 FRAME_B_INFO = 3200 FRAME_B_PAD_OFF = 3208 FRAME_B_CRC_OFF = 4206 SCREEN_W = 160 SCREEN_H = 160 def verify_frame_b(data): """校验一帧帧 B;返回 (是否合格, 说明)。 只验"结构对不对":长度、CRC(覆盖 [0, 4205],低字节在前)、998 B 填充全 0、 info[4..5] 不是 LOGO 魔数。**不验点阵内容**——那要么 dump 出来看(--dump), 要么拿主机侧显存逐字节比(需要主机侧配合,台架这一级做不到)。 """ if len(data) != FRAME_B_LEN: return False, "len=%d(应 %d)" % (len(data), FRAME_B_LEN) crc = crc16_modbus(data[:FRAME_B_CRC_OFF]) if (data[FRAME_B_CRC_OFF] != (crc & 0xFF)) or (data[FRAME_B_CRC_OFF + 1] != ((crc >> 8) & 0xFF)): return False, "CRC 不符(帧尾 %02X %02X,算得 %04X 低字节在前)" % ( data[FRAME_B_CRC_OFF], data[FRAME_B_CRC_OFF + 1], crc) pad = data[FRAME_B_PAD_OFF:FRAME_B_CRC_OFF] if any(pad): bad = next(i for i, b in enumerate(pad) if b) return False, "填充段非 0(偏移 %d)" % (FRAME_B_PAD_OFF + bad) if (data[FRAME_B_INFO + 4] == 0x89) and (data[FRAME_B_INFO + 5] == 0x45): return False, "info[4..5] 是 LOGO 魔数(本应是普通图帧)" return True, "OK" def dump_pbm(path, data): """把点阵段存成 PBM(P4,160x160)。显存本身就是 1 bpp / 20 B 一行 / MSB 在前, 与 PBM 的二进制布局逐字节同构,所以直接拼头 + 原字节即可。""" with open(path, "wb") as fp: fp.write(b"P4\n%d %d\n" % (SCREEN_W, SCREEN_H)) fp.write(data[:FRAME_B_BMP]) def capture_reply(ser, frame, budget_s=0.6): """发一帧召唤,收主机回的 4208 B;返回 (数据, 写时刻, 首字节时刻, 末字节时刻)。 ⚠ **CH340 收不了 700 kbaud 的连续流**(实测 2026-10-29:4208 B 只收到 ~2373 B, 等效约 9.5 kB/s)。这不是固件问题——同一次运行里固件侧 `tx=94 done=93` 说明 4208 B 每次都发完了;CH340 的 RX FIFO 只有几十字节、USB 每 1 ms 才搬一次,70 kB/s 必然溢出。 要真正核对帧 B 的内容,换 FTDI / CP210x 之类的适配器,或接真屏板。 """ ser.reset_input_buffer() t_write = time.monotonic() ser.write(frame) ser.flush() buf = bytearray() t_first = None deadline = t_write + budget_s while len(buf) < FRAME_B_LEN and time.monotonic() < deadline: # 大块读:read(n) 会等到收满 n 或超时,比"按 in_waiting 一个字节一个字节读"快得多 chunk = ser.read(min(1024, FRAME_B_LEN - len(buf))) if chunk: if t_first is None: t_first = time.monotonic() buf += chunk return bytes(buf), t_write, t_first, time.monotonic() def main(): ap = argparse.ArgumentParser(description="屏板链路台架激励(PC 顶替屏板)") ap.add_argument("--port", required=True, help="USB 转 TTL 适配器的串口,如 COM6") ap.add_argument("--baud", type=int, default=700000, help="波特率(默认 700000,与固件一致)") ap.add_argument("--seconds", type=float, default=30.0, help="发多久(秒),默认 30") ap.add_argument("--period-ms", type=float, default=280.0, help="召唤周期(ms),默认 280(屏板是 RT_count>55 -> 第 56 拍 x 5 ms)") ap.add_argument("--key", type=int, default=0, help="每 N 个周期把帧换成按键帧(0 = 只发周期召唤)") ap.add_argument("--key-fixed", type=int, default=0, help="固定发这个键值(1/2/3/4/7/8/9),与 --key 二选一。" "用来「持续改画面」以便拿到多次组帧样本;2=UP / 3=DOWN 只移动高亮," "不会确认任何设置(1=ENTER 可能改配置,别用来做长跑)") ap.add_argument("--glitch-baud", type=int, default=0, help="先按这个(错的)波特率灌一串垃圾再正常发,用来验 ORE/NE/FE 之后能否自愈" "(0 = 不灌;建议 115200)") ap.add_argument("--glitch-bytes", type=int, default=32, help="灌多少字节垃圾(默认 32)") ap.add_argument("--reply", action="store_true", help="每帧收主机回的 4208 B 应答并校验(长度 / CRC / 填充一致)") ap.add_argument("--dump", default="", help="把最后一帧应答的点阵存成 PBM(P4 160x160,可直接看)") args = ap.parse_args() if args.period_ms <= 0 or args.seconds <= 0: print("[ERR] --period-ms / --seconds 必须为正") return 2 if args.dump and not args.reply: print("[ERR] --dump 需要同时给 --reply(没有应答就没有点阵可存)") return 2 rt_frame = build_frame(CMD_RT) print("RT frame : %s" % rt_frame.hex(" ").upper()) # ---- 噪声注入(可选):用错波特率灌垃圾,制造 NE/FE/ORE ---- # 真实现场对应的是"拔插一次 TX 线";错波特率产生的错误更密集,是更强的考验。 # 关键判据不是"有没有报错",而是**之后还能不能正常收**——ORE 一旦清不掉, # 接收就永久进不来,现象与"屏板没上电"完全一样(见 sb_link.c 文件头第 ② 条)。 if args.glitch_baud > 0 and args.glitch_bytes > 0: try: gser = serial.Serial(args.port, args.glitch_baud, bytesize=8, parity="N", stopbits=1, timeout=0) junk = bytes((0xA5, 0x5A, 0x00, 0xFF, 0x3C, 0xC3)) * (args.glitch_bytes // 6 + 1) gser.write(junk[:args.glitch_bytes]) gser.flush() gser.close() print("[GLITCH] %d B @%d(错波特率灌噪声)" % (args.glitch_bytes, args.glitch_baud)) except Exception as exc: # noqa: BLE001 print("[WARN] 噪声注入失败(继续正常发):%s" % exc) try: # 收应答时要给 read() 一个超时,否则 timeout=0 会让 read(n) 立刻返回空、空转 ser = serial.Serial(args.port, args.baud, bytesize=8, parity="N", stopbits=1, timeout=(0.1 if args.reply else 0)) except Exception as exc: # noqa: BLE001 - 打开失败原因要原样给人看 print("[ERR] 打开 %s @%d 失败:%s" % (args.port, args.baud, exc)) return 1 sent = 0 keys = 0 reply_ok = 0 reply_bad = 0 lat_min = None lat_max = 0.0 lat_sum = 0.0 span_min = None span_max = 0.0 span_sum = 0.0 last_bmp = None t0 = time.monotonic() next_at = t0 try: while True: now = time.monotonic() if now - t0 >= args.seconds: break if now < next_at: time.sleep(min(next_at - now, 0.02)) continue if args.key_fixed > 0: frame = build_frame(CMD_KEY, args.key_fixed) keys += 1 tag = "KEY %d" % args.key_fixed elif args.key > 0 and (sent % args.key) == (args.key - 1): key = KEY_VALUES[keys % len(KEY_VALUES)] frame = build_frame(CMD_KEY, key) keys += 1 tag = "KEY %d" % key else: frame = rt_frame tag = "RT" if args.reply: data, t_write, t_first, t_last = capture_reply(ser, frame) good, why = verify_frame_b(data) if t_first is None: lat = span = None else: lat = (t_first - t_write) * 1000.0 span = (t_last - t_first) * 1000.0 lat_sum += lat lat_min = lat if lat_min is None else min(lat_min, lat) lat_max = max(lat_max, lat) span_sum += span span_min = span if span_min is None else min(span_min, span) span_max = max(span_max, span) if good: reply_ok += 1 last_bmp = data else: reply_bad += 1 if sent < 3 or tag != "RT" or not good: if t_first is None: print(" #%d %s -> 无应答(%.0f ms 内没收到首字节)" % (sent + 1, tag, 600.0)) else: print(" #%d %s -> %d B %s lat=%.2f ms span=%.2f ms" % (sent + 1, tag, len(data), ("OK" if good else "BAD: " + why), lat, span)) else: ser.write(frame) ser.flush() if sent <= 3 or tag != "RT": print(" #%d %s -> %s" % (sent + 1, tag, frame.hex(" ").upper())) sent += 1 next_at += args.period_ms / 1000.0 # 落后太多就别追了(PC 侧卡顿不该变成突发流量) if next_at < time.monotonic() - 1.0: next_at = time.monotonic() except KeyboardInterrupt: print("\n[INFO] 用户中断") finally: ser.close() elapsed = time.monotonic() - t0 print("[DONE] 发送 %d 帧(其中按键帧 %d)用时 %.1f s,平均周期 %.1f ms" % (sent, keys, elapsed, (elapsed * 1000.0 / sent) if sent else 0.0)) if args.reply: print("[REPLY] 合格 %d / 不合格 %d" % (reply_ok, reply_bad)) if reply_bad > 0 and reply_ok == 0: print("[HINT] 一个都没收全:先怀疑适配器。CH340 的 RX 顶不住 700 kbaud 连续流" "(4208 B 只到 ~2400 B 就是它的典型表现),换 FTDI / CP210x 再试。") print("[HINT] 固件侧是否真发了:看日志口 grep '[SBLINK]' 的 tx / done / fb 三列。") if reply_ok > 0: print("[REPLY] 召唤末位→应答首位:min %.2f / avg %.2f / max %.2f ms" "(判据 30 µs ~ 5 ms;USB 适配器自身有 1~2 ms 量级延迟,只能证上界)" % (lat_min, lat_sum / reply_ok, lat_max)) print("[REPLY] 整帧耗时(首字节→末字节):min %.2f / avg %.2f / max %.2f ms" "(线上 59.6 ms;**不能**据此判「帧内无间隙」,见文件头)" % (span_min, span_sum / reply_ok, span_max)) print("[REPLY] 整帧 ≤200 ms 判据:%s" % ("PASS" if lat_max + span_max < 200.0 else "FAIL")) if args.dump and last_bmp is not None: dump_pbm(args.dump, last_bmp) print("[DUMP] 点阵已存 %s(P4 160x160,可直接打开看)" % args.dump) print("[NEXT] 回看日志口:grep '[SBLINK]' —— 期望 rx == %d、ok == rx、crc=0、len=0%s" % (sent, "、tx == ok、done == tx、drop=0、fail=0、abort=0" if args.reply else "")) return 3 if reply_bad > 0 else 0 if __name__ == "__main__": sys.exit(main())