feat(UI): 装置维护 3 页落地(时钟 / 强制复归 / 清除记录),内容页黄金 27→40

三个薄页面模块 ui_page_clock / ui_page_reset / ui_page_clear,页面注册由生成器
自动写双端(裁定 A-1:「三叶各绑一页」与「零新增 .c」不可兼得,选薄页面加生成器,
不写手写 node switch——那种分发在增删叶子时会静默换功能,且没有任何门禁照得到)。

数据侧是注入式函数表:UI 不 include App 层的头。两条硬理由都实测过——分层方向
(layer-deps)之外,模拟器根本不链 sys_clock,ALLOW 豁免挡得住 layer-deps,
挡不住模拟器链接期。面板对时的唯一真判据是回读 RTC:sys_clock_set() 恒返 0
(RTC 写失败只打日志),所以真机侧写 1 后回读 1,最坏约 200 ms 落在一次「保存」
按键上,IWDG 800 ms 窗有余;上界与依据都写进注释。

「无时钟」徽标位置有两个像素级实测坑:放标题带右侧会被 4 字标题连成「时钟设置
无时钟」一句话;放页脚带左侧 x=7 会压住页脚——页脚从 x=6 起排,不是居中。
最终落在清单末行与页脚之间的空带 y=124。clear_done 那张顺带给出像素证据:
清 run.log 之后「操作记录」那一行从 2 变 3,页面上没缓存、没撒谎。

ui_w_records 的 UI_W_REC_LOG_* 顺序必须等于 syslog_id_t 顺序(7 类,只准尾追加),
已加编译期顺序守卫;执行侧 7 条映射表同样带编译期守卫。

测试与真值:内容页黄金 27 → 40 页(+13),文本断言 +68、反向断言 ABSENT +19;
27 张旧参考 git diff 为空 = 逐字节未变。往返池 5448 B 与菜单常驻堆 5368 B 均未变;
字库未扩(源码在用 171 并 KEEP_EXTRA 39 = 清单 183),font-charset 与 cn-chars、
cn-font 字节一致。@build 0 错误 0 警告;uiTask 静态最坏链 1,336 B / 16,384
(余 91%)与基线相同,页面钩子没进最坏链。

一条测试学结论单独留痕:反向断言不能当「位置不重叠」的证据——本脚本的字形匹配
把 y 带判得比字面宽(徽标在 125..136、页脚在 144..155,仍被 (140,160) 判命中)。
该防线改由正向断言「页脚未被徽标压住」承担(压字那一版实测过不了它)。

两条自我更正一并记档(作者自报,按真值而非原口径):
① 先前报「文案零新增汉字」是错的——观测行拟用「动作元件」,「元」不在子集,
cn-chars 与 font-charset 如实红;最终改「保护动作 N」才让「零新增」成为事实。
顺序是:先按机器红,再按真值写结论。
② 先前报「6 个文件 BOM 逐个核过」被 bom-guard 证伪:丢 BOM 发生在上一次编译之后,
正是 AGENTS 那句「历史 0 警告不代表安全」的活例;此后一律以退出码为准。

未覆盖项不假装:台架「N>0 → N=0」在产品构建的未标定闭锁期拿不到(闭锁期 N 恒 0),
判据显式退化为「页面受理 + 落一条操作记录」,写在 rs_watch 注释里。

生成物入库例外照用:uvprojx 只加 3 个 ui_page_ 的 FilePath(真增删源文件),
CMakeLists 同批;两处都由 gen_menu.py 自己写,没手写。

本笔先走了一次真钩子:20 条钩子实跑通过(ui-golden 菜单 8 状态加内容页 40 页、
generated-fresh、registration-check、font-charset、ui-data 单测、cppcheck、
coding-standard、bom、mojibake、id-check、doc-links 等),只有 mem-numbers 与
mem-budget 两条红——磁盘上的 .map 出自 20:39 那次构建、含三条在飞道的代码,
与 HEAD 台账必然不同码。按仓库既有约定(这两条等台账回填时允许 --no-verify 并
正文留证)落这一笔;回填由我统一在收尾一笔做。

三条在飞道(task-26 崩溃证据路径、task-16 与 task-17 谓词收敛和卷锁观测、
task-36 台架工具)的未暂存改动被 autostash 暂时收走,钩子看到的是 HEAD 加本笔;
过程中曾把 comm 道未跟踪的 iec104_comm.c 临时移出(它不在 stash 覆盖面内),
那次移出反而让 ui-golden 撞见 CMake 缓存里的悬空引用而红——已改用「先落 comm
那笔、再落本笔」的顺序消掉这个中间态。ui_data_maint_ready() 待删(只有测试在读
的自检口),排在本笔之后单独一笔。
This commit is contained in:
2026-10-26 22:43:54 +08:00
parent 45086cf5f9
commit 52e28885cb
34 changed files with 2854 additions and 175 deletions
+263
View File
@@ -32,6 +32,16 @@
* 的数据源);写路径仍不引入任何 App 依赖(执行事实
* 经 ui_data_param_ops_t 由 App 侧注入,同契约 §7.2
* 记录页注入式 reader 的路子)
* v1.2 2026-10-26 DTU 项目组 道F task-3(docs/任务规划/UI补齐.md §2 第 3 行
* 「装置维护 3 页」的前置):新增通道四——维护写请求
* (ui_data_maint_*:墙钟设置 / 强制复归 / 清除记录,
* 常驻注入 ui_data_maint_ops_t)与时钟字段换算
* (ui_data_clock_*,本地域无时区偏移,兼作"无效值
* 拒收"的唯一判据处)。三通道快照结构未变 ⇒
* UI_DATA_VER 仍为 2。仍然零 App include:维护动作的
* 真源全在 App 侧,本层只转达请求与结果(同 §7.2);
* ⚠ 页面也不得直调 sys_clock_*——模拟器没链 sys_clock,
* 那种写法只在模拟器链接期才炸,理由见通道四表注
*/
#ifndef UI_DATA_H
@@ -426,6 +436,259 @@ uint64_t ui_data_param_err_mask(void);
*/
int ui_data_param_drain(const ui_data_param_ops_t *a_ops);
/* ── 通道四:装置维护写请求(时钟 / 强制复归 / 清除记录)───────────────────── */
/*
* 执行侧函数表(道F task-3 新增;注入式,与 ui_data_param_ops_t、
* ui_w_records.h 的 ui_rec_reader_t 同一条路子)
*
* 为什么是注入而不是直接调:分层方向硬约束——BSP 层目录 Drivers/BSP 不得反向
* include App 层的头(scripts/check_layer_deps.py 拦提交),而 sys_clock_set_unix /
* prot_elm_request_reset / syslog_request_clear 三件事实的属主全在 App 侧。
* ⚠ **另有一条同样硬的理由**:模拟器根本没有 sys_clock
* (simulator/dbg_log.h:4「模拟器无 sys_clock/RTC」,CMakeLists 也没这份源),
* 所以页面里哪怕只用一次 sys_clock_*,也是"MCU 编得过、模拟器链接期爆",
* 而这种错只在另一端构建时才暴露。写动作一律走本表,两端各给实现。
* ⚠ 本表刻意不含任何"范围数值/枚举值":什么是有效时间、哪条记录对应哪个文件,
* 真源仍分别在各属主模块(本层不做第二真源,只转达请求与结果)。
*/
typedef struct {
/*
* 函数功能:设置系统墙钟(本地域 epoch 秒)
* 入口参数:unix_epoch - 要设置的秒 uint32_t
* 返回值:0 - 已生效;-1 - 设置失败 int
* 限定条件:真机实现**必须**在 sys_clock_set_unix() 之后回读 sys_clock_get()
* 复核(差值容差见 App 侧实现),不得"调了就算成"
*/
int (*clock_set)(uint32_t a_unix_epoch);
/*
* 函数功能:请求复归全部保护元件状态
* 入口参数:无
* 返回值:0 - 已受理;-1 - 拒收 int
* 限定条件:真机实现 = prot_elm_request_reset()(只置单字节标志,由
* protectTask 下一拍消费),本层不等执行结果
*/
int (*reset_req)(void);
/*
* 函数功能:取当前处于动作态的保护元件数("动作位复归"的观测量)
* 入口参数:无
* 返回值:元件数(0 = 无元件动作) uint32_t
* 限定条件:真机实现 = popcount(prot_elm_active_mask())
*/
uint32_t (*active_count)(void);
/*
* 函数功能:往"操作记录"投一条留痕(人工在面板上做过一件事)
* 入口参数:desc - 描述串(NUL 结尾,调用方持有的常量串;投递侧即拷贝)
* const char *
* 返回值:无
* 限定条件:真机实现 = syslog_post_quiet(SYSLOG_OPER, desc)。**谁触发谁投**:
* 远端 104 遥控那条已由 App/protocol/iec104/iec104_pts.c 投,
* 本地面板这条由页面在受理成功后投,两边各一条、不得互替
*/
void (*oper_note)(const char *a_desc);
/*
* 函数功能:请求清除某类记录(只登记请求,不在本上下文删任何东西)
* 入口参数:log_id - UI 侧记录源编号(UI_W_REC_LOG_*,与 ui_rec_reader_t 同一编号空间) uint8_t
* 返回值:0 - 请求已登记;-1 - 编号越界或登记失败 int
* 限定条件:真机实现 = syslog_request_clear(映射后 id)。⚠ 共享代码**严禁**
* 调 MCU 后端的 syslog_clear_apply()(模拟器无实现 = 链接期报错),
* 也严禁 UI 直接 f_unlink / 碰卷锁(defaultTask 是唯一写者)
*/
int (*clear_req)(uint8_t a_log_id);
/*
* 函数功能:查某类记录"清除请求"的状态
* 入口参数:log_id - 记录源编号 uint8_t
* 返回值:UI_DATA_CLEAR_ST_NONE / _OK / _WAIT / _FAIL 之一 int
* 限定条件:真机实现 = syslog_clear_state(映射后 id),返回值直通不改码。
* ⚠ WAIT 是正常态(删除在 defaultTask 的 syslog_drain() 尾部落地);
* 模拟器端同步删除,永不出现 WAIT —— 页面两种都要能显示
*/
int (*clear_state)(uint8_t a_log_id);
} ui_data_maint_ops_t;
/*
* 清除状态(与 App/log/sys_log.h 的 SYSLOG_CLEAR_NONE/OK/WAIT/FAIL **逐值相等**;
* 适配层直通、不做映射表,等值由主机单测在同一 TU 里逐项核对钉住,
* 同 UI_W_SOE_T_* 的先例——等值只由测试钉,不由文档转述)
*/
#define UI_DATA_CLEAR_ST_NONE 0 /* 从未请求过清除 */
#define UI_DATA_CLEAR_ST_OK 1 /* 已清空:同拍该记录条数必为 0 */
#define UI_DATA_CLEAR_ST_WAIT (-1) /* 请求排队中(正常态,不是失败) */
#define UI_DATA_CLEAR_ST_FAIL (-2) /* 落地失败:原记录一条没少,可再发一次请求 */
/*
* 函数功能:注入装置维护执行侧函数表(启动期一次;先于 ui_menu_init)
* 入口参数:ops - 函数表(六个成员指针都不得为 NULL;生命周期由注入方保证,
* 本层只存指针不拷贝内容) const ui_data_maint_ops_t *
* 返回值:0 - 已注入;-1 - ops 为 NULL 或某成员为 NULL(存量不动,页面按
* "维护能力不可用"渲染,不崩不猜) int
* 限定条件:uiTask 启动期(与 ui_w_records_bind 同一处);传 NULL = 撤销注入
* 函数说明:与 ui_data_param_drain() 的"每次调用时传入"不同,本表是**常驻注入**:
* 维护动作由用户按键触发、无固定节拍,若也走"每次传表"就得在 uiTask
* 主循环里每轮挂一次,而那一轮并没有任何东西要落定(空转一次函数调用
* 换不来任何判据)。撤销窗口只在测试里存在
*/
int ui_data_maint_bind(const ui_data_maint_ops_t *a_ops);
/*
* 函数功能:查询维护通道是否已注入
* 入口参数:无
* 返回值:1 - 已注入;0 - 未注入 uint8_t
* 限定条件:无(只读单字)
* 函数说明:页面用它决定"这页能不能操作"——未注入时按钮一律拒并按 LOCK 显示,
* 比默默吞掉按键好(现场能立刻看出是装配缺了一步,而不是设备坏了)
*/
uint8_t ui_data_maint_ready(void);
/*
* 函数功能:请求设置墙钟(时钟设置页"保存"行的唯一出口)
* 入口参数:unix_epoch - 由 ui_data_clock_to_epoch() 合成、且已整组校验过的秒 uint32_t
* 返回值:0 - 执行侧受理(终态由执行侧的回读校验给出);-1 - 未注入或执行侧失败;
* -2 - 参数无效(本层不判日期合法性,那是
* ui_data_clock_to_epoch() 的活,到这儿只判 0 哨兵) int
* 限定条件:uiTask 上下文(页面按键钩子)
*/
int ui_data_maint_clock_set(uint32_t a_unix_epoch);
/*
* 函数功能:请求复归全部保护元件(强制复归页的执行出口)
* 入口参数:无
* 返回值:0 - 已受理;-1 - 未注入或执行侧拒收 int
* 限定条件:uiTask 上下文;⚠ 操作记录**不在本函数里投**——"这是本地面板发起的"
* 这一事实只有页面知道,由页面在返 0 之后显式调
* ui_data_maint_oper_note() 投(拒收时不得投,屏上留痕与记录留痕
* 必须同一条判据)
*/
int ui_data_maint_reset_req(void);
/*
* 函数功能:取当前动作元件数(复归页"保护动作 N"行的数据源;屏上用字须已入子集)
* 入口参数:无
* 返回值:元件数;未注入返回 0(= 显示"无元件动作",与真值 0 同形,
* 但页脚/态标另有 LOCK 提示装配未接好) uint32_t
* 限定条件:页面绘制路径可直调(一次函数指针调用,无 I/O、无锁)
* 函数说明:本函数是"动作位复归可证"的**唯一观测量**:复归请求受理后,
* 页面按帧重读,N 从 >0 变 0 就是屏上可见的证据;复归不掉只可能是
* 测量仍在越限(元件重新 pickup),不是请求丢了
*/
uint32_t ui_data_maint_active_count(void);
/*
* 函数功能:投一条操作记录留痕(页面在本地动作受理后调)
* 入口参数:desc - 描述串(ASCII,长度受执行侧 SYSLOG_DESC_MAX 约束;NULL = 什么都不做)
* const char *
* 返回值:0 - 已投递;-1 - 未注入或 desc 为 NULL int
* 限定条件:uiTask 上下文;投递本身不阻塞、可能因队列满被丢弃(执行侧口径,
* 与 syslog_post_quiet 一致——本层不加"投递失败"的第二套显示语义:
* 留痕丢了不等于操作没发生,把记录通道故障显示成操作失败更坏)
*/
int ui_data_maint_oper_note(const char *a_desc);
/*
* 函数功能:请求清除某类记录(清除记录页的执行出口)
* 入口参数:log_id - 记录源编号(UI_W_REC_LOG_*) uint8_t
* 返回值:0 - 已受理;-1 - 未注入 / 编号越界 / 执行侧拒绝 int
* 限定条件:uiTask 上下文;"受理"≠"已清除",终态读 ui_data_maint_clear_state(),
* 事实复核读记录条数(ui_w_records 的同一枚 reader)
*/
int ui_data_maint_clear_req(uint8_t a_log_id);
/*
* 函数功能:查某类记录的清除状态(清除记录页值列的数据源)
* 入口参数:log_id - 记录源编号 uint8_t
* 返回值:UI_DATA_CLEAR_ST_NONE / _OK / _WAIT / _FAIL;未注入或编号越界返回 NONE
* ("从没请求过",页面据此显示条数而不是结果词) int
* 限定条件:页面绘制路径可直调;返回值直通执行侧、不做二次加工(同
* ui_data_param_state() 的理由:两处解释同一件事必然漂移)
*/
int ui_data_maint_clear_state(uint8_t a_log_id);
/* ── 时钟字段换算(本地域,无时区偏移;"无效值拒收"的唯一判据处)─────────── */
/*
* 年份上下限(两条硬约束的交集,不是拍脑袋的整数区间):
* 下限 2000:SD2506 RTC 年字段是相对值 0~99(+2000,见
* Drivers/BSP/SD2506/sd2506.h 的 sd2506_time_t.year 注释),
* 2000 以下写不进 RTC
* 上限 2038:墙钟 epoch 是 uint32_t 秒,上界 0x7FFFFFFF = 2038-01-19 03:14:07
* (App/util/sys_clock.h 的 sys_clock_set_unix 取值域一致)
*/
#define UI_DATA_CLOCK_YEAR_MIN 2000u
#define UI_DATA_CLOCK_YEAR_MAX 2038u
#define UI_DATA_CLOCK_EPOCH_MAX 0x7FFFFFFFu
#define UI_DATA_CLOCK_TS_LEN 19u /* "YYYY-MM-DD HH:MM:SS"(与 rt.clock 同串型) */
/* 时钟日历字段(本地域;数值就是屏上显示的那六个数,不含任何时区语义) */
typedef struct {
uint16_t year; /* UI_DATA_CLOCK_YEAR_MIN..UI_DATA_CLOCK_YEAR_MAX */
uint8_t month; /* 1..12 */
uint8_t day; /* 1..该月真实天数(闰年由本层判,不交给调用方) */
uint8_t hour; /* 0..23 */
uint8_t minute; /* 0..59 */
uint8_t second; /* 0..59 */
} ui_data_clock_t;
/*
* 函数功能:取某年某月的天数(含闰年 2 月)
* 入口参数:year - 年 uint16_t month - 月 1..12 uint8_t
* 返回值:28 / 29 / 30 / 31;月份越界返回 0(调用方据此判非法,不用第二条错误码) uint8_t
* 限定条件:闰年规则 = (4 年 && !100 年) || 400 年(与 App/util/sys_clock.c
* 的 unix_to_sd2506 / sd2506_to_unix 同一判据,两侧都是完整格里历规则;
* 2100 年不在允许年域内,不需要讨论那个例外)
*/
uint8_t ui_data_clock_days_in_month(uint16_t a_year, uint8_t a_month);
/*
* 函数功能:日历字段 → 本地域 epoch 秒(**同时是这组字段的合法性判据**)
* 入口参数:p_in - 六个字段 const ui_data_clock_t *
* p_out - 秒 uint32_t *
* 返回值:0 - 合法并已输出;-1 - 参数为 NULL;-2 - 字段组合非法(年/月/日/时/分/秒
* 任一越界,含 2 月 30 日、非闰年 2 月 29 日、月 13、时 24 等);
* -3 - 组合合法但超出取值域(epoch > UI_DATA_CLOCK_EPOCH_MAX) int
* 限定条件:无(纯整数,零依赖,可在主机单测跑全表)
* 函数说明:日的当月真实上限**在这里**判,不在字段编辑器里判——编辑器只有静态
* min/max(日一律 1..31),把闰年规则抄一份进编辑器就是第二真源。
* ⚠ 不含时区偏移:本工程墙钟的 epoch 域本身就是本地时间
* (App/util/sys_clock.h 的 sys_clock_set_unix 注明"秒,本地时间",
* 其正/反换算两头都没有 ±8 运算;App/task/ui_task.c 的 SOE 时标注记
* 已把"别顺手补 8 小时"钉过一次)。这里加 28800 的结果是"屏上输 08:00、
* 存进去 16:00",且只在真机上错,模拟器照样自洽
*/
int ui_data_clock_to_epoch(const ui_data_clock_t *p_in, uint32_t *p_out);
/*
* 函数功能:本地域 epoch 秒 → 日历字段
* 入口参数:unix_epoch - 秒 uint32_t p_out - 输出 ui_data_clock_t *
* 返回值:0 - 已输出;-1 - p_out 为 NULL;-2 - 超出允许年域(< 2000-01-01 或
* > UI_DATA_CLOCK_EPOCH_MAX) int
* 限定条件:与 ui_data_clock_to_epoch() 互为反函数(同一套格里历规则,
* 往返必等;这条往返性是主机单测的断言之一,不是注释里的口头保证)
*/
int ui_data_clock_from_epoch(uint32_t a_unix_epoch, ui_data_clock_t *p_out);
/*
* 函数功能:解析 "YYYY-MM-DD HH:MM:SS" 串为日历字段
* 入口参数:ts - 待解析串(快照 rt.clock 的形状) const char *
* p_out - 输出 ui_data_clock_t *
* 返回值:0 - 成功;-1 - 参数 NULL;-2 - 串型不对(长度非 19、分隔位不是
* '-'/' '/':'、出现非数字);-2 同时覆盖"型对但值非法"
* (解析出的组合再过 to_epoch()) int
* 限定条件:时钟无效时快照里是**空串**(ui_data.h:149 的口径),空串走 -2;
* 调用方据此显示"无时钟"而不是 0000-00-00
*/
int ui_data_clock_parse(const char *a_ts, ui_data_clock_t *p_out);
/*
* 函数功能:日历字段 → "YYYY-MM-DD HH:MM:SS"(与 ui_data_clock_parse 同串型)
* 入口参数:p_in - 字段 const ui_data_clock_t *
* buf - 输出缓冲 char * len - 缓冲容量(含 NUL;不足则整串不写) uint32_t
* 返回值:0 - 已写出;-1 - 参数无效或容量不足 int
* 限定条件:不判字段合法性(调用方刚从 parse/to_epoch 那条链出来);
* 手写十进制、不引 stdio——本层是零 LVGL 零 App 的叶子,拉进
* lv_snprintf/stdio 就等于给主机单测加依赖
*/
int ui_data_clock_format(const ui_data_clock_t *p_in, char *a_buf, uint32_t a_len);
#ifdef __cplusplus
}
#endif