feat(屏板): 帧 B 组帧 + 中断起发 TX DMA + 按键队列 + 离线判定(批 2)
批 2 五项全部落地(docs/任务规划/屏板链路.md §3): - 2-1 sb_link_frame_update():uiTask 渲染后按需组帧,不留 3200 B 影子 副本,直接拿活显存与帧内点阵段 memcmp;组完自校验,过了才置 ready。 静态 4208 B 帧缓冲实测落 RW_IRAM1(0x20006d98),不在 CCM。 - 2-2 空闲中断校完 6 B 召唤帧直接点 TX DMA(sb_tx_kick),不排队不攒批; busy 期间再来的召唤丢帧并计数,绝不叠发(屏板收帧一次 IDLE 一包)。 - 2-3 按键走环形队列(中断入、任务出),uiTask 按 §4.3 表映射 UI_KEY_* 后调 ui_port_queue_key;9/10(RESET/FACTORY)按决策四先丢弃并留痕。 - 2-4 HAL_UART_TxCpltCallback 在 rs485.c 加第三行转发(三个全局回调 落点收一处,防日后重复定义);busy 冻结帧缓冲,错误中断按 gState 收拾被打断的 TX,防残留 busy 把链路永久锁死。 - 2-5 连续 >=10 s 无有效召唤帧才判离线(不按 500 ms——屏板约 5000 s 自复位有 1 s 空窗),恢复即清标志;CRC 错帧静默丢弃并计数。 实测:台架 40 s(PC 顶替屏板,--key-fixed 2 持续改画面)rx=143 ok=143 keys=143 crc=0 / tx=143 done=143 drop=0 abor=0 fb=144 bad=0;脚本一停 10 s 准点出 OFFLINE。组帧耗时 509 µs 典型 / 709 µs 峰值——原 0.3 ms 判据系估算,已按实测在规划文档改口为 1 ms 并给出压降路径(未做)。 附带:rs485_on_rx 补 CODE-RULE-09 文档块(本轮改 rs485.c 触发整文件 重扫被拦);CRC 循环计数改 32 位(消掉 ARMCC 每轮 UXTH);bench 脚本 加 --reply/--key-fixed/--dump,并写明 CH340 收不了 700 kbaud 连续流、 --reply 收不全 4208 B 不是固件问题。 主机单测 146/146 PASSED(+65);Keil 0 错误 0 警告;ci_local 全绿 (SKIP=nested-comment,worktree 限制,主树脚本另查 0 新发)。 Code=238,936 B / RW_IRAM1 65.5 % / RW_CCM 87.9 %。
This commit is contained in:
+170
-16
@@ -3,15 +3,22 @@
|
||||
"""屏板链路台架激励:用 PC + USB 转 TTL **顶替屏板**,按屏板的召唤节拍发 6 B 帧 A。
|
||||
|
||||
做什么:
|
||||
在一个 TTL 串口上按屏板的节拍(默认 280 ms)发帧 A,模拟屏板的周期召唤;
|
||||
可穿插按键帧(屏板有键时最早 205 ms 就会再来一帧,本脚本只在周期点替换内容,
|
||||
不额外插帧——批 1 只验"收得到、长度对、CRC 过",不验应答时序)。
|
||||
主机侧的统计由固件自己打(`[SBLINK] rx=... ok=... crc=... len=...`),
|
||||
本脚本只负责"有东西可收",不读被测板——**收侧观测面是串口日志,不是本脚本**。
|
||||
1. **发**:在一个 TTL 串口上按屏板的节拍(默认 280 ms)发帧 A,模拟屏板的周期召唤;
|
||||
可穿插按键帧(`--key N`)。
|
||||
2. **收**(`--reply`,批 2 起):每发一帧就收主机回的 4208 B 帧 B,校验长度 / CRC /
|
||||
998 B 填充,量"召唤末位 → 应答首位"的间隔与整帧耗时,可选把点阵 dump 成 PBM。
|
||||
主机侧的统计由固件自己打(`[SBLINK] rx=... tx=...`),**收侧观测面是串口日志**;
|
||||
本脚本的 `--reply` 是另一路观测(应答本身对不对),两者互补,不能互相替代。
|
||||
|
||||
为什么不用串口助手 GUI:
|
||||
1-3 的判据含"连续 10 min 无丢帧",手点是做不到的;而且帧内容必须逐字节对
|
||||
(CRC 低字节在前,写错一位就是 100 % CRC 错帧,与"波特率错"同形)。
|
||||
2-2 的判据含"整帧 59.6 ms ±0.5 ms、≤200 ms 内结束",更是只能靠脚本量。
|
||||
|
||||
⚠ **USB 转 TTL 适配器量不到"帧内无间隙"**:USB 帧是 1 ms 量级,适配器把字节成批交上来,
|
||||
所以 `--reply` 报的 `span` 只能证明"整帧在 60 ms 量级内发完",不能证明"没有 ≥28 µs
|
||||
的间隙"(那是屏板断帧的硬约束,只能上逻辑分析仪或接真屏板时看)。别把 span 通过
|
||||
当成那条判据通过。
|
||||
|
||||
接法(docs/任务规划/屏板链路.md §2 决策一):
|
||||
适配器 TXD -> U5.B10(网络 ST_RX0,PA10) 适配器 RXD -> U5.A10(网络 ST_TX0,PA9)
|
||||
@@ -26,11 +33,15 @@
|
||||
用法(仓库根目录):
|
||||
& '.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 每帧收应答并校验(批 2 的 2-2 / 2-4)
|
||||
--dump P 把最后一帧应答的点阵存成 PBM(P4,160x160,可直接看)
|
||||
|
||||
退出码:0 = 按时发完;1 = 串口打不开 / 发送失败;2 = 用法错误。
|
||||
退出码:0 = 按时发完且(`--reply` 时)所有应答都对;1 = 串口打不开 / 发送失败;
|
||||
2 = 用法错误;3 = 有应答不合格。
|
||||
|
||||
设计依据:docs/任务规划/屏板链路.md §3 批 1 的 1-3 判据、§5 台架联调步骤第 2 步。
|
||||
设计依据:docs/任务规划/屏板链路.md §3 批 1 的 1-3 判据、批 2 的 2-2/2-4、§5 台架联调步骤第 2、3 步。
|
||||
创建日期:2026-10-29
|
||||
"""
|
||||
import argparse
|
||||
@@ -72,6 +83,76 @@ def build_frame(cmd, key=0):
|
||||
return body + bytes((crc & 0xFF, (crc >> 8) & 0xFF))
|
||||
|
||||
|
||||
# ---- 帧 B(主机 -> 屏板,4208 B):批 2 的应答校验 ----
|
||||
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 之类的适配器,或等批 4 接真屏板。
|
||||
"""
|
||||
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")
|
||||
@@ -81,16 +162,27 @@ def main():
|
||||
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 应答并校验(批 2 的 2-2 / 2-4)")
|
||||
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())
|
||||
@@ -112,13 +204,24 @@ def main():
|
||||
print("[WARN] 噪声注入失败(继续正常发):%s" % exc)
|
||||
|
||||
try:
|
||||
ser = serial.Serial(args.port, args.baud, bytesize=8, parity="N", stopbits=1, timeout=0)
|
||||
# 收应答时要给 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:
|
||||
@@ -130,7 +233,11 @@ def main():
|
||||
time.sleep(min(next_at - now, 0.02))
|
||||
continue
|
||||
|
||||
if args.key > 0 and (sent % args.key) == (args.key - 1):
|
||||
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
|
||||
@@ -139,12 +246,40 @@ def main():
|
||||
frame = rt_frame
|
||||
tag = "RT"
|
||||
|
||||
ser.write(frame)
|
||||
ser.flush()
|
||||
sent += 1
|
||||
if sent <= 3 or tag != "RT":
|
||||
print(" #%d %s -> %s" % (sent, tag, frame.hex(" ").upper()))
|
||||
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:
|
||||
@@ -157,8 +292,27 @@ def main():
|
||||
elapsed = time.monotonic() - t0
|
||||
print("[DONE] 发送 %d 帧(其中按键帧 %d)用时 %.1f s,平均周期 %.1f ms"
|
||||
% (sent, keys, elapsed, (elapsed * 1000.0 / sent) if sent else 0.0))
|
||||
print("[NEXT] 回看日志口:grep '[SBLINK]' —— 期望 rx == %d、ok == rx、crc=0、len=0" % sent)
|
||||
return 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__":
|
||||
|
||||
Reference in New Issue
Block a user