票 = task-25 第二半(配套上一笔的 scripts/board_ui_nav.py)。 docs/UI设计/UI_自动化测试流程.md 新增第七节: - 7.1 为什么必须闭环:KEY 空帧 + 盲走 9 帧仅 3 帧名副其实的实测表(含每帧落点与墨量), 三张证据 PNG 落 docs/测试说明/boot_log_data/(弹框裁切现场、盲走落密码页、盲走停在级联)。 「512 bits 神秘空白态」在表里定名为**密码输入页**——走带 #P 注记的叶子时第一屏是要密码。 - 7.2 认屏三判据(表格:判据 + 为什么这样判)与两条实测纪律(l 收列、#P 注记顺序)。 - 7.3 四种用法与钩子档位:钩子只跑快检档,完整档(含闭环)合入前 / 上板前手工跑;「没跑」 与「跑过且通过」在脚本输出里是两种打印,不互相顶替。 - 7.4 四条已知限度:末两行叶子弹框被裁(真机定位留台架窗口取证,是取证题不是编码题)、 同标题多态不区分、与黄金文案的有意耦合、快检不证导航。 - 7.5 过密码口径:查看 000 / 修改 123,键序与黄金回归编辑态完全一致;只读判据不自动升格。 docs/UI设计/UI_数据供给设计说明.md 追加落地注·五(指针级):写路径(批 5)必须把「过密码」 当显式状态——快照层不提供「曾否进过某页」,可写性只由 UI 权限态与 par.locked 决定。 纯文档与取证图,无行为改动。取证 PNG 由 simulator/i1_to_png.py 从留档帧转出(4 倍放大,与 其余 UI 证据图同规格);本笔钩子按各自触发面执行,其中 doc-links 会核对本节新增的两处 相对链接与三张图的存在性。
36 KiB
UI 数据供给设计说明(高内聚低耦合)
定位:解决"内容页拿不到实时数据"的架构问题——给出数据从 App 层到 UI 层的唯一通道、数据契约、并发模型、写路径与测试策略。 问题来源:
docs/UI设计/页面补齐规划.md§1 与 §3.1(现状:交流量第 2/3 页、直流量全部为硬编码占位值)。 门禁口径:scripts/check_layer_deps.py的文件头已写明三种合法做法(注入 / 上层取好传进来 / 叶子工具进 ALLOW)。本文选择并细化前两种。 前置阅读:docs/UI设计/内容页架构.md(页面描述符与 4 个钩子)、docs/架构设计/项目架构总览.md(六层单向依赖)。
1. 问题与约束
1.1 现象
| 页 | 应该显示 | 实际显示 |
|---|---|---|
| 交流量 | 8 路电气量 + 温度的有效值 | 第 1 页写死实测值,第 2/3 页写死占位值 |
| 直流量 | 直流通道 | 3 行写死(3.01/3.15/3.288) |
页面代码本身没问题——问题在于页面没有任何合法途径拿到 App 层的数据。
1.2 根因
六层单向依赖规定 Drivers/BSP/** 不得 include App/** 的头,且这条规则已经机械化(scripts/check_layer_deps.py,2026-10-22 起拦新增)。而 UI 需要的全部数据都在 App/:
| 数据 | 现居位置 | 层 |
|---|---|---|
| 相量(有效值/相角) | App/algo/phasor.h |
算法层 |
| 频率 | App/algo/freq.h |
算法层 |
| 定值(40 项 + 封存态) | App/algo/prot_set.h |
算法层 |
| 遥信位图 / 品质 | App/protocol/iec104/iec104_pts.h |
协议层 |
| 时钟 | App/util/sys_clock.h |
工具层(已在 ALLOW 豁免内) |
| 运行/自检记录 | App/log/sys_log.h |
服务层 |
已有的 3 条基线违规(ui_page_runlog.c / ui_page_selftest.c / ui_w_records.h → sys_log.h)就是这条通道缺失的化石证据——当时为了做记录页,选择了"直接 include 上层头",门禁注释里写着"记录读取应由上层递进来",但递进来的机制一直没建。
1.3 反模式:extern 全局变量
门禁只扫 #include。因此在 BSP 里写 extern const float g_phasor_ua; 就能完全绕过检查。
这是本设计要明确禁止的做法,理由不是"门禁会拦",而是它比 include 更糟:
| 维度 | include 头 | extern 全局 |
|---|---|---|
| 编译期契约 | 有(类型/签名) | 无(类型写错是未定义行为) |
| 所有权 | 明确 | 无(谁写谁读说不清) |
| 并发 | 至少能看到调用 | 完全隐式 |
| 可测性 | 可桩替 | 必须链接真实现 |
| 门禁可见性 | 会报警 | 静默通过 |
结论:数据通道必须是显式的、有契约的、可测的,不能靠"能编过"。
2. 方案选型
2.1 四个候选
| 方案 | 做法 | 依赖方向 | 绘制路径开销 | 可测性 | 并发 |
|---|---|---|---|---|---|
| A 直连 | BSP include App/algo/phasor.h |
BSP → App(反向) | 每行一次跨层调用 | 差(要链真实现) | 隐式 |
| B extern | BSP 声明 extern 全局 |
无(不可见) | 读全局 | 差 | 无保护 |
| C 注入(pull) | BSP 定义 ui_data_src_t 函数指针表,App 启动时注入;UI 主动调 src->get_yc(i) |
App → BSP(正确) | 每行一次函数指针调用 | 好 | 调用时序耦合 |
| D 快照推送(push) | BSP 拥有快照存储 + 发布 API;App 周期组装并发布;页面只读本地快照 | App → BSP(正确) | 纯本地读 | 好(直接 publish 假帧) | 单写者 + 同上下文 |
2.2 评分
| 维度 | A | B | C | D |
|---|---|---|---|---|
| 依赖方向正确 | ✗ | ✗ | ✓ | ✓ |
| 页面与数据源解耦(换数据源不改页面) | ✗ | ✗ | 部分(接口在 BSP,实现可换) | ✓(页面只认快照结构) |
| 绘制路径无跨层调用 | ✗ | ✓ | ✗ | ✓ |
| 主机单测可驱动 | ✗ | ✗ | ✓ | ✓ |
| 并发模型简单 | ✗ | ✗ | ✓ | ✓(单写者同上下文) |
| 模拟器 golden 可稳定 | ✗ | ✗ | 需假实现 | ✓(publish 固定帧) |
| 新增字段的扩散面 | 大 | 大 | 中(改接口 + 实现) | 小(改结构体 + 填充处) |
选 D 为主通道,C(注入)用于两类不适合快照的场景:写路径(定值整定)与记录分页读取(数据量随记录增长,不能全量快照)。
2.3 为什么 D 能做到"高内聚"
- 内聚:
ui_data只干一件事——存最新一帧并给出只读访问;ui_data_feed只干一件事——把 App 的散装数据组装成一帧。两者都不含对方的知识。 - 耦合:页面只依赖一个结构体,不依赖任何 App 模块;App 只依赖一个发布函数,不依赖任何 UI 概念(页面、行、几何一概不知)。
- 变化隔离:将来遥测链(谐波方和根 + 平均 + 死区)落地,只改
ui_data_feed里的取数来源,页面零改动;反过来页面改版式,App 零改动。
3. 总体架构
3.1 依赖方向
flowchart TB
subgraph APP["App 层(业务)"]
F["App/app/ui_data_feed.c<br/>组装一帧(唯一填充者)"]
SRC["phasor / freq / prot_set / iec104_pts / sys_clock"]
F --> SRC
end
subgraph BSP["Drivers/BSP/UI(表现层)"]
D["ui_data.c/h<br/>快照存储 + 只读访问器"]
P["ui_page_ac / ui_page_yx / ui_page_setlist ...<br/>只读快照 + 格式化 + 布局"]
P --> D
end
T["App/task/ui_task.c<br/>调度:每轮调 feed_tick()"] --> F
T --> P
F -- "ui_data_publish_rt/par(&帧)" --> D
include 方向(全部合法):
| 文件 | include | 方向 |
|---|---|---|
App/app/ui_data_feed.c |
ui_data.h(BSP)+ 各 App 头 |
App → BSP ✓ |
App/task/ui_task.c |
ui_data_feed.h + ui_menu.h |
App → BSP ✓ |
Drivers/BSP/UI/ui_page_*.c |
ui_data.h(同层 BSP) |
同层 ✓ |
Drivers/BSP/UI/ui_data.c |
仅 stdint.h / string.h |
零依赖 ✓ |
ui_data.c 不 include 任何 App 头,也不 include 任何页面头——它是叶子。
3.2 三个模块的职责边界
| 模块 | 位置 | 只做 | 绝不做 |
|---|---|---|---|
ui_data |
Drivers/BSP/UI/ |
存最新一帧、给只读访问、记发布序号 | 取数、格式化、判断业务含义 |
ui_data_feed |
App/app/ |
按节拍从各 App 模块取数、组装、发布;变化检测 | 布局、字符串格式化、LVGL 调用 |
页面 ui_page_* |
Drivers/BSP/UI/ |
读快照、按行表格式化、交给框架绘制 | 直接调 App、自己算业务量 |
4. 数据契约
分两条独立通道,因为更新频率与语义完全不同:
| 通道 | 内容 | 节拍 | 写者 |
|---|---|---|---|
rt(实时量) |
遥测 9 路 + 品质、遥信位图、频率、时钟 | 200 ms | ui_data_feed(uiTask 上下文) |
par(参数) |
定值 40 项(Q10)+ 校验和 + 封存态 + 越界位图 | 变化触发 | ui_data_feed(同上下文) |
4.1 实时快照 ui_data_rt_t
#define UI_DATA_YC_N 9u /* 语义通道数:UA/UB/UC/UN/IA/IB/IC/IN/温度 */
#define UI_DATA_YX_N 33u /* 遥信点数(与 104 点表同序) */
/* 品质位(与 104 QDS 语义对齐,页面只消费前 3 位) */
#define UI_DATA_Q_IV 0x01u /* 无效:该通道无源/不可用 */
#define UI_DATA_Q_NT 0x02u /* 未就绪:尚未攒满一周波 */
#define UI_DATA_Q_OV 0x04u /* 越限:超过量程/削顶 */
#define UI_DATA_Q_SB 0x08u /* 取代值 */
#define UI_DATA_Q_BL 0x10u /* 闭锁 */
#define UI_DATA_Q_RAW 0x20u /* 原始码值域:val 不是工程量而是转换器码值(TEMP 用) */
typedef struct {
float val; /* 数值;口径由 ui_data_rt_t.eng 决定 */
uint8_t qds; /* 品质位组合 */
} ui_data_yc_t;
typedef struct {
uint32_t seq; /* 发布序号(单调递增;页面据此判"是否需重建行缓存") */
uint32_t tick_ms; /* 发布时刻(tick 域,差值法可比) */
uint8_t valid; /* 0 = 尚无首帧(页面显示 "--") */
uint8_t eng; /* 量纲口径:0 = ADC 引脚电压(现状)/ 1 = 二次值 /
* 2 = 一次值(预留)——真值语义见下方"要点"第 1 条 */
ui_data_yc_t yc[UI_DATA_YC_N];
uint64_t yx_bits; /* 遥信位图:bit i = 第 i 点"合/动作" */
uint64_t yx_valid; /* 遥信有效位图:bit i = 1 该点有硬件来源 */
uint64_t yx_dp; /* 遥信双点位图:bit i = 1 该点是双点型(M_DP) */
uint8_t freq_valid;
float freq_hz;
uint8_t clock_ok; /* 0 = 时钟无效(RTC 异常) */
char clock[20]; /* "YYYY-MM-DD HH:MM:SS" 固定长度,无指针 */
} ui_data_rt_t;
要点:
eng字段是诚实性的关键(【2026-10-25 落地注】本条原写"0 = 原始码值(未标定)、val是 ADC 码值(千级)",那是标定落地前的旧口径,已按现场改真值语义):标定层adc_cal的 ROM 默认系数按引脚侧 LSB定死(TPAFE 152.59 µV/码、STM32 805.66 µV/码),采样环折算出来的val现在是 ADC 引脚电压 V——既不是千级码值,也不是端子二次值。承诺边界的依据是docs/任务规划/数据可信.md§2.6:这一波只承诺到"引脚电压","端子二次值"要等变比确认与两点标定。所以页面文案是eng=0→ "未标定"、eng=1→ "二次值",而不是"码值";升档条件写在App/app/ui_data_feed.c的feed_eng()——电气 8 路adc_cal_flags()的 VALID 全置位才升(整帧共用一个eng,只要还有一路是引脚电压就不能给整帧贴"二次值"标签,宁可整体保守)。TEMP 不参与线性标定 → 该路恒带UI_DATA_Q_RAW,页面据此不挂单位、不与其他路互比。- 字符串只用定长数组:
clock[20]在结构体内,无指针、无生命周期问题(跨层传指针是经典事故源)。 yx_valid与yx_bits分开:无效点不参与显示、不参与变位判断(沿用 104 点表的既有做法,避免"IV 通道抖动成变位")。yx_dp(2026-10-25 批 4 追加):单点还是双点是 104 点表事实(iec104_pts.c的YX_DP_N/iec104_pts_yx_is_dp()),由写侧ui_data_feed.c逐点问点表后下发位图;页面只按位图选词 (双点"合位/分位"、单点"动作/复归"、无效 "--")。把"前 4 点是双点"抄进表现层就是第二真相: 点表改成双点段挪位或加高起始下标,屏上不会报错,只会用错词形容同一个点。V0.1 这 4 点无开入 硬件 → 不在yx_valid内,值列仍打 "--"(本字段不参与无效判定)。点数一致性由写侧一条 一次性DBG_ERROR兜(UI_DATA_YX_N与iec104_pts_yx_count()不等时留痕),因为该常量 在协议层是 .c 内的#define,编译期拿不到。
4.2 参数快照 ui_data_par_t
#define UI_DATA_SET_N 40u /* 定值项数(与 prot_set 枚举同序,见 §9.2 一致性测试) */
typedef struct {
uint32_t seq;
uint32_t checksum; /* 与 prot_set_checksum() 同值:页面据此判"参数是否变过" */
uint8_t valid;
uint8_t locked; /* 定值闭锁(校验和自检失败等) */
uint8_t bad_valid; /* 越界位图有效 */
uint8_t rsv;
uint64_t bad_mask; /* 40 位:置 1 = 该定值项越界 */
int32_t q10[UI_DATA_SET_N]; /* Q10 原值;页面按行表的小数位换算显示 */
} ui_data_par_t;
为什么存 Q10 而不是 float:定值是整表校验和覆盖的定点值,快照存浮点会引入"显示值与校验和口径不一致"的二义性。Q10 → 显示值的小数位换算属表现层(§8)。
4.3 扩展规则
- 只允许尾部追加字段,结构体开头保留
seq/valid。 - 追加字段必须同时升
UI_DATA_VER(编译期常量),并在ui_data_feed里填默认值。 - 禁止删除或重排字段(页面按名字取,重排是静默错位)。
4.4 通道三:装置信息 ui_data_info_t(2026-10-25 落地新增)
落地时比本文原稿多出一条同构通道,承载既不属于 200 ms 实时量、也不属于校验和门控参数帧的那批量:
| 字段 | 内容 | 为什么不塞进已有的两条通道 |
|---|---|---|
model / sw_ver / hw_ver / build_date / sw_sum |
装置身份(型号、软硬件版本、程序日期、软件校验) | 编译期常量,一辈子不变;塞进 rt 等于每 200 ms 白拷约 60 B 字符串 |
uptime_s |
开机运行时长 | 每秒都在变;塞进 par 会被"校验和没变就不重发"的门控冻住(页面显示一个不再增长的时长 = 假数据) |
port_104 / port_disp / ftp_user |
通讯参数(104 端口、远程显示端口、FTP 用户名) | 值必须在 App 侧取(页面不得自己 include 协议头,docs/UI设计/页面扩充详细方案.md §3.5),但它们是静态量,与遥测节拍无关 |
- 结构与两条主通道同构:
seq+valid打头、定长字符数组、零指针、由ui_data盖章序号、由App/app/ui_data_feed.c独家填充,随实时帧同节拍发布。 - 公共地址 CA 故意不在本帧:固件里 CA 是逐帧从 ASDU 头解析的,没有集中常量可取;等 104 侧给出查询点再按
docs/UI设计/UI_数据供给设计说明.md§4.3 的扩展规则追加字段并升UI_DATA_VER。 - 版本类字段目前是
App/app/ui_data_feed.c里的宏(仓库内没有产品级版本登记表,值沿用页面既有显示值,有正式口径后只改那一处);软件校验位在拿到构建期 CRC 生成步骤之前一律显示--,不抄参考项目遗留值。
另一处落地细化:
ui_data_rt_t除yc[](语义通道序基波有效值)外还带一条ph[]相量实例槽带(ph_n给实际槽数)。原因是谐波(Ia的二次/五次)无法在"语义通道"下标空间里表达——同一通道的谐波会与基波撞位,而交流量页要显示它们(docs/UI设计/页面扩充详细方案.md§3.1.1 第 4/5 行)。两条带的元素同形(数值 + 品质),页面行表按需选一条。
5. 并发与时序
5.1 单写者 + 同上下文(核心简化)
约定:rt 与 par 的唯一发布者是 ui_data_feed_tick(),它在 uiTask 上下文运行;页面的读取也在 uiTask 上下文(绘制钩子与按键钩子都跑在 ui_task_func 主循环里,见 docs/UI设计/内容页架构.md §六)。
→ 读写同线程,不需要锁、不需要双缓冲、不需要 seqlock。
这一点值得展开,因为它与"直觉上要加保护"相反:
| 顾虑 | 实际情况 |
|---|---|
| 发布时页面正在绘制? | 不可能:绘制与发布都在 uiTask,串行执行 |
| 104 主站写定值会改快照? | 不会:主站只改 prot_set,快照由 feed 下一拍重新拉取 |
| 其他任务要推数据? | 不允许——要加新数据,就在 feed 里加取数,仍由 uiTask 发布 |
如果将来真出现第二个发布者(例如 netTask 收到主站写定值后要"立刻"刷新 UI),扩展路径是:给 ui_data 内部加"双槽 + 每槽 seq"(写非活动槽 → 盖 seq → 翻转单字节活动下标,同 phasor 双区先例)。页面代码不受影响——这正是把并发策略藏在 ui_data 内部的价值。
5.2 发布节拍
| 通道 | 节拍 | 理由 |
|---|---|---|
rt |
200 ms(内部计数分频,UI tick 10 ms × 20) | 与遥测链目标刷新率一致;人眼在 160×160 单色屏上感知不到更快 |
par |
变化触发:每 200 ms 比一次 prot_set_checksum(),变则重发 |
避免每拍拷 160 B;定值变化是低频事件 |
ui_data_feed_tick() 每轮调用是廉价的:一次 tick 差值判断 + 一次 uint32 比较。真正的组装只在到期时发生。
5.3 启动时序
内核启动前:ui_data_init()(清零 + valid=0)—— 纯内存操作,无 OS 依赖
↓
uiTask 起来(NET_READY 屏障后):ui_menu_init() → 进入主循环
↓
首轮循环:ui_data_feed_tick() 立即发布首帧(不等 200 ms)
↓
首帧之前任何页面被打开:valid=0 → 全部显示 "--"(不显示 0.000,避免误读为真实零值)
为什么首帧要立即发:待机屏与菜单屏在启动早期就可能被远端推流打开,若等 200 ms 会出现"一闪的 --"。首帧立即发布后,页面从第一帧就是真值(或明确的"未就绪")。
5.4 页面的行缓存(性能)
框架的 refresh(row, name, value) 是逐行调用的(每帧每行一次)。若每行都 ui_data_rt(&snap) 拷 130 B,一页 8 行 = 1 KB/帧的冗余拷贝。
做法:页面用 ui_data_rt_seq() 判"快照是否变过",变过才整帧拷进页面私有静态缓存,其余行只读缓存。
static ui_data_rt_t s_rt; /* 页面私有缓存 */
static uint32_t s_seq; /* 上次拷贝时的序号 */
static void pg_refresh(uint16_t row, char *name, char *value) {
uint32_t seq = ui_data_rt_seq(); /* 轻量查询:只读一个 uint32 */
if (seq != s_seq) { /* 快照变过才整帧拷贝 */
(void)ui_data_rt(&s_rt);
s_seq = seq;
}
/* 以下只读 s_rt,按行表格式化 */
}
用
ui_data_rt_seq()门控而不是"row==0 才刷新",是为了避免依赖"框架总从第 0 行开始绘制"这一隐式时序假设——框架换绘制顺序时页面不会静默出错。
6. 写路径(定值整定)
6.1 为什么 UI 不能直写
prot_set_set()之后必须prot_set_seal()重算校验和;逐项写会把校验和打成中间态,被自检判为损坏。- 落盘由
defaultTask(配置引擎属主)执行,UI 直接写盘会撞单写者纪律。 - 越界校验是 all-or-nothing:任一项越界整表不装载。
6.2 请求 / 状态交接(复用 net_cfg 范式)
ui_page_setlist(编辑态)
│ 用户按"保存"
▼
ui_data_param_request(&req) ← BSP 侧只投递,不执行
│ (小环形队列,深度 4;满则返回 -1,页面提示"忙")
▼
ui_data_feed_tick()(App 侧,uiTask 上下文)
│ 取出请求 → 校验(调 prot_set 的只读接口预检)
▼
prot_set_set_sealed() / prot_set_set() + prot_set_seal()
│ 成功 → prot_set_dirty_mark() → defaultTask 落盘
▼
ui_data_param_state(OK / ERR_RANGE / ERR_LOCKED / ERR_BUSY)
│
▼
ui_page_setlist 显示"保存成功 / 越界:Ⅲ段定值"
这套"UI 只投递、属主任务执行、状态回读"的结构,与 net_cfg 的 net_cfg_save_request / net_cfg_save_state 完全同构(Drivers/BSP/NETCFG/net_cfg.h),不引入新范式——这是"低耦合"的另一个含义:项目里同类问题只有一种解法。
6.3 失败语义
| 返回 | 含义 | 页面表现 |
|---|---|---|
OK |
已封存并置脏 | 行值刷新,页脚短暂"已保存" |
ERR_RANGE |
某项越界 | 高亮越界行(用 bad_mask),页脚"越界" |
ERR_LOCKED |
定值闭锁中(自检失败) | 页脚"定值闭锁",禁止编辑 |
ERR_BUSY |
请求队列满 | 页脚"忙,请重试" |
ERR_TIMEOUT |
3 s 未取走 | 页脚"无响应" |
7. 记录页:另一种通道(注入式 reader)
7.1 为什么记录不走快照
记录是分页读文件:条数随运行增长,不可能全量快照;且读盘有 I/O 延迟,不能放在绘制路径里同步做。
7.2 注入式 reader
把 ui_w_records 从"直接 include sys_log.h"改为"接受一个只读函数表":
/* Drivers/BSP/UI/ui_w_records.h —— 不含任何 App 头 */
typedef struct {
uint16_t (*count)(uint8_t log_id);
/* 返回 0 成功;-1 越界;-2 读失败 */
int (*get)(uint8_t log_id, uint32_t idx,
char *ts, uint32_t ts_len, char *desc, uint32_t desc_len);
} ui_rec_reader_t;
void ui_w_records_bind(const ui_rec_reader_t *reader); /* App 启动时注入 */
log_id用uint8_t(不透明编号),BSP 不知道SYSLOG_RUN是什么,只知道"0 号日志"。- 注入点在
App/task/ui_task.c(ui_menu_init之前),实现体是sys_log的两个薄包装。 - 【2026-10-25 落地注】模拟器同形镜像:
simulator/main.c的sim_rec_count/sim_rec_get, 无头与窗口两个初始化点都在ui_menu_init前调ui_w_records_bind;UI 侧编号常量UI_W_REC_LOG_RUN/SELFTEST(映射只在两端适配层,UI 不感知syslog_id_t)。ui_rec_reader_t按本文契约原样实现(int错误码 0/-1/-2 未改型)。
7.3 收益:可削减 3 条既有基线
改造完成后,scripts/check_layer_deps.py 的 BASELINE 可删掉:
("Drivers/BSP/UI/ui_page_runlog.c", "App/log/sys_log.h"),
("Drivers/BSP/UI/ui_page_selftest.c", "App/log/sys_log.h"),
("Drivers/BSP/UI/ui_w_records.h", "App/log/sys_log.h"),
基线"只减不增"——本设计顺带把存量债还掉三条,而不是新增。
另外两条(
net_cfg/ftp_cfg→cfg_file.h)属配置引擎,与 UI 无关,不在本文范围。
7.4 SOE 记录:同构 reader + UI 侧显示名映射(2026-10-25 落地新增)
/log/soe.log 是二进制定长 64 B 记录(时标/类型/元件号/序号/值),不是"时间戳 + 文本描述",不能与 ui_w_records 混读,故另立同构组件 Drivers/BSP/UI/ui_w_soe.c:
- 注入式 reader
ui_soe_reader_t(count()+get(idx, &view),错误码 0/-1/-2 与ui_rec_reader_t同族);get返回的是 UI 侧视图结构ui_w_soe_rec_t(时标串 + 不透明类型号 + 不透明元件号 + 标志 + 序号/值),适配层负责解码与 CP56 → 文本换算,UI 不 includeApp/log/soe_rec.h。 - 显示名映射在 UI 侧(
ui_w_soe_type_name()/ui_w_soe_elm_name()):类型与元件都是表现层文案,不进快照也不回落到 App 层(同docs/UI设计/UI_数据供给设计说明.md§8 的格式化归属结论)。UI 侧另立一套不透明编号常量(UI_W_SOE_T_*/UI_W_SOE_ELM_*),其数值与 App 枚举逐项相等——这条等价关系不靠文档转述,由主机单测在同一 TU 里 include 两侧头逐项核对(test/host/ui_data/的用例TC-UIDATA-012/013,2026-10-25 现场核对结果:类型 1..8 + 哨兵 9、ELM_NONE=0xFF、FLG_IV=0x80、元件 0..8 与PE_COUNT=9)。 - 未标定闭锁/标定复归两类新事件已进映射表(
UI_W_SOE_T_CAL_BLOCK=7/UI_W_SOE_T_CAL_RESUME=8,缺字 采/样/恢/效 已走字体重生成 + 像素回归)。单测额外钉了两条容易悄悄发生的错:八类事件必须有名(忘配名只会在屏上显示--,像素门禁照不出来)、"采样闭锁/恢复"与"标定闭锁/复归"四名必须互不相同(同名 = 现场把未标定闭锁读成采样故障)。 - 本批不绑定 reader:落盘侧还没有只读 API(
App/log/soe_store.c只有post/drain/dropped),需要加法式的soe_count()/soe_read(idx, &rec)(FatFsf_open+f_lseek,与syslog_get_record同构);该文件属道F 的潜在写域,先后次序由 Lead 排。未注入期间组件按空态渲染("无记录"伪行),因此本批不产生任何像素变化。
8. 格式化归属:谁决定小数位
结论:格式化属表现层,全部在页面行表里;快照只给数值与品质。
| 信息 | 归属 | 理由 |
|---|---|---|
| 数值(float / Q10) | 快照 | 业务事实 |
| 品质(IV/NT/OV) | 快照 | 业务事实 |
| 通道名("Ua"/"Ia") | 页面行表 | 显示文案 |
| 小数位(2 位/3 位) | 页面行表 | 显示规格 |
| 单位(V/A/Hz) | 页面行表 | 显示规格 |
无效符号(--) |
页面行表 | 显示规格 |
| 排序/分组/分页 | 页面行表 | 版式 |
行表结构相应扩展(在现有 cp_row_t 基础上加两个字段,或新增页面侧结构):
typedef struct {
const char *name; /* "Ua" */
uint8_t src; /* 快照内下标(0..UI_DATA_YC_N-1) */
uint8_t dot; /* 小数位:电压 2 / 电流 3 / 频率 3 */
const char *unit; /* "V" / "A" / "Hz" */
} ui_ac_row_t;
为什么不在快照里放格式化后的字符串:那会让 App 知道"电压要 2 位小数",把显示规格漏进业务层——这正是要避免的耦合。
9. 可测试性
9.1 主机单测(L0.5)
ui_data 是纯内存模块,天然可测:
| 用例 | 判据 |
|---|---|
| 首帧前读取 | ui_data_rt() 返回 -1,valid=0 |
| 发布后读取 | 字段逐项一致;seq 递增 |
| 参数变化检测 | checksum 不变 → 不重发;变 → 重发 |
| 请求队列 | 深度 4;满则 -1;取走后可再投 |
| 状态回读 | OK/ERR_RANGE/ERR_LOCKED 正确映射 |
页面渲染可在模拟器侧断言(见 9.2),主机侧只测数据模块。
9.2 模拟器:固定帧保证 golden 稳定
simulator/main.c 不链接 App/task/ui_task.c,因此需要自己发布快照:
- 默认(golden 模式):发布一帧固定数据(写死的 9 路值 + 位图),保证像素参考可复现。
--live模式:用合成正弦逐帧更新,供人工观察动态刷新(不参与 golden)。
这是本设计对"可测性"的直接贡献:页面数据来源可注入 → 像素回归不再依赖真实硬件。
【2026-10-25 落地注】
simulator/main.c的实现形态与两条原稿未写死的细节:
- 固定帧由
sim_publish_data_frames()一次性发布,无头与窗口两个初始化点都调(与记录 reader 注入同址、同"先于ui_menu_init"纪律);- 固定帧里的定值不手抄 40 个数字,而是把
App/algo/prot_set.c(零平台依赖的纯算法,双端共享核心)链进模拟器、prot_set_init()后取出厂默认——抄表等于造第二真相(同scripts/check_font_charset.py的"单一规则源"口径);build_date用固定串而不是__DATE__:否则跨天重建会把装置信息页的黄金参考刷掉,而黄金参考是判据本身。- 原稿设的
--live合成正弦模式尚未实现(当前无消费者,等动态刷新真的需要人眼观察时再加)。
9.3 与"虚拟注入"的关系
docs/UI设计/页面补齐规划.md §7 提的厂家调试"虚拟注入",天然落在 ui_data_feed 上:注入值只要在 feed 组装时覆盖对应字段即可,页面完全不知情。
约束(必须写进代码注释):虚拟注入只能在 feed 层覆盖快照字段,绝不能改 prot_set / phasor 的输入——否则会污染保护判据路径。
9.4 一致性测试(防"魔法下标"漂移)
页面行表用不透明下标(src / set_idx),BSP 侧不知道它对应哪个 App 枚举——这是解耦的代价,必须用测试兜住:
| 断言 | 位置 | 内容 |
|---|---|---|
| 通道数一致 | App/app/ui_data_feed.c |
typedef char chk[(UI_DATA_YC_N == ADC_SEM_COUNT) ? 1 : -1]; |
| 定值项数一致 | 同上 | typedef char chk2[(UI_DATA_SET_N == PS_ID_COUNT) ? 1 : -1]; |
| 下标语义一致 | test/host/ui_data/ |
主机测试同时 include 两侧头,逐项断言"页面表的 src/set_idx 指向预期枚举" |
第 3 条是关键:它把"跨层魔法数字"从运行期风险降级为编译/测试期错误。
10. 内存与性能
| 项 | 量 | 说明 |
|---|---|---|
ui_data rt 快照 |
≈ 132 B | 9×8 + 位图 + 时钟串 |
ui_data par 快照 |
≈ 180 B | 40×4 + 头部 |
| 状态/队列 | ≈ 60 B | 请求环 4 条 + 状态字 |
| 合计(RW_IRAM1) | ≈ 380 B | 当前 RW_IRAM1 余 57,504 B → 无压力 |
| 页面行缓存 | 每页 ≈ 132 B(仅数据页) | 页面私有静态,随页数线性;若 6 个数据页 ≈ 800 B |
| LVGL 池 | 0 | 快照不进池(不是 LVGL 对象) |
| 每 200 ms 拷贝 | ≈ 132 B | 一次 memcpy,微秒级 |
| 每帧(10 ms) | 0 次拷贝(比 seq 后只在变化时拷) | 见 §5.4 |
结论:代价是"几百字节 + 每 200 ms 一次 memcpy",换来的是整个 UI 层的数据可及性。
11. 落地步骤(每步可独立验证)
| 步 | 内容 | 验证 | 依赖 |
|---|---|---|---|
| 步骤1 | ui_data.h/.c + 主机单测(无任何消费者) |
test/host/ui_data 全绿;layer-deps 无新增 |
无 |
| 步骤2 | App/app/ui_data_feed.c + ui_task.c 接线(每轮调 tick) |
串口打印首帧(临时锚点);@build 0/0 |
步骤1 |
| 步骤3 | 交流量 / 直流量回炉(接快照 + 行表加 src/dot/unit) |
模拟器固定帧 golden 更新;台架看真实码值变化 | 步骤2 |
| 步骤4 | 遥信量 + 定值/控制字/软压板查看(4 页) | 主站改定值 → 页面 200 ms 内变 | 步骤3 |
| 步骤5 | 写路径(请求/状态)+ 整定 4 页 | 就地改定值 → 保存 → 掉电重启 → 值保持 | 步骤4 |
| 步骤6 | 记录 reader 注入 + 删 3 条 layer-deps 基线 | check_layer_deps.py 基线由 6 → 3 |
可与步骤3 并行 |
步骤1~步骤2 是关键路径:它们不产出任何用户可见效果,但决定后面所有页面是否可信。不要跳过步骤1 直接改页面——那会退化成"页面里塞假数据 2.0"。
【2026-10-25 落地进度】步骤1、步骤2 已落(道E task-14,分支
task/ui-data-feed):ui_data三通道 + 主机单测 82 项全绿(test/host/ui_data/,已挂ui-data-unit-test钩子)、ui_data_feed已在App/task/ui_task.c主循环接线并在建页前发首帧。改动两处与原稿不同:① 实时帧的eng与品质位按标定层真值语义落地(见docs/UI设计/UI_数据供给设计说明.md§4.1 的落地注);② 新增第三条通道info(同文档 §4.4)。写路径(步骤5 的ui_data_param_request/state)随整定 4 页一起落,本批无消费者,先落等于落一段永不执行的代码。步骤3 起(页面接线)在道E 第二个工作项。【2026-10-25 落地进度·二】步骤3 的第一批页面已接(道E task-15,分支
task/pages-ui):装置信息读info通道(型号/软件/硬件/校验/日期/运行时长)+net_cfgRAM 副本(MAC/IP),交流量读rt的ph[]实例槽与freq_*、第 7 行读eng,直流量读rt.yc[TEMP]并透传Q_RAW。 三条纪律在页面侧被验证:① 页面只读快照、零 App include(分层基线不变);② 帧缓存按seq门控整帧拷一次,逐行只读标量;③ 品质位判空一律--,"有数但不可信"与"没数"同形。参数帧par的首批消费者是定值/参数查看页(本批未接,bad_mask/locked的屏上表达随那 4 页落)。【2026-10-25 落地进度·三】
par的首批消费者已落(同分支批 3):参数 / 定值 / 控制字 / 软压板四个查看页 (ui_w_setview四张行表 +ui_w_setlist组件,查看态commit=NULL无保存行)。Q10 → 显示值的换算落在表现层 (本契约 §8 的分工:写侧只搬原值),换算式在ui_w_setview.c里做整数拆分而非浮点/64 位除(MicroLIB 软浮点与__aeabi_ldiv一进镜像几百字节,模拟器永远照不出这笔代价)。bad_mask的屏上表达 = 该行反显高亮(且只在bad_valid=1时参与,把没数据画成有毛病是假告警);locked的屏上表达是 禁编辑,查看页本来就无编辑通路,它随整定 4 页一起显形。四个返回系数按 2 位小数显示的理由见docs/UI设计/页面扩充详细方案.md§3.2.2 落地注;行表与prot_set枚举序/上下限的一致性由tools/setview_check/check_setview_limits.py核验(BSP 侧看不到 App 头,物理上无法自证,必须有人从外面钉)。
【2026-10-25 落地进度·四】遥信通道已被消费:遥信量页(
ui_page_yx.c,33 行固定名表, 方案 A)按yx_valid→yx_dp→yx_bits的次序取值列(先判无效:无效位上的位值不代表任何 事实;再判单/双点选词;最后才看动作态)。起草时为此补了yx_dp一个字段(§4.1),由ui_data_feed.c逐点问iec104_pts_yx_is_dp()得到——点数与单双点性质都留在协议层,页面 不复制这些事实;UI_DATA_YX_N与iec104_pts_yx_count()一旦不等,写侧打一条一次性DBG_ERROR(点数在协议层是 .c 内的宏,编译期拿不到,只能运行期留痕)。模拟器侧同步发布yx_dp = 0x0F但不把这 4 位置进yx_valid:V0.1 没有开入硬件,屏上就是--——刻意不用 「让它显示合位/分位」的假数据把画面填满,那等于让黄金参考声称本机有开关位置遥信。因此 「合位/分位」这条词映射当前没有任何自动化证据覆盖(像素恒 IV、页面依赖 LVGL 也进不了 主机单测),它已登记为开入落地后的台架判据,而不是假装测过。
【落地注·五(写路径判据的前置口径,指针级,不重述细节)】带
#P注记的叶子第一屏是密码 页、不是内容页:真机实测里三帧「深页盲走」的落点都是密码输入页(墨量 512,与黄金参考 的两张密码态逐字对得上)。所以批 5 的写路径判据必须把「过密码」当显式状态处理——快照 层不提供「曾否进过某页」这种隐式状态,可写性只由 UI 侧权限态与par.locked共同决定, 「当前是否已过密码」既不真实也不该被推断。判据侧的过法只有一套(查看密码 = 出厂 000 / 修改密码 = 123,键序与黄金回归的网口设置编辑态完全一致),口径与纪律见docs/UI设计/UI_自动化测试流程.md§7.5;本节只登记这条事实,避免两份文档各说各话。
12. 验收判据
| # | 判据 | 方法 |
|---|---|---|
| 1 | Drivers/BSP/** 无新增 App include |
scripts/check_layer_deps.py 退出码 0 |
| 2 | 记录页改造后基线由 6 条减到 3 条 | 同上 --prune |
| 3 | 交流量页显示真实相量(非写死值) | 台架:注入已知信号 → 页面值随信号变 |
| 4 | 未标定时页面明确标识(不把码值当伏特) | 目检 + 文本断言(eng=0 时带标记) |
| 5 | 首帧前显示 -- 而非 0.000 |
模拟器:不 publish 直接开页,断言文本 |
| 6 | 定值整定全链闭环 | 就地改 → 保存 → 重启 → 值保持 + 校验和自检通过 + 主站 C_RD 一致 |
| 7 | 像素回归稳定 | ui-golden 连续两次运行零差异 |
| 8 | 主机单测覆盖数据模块 | test/host/ui_data 全绿并挂 pre-commit 钩子 |
13. 反模式清单(禁止事项)
| # | 禁止 | 原因 |
|---|---|---|
| 1 | BSP 里 extern App 全局变量 |
绕过门禁且比 include 更危险(无契约/无所有权) |
| 2 | 页面直接 include App/** 头 |
门禁拦截;且页面被绑死在具体 App API |
| 3 | 快照里放指针或动态字符串 | 跨层生命周期事故;只用定长数组 |
| 4 | 每行 refresh 都整帧拷贝快照 |
8 行/帧冗余;用 seq 门控 |
| 5 | 在快照里放格式化后的字符串 | 显示规格漏进业务层 |
| 6 | 多个任务同时发布同一通道 | 破坏单写者约定;需要时在 ui_data 内部加保护,不改页面 |
| 7 | 虚拟注入改 prot_set/phasor 输入 |
会污染保护判据路径 |
| 8 | 定值逐项 prot_set_set() 不封存 |
校验和中间态 → 自检判损坏 |
| 9 | 为省事把 sys_log.h 加进 ALLOW |
ALLOW 是"叶子工具"通道,sys_log 是有状态服务,加进去等于永久开后门 |
14. 与既有文档和门禁的关系
- 门禁:
scripts/check_layer_deps.py的三种合法做法本文全部用上(快照推送=②,写路径与记录 reader=①),不新增任何ALLOW或BASELINE条目;计划的 3 条削减已于 2026-10-25 兑现(task/UI-rec-reader 落地 §7.2 契约,基线 6 → 3,详见docs/门禁说明/layer-deps.md§1)。 - 页面框架:
docs/UI设计/内容页架构.md的refresh/count_cb钩子签名不变,页面只是改为从ui_data取数。 - 页面清单:
docs/UI设计/页面补齐规划.md的"批 1"第一项即本文 步骤1~步骤3。 - 数据真实性:
docs/问题记录/采集与数据处理短板评测.md的标定(§4.1)与遥测链(§4.2)落地后,只需改ui_data_feed的取数来源与eng取值,页面零改动。