Files
STM32F4-Base/test/host/screenlink_bench.py
wandering1 8744c833e5 docs(屏板链路): 规划文档收编为设计说明事实源,全仓引用与端口口径同步
- 重写 docs/子系统设计/屏板链路设计说明.md:链路角色、串口与端口配置、
  物理接线、协议实现口径、键值与状态灯映射、资源与耗时、对外接口、
  已验证/未闭环、坑账本——只写现状事实,演进过程查 git 历史
- 删除 docs/任务规划/屏板链路.md(内容已全部沉淀进上述设计说明,
  登记簿「完成即销项」;docs/任务规划/README.md 相应两行一并移除)
- 代码与主机侧测试的文档指针改指设计说明对应章节:
  sb_link.h/.c、sb_proto.h/.c、sb_led_map.h、console_log.c、ui_task.c、
  test/host 的 sb_link/sb_proto/sb_led_map 三套与两个台架脚本
- docs/README.md:设计文档索引行与设计说明描述更新,任务规划目录 7→6 文件
- docs/产品需求/产品需求设计文档.md:串口通道现状口径同步——第 11/12/13 条
  已交付(4208 B 应答/状态灯掩码/键上行/双出口并发)、LOGO 下发出局不实现、
  帧长实测固化、调试日志口 UART5/RS232#1(§4.2/§5.16/§6.6/§9.12/§9.13)
- docs/子系统设计/控制台单写者日志设计说明.md v1.8:全文端口口径由
  USART1 同步为 UART5/RS232#1(CON_UART* 单点宏),ORE 机理标注跨端口
  同因,时间线补端口迁移行;头部修订指针改为实际章节名

说明:本任务经用户显式授权在任务树内编辑 docs/任务规划/ 与产品需求基线
(铁律「单写者」豁免一次);主树合并后统一跑全量 check_doc_links --all。
2026-10-30 09:22:44 +08:00

320 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""屏板链路台架激励:用 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 <cmd> <key> <CRC_L> <CRC_H>,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())