Files
STM32F4-Base/test/host/remolcd_pull_test.py
T
wandering1 0b84c43d2e feat(屏板): 状态灯位映射 sb_led_map + 104 RUN 观测口(批 3);附 4-1 并发压测脚本
批 3(LOGO 按用户裁定出局,不实现、sb_proto 的 LOGO 组帧挂待删标注):
- 新增 App/algo/sb_led_map.c/h:业务状态 → 屏板 LED 位掩码的唯一映射表
  (纯逻辑,主机单测可跑)。ID1 运行心跳 500ms 半周期闪(与主板 LED 同拍)、
  ID2 保护动作、ID3 104 链路 RUN、ID4 采样电平故障(不选 sticky——灯要能灭)、
  ID5 保护启动 picked;bit0 与 ID 6~19 恒 0。这张表参考源码里没有,属自定,
  出处 docs/产品需求/产品需求设计文档.md §5.16.3 ①。
- iec104_serv 新增 iec104_serv_link_run()(透传 link 状态机,不另存镜像量)。
- ui_task.c 帧组装块每拍现采四路状态源(均为任意上下文可读观测口)折算后
  传 sb_link_frame_update——替换批 2 的恒 0 占位;掩码变了帧就重组,
  复用 busy/dirty 机制,无新通路。
- 主机单测 test/host/sb_led_map 17/17(位语义/恒零位/闪烁相位/tick 回绕/
  NULL 契约);注册豁免走 SIM_EXEMPT(模拟器无屏板串口)。

批 4 的 4-1 双通道并发回归(真屏板在线实测):
- 新增 test/host/remolcd_pull_test.py:RemoDispBus 拉帧压测客户端
  (INIT→LCDMEM 10 Hz、逐帧 XOR+长度校验、往返延迟统计、--dump PBM)。
- 实测:网络侧两轮 2200 拉帧零错零超时(min 35/avg 63/max 131 ms);
  屏板侧 60 s 隔离窗口 rx +233 全收、drop 零新增、bad=0、off=0、无
  OFFLINE——两侧互不拖。观察项 len=7(断帧尺寸≠6,累计 7 次)出现窗口
  无法归因压测、隔离窗口零新增、有效帧零损失,已挂设计说明 §5.7 待查。

附带:sb_link.h frame_update 注释回填实测耗时(509/688 µs 取代旧估算)。
Keil 0 错误 0 警告;ci_local 全绿(SKIP=nested-comment,主树脚本另查 0 新发);
Code=239,064 B / RW_IRAM1 65.5 % / RW_CCM 87.9 %;覆盖率 90.1 % / 44 套件。
2026-10-30 00:39:57 +08:00

220 lines
8.6 KiB
Python
Raw 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 -*-
"""RemoDispBus(7003) 拉帧压力客户端:批 4 的 4-1「双通道并发回归」的网络侧激励。
做什么:
连 192.168.1.100:7003,先发 INIT 查询屏幕参数(应得 8 B:宽u16/高u16/帧字节数u32,
大端),随后按固定节拍持续发 LCDMEM(偏移 0) 拉整帧 3200 B 显存,逐帧校验
XOR 校验和与长度,统计请求数 / 应答数 / CRC 错 / 超时 / 往返延迟 min-avg-max。
可 --dump last_frame.pbm 存最后一帧(1bpp 布局与屏板帧 B 点阵段逐字节同构)。
为什么它能当 4-1 的激励:
7003 的每次 LCDMEM 往返在固件里是「uiTask 入队 + 等 netTask 回执」
(osMessageQueuePut(osWaitForever) + xTaskNotifyWait(portMAX_DELAY),最坏 ≈4 s,
见 ui_net_sink.h 的 ⚠),即**每一拍拉帧都在真实占用 uiTask**——这正是
「7003 拉帧期间屏板不超时」要压的条件。屏板侧应答起在 USART1 空闲中断里,
理论上不被 uiTask 拖住;4-1 的判据就是拿两边的数交叉证明这件事:
网络侧(本脚本):超时 ≈0、延迟分布正常;
固件侧(日志口 grep SBLINK):tx 跟得上 rx(drop 无新增)、bad=0、kerr=0。
协议(Drivers/BSP/UI/ui_net_sink.c 实测口径):
帧 = <TAG 1B> <CMD 1B> <LEN u16 大端> <payload LEN B> <XOR(payload) 1B>
客户端 TAG=0xAA,设备 TAG=0xBB;CMD:0=KEEP 1=INIT 2=KEY 3=LCDMEM。
LCDMEM 请求 payload = 偏移 u32 大端(仅支持 0);应答 payload = 偏移 u32 + 3200 B 位图。
(设备对 CRC 只做异或累加——与屏板链路的 CRC-16 是两套,别混。)
用法(仓库根目录,先清代理再跑——本机有 HTTP 代理会劫持内网连接):
& '.venv\\Scripts\\python.exe' test/host/remolcd_pull_test.py --seconds 30
--hz 10 拉帧节拍(默认 10 = 每秒 10 次,重压;1 次/秒 ≈ DTU-RemoteLCD 观感)
--dump x.pbm 退出前把最后一帧写成 PBM(160x160 1bpp,可直接图片查看)
退出码:0 = 全程无超时且 CRC 全对;1 = 出现超时/CRC 错/断连;2 = 用法错误。
设计依据:docs/任务规划/屏板链路.md §3 批 4 的 4-1。
创建日期:2026-10-30
"""
import argparse
import socket
import struct
import sys
import time
DEFAULT_IP = "192.168.1.100"
DEFAULT_PORT = 7003
TAG_CLIENT = 0xAA
TAG_DEVICE = 0xBB
CMD_KEEP = 0
CMD_INIT = 1
CMD_LCDMEM = 3
FB_SIZE = 3200
SCREEN_W = 160
SCREEN_H = 160
def build_frame(cmd, payload=b""):
"""组一帧客户端请求:AA <cmd> <len16be> <payload> <xor>。"""
body = bytes([TAG_CLIENT, cmd, (len(payload) >> 8) & 0xFF, len(payload) & 0xFF])
xor = 0
for b in payload:
xor ^= b
return body + payload + bytes([xor])
class FrameReader:
"""设备应答流重组(逐字节状态机,TAG 定界——与固件侧同思路,噪声字节自动重同步)。"""
def __init__(self):
self.buf = bytearray()
def pop(self):
"""从缓冲里取一个完整 (cmd, payload);没有则 None。坏帧丢弃继续找下一个 TAG。"""
while True:
i = self.buf.find(bytes([TAG_DEVICE]))
if i < 0:
del self.buf[:] # 整段都不是帧头:全弃
return None
if i > 0:
del self.buf[:i] # 丢帧头前的噪声
if len(self.buf) < 4:
return None
cmd = self.buf[1]
ln = (self.buf[2] << 8) | self.buf[3]
total = 4 + ln + 1
if len(self.buf) < total:
return None
payload = bytes(self.buf[4:4 + ln])
crc = self.buf[4 + ln]
del self.buf[:total]
xor = 0
for b in payload:
xor ^= b
if crc != xor:
return ("CRCERR", payload)
return (cmd, payload)
def feed(self, data):
self.buf += data
def run(args):
try:
s = socket.create_connection((args.ip, args.port), timeout=5.0)
except OSError as exc:
print("[ERR] 连 %s:%d 失败:%s" % (args.ip, args.port, exc))
print(" (若在内网却报超时/奇怪拒绝:先清 HTTP 代理再跑,见文件头用法行)")
return 1
s.settimeout(5.0)
rd = FrameReader()
def wait_frame(want_cmd):
"""收一个 cmd==want_cmd 的应答;CRC 错返回 ('CRCERR', None);超时返回 None。"""
t0 = time.monotonic()
while time.monotonic() - t0 < 5.0:
f = rd.pop()
if f is not None:
cmd, payload = f
if cmd == "CRCERR":
return ("CRCERR", None)
if cmd == want_cmd:
return (cmd, payload)
# 其它命令的应答(如残留 KEEP 回显):跳过继续找
continue
try:
data = s.recv(4096)
except socket.timeout:
return None
if not data:
return "CLOSED"
rd.feed(data)
return None
# ---- INIT:查询屏幕参数,顺带把几何钉死 ----
s.sendall(build_frame(CMD_INIT))
r = wait_frame(CMD_INIT)
if r is None or r == "CLOSED" or r[0] == "CRCERR":
print("[ERR] INIT 无有效应答(%s)" % (r,))
s.close()
return 1
_, payload = r
if len(payload) >= 8:
w, h, nbytes = struct.unpack(">HHI", payload[:8])
print("[INIT] %dx%d frame_bytes=%u" % (w, h, nbytes))
if w != SCREEN_W or h != SCREEN_H or nbytes != FB_SIZE:
print("[WARN] INIT 几何与预期 160x160/3200 不符——继续,以设备为准")
else:
print("[WARN] INIT 应答长度 %d < 8,跳过几何核对" % len(payload))
# ---- LCDMEM 主循环 ----
n_req = n_resp = n_crc = n_to = 0
lat_sum = lat_min = lat_max = 0.0
last_bmp = None
period = 1.0 / args.hz
t_end = time.monotonic() + args.seconds
next_at = time.monotonic()
while time.monotonic() < t_end:
now = time.monotonic()
if now < next_at:
time.sleep(min(next_at - now, 0.005))
continue
t0 = time.monotonic()
s.sendall(build_frame(CMD_LCDMEM, struct.pack(">I", 0)))
n_req += 1
r = wait_frame(CMD_LCDMEM)
if r == "CLOSED":
print("[ERR] 第 %d 次拉帧时对端断开" % n_req)
s.close()
break
if r is None:
n_to += 1
print("[WARN] #%d LCDMEM 超时(5 s)" % n_req)
else:
cmd_got, payload = r
if cmd_got == "CRCERR":
n_crc += 1
print("[WARN] #%d CRC 错" % n_req)
elif len(payload) != 4 + FB_SIZE:
n_crc += 1
print("[WARN] #%d 应答长度 %d != %d" % (n_req, len(payload), 4 + FB_SIZE))
else:
dt = time.monotonic() - t0
n_resp += 1
lat_sum += dt
lat_min = dt if lat_min == 0 else min(lat_min, dt)
lat_max = max(lat_max, dt)
last_bmp = payload[4:]
next_at += period
if next_at < time.monotonic() - 1.0: # 落后太多不追(避免突发风暴)
next_at = time.monotonic()
s.close()
avg = (lat_sum / n_resp * 1000.0) if n_resp else 0.0
print("[STAT] 请求 %d / 应答 %d / CRC错 %d / 超时 %d" % (n_req, n_resp, n_crc, n_to))
print("[STAT] 往返延迟 min %.1f / avg %.1f / max %.1f ms"
% (lat_min * 1000.0, avg, lat_max * 1000.0))
if args.dump and last_bmp:
with open(args.dump, "wb") as f:
f.write(b"P4\n%d %d\n" % (SCREEN_W, SCREEN_H))
f.write(last_bmp)
print("[DUMP] 最后一帧已写 %s(P4 PBM,与屏板帧 B 点阵段同构)" % args.dump)
ok = (n_to == 0) and (n_crc == 0) and (n_resp > 0)
print("[VERDICT] %s" % ("PASS" if ok else "FAIL"))
return 0 if ok else 1
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--ip", default=DEFAULT_IP)
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--seconds", type=float, default=30.0)
ap.add_argument("--hz", type=float, default=10.0, help="拉帧节拍(次/秒,默认 10)")
ap.add_argument("--dump", default="", help="退出前把最后一帧写成的 PBM 路径")
args = ap.parse_args()
if args.seconds <= 0 or args.hz <= 0:
print("[ERR] --seconds / --hz 必须为正")
return 2
return run(args)
if __name__ == "__main__":
sys.exit(main())