# 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 依赖方向 ```mermaid flowchart TB subgraph APP["App 层(业务)"] F["App/app/ui_data_feed.c
组装一帧(唯一填充者)"] SRC["phasor / freq / prot_set / iec104_pts / sys_clock"] F --> SRC end subgraph BSP["Drivers/BSP/UI(表现层)"] D["ui_data.c/h
快照存储 + 只读访问器
+ 定值写入队列与状态机"] P["ui_page_ac / ui_page_yx / ui_w_setview+ui_w_setlist ...
只读快照 + 格式化 + 布局(整定页另加写投递)"] P --> D P -- "整定保存:ui_data_param_request(&req)" --> D end T["App/task/ui_task.c
调度:每轮调 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` ```c #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` ```c #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.o` 116 B `.bss`(信息帧本地缓存 + 发布序号); info 帧字段增量落在 `ui_data.o`(1,192 B `.bss`,同对象还装 rt / par 两通道的双区)与 `ui_data_feed.o`(217 B)里。 随帧发布不新增拷贝节拍。水位: `RW_IRAM1` 62.2% / `RW_CCM` 87.9%(阈值 92%), `mem-budget` PASS, 台账已由 `scripts/ram_baseline.py --update` / `--write` 回填(docs/内存管理/RAM预算台账.md)。 `@build` 后回填),未触 `mem-budget` 92% 红线。 > 另一处落地细化:`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()` 判"快照是否变过",变过才整帧拷进**页面私有静态缓存**,其余行只读缓存。 ```c 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 | 三条实现侧口径,都是原稿没写死的: 1. **`LOCK` 还有一个与状态机无关的来源**:帧里 `par.locked == 1` 时保存行**直接显 `LOCK` 并禁 ENTER**(优先级高于历史结果)。闭锁是持续状态,不该只在"某次投递被拒"那一帧才说一次; 禁编辑必须落在键路上,只靠屏上词会让用户白改一轮。 2. **`ERR_TIMEOUT`(原稿的"3 s 未取走")本层不产出**——uiTask 每 10 ms 就 drain 一次, 真出现"永不落定"是 App 侧没接线的装配缺陷,不是运行期状态。页面若要兜底,可自己按投递 时刻计时(属页面本地可选项,当前没做:没有消费者就别先加一条无人走的分支)。 3. **状态是全局单值,页面必须锁存显示**:`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`"改为"接受一个只读函数表": ```c /* 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` 基础上加两个字段,或新增页面侧结构): ```c 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` 的实现形态与两条原稿未写死的细节: > 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` 合成正弦模式**尚未实现**(当前无消费者,等动态刷新真的需要人眼观察时再加)。 > 5. **定值写路径在模拟器侧有一份同构的执行实现**(`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` 也按同一条口径钉在引脚档), > 屏上的话必须与本机一致,模拟器才配当像素基准。 > 6. 落定与"页面自己发现数据变了"的分工:`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_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;本节只登记这条事实,避免两份文档各说各话。 > > 【落地注·六(写路径消费者已上屏,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 态)——**集合现已扩至 66 张**,**双次一致已验(2026-10-29:同一批 66 张连跑三次逐字节一致,池水位 5,448 B;菜单 8 状态连跑三次亦一致,5,368 B)**;轮询定时器不影响——无头模式不调 `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` 取值,页面零改动。