Files
STM32F4-Base/docs/UI设计/UI_数据供给设计说明.md
T
wandering1 c23b21eed8 docs(UI自动化): 新增第七节「板端有状态导航判据」+ 过密码口径指针
票 = 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 会核对本节新增的两处
相对链接与三张图的存在性。
2026-10-25 18:02:32 +08:00

36 KiB
Raw Blame History

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 不 include App/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)(FatFs f_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 的实现形态与两条原稿未写死的细节:

  1. 固定帧由 sim_publish_data_frames() 一次性发布,无头与窗口两个初始化点都调(与记录 reader 注入同址、同"先于 ui_menu_init"纪律);
  2. 固定帧里的定值不手抄 40 个数字,而是把 App/algo/prot_set.c(零平台依赖的纯算法,双端共享核心)链进模拟器、prot_set_init() 后取出厂默认——抄表等于造第二真相(同 scripts/check_font_charset.py 的"单一规则源"口径);
  3. build_date 用固定串而不是 __DATE__:否则跨天重建会把装置信息页的黄金参考刷掉,而黄金参考是判据本身。
  4. 原稿设的 --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_cfg RAM 副本(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 取值,页面零改动。