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 套件。
This commit is contained in:
2026-10-30 00:39:57 +08:00
parent 9c6d150261
commit 0b84c43d2e
17 changed files with 768 additions and 39 deletions
+219
View File
@@ -0,0 +1,219 @@
#!/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())
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""屏板状态灯位映射(sb_led_map)主机侧纯逻辑单元测试运行器(纯标准库)。
做什么:用本机 MSVC 离线编译 test/host/sb_led_map/sb_led_map_test.c
(#include 纳入生产源文件 App/algo/sb_led_map.c),验证位语义
(每路状态只点亮对应位、可叠加不遮蔽)、恒零位(bit0 / ID 6~19)、
心跳闪烁相位与 tick 回绕、NULL 输入契约。
为什么这套判据在真机上测不动:真机只能看到"哪盏灯亮了"(目视),看不到
"掩码里多没多出一位保留位"——保留位被顺带点亮时屏板毫无反应,现场表现为
"改了表没效果";主机侧用"掩码 & ~定义集 == 0"一条就把映射表钉死。
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' test/host/sb_led_map/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/任务规划/屏板链路.md §3 批 3 的 3-1。
创建日期:2026-10-29
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
BUILD = TestBuild(
name="SB LED Map Tests",
src=os.path.join(HERE, "sb_led_map_test.c"),
include_dirs=[
os.path.join(ROOT, "App", "algo"),
HERE,
],
summary_marker="=== SB LED Map Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+234
View File
@@ -0,0 +1,234 @@
/*
* 模块名称:屏板状态灯位映射(sb_led_map)主机侧单元测试
* 模块功能:在 PC 上离线验证 App/algo/sb_led_map.c 的位语义、恒零位、心跳闪烁
* 相位与时基回绕行为。
*
* 被测对象:真实源文件 App/algo/sb_led_map.c(#include 原样纳入)。
* 本层纯逻辑(仅 stdint.h),**无需任何桩**——与 adc_health 套件同形。
*
* 为什么值得单测(真机上验的是"灯亮没亮",这里验的是"亮的是不是那一位"):
* 1. bit0 恒 0——屏板 ID 0 是硬件常亮电源灯,掩码 bit0 根本不被读;但一旦
* 哪天有人往映射里塞 bit0,测试先拦住,而不是到现场发现"多一位没反应"。
* 2. ID 6~19 恒 0——映射表之外的位不许被顺带点亮(掩码里出现未定义位 = 表被改
* 而测试没跟上,这是这张"自定表"唯一的防漂移手段)。
* 3. 心跳相位在 tick 回绕点不得连灭两个 500 ms 段(操作员眼里的"装置停了")。
* 4. NULL 输入只跑心跳(心跳证明的是"代码活着",不该被空输入连坐熄灭)。
*
* 编号:TC-SBLEDMAP-001 起(小写子项 = 同一判据的另一面)。
* 创建日期:2026-10-29(批 3 · 3-1)
*/
#include <stdint.h>
#include <stdio.h>
#include "sb_led_map.h"
/* 被测生产源(include_dirs 里有 App/algo) */
#include "sb_led_map.c"
static int s_pass;
static int s_fail;
#define CHECK(cond, name) \
do { \
if (cond) { \
s_pass++; \
} else { \
s_fail++; \
printf("FAIL: %s (line %d)\n", (name), __LINE__); \
} \
} while (0)
/* 亮/灭心跳各取一个代表时刻(偶段 = 亮,见 sb_led_map.c) */
#define T_ON (0U) /* [0,500) 偶段 → 亮 */
#define T_OFF (500U) /* [500,1000) 奇段 → 灭 */
/*
* TC-SBLEDMAP-001 全零输入:只有心跳位,其余状态位全灭
*/
static void tc_zero_input(void)
{
sb_led_in_t in;
uint32_t m;
in.prot_active = 0U;
in.prot_pick = 0U;
in.adc_flags = 0U;
in.iec104_run = 0U;
m = sb_led_map_eval(&in, T_ON);
CHECK(m == SB_LED_BIT_RUN, "TC-SBLEDMAP-001 zero input lights only the run blink (on phase)");
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == 0U, "TC-SBLEDMAP-001b zero input in the dark half-cycle is all-zero");
}
/*
* TC-SBLEDMAP-002 每一路状态各自点亮且只点亮对应位(非零即真,不看具体值)
*/
static void tc_one_source_each(void)
{
sb_led_in_t in;
uint32_t m;
in.prot_active = 0U;
in.prot_pick = 0U;
in.adc_flags = 0U;
in.iec104_run = 0U;
in.prot_active = 0x00080000U; /* 随便哪个元件动作 */
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == SB_LED_BIT_ALARM, "TC-SBLEDMAP-002 prot_active -> ID 2 only");
in.prot_active = 0U;
in.prot_pick = 1U;
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == SB_LED_BIT_PICKUP, "TC-SBLEDMAP-002b prot_pick -> ID 5 only");
in.prot_pick = 0U;
in.adc_flags = 0x80000000U; /* 高位故障位同样算非零 */
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == SB_LED_BIT_ADC_FAULT, "TC-SBLEDMAP-002c adc_flags -> ID 4 only");
in.adc_flags = 0U;
in.iec104_run = 1U;
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == SB_LED_BIT_COMM, "TC-SBLEDMAP-002d iec104_run -> ID 3 only");
in.iec104_run = 0U;
}
/*
* TC-SBLEDMAP-003 多路同时:位可叠加、互不遮蔽(告警+通信同时真是合法工况)
*/
static void tc_stack(void)
{
sb_led_in_t in;
uint32_t m;
in.prot_active = 3U;
in.prot_pick = 3U;
in.adc_flags = 1U;
in.iec104_run = 1U;
m = sb_led_map_eval(&in, T_ON);
CHECK(m == (SB_LED_BITS_DEFINED), "TC-SBLEDMAP-003 all sources + blink = exactly the defined set");
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == (SB_LED_BITS_DEFINED & ~SB_LED_BIT_RUN),
"TC-SBLEDMAP-003b dark half-cycle drops only the run bit");
}
/*
* TC-SBLEDMAP-004 恒零位:bit0 与 ID 6~19 在任何输入下不得出现
*(bit0 屏板根本不读;ID 6~19 是保留位——掩码出现表外位 = 表被改而没走测试)
*/
static void tc_reserved_bits(void)
{
sb_led_in_t in;
uint32_t t;
int clean = 1;
in.prot_active = 0xFFFFFFFFU;
in.prot_pick = 0xFFFFFFFFU;
in.adc_flags = 0xFFFFFFFFU;
in.iec104_run = 1U;
/* 扫 3 秒时基(6 个半周期)+ 边界时刻,全取定义集做差 */
for (t = 0U; t < 3000U; t += 499U) {
uint32_t m = sb_led_map_eval(&in, t);
if ((m & ~SB_LED_BITS_DEFINED) != 0U) {
clean = 0;
}
}
CHECK(clean == 1, "TC-SBLEDMAP-004 no bit outside the defined set ever appears");
CHECK((sb_led_map_eval(&in, T_ON) & 1U) == 0U, "TC-SBLEDMAP-004b bit0 stays 0 (screen board ignores it)");
}
/*
* TC-SBLEDMAP-005 心跳相位:连续 8 个半周期亮灭交替,边界含等号(500 整 = 暗段起点)
*/
static void tc_blink_sequence(void)
{
int i;
int prev = -1;
int alt = 1;
uint32_t m;
for (i = 0; i < 8; i++) {
uint32_t t = (uint32_t)i * SB_LED_BLINK_HALF_MS;
int on;
m = sb_led_map_eval(NULL, t);
on = ((m & SB_LED_BIT_RUN) != 0U) ? 1 : 0;
if ((prev >= 0) && (on == prev)) {
alt = 0;
}
prev = on;
}
CHECK(alt == 1, "TC-SBLEDMAP-005 run bit alternates every half-period");
CHECK((sb_led_map_eval(NULL, 0U) & SB_LED_BIT_RUN) != 0U,
"TC-SBLEDMAP-005b power-on first half-period is LIT (blink visible from t=0)");
CHECK((sb_led_map_eval(NULL, SB_LED_BLINK_HALF_MS) & SB_LED_BIT_RUN) == 0U,
"TC-SBLEDMAP-005c the boundary instant belongs to the dark half-period");
CHECK((sb_led_map_eval(NULL, SB_LED_BLINK_HALF_MS - 1U) & SB_LED_BIT_RUN) != 0U,
"TC-SBLEDMAP-005d last ms before the boundary still lit");
}
/*
* TC-SBLEDMAP-006 tick 回绕:跨 0xFFFFFFFF 连续熄灭时长不得超过一个半周期
*(相位由除法直接得出、不依赖"上次"状态。回绕把末段截短、下一段从"偶段=亮"
* 重新开始——最坏也只是某段被截短,不会出现操作员眼里的"连灭 1 s = 装置停了"。
* 1 ms 步进扫回绕前后各 1.5 个周期,量最长暗段。)
*/
static void tc_wraparound(void)
{
uint32_t t;
int run = 0; /* 当前连续暗 ms 数 */
int worst = 0;
/* now 从 0xFFFFFFFF-1500 走到 0xFFFFFFFF 再接 0..1500(模 2^32 连续) */
for (t = 0xFFFFFFFEU - 1500U; t != 0xFFFFFFFFU; t++) {
int dark = ((sb_led_map_eval(NULL, t) & SB_LED_BIT_RUN) == 0U) ? 1 : 0;
run = (dark != 0) ? (run + 1) : 0;
if (run > worst) {
worst = run;
}
}
for (t = 0U; t <= 1500U; t++) {
int dark = ((sb_led_map_eval(NULL, t) & SB_LED_BIT_RUN) == 0U) ? 1 : 0;
run = (dark != 0) ? (run + 1) : 0;
if (run > worst) {
worst = run;
}
}
/* 半周期 500 ms + 1 ms 扫描步进余量 */
CHECK(worst <= (int)SB_LED_BLINK_HALF_MS + 1,
"TC-SBLEDMAP-006 dark run never exceeds one half-period across tick wraparound");
}
/*
* TC-SBLEDMAP-007 NULL 输入 = 全零输入 + 心跳照常(注释契约:心跳不被连坐)
*/
static void tc_null_input(void)
{
CHECK(sb_led_map_eval(NULL, T_ON) == SB_LED_BIT_RUN,
"TC-SBLEDMAP-007 NULL input still blinks (liveness signal must not depend on inputs)");
CHECK(sb_led_map_eval(NULL, T_OFF) == 0U,
"TC-SBLEDMAP-007b NULL input lights nothing else");
}
int main(void)
{
printf("-- sb_led_map unit tests --\n");
tc_zero_input();
tc_one_source_each();
tc_stack();
tc_reserved_bits();
tc_blink_sequence();
tc_wraparound();
tc_null_input();
printf("=== SB LED Map Tests: %d/%d PASSED ===\n", s_pass, s_pass + s_fail);
return (s_fail == 0) ? 0 : 1;
}