同族第三道(文档面)。上一批只改了 10 篇测试文档,本笔清掉余下 27 篇的层级号,并修 那批机械替换留下的两类疤。 - 层级号改语义名:各单元测试说明的「**门禁层级** | L0.5 层(介于 L0 静态门禁与 L1 板端套件之间)」→「主机侧纯逻辑单测(介于静态门禁与板端测试套件之间)」; 「板端(L1)」「L1/L2」「L3 人工」等表头与列值同批改写;测试文档模板规范的模板 字段同步(它决定以后每篇测试说明怎么写这一栏)。 - 「模拟器回归」旧号(原文标 L3/L2)**不映射**成新语义名,就地写「模拟器回归」: 它与「主机侧回归」是两回事,硬映射等于造一处新的口径错。 - 修双写疤 17 处(替换串套替换串的产物):文档道改完 Lead 点名的 8 处后中途失败, Lead 接手余下 9 处——测试开发标准流程 7、CI README 1、门禁说明/host-unit-tests 1、 任务规划/测试与架构专项 1。 - 修**错族替换疤**(比双写更坏:字面改对、意思改错):ADC输入层单元测试说明与 相量层单元测试说明的「介于 L0 静态门禁与 语义取数与有效性板端套件之间」——上一批 把测试层的 L1 换成了采集链层名;本笔随该行改写一并修正。产品需求 §7.4 同类问题 在上一笔(代码道)修。 - 变更记录历史行按「行内换号、日期与技术事实不动」处理(与「卡」族先例一致), 换算表与撞族警示在 scripts/archive/id_gate/编号治理历史.md §10 / §10.1。 - 刻意未动:docs/芯片手册(外部手册原文)、UI 菜单层级残留 2 处与采集链旧层名残留 (已登记 task-12)、test/board 的链路时延段号残留(task-11)。 - 门禁:check_doc_links --all --sections-strict 通过(195 篇、章节引用 3292 处); id-check-new --worktree rc=0(剩 2 处 WARN 是变更记录行里的已停发测试专项号, 不拦提交,本笔不越族去动它);112 个改动文件 U+FFFD=0、无 CR、BOM 与 HEAD 一致; git diff --check 干净。
54 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/>快照存储 + 只读访问器<br/>+ 定值写入队列与状态机"]
P["ui_page_ac / ui_page_yx / ui_w_setview+ui_w_setlist ...<br/>只读快照 + 格式化 + 布局(整定页另加写投递)"]
P --> D
P -- "整定保存:ui_data_param_request(&req)" --> D
end
T["App/task/ui_task.c<br/>调度:每轮调 feed_tick()"] --> F
T --> P
F -- "ui_data_publish_rt/par(&帧)" --> D
F -- "落定:ui_data_param_drain(&ops),执行侧由 App 注入" --> 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 任何页面头——它是叶子。
写路径没有给上表新增任何一行:它走的是反向调用(BSP 声明函数表、App 提供实现并按节拍
把表传进来,见 §6.2),所以 include 方向不变、layer-deps 基线不变。这类"下层定义抽象、
上层实现并注入"的第三条腿在 §7.2 的记录页 reader 上已经用过一次,两次都不新增 ALLOW 条目。
3.2 三个模块的职责边界
| 模块 | 位置 | 只做 | 绝不做 |
|---|---|---|---|
ui_data |
Drivers/BSP/UI/ |
存最新一帧、给只读访问、记发布序号;存定值写入请求队列、跑那台状态机(越界与闭锁的判定按注入的 ops 问,自己一条业务规则都不持有) | 取数、格式化、判断业务含义、碰 prot_set 与文件系统 |
ui_data_feed |
App/app/ |
按节拍从各 App 模块取数、组装、发布;变化检测;每轮 drain 一条定值请求并注入执行侧实现 | 布局、字符串格式化、LVGL 调用 |
页面 ui_page_* |
Drivers/BSP/UI/ |
读快照、按行表格式化、交给框架绘制;整态把改动攒在编辑叠层里(改动位图 + 工作值,ui_w_setview.c 模块级一份——同屏只开一页,与各查看页的行缓存同一假设),只在按"保存"时投递一次 |
直接调 App、自己算业务量、自己判越界(行内上下限只是镜像,用于早拦一手;真源在 App,见 §9.4 一致性测试) |
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 一致性测试) */
#define UI_DATA_VER 2u /* 契约版本;字段只尾追加、追加即升版(§4.3) */
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 原值;页面按行表的小数位换算显示 */
uint8_t cal_block; /* v2 尾追加:未标定闭锁(判据停走)—— 定值**仍可改** */
} ui_data_par_t; /* 当前 192 B(v2 前 184 B;尾字节由 8 字节对齐补齐) */
为什么存 Q10 而不是 float:定值是整表校验和覆盖的定点值,快照存浮点会引入"显示值与校验和口径不一致"的二义性。Q10 → 显示值的小数位换算属表现层(§8)。
locked 与 cal_block 是两个事实,屏上是两条通道、两个词,不得合并(判据口径
docs/任务规划/数据可信.md §4.3):
| 字段 | 真源 | 说的是什么事 | 屏上落点 | 还能改定值吗 |
|---|---|---|---|---|
locked |
prot_set_locked()(定值校验和自检失配) |
这台装置现在写不进去 | 整定页保存行值列 LOCK,且 ENTER 一律不消费(禁编辑) |
❌ |
cal_block |
adc_cal_prot_blocked()(App/algo/adc_cal.c:UA/UN/IA 三路 ADC_CAL_F_VALID 全齐才放行。判据停走用的是同一个函数——App/task/protect_task.c 也调它,道F task-16 起全仓一份判定、DTU_BENCH 旁路也只有这一处;机器证据 = test/host/adc_cal 的 TC-ADCCAL-018) |
定值能改,但改了也不会动作(判据停走) | 整定页标题右侧「未标定」徽标 | ✅ |
合成一个词就等于互相冒充:现场看到 LOCK 会以为"改了白改"而不去标定,看到「未标定」
会以为"这台装置写不进定值"而放弃正当的整定操作。两个字段的取数都在 App/app/ui_data_feed.c
完成,页面零 App include(分层方向由 scripts/check_layer_deps.py 守住)。
【v2 落地注(2026-10-26,道F task-1)】本次只尾追加
cal_block,帧长 184 → 192 B; 组装缓冲s_scr是 rt/par/info 的联合体,而最大者是 rt(208 B),所以这笔追加 实际没多花一字节 RAM——这条"谁兜住了谁"的关系由主机单测的尺寸断言钉住 (帧长一变,那处注释就是唯一会撒谎的地方)。升UI_DATA_VER是必须的:版本号是 页面与写侧唯一的对齐凭据,追加字段不升版 = 老页面按新帧长 memcpy 时静默错位。
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 生成步骤之前一律显示--,不抄参考项目遗留值。 2026-10-28 追加(「运行报告」页落地):info 帧尾部追加 9 个字段,把运行报告要用的四个数从 App 侧搬下来——boot_count/boot_seq_state/boot_write_failed/rst_cause/boot_ready(来自App/util/boot_stats.h的开机账本)与iec104_conn_up/iec104_apdu_tx/iec104_tx_fail/iec104_rx_bytes(来自App/task/iec104_serv.h的四个只读观测计数)。 -
为什么不另开第二条注入通道:这些量与装置身份同属
慢变、非节拍、零指针的一批,info 帧已是它们的属主; 再开一个 reader 就是把 task-6 刚收紧的分层反向边接回来(记录页那套注入表是因为要读盘,本批不读盘)。 -
搬运不算第二份口径:
feed_fill_info()只做逐字段拷贝,不换算、不在别处再算一份时长(uptime_s仍由本函数按 tick 差算)。 -
屏上文案归 UI:四个数各自的类别标签(「自电池周期」「本次开机」)、
boot_ready == 0显--而不是 0、 以及「禁写终身次数」「跨开机累计不做」这两条口径,落在Drivers/BSP/UI/ui_page_runreport.c文件头;口径出处只在App/util/boot_stats.h四数台账与本节各述一次(同一事实只在一处详述,docs/文档规范.md同此要求)。 -
代价(本批
@build实测,0 错 0 警):Program Size由本批前 Code=235536 RO=26392 RW=1572 ZI=136924 变为 Code=236456 RO=28124 RW=1876 ZI=137084(ROM 合计 +2,652 B) → RAM 合计 +464 B(RW +304 / ZI +160)。逐对象(.map):5 个记录页各 60 B.data(每页一份ui_w_records_t读盘缓存, 按实例存是记录页注入改造时定下的纪律,共享会串页);ui_page_runreport.o116 B.bss(信息帧本地缓存 + 发布序号); info 帧字段增量落在ui_data.o(1,192 B.bss,同对象还装 rt / par 两通道的双区)与ui_data_feed.o(217 B)里。 随帧发布不新增拷贝节拍。水位:RW_IRAM162.2% /RW_CCM87.9%(阈值 92%),mem-budgetPASS, 台账已由scripts/ram_baseline.py --update/--write回填(docs/内存管理/RAM预算台账.md)。@build后回填),未触mem-budget92% 红线。
另一处落地细化:
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 |
变化触发:每轮比三个各自独立的变化源——prot_set_checksum()(定值改动)、prot_set_locked()(自检闭锁)、未标定判定(标定完成),任一翻转就重发一帧 |
定值变化是低频事件,只盯校验和能省下每拍的 192 B 拷贝;但 locked 与 cal_block 都不改动校验和,只盯校验和会让保存行的 LOCK 与标题的「未标定」永远停在旧值上(真机现象:标定做完了徽标还挂着,直到某次定值改动被顺带刷掉——那是把两件不相干的事当同步点) |
ui_data_feed_tick() 每轮调用是廉价的:一次 tick 差值判断 + 一次 uint32 比较 + 两次字节比较(locked/cal_block 上一帧值)。真正的组装只在到期或有源翻转时发生,无变化时零拷贝。
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 范式)
落地形态(道F task-1 落本层队列与状态机,task-2 落页面侧消费者,2026-10-26):
ui_page_view_param / _set / _word / _plate(整定态:123 授权 → attach_edit 绑 set/commit/state)
│ 选中"保存"行按 ENTER(组件 ui_w_setlist_key 先过两道门:可写权限 + state()!=LOCK)
▼
sv_commit():req.mask = 改动位图;req.q10[] 只按 mask 置位的槽换算(显示值 → Q10)
▼
ui_data_param_request(&req) ← 本层只投递,不执行、不判越界、不碰文件系统
│ 静态环形队列,深度 4(UI_DATA_PARAM_QD);满 → -1(页面显 ERR + 页脚"忙")
│ 入队 = 168 B 整条拷入(.bss 4 × 168 = 672 B;零堆、零变长拷贝)
▼
ui_data_feed_tick()(App 侧,uiTask 上下文;先 drain 再比校验和,顺序即判据)
│ ui_data_param_drain(&s_param_ops):每轮最多落定一条
│ ② ops->locked() == 1 → ERR_LOCKED,**本条丢弃**(不排队等解锁)
│ ③ ops->check() 按 mask 逐项整批预检,任一项越界 → ERR_RANGE + err_mask,一字不写
│ ④ 预检全过再逐项 ops->apply() → prot_set_set_sealed()(只写 mask 内的 id)→ OK
│ 值真的变了 → 同轮重发 par 帧(页面按 seq 失效缓存)
▼
ui_data_param_state() / ui_data_param_err_mask()(保存行显词 + 越界行反显)
四个公开函数(声明 Drivers/BSP/UI/ui_data.h、实现 ui_data.c,全在本层,零 App include):
| 函数 | 作用 | 返回值口径 |
|---|---|---|
ui_data_param_request() |
投递一条请求(页面"保存"的唯一出口) | 0 已入队;mask=0 的空请求也返 0,但不占槽位、直接落终态 OK(一个都没改就按保存是正常操作,报 ERR 会让现场以为装置有问题);-1 队列满;-2 参数无效(NULL,或 mask 含 UI_DATA_SET_N 以上的位——调用方的编程错误不该落成屏上的"越界") |
ui_data_param_state() |
回读写路径状态 | UI_DATA_PARAM_ST_{IDLE,WAIT,OK,ERR_RANGE,ERR_LOCKED,ERR_BUSY} |
ui_data_param_err_mask() |
最近一条被本次预检挡下的项位图 | bit i = 1 → 第 i 项越界;最近终态不是 ERR_RANGE 时恒 0 |
ui_data_param_drain() |
落定一条(App 装配节拍入口) | 1 处理并提交了一条;0 队列空(状态不动);-1 ops 或其函数指针为 NULL |
"算不算越界"与"现在能不能写"都是 App 侧事实,本层一份都不复制:执行能力经
ui_data_param_ops_t(locked / check / apply 三个函数指针)由 App/app/ui_data_feed.c
每次调 drain 时注入,本层不保存副本。原因有两条,都不含糊:① 分层方向硬约束——
Drivers/BSP/** 不得 include App/**(scripts/check_layer_deps.py 拦提交),而上下限真源
在 App/algo/prot_set.c 的 g_prot_set_meta;② 注入表刻意不含任何范围数值,多传一份
上下限就是第二真源、就会漂。同 §7.2 记录页的注入式 reader 一条路子,额外的好处是主机单测
可以把三个指针换成桩件,于是队列与状态机的生产代码(不是副本)能在 PC 上把"满队列 /
越界不改值 / 闭锁拒写 / 正常往返"跑全(test/host/ui_data/)。
请求形状 = 「位图 + 定长 40 槽」(168 B)而不是「项数 + 变长 id 数组」:表现层的编辑叠层
本就是一张改动位图 + 一张工作值表,本形状与它逐位同构 → 提交侧一次赋值、执行侧逐项按位取,
两侧都不必维护第三种表示;定长还让队列退化成 O(1) 静态数组。边界与代价:mask 只有 64 位
→ 定值项数硬上限 64(ui_data.c 有编译期守卫,破 64 是编译失败而不是静默丢项);队列 672 B
- 控制字 16 B 是买"整批原子 + 零变长 + 零堆"的价钱(
docs/内存管理/RAM使用分析与优化方案.md)。
⚠ 提交侧必须只提交
mask内置位的 id,绝不"从显示值回提全表":Q10 存不下 0.95 这类 返回系数(实际存 972/1024),按显示域往返会把用户没动的项顺手改掉。判据"越界值不落盘"与 "改哪条只落哪条"都建在这条上(docs/任务规划/UI补齐.md§2 第 2 行)。 换算的往返精度因此也不需要逐位可逆:没动的行根本不进换算函数。
这套结构与 net_cfg 的 net_cfg_save_request / net_cfg_save_state 完全同构
(Drivers/BSP/NETCFG/net_cfg.h),不引入新范式——这是"低耦合"的另一个含义:
项目里同类问题只有一种解法。
6.3 失败语义
落定结果只有 5 个值(UI_DATA_PARAM_ST_*),页面侧的词与位置由整定视图统一映射,
四张整定页共用一套(组件按 state() 回调给的值画保存行值列,页面不参与解释):
| 状态 | 含义 | 保存行值列 | 页面附带动作 | 产出方 |
|---|---|---|---|---|
IDLE |
本次打开页面从未投递过 | ENTER |
— | 本层初值 |
WAIT |
已投递、队列里还有请求在路上 | WAIT |
编辑叠层保留(不清稿) | request 受理 / drain 未排空 |
OK |
已逐项写入并置脏 | OK |
按 commit 位图清掉叠层,让位给新帧 |
drain 第④步 |
ERR_RANGE |
整批预检有任一项越界 → 一字未写 | ERR |
用 ui_data_param_err_mask() 反显越界行 |
drain 第③步 |
ERR_LOCKED |
投递时定值闭锁 → 本条丢弃 | LOCK |
反显同 ERR_RANGE;drain 不回滚(prot_set 无回滚接口,谎称"整批未写"比如实报更坏) |
drain 第②步 |
ERR_BUSY |
投递被拒:队列满 | ERR |
页脚"忙,请重试";在队请求一条不动,用户直接再按一次保存 | request 返 -1 |
三条实现侧口径,都是原稿没写死的:
LOCK还有一个与状态机无关的来源:帧里par.locked == 1时保存行直接显LOCK并禁 ENTER(优先级高于历史结果)。闭锁是持续状态,不该只在"某次投递被拒"那一帧才说一次; 禁编辑必须落在键路上,只靠屏上词会让用户白改一轮。ERR_TIMEOUT(原稿的"3 s 未取走")本层不产出——uiTask 每 10 ms 就 drain 一次, 真出现"永不落定"是 App 侧没接线的装配缺陷,不是运行期状态。页面若要兜底,可自己按投递 时刻计时(属页面本地可选项,当前没做:没有消费者就别先加一条无人走的分支)。- 状态是全局单值,页面必须锁存显示:
ui_data_param_state()落定后一直停在那个值上, 直读会让用户移动一下光标就看到别的页留下的旧结果。整定视图把它锁存成"本页投过一次 才显示那次的答案、开页复位成ENTER"(ui_w_setview.c的s_disp)。
词表刻意用 ASCII(
ENTER/WAIT/OK/ERR/LOCK)与net_cfg保存行同排; 中文「未标定」徽标是另一件事(cal_block),见 §4.2 的两源两通道表,不要为了 省一个词把它并进LOCK。
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 主机侧纯逻辑单测
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合成正弦模式尚未实现(当前无消费者,等动态刷新真的需要人眼观察时再加)。- 定值写路径在模拟器侧有一份同构的执行实现(
sim_param_locked/check/apply+sim_param_pump()):真机那段在App/app/ui_data_feed.c的 tick 里,而模拟器不链接装配器, 所以在main.c里镜像一份——check()用的仍是同一份g_prot_set_meta真源(链进来的App/algo/prot_set.c),因此"模拟器里越界被拒"与"真机上越界被拒"是同一个判定,不是两份规矩。 固定帧的cal_block显式填 1:真机现在就是未标定态(rt.eng也按同一条口径钉在引脚档), 屏上的话必须与本机一致,模拟器才配当像素基准。- 落定与"页面自己发现数据变了"的分工:
sim_param_pump()在无头模式排在注键之后、截图之前 同步排空(真机由每 10 ms 的 tick 排);整定视图另有一条 100 ms 的 LVGL 轮询定时器, 负责把"主站写进来的新值 / 标定完成 / 落定但不改校验和的 ERR"推上屏。这条定时器在无头 回归里一次都不会触发——run_headless()严禁调lv_timer_handler()(I1+FULL 全屏刷新会 进渲染死循环),所以黄金像素与它无关;两次连跑逐字节一致即为证据(simulator/README.md的同条注)。窗口模式才用它。
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. 内存与性能
| 项 | 量(.map 实测) |
说明 |
|---|---|---|
rt 帧 |
208 B | 遥测 9 路 + 相量槽带 + 位图 + 时钟串;三帧串行组装用的 s_scr 是联合体,取最大者 = rt |
par 帧 |
192 B | 40×4 Q10 + 头部 + cal_block(v2);追加这 8 B 由 rt 兜住,净增 0(尺寸关系由主机单测钉住,见 §4.2 落地注) |
info 帧 |
随字符串字段定长 | 与 rt 同节拍发布,同样落在联合体里,不额外占 |
ui_data.o 全部静态 |
.bss 1,168 B |
含三帧存储、s_scr 联合体、定值请求队列 4×168 = 672 B + 控制字 16 B |
ui_w_setview.o 静态 |
.bss 352 B / .data 48 B |
四视图行缓存 + 整定编辑叠层 190 B(int32_t[40] + 三张位图 + 锁存态) |
| 页面行缓存 | 每页 ≈ 52 B(ui_page_view_*.o 各 .bss 52 B) |
定值相关页共用一份视图缓存(同屏只开一页),所以四张整定页没有再各存一份 192 B |
| LVGL 池 | 快照 0 B;轮询定时器一只 | 快照不进池(不是 LVGL 对象);定时器是 LVGL 池对象,只建一次不删 |
| 每 200 ms 拷贝 | 一帧(≤208 B),且只在变化时 | 一次 memcpy,微秒级 |
| 每轮(10 ms) | 0 次拷贝 | 比 seq / 比三个变化源;见 §5.2、§5.4 |
栈侧(同一份 .map 的调用图,改前改后各跑一次 scripts/stack_report.py):uiTask 静态最坏链
1336 B / 配置 16 KB(余 15,048 B,91%)在接入写路径前后逐字节不变——新链路自身都不深
(sv_commit 帧 192 B 含 168 B 请求体、链深 208 B;「未标定」徽标帧 160 B、链深 536 B;按键链
pg_key 起算 480 B),最坏链仍是 LVGL 建树那条,没有换人。这是"页面 commit 会在 uiTask 栈上
放 168 B 局部量"这类担心的实测答案,也是本表值得写实测数而不写"约几百字节"的理由。
结论:代价 = 约 1.5 KB 静态 + 每 200 ms 一次 memcpy,换来整个 UI 层的数据可及性与一条
可测的写路径。区域水位(RW_IRAM1 / RW_CCM / Flash)不在本文复制——那是受管快照,
由 scripts/ram_baseline.py 从每次 @build 的 .map 生成,第二处写死就是一份会过期的
真源(本仓已有一条门禁在钉这个,口径见 docs/内存管理/RAM使用分析与优化方案.md §2.1 与
docs/内存管理/RAM预算台账.md)。本次新增约 1.5 KB 静态 + 一条 LVGL 池定时器,量级无压力。
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。已落(道F task-1 落本层队列与状态机、task-2 落整定 4 页与模拟器侧,2026-10-26);「掉电重启值保持」只能上板收口,见 §12 判据 6 |
| 步骤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;本节只登记这条事实,避免两份文档各说各话。【落地注·六(写路径消费者已上屏,2026-10-26,道F task-2)】整定 4 页(参数 / 定值 / 控制字 / 软压板)绑到与查看列同一批行表、同一个
ui_w_setlist组件:同一份页面 描述符按ui_content_page_is_editable()分派只读 / 可写两态,两态的差别只有绑不绑set/commit/state三个回调。于是本节契约里原本只是示意图的那条链路第一次真的被按到: 越界值不落盘(提交只带改动位图)、闭锁禁编辑(state()回 LOCK 或帧里locked=1就直接挡住 ENTER,而不是只换个屏上词)、落定要自己上屏(ERR_RANGE与ERR_LOCKED都不改校验和,页面收不到新帧,靠的是整定视图那条 100 ms 轮询,见 §9.2 注 6)。这一批还暴露了两条「只有第一个消费者才会暴露」的缺陷,都已修(为什么必现写在
docs/UI设计/页面扩充详细方案.md§3.2 落地注):二态行 ENTER 不回绕(写出 opts 之外的 下标:屏上看着没变、叠层里已是越界值,保存必被预检挡回);「参数帧已就位」原先只在 绘制路径里被置位,而按键门读它 → 无头回归里开页后的第一个 ENTER 永远被挡(真机开页 10 ms 内就重绘,症状只在测试侧出现)。第二条的修法是把「数据就位」与「画过没有」解耦: 判就位之前先按 seq 做一次 sync(帧没变时只是一次 uint32 比较)。另记一条同批暴露、与本契约无关的事实:菜单树重跑时
ui_menu_tab.h有一行注释 跟着生成器的模板文字变了 —— 说明 HEAD 上「生成器模板」与「已提交生成物」本就不同步 (改模板那笔没重跑生成器)。生成物以生成器输出为准即可,但这条滞后该补一条门禁, 已登记为待办「生成器幂等性检查」。
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 一致。半收口(2026-10-26):模拟器侧「改 → 保存 → OK → 回首屏新值已在帧里」已由像素 + 文本断言覆盖(黄金用例 sparam_save / sparam_landed,越界侧 sparam_oob);重启保持与主站 C_RD 一致只能上板——模拟器没有 NAND,apply() 到 prot_set_set_sealed() 就是终点,这条差距已登记在 docs/测试说明/模拟器差距清单.md;另注意 40 项里有 13 项 comm_addr=0(纯本地项),主站侧根本读不到,不能拿 C_RD 当它们的判据 |
| 7 | 像素回归稳定 | ui-golden 连续两次运行零差异。已验(2026-10-26,当时下限为 27 页):27 页连跑两次逐字节一致(含整定 9 态)——集合现已扩至 60 张,双次一致需按同口径重跑回填;轮询定时器不影响——无头模式不调 lv_timer_handler()(§9.2 注 6) |
| 9 | 「写不进去」与「改了不动作」两条事实互不冒充 | 反向文本断言(check_content_pages.py 的 ABSENT:查看页与只读态不得出现「未标定」徽标与「保存」行)+ 台架:未制造该态——要它必须改坏 /prot_set.bin(破坏产品配置存储,用户裁定 2026-10-26 不做),故 LOCK 态目前只有代码级证据与三重反证(ENTER 被正常消费、遥信 YX31=OFF、开机 CORRUPT=0),没有像素证据 |
| 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取值,页面零改动。