8744c833e5
- 重写 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。
320 lines
15 KiB
Python
320 lines
15 KiB
Python
#!/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())
|