diff --git a/App/task/ui_task.c b/App/task/ui_task.c index 35b2867c..4ac1ca29 100644 --- a/App/task/ui_task.c +++ b/App/task/ui_task.c @@ -26,6 +26,12 @@ * 按位连击判据 = docs/子系统设计/看门狗设计说明.md §5.3)。 * 单个往返自身仍无上界——该上界与彻底解耦(事件驱动 * accept)归登记簿「net 层跨上下文收口」(批 3),本票只做心跳续命。 + * v1.5 2026-10-26 DTU 项目组 道F task-3 装置维护三页的执行侧:新增 + * ui_data_maint_ops_t 六件适配(时钟=写+回读 RTC、 + * 复归=prot_elm_request_reset、清除=syslog_request_clear + * /clear_state 直通)并在 ui_menu_init 之前注入;记录 + * reader 的 2 条 if 链改成 7 条映射表 s_log_map,并加 + * 编译期守卫把 UI_W_REC_LOG_* == syslog_id_t 枚举序钉死 */ #include "ui_task.h" @@ -47,6 +53,7 @@ #include "soe_store.h" /* task-15 SOE 只读遍历(count/read);写侧仍归 protectTask/defaultTask */ #include "ui_tokens.h" /* UI 设计令牌:循环周期 UI_TASK_TICK_MS / 待机阈值 UI_STANDBY_IDLE_MS */ #include "sys_clock.h" +#include "prot_elm.h" /* 道F task-3 装置维护:强制复归请求 + 动作态元件位图 */ #include "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */ #include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */ #include "task_watchdog.h" @@ -65,38 +72,231 @@ static void ui_task_time_cb(char *buf, size_t len) { } /* - * 函数功能:记录 reader 适配(task-6 注入)——UI 不透明编号 0/1 → syslog_id_t 映射 + * UI 记录源编号 → syslog_id_t 映射表(道F task-6 起 7 类,task-3 装置维护「清除记录」 + * 要在 7 行上分行显示;表长 = UI_W_REC_LOG_N,两端映射表同源同长) + * + * ⚠ 顺序契约(Lead 裁定 2026-10-26):UI_W_REC_LOG_* 逐字等于 syslog_id_t 枚举序, + * 只准尾部追加——枚举序就是 App/log 两端 s_paths[]/s_caps[] 的下标,中间插值会 + * 安静地把事故读成告警。下面的编译期守卫就是这条契约在本端的机械防线(记录侧 + * 已有 TC-SYSLOG-130/131/131b,UI 侧对应的一条在 test/host/ui_data TC-UIDATA-024, + * 两端各钉一次,谁中间插号谁编译不过 / 单测红)。 + * 即便如此,适配层**始终显式逐项映射**、从不把 UI 编号强转成 syslog_id_t: + * 等值是今天的事实,不是页面可以依赖的契约(UI_W_REC_LOG_* 对页面仍是不透明号)。 + */ +static const syslog_id_t s_log_map[UI_W_REC_LOG_N] = { + SYSLOG_RUN, SYSLOG_SELFTEST, SYSLOG_ACCIDENT, SYSLOG_ALARM, + SYSLOG_START, SYSLOG_OPER, SYSLOG_YK +}; + +#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L) +typedef char ui_task_log_order_check_t[ + ((UI_W_REC_LOG_RUN == (uint8_t)SYSLOG_RUN) && + (UI_W_REC_LOG_SELFTEST == (uint8_t)SYSLOG_SELFTEST) && + (UI_W_REC_LOG_ACCIDENT == (uint8_t)SYSLOG_ACCIDENT) && + (UI_W_REC_LOG_ALARM == (uint8_t)SYSLOG_ALARM) && + (UI_W_REC_LOG_START == (uint8_t)SYSLOG_START) && + (UI_W_REC_LOG_OPER == (uint8_t)SYSLOG_OPER) && + (UI_W_REC_LOG_YK == (uint8_t)SYSLOG_YK) && + (UI_W_REC_LOG_N == (uint8_t)SYSLOG_COUNT)) ? 1 : -1]; +#endif + +/* + * 函数功能:记录 reader 适配——UI 不透明编号 → syslog_id_t 后取条数 * 入口参数:log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t - * 返回值:记录条数;未知编号返回 0(页面退化为空态) uint16_t - * 限定条件:uiTask 上下文同步调用(记录页绘制路径,与合并前直调同形) + * 返回值:记录条数;未知编号返回 0(页面退化为空态,与合并前直调同形) uint16_t + * 限定条件:uiTask 上下文同步调用(记录页与清除记录页的绘制路径,无 I/O 之外开销: + * syslog_count 读的是文件长度/内存计数,不逐条读盘) */ static uint16_t ui_task_rec_count(uint8_t log_id) { - if (log_id == UI_W_REC_LOG_RUN) { - return (uint16_t)syslog_count(SYSLOG_RUN); + if (log_id >= (uint8_t)UI_W_REC_LOG_N) { + return 0u; } - if (log_id == UI_W_REC_LOG_SELFTEST) { - return (uint16_t)syslog_count(SYSLOG_SELFTEST); - } - return 0u; + return (uint16_t)syslog_count(s_log_map[log_id]); } /* - * 函数功能:记录 reader 适配(task-6 注入)——读记录字段直通 sys_log(错误码不改写) + * 函数功能:记录 reader 适配——读记录字段直通 sys_log(错误码不改写) * 入口参数:log_id/idx/ts/ts_len/desc/desc_len - 契约见 ui_w_records.h ui_rec_reader_t * 返回值:0 成功;-1 编号/序号越界(未知编号同 -1);-2 读失败 int * 限定条件:同上 */ static int ui_task_rec_get(uint8_t log_id, uint32_t idx, char *ts, uint32_t ts_len, char *desc, uint32_t desc_len) { - if (log_id == UI_W_REC_LOG_RUN) { - return syslog_get_record(SYSLOG_RUN, idx, ts, (size_t)ts_len, desc, (size_t)desc_len); + if (log_id >= (uint8_t)UI_W_REC_LOG_N) { + return -1; } - if (log_id == UI_W_REC_LOG_SELFTEST) { - return syslog_get_record(SYSLOG_SELFTEST, idx, ts, (size_t)ts_len, desc, (size_t)desc_len); - } - return -1; + return syslog_get_record(s_log_map[log_id], idx, ts, (size_t)ts_len, desc, + (size_t)desc_len); } + +/* + * ============================ 道F task-3:装置维护写通道(UI 注入执行侧) + * + * 为什么在这一层实现而不是在页面里:页面属 Drivers/BSP/UI,反调 App 侧的 + * sys_clock / prot_elm / sys_log 是分层方向的反例(layer-deps 拦),更关键的是 + * **模拟器根本不链 sys_clock**(simulator/dbg_log.h:4「模拟器无 sys_clock/RTC」)—— + * 页面里用一次就是"MCU 编得过、模拟器链接期爆"。写动作一律走 ui_data_maint_ops_t, + * 两端各给实现(口径 docs/UI设计/UI_数据供给设计说明.md §6.2/§7.2)。 + * + * uiTask 阻塞上界(IWDG 800ms 窗、本任务在 required_mask 内):本通道里只有 + * 时钟设置会碰硬件——sys_clock_set_unix 内含 1 次 SD2506 写,再叠加本适配层刻意 + * 加的回读复核 1 次读,各 SD2506_I2C_TIMEOUT_MS=100ms ⇒ 最坏 ≈200ms 落在一次 + * "保存"按键上(心跳在循环尾,仍远小于 800ms;对时任务同样在本机上做过这件事)。 + * 复归/清除两条只写内存标志或投递给别的任务,无 I/O。 + */ + +/* + * 函数功能:设置墙钟并**回读复核**(真机 = 写 RAM 基准 + 写 SD2506 RTC) + * 入口参数:a_epoch - Unix 秒(本地时间域,与 sys_clock 同一口径,无时区偏移) uint32_t + * 返回值:0 - 已生效且 RTC 回读一致;-1 - 越界 / 未生效 / RTC 回读不符 int + * 限定条件:uiTask 上下文(页面"保存"按键直达,同步返回终态) + * 函数说明:⚠ **sys_clock_set() 恒返 0**(App/util/sys_clock.c:462 起:RTC 写失败 + * 只打 DBG_ERROR,不改返回值),所以本函数不能拿它的返回值当结论—— + * 回读是唯一真判据;而只回读 sys_clock_get() 证明的仅是"RAM 基准换了", + * 面板对时的用户意图是**掉电后还要是对的**,故必须回读 RTC 本身。 + * RTC 坏了但墙钟已改时本函数返 -1(屏上"保存行=ERR + 值列跟着新时间", + * 两个事实各说各的,比笼统报 OK 诚实);秒位允许 ±3 与"分/时/日回绕"两种 + * 形态,因为两次读之间墙钟与 RTC 都在走。 + * 上限 UI_DATA_CLOCK_EPOCH_MAX(2038) 在 UI 层已挡,这里再挡一次: + * 执行侧不信任调用侧(0 还兼作"未建钟"哨兵,面板永远不该发 0) + */ +static int ui_task_maint_clock_set(uint32_t a_epoch) { + sd2506_time_t want; + sd2506_time_t got; + uint32_t now; + int ds; + + if ((a_epoch == 0u) || (a_epoch > (uint32_t)UI_DATA_CLOCK_EPOCH_MAX)) { + return -1; + } + if (sys_clock_set_unix(a_epoch) != 0) { + return -1; /* 今天到不了这条腿(sys_clock_set 恒 0),留着是因为它是契约 */ + } + now = sys_clock_get(); + if ((now == UINT32_MAX) || ((now + 2u) < a_epoch) || (now > (a_epoch + 2u))) { + return -1; /* RAM 基准没跟上:连"现在就对"都没成立 */ + } + if (sys_clock_get_time(&want) != 0) { + return -1; + } + if (sd2506_get_time(&got) != BSP_OK) { + return -1; /* RTC 读不动:掉电保持这件事无法证明 */ + } + if ((got.year != want.year) || (got.month != want.month) || (got.day != want.day) || + (got.hour != want.hour) || (got.minute != want.minute)) { + return -1; + } + ds = (int)got.second - (int)want.second; + if (ds < 0) { + ds = -ds; + } + if ((ds > 3) && + !(((want.second >= 58u) && (got.second <= 3u)) || + ((got.second >= 58u) && (want.second <= 3u)))) { + return -1; + } + return 0; +} + +/* + * 函数功能:请求强制复归(跨上下文:protectTask 下一拍消费) + * 入口参数:无 + * 返回值:0 - 已投递(prot_elm_request_reset 无拒绝分支) int + * 限定条件:uiTask 上下文;不等待执行结果 + * 函数说明:只投递不阻塞(契约:UI 页面二次确认后调,页面立刻给"已请求复归")。 + * **不在这里投操作记录**:远端 104 YK11 那条已经在 + * App/protocol/iec104/iec104_pts.c 投过,在这里再投一次就会给每一次 + * 主站复归多记一条"面板复归",审计面直接失真。本地这条由页面投 + * (只有页面知道"这是人按的"),措辞也不同串:见 Drivers/BSP/UI/ui_page_reset.c + */ +static int ui_task_maint_reset_req(void) { + prot_elm_request_reset(); + return 0; +} + +/* + * 函数功能:取当前处于动作态的保护元件个数(复归页观测行 "保护动作 N" 的 N) + * 入口参数:无 + * 返回值:位图中置 1 的位数(纯 RAM 读,无锁) uint32_t + * 限定条件:uiTask 绘制路径每帧可直调 + * 函数说明:给个数而不是位图,是为了让 160x160 上一行放得下、也让"复归前 N>0 → + * 复归后 N=0"这个台架判据肉眼可读;判据前提(产品构建处于未标定闭锁, + * App/task/protect_task.c:98 停走判据 ⇒ 闭锁期 N 恒 0)已写进页面注释 + */ +static uint32_t ui_task_maint_active_count(void) { + uint32_t mask = prot_elm_active_mask(); + uint32_t n = 0u; + + while (mask != 0u) { + n += (mask & 0x1u); + mask >>= 1; + } + return n; +} + +/* + * 函数功能:投一条本地操作记录(面板动作留痕) + * 入口参数:a_desc - 描述串(ASCII,长度须 <= SYSLOG_DESC_MAX=28) const char * + * 返回值:无 + * 限定条件:uiTask 上下文;只在动作真正被受理之后由页面调用 + * 函数说明:用 quiet 口(与 iec104_serv.c 对时留痕同一选择):uiTask 不属于深链 + * 任务,但 quiet 口的收/丢判据与 syslog_post 一致且不在调用链上摊开 + * 行缓冲,留痕这件事没理由选更贵的那个口。 + * 丢条不回报页面:syslog 侧有累计丢弃计数(syslog_post_quiet_dropped()) + * 可查;页面上"动作做了没"与"这条审计写没写"是两件事,不该混在一个 + * 态标里——把后者做成 ERR 会让人以为复归没做而再按一次 + */ +static void ui_task_maint_oper_note(const char *a_desc) { + if (a_desc == NULL) { + return; + } + syslog_post_quiet(SYSLOG_OPER, a_desc); +} + +/* + * 函数功能:请求清除某一类记录(只投递,落地在 defaultTask 的 syslog_drain 尾部) + * 入口参数:a_log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t + * 返回值:0 - 已受理(MCU 端随后通常是 SYSLOG_CLEAR_WAIT);-1 - 越界编号 int + * 限定条件:uiTask 上下文 + * 函数说明:本层是 UI 编号 → syslog_id_t 的**唯一**映射点之一(与 s_log_map 同表), + * 绝不在 UI 侧做 f_unlink / 也不调 MCU 专属的 syslog_clear_apply(): + * 记录卷的属主是 defaultTask,uiTask 去碰 FAT 既违反唯一写者,又给 + * IWDG required_mask 内的任务添一条无上界的存储路径(口径 App/log/README.md) + */ +static int ui_task_maint_clear_req(uint8_t a_log_id) { + if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) { + return -1; + } + return (syslog_request_clear(s_log_map[a_log_id]) == 0) ? 0 : -1; +} + +/* + * 函数功能:取上条清除请求的状态(值直通,不改写、不翻译) + * 入口参数:a_log_id - UI 记录源编号 uint8_t + * 返回值:UI_DATA_CLEAR_ST_NONE/_OK/_WAIT/_FAIL;越界编号按 NONE int + * 限定条件:uiTask 绘制路径每帧可直调(纯内存状态读) + * 函数说明:SYSLOG_CLEAR_* 与 UI_DATA_CLEAR_ST_* 逐值相等(0/1/-1/-2),由 + * test/host/ui_data TC-UIDATA-024 钉住;这里保持直通,是为了让页面 + * 显示的就是日志侧的事实,而不是"适配层以为的事实" + */ +static int ui_task_maint_clear_state(uint8_t a_log_id) { + if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) { + return UI_DATA_CLEAR_ST_NONE; + } + return syslog_clear_state(s_log_map[a_log_id]); +} + +/* 维护通道函数表(六件齐全;ui_data_maint_bind 拒绝残表,缺件就是装配错) */ +static const ui_data_maint_ops_t s_maint_ops = { + ui_task_maint_clock_set, + ui_task_maint_reset_req, + ui_task_maint_active_count, + ui_task_maint_oper_note, + ui_task_maint_clear_req, + ui_task_maint_clear_state +}; + static const ui_rec_reader_t s_rec_reader = { ui_task_rec_count, ui_task_rec_get }; /* @@ -225,6 +425,12 @@ void ui_task_func(void *argument) { * 但契约把"启动期先绑定"写死;未绑定窗口组件按空态防御——见 ui_w_records.h) */ ui_w_records_bind(&s_rec_reader); ui_w_soe_bind(&s_soe_reader); /* task-15:SOE 记录页数据入口(未绑定按"无记录"空态) */ + /* 装置维护写通道注入(道F task-3:时钟设置 / 强制复归 / 清除记录):同样必须先于 + * ui_menu_init。未注入的窗口里六个入口一律安全返回(页面显 ERR 或空态), + * 既不崩也不假成功;但残表是真故障,必须能在启动日志里看见,不能静默 */ + if (ui_data_maint_bind(&s_maint_ops) != 0) { + DBG_ERROR("UI maint bind: FAIL"); + } /* 快照层复位:必须先于 ui_menu_init(首帧之前任何页面被打开都按"无数据" * 渲染 "--",不会读到上电残留)——UI_数据供给设计说明.md §5.3 */ ui_data_init(); diff --git a/Drivers/BSP/UI/ui_data.c b/Drivers/BSP/UI/ui_data.c index c19e10be..60f33686 100644 --- a/Drivers/BSP/UI/ui_data.c +++ b/Drivers/BSP/UI/ui_data.c @@ -14,10 +14,16 @@ * App 侧节拍里的 ui_data_param_drain(),两者同在 uiTask → 环形队列照旧 * 无锁;若将来出现第二个投递上下文,唯一要做的是给 request/drain 两侧 * 各加一把互斥(队列本身是单字 head/cnt 推进,页面侧不会看到半成品) - * 模块功能补充:除三通道快照外,本模块还持有定值写路径的请求队列与状态字 - * (ui_data_param_request/state/drain)——本层只做"排队 + 状态机", - * 不判断值域、不碰 prot_set、不碰文件系统(值域与写入事实经 - * ui_data_param_ops_t 由 App 侧注入,理由见 ui_data.h 写路径节) + * 模块功能补充:除三通道快照外,本模块还持有三条与页面相关的东西: + * ① 定值写路径的请求队列与状态字(ui_data_param_request/state/drain) + * ——只做"排队 + 状态机",不判断值域、不碰 prot_set、不碰文件系统 + * (值域与写入事实经 ui_data_param_ops_t 由 App 侧注入); + * ② 装置维护写通道(ui_data_maint_*:墙钟设置 / 强制复归 / 清除记录) + * ——常驻注入的 ui_data_maint_ops_t 转发,本层不解释业务含义; + * 为什么这里常驻注入而 ① 每次传表,见 ui_data.h 通道四的函数说明; + * ③ 时钟字段换算(ui_data_clock_*:本地域、无时区偏移、纯整数零依赖) + * ——「无效值拒收」的唯一判据处,也是唯一能在主机单测里跑全表的 + * 日历判据(页面 .c 依赖 lvgl.h,编不进主机测试单元) * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) * 作者:王建锋 * 创建日期:2026-10-25 @@ -27,6 +33,15 @@ * 与状态机(IDLE/WAIT/OK/ERR_RANGE/ERR_LOCKED/ERR_BUSY), * 执行经注入函数表回调 App 侧;参数帧新增 cal_block * 字段随 publish 原样入存(本层不解释含义) + * v1.2 2026-10-26 DTU 项目组 道F task-3(装置维护 3 页的前置通道):新增维护 + * 写通道 ui_data_maint_*(常驻注入 ui_data_maint_ops_t: + * clock_set / reset_req / active_count / oper_note / + * clear_req / clear_state 六口,未注入一律安全返回)与 + * 时钟字段换算 ui_data_clock_*(本地域无时区偏移; + * to_epoch 同时是"无效值拒收"的唯一判据处)。 + * 三通道快照结构一个字节都没动 ⇒ UI_DATA_VER 保持 2 + * (版本号是页面与写侧唯一的对齐凭据,无字段变更却升版 + * = 让老页面以为新帧换了布局) */ /* 头文件包含区 */ @@ -420,3 +435,459 @@ int ui_data_param_drain(const ui_data_param_ops_t *a_ops) { s_pq_state = (s_pq_cnt != 0u) ? UI_DATA_PARAM_ST_WAIT : settled; return 1; } + +/* ── 通道四:装置维护写请求(时钟 / 强制复归 / 清除记录)──────────────────── */ + +/* + * 注入的维护执行侧函数表(NULL = 未注入)。 + * 只存指针不拷内容:注入方(App/task/ui_task.c 与 simulator/main.c)的两端实现 + * 都是文件级 static const 表,生命周期 = 整个运行期,与 ui_w_records_bind() 存的 + * reader 指针同一条约定。撤销只可能出现在宿主测试里。 + */ +static const ui_data_maint_ops_t *s_maint; + +/* + * 函数功能:注入装置维护执行侧函数表(启动期一次) + * 入口参数:ops - 函数表;六个成员指针都不得为 NULL;NULL = 撤销注入 + * const ui_data_maint_ops_t * + * 返回值:0 - 已注入(或已撤销);-1 - ops 非空但某成员为 NULL(存量不动) int + * 限定条件:uiTask 启动期,先于 ui_menu_init()(与 ui_w_records_bind 同一处); + * uiTask 之外的上下文不得调用(本层无锁,与快照层同一条并发前提) + * 函数说明:成员逐条校验而不只看表指针——半途接错一条(例如忘了注 clear_state) + * 会让清除记录页在"按下才崩"的位置崩,生成期/启动期就拒掉整表才是省事 + */ +int ui_data_maint_bind(const ui_data_maint_ops_t *a_ops) { + if (a_ops == NULL) { + s_maint = NULL; /* 撤销注入:页面随即按"能力不可用"渲染 */ + return 0; + } + if ((a_ops->clock_set == NULL) || (a_ops->reset_req == NULL) || + (a_ops->active_count == NULL) || (a_ops->oper_note == NULL) || + (a_ops->clear_req == NULL) || (a_ops->clear_state == NULL)) { + return -1; + } + s_maint = a_ops; + return 0; +} + +/* + * 函数功能:查询维护通道是否已注入 + * 入口参数:无 + * 返回值:1 - 已注入;0 - 未注入 uint8_t + * 限定条件:无(只读单字,任意上下文安全) + * 函数说明:页面用它把"没接线"和"操作被拒"分开显示——前者是装配缺陷, + * 现场再按一百次也不会好,必须看得见 + */ +uint8_t ui_data_maint_ready(void) { + return (s_maint != NULL) ? 1u : 0u; +} + +/* + * 函数功能:请求设置墙钟(时钟设置页"保存"行的唯一出口) + * 入口参数:unix_epoch - 已整组校验过的秒(见 ui_data_clock_to_epoch) uint32_t + * 返回值:0 - 执行侧受理;-1 - 未注入或执行侧失败;-2 - 参数无效(0 = 本层保留的 + * "未初始化"哨兵,与 sys_clock 的 UINT32_MAX 哨兵不同族, + * 这里只挡 0 这一个明显不该写进 RTC 的值) int + * 限定条件:uiTask 上下文(页面按键钩子) + * 函数说明:本层不判日期合法性——判据只有一份,在 ui_data_clock_to_epoch(); + * 也不做回读,回读属执行侧(它才知道 sys_clock_get() 的真值) + */ +int ui_data_maint_clock_set(uint32_t a_unix_epoch) { + if (s_maint == NULL) { + return -1; + } + if (a_unix_epoch == 0u) { + return -2; + } + return s_maint->clock_set(a_unix_epoch); +} + +/* + * 函数功能:请求复归全部保护元件(强制复归页的执行出口) + * 入口参数:无 + * 返回值:0 - 已受理;-1 - 未注入或执行侧拒收 int + * 限定条件:uiTask 上下文 + * 函数说明:本函数**不投**操作记录,页面在返 0 之后自己投("本地面板发起"这个 + * 事实只有页面知道;远端 104 那条另有投递点,两边各一条、不得互替); + * 返 -1 时页面必须一条都不投——给一次没发生的操作留审计记录,比少一条 + * 记录坏得多 + */ +int ui_data_maint_reset_req(void) { + if (s_maint == NULL) { + return -1; + } + return s_maint->reset_req(); +} + +/* + * 函数功能:取当前动作元件数(复归页"保护动作 N"行的数据源) + * 入口参数:无 + * 返回值:元件数;未注入返回 0 uint32_t + * 限定条件:页面绘制路径可直调(一次函数指针转发,零 I/O、零锁) + * 函数说明:返回类型取 uint32_t 与执行侧位图同宽,不在本层压成 uint8_t—— + * 元件数将来超 8 时,窄化会在页面里静默截成 0("明明动作了屏上说没有") + */ +uint32_t ui_data_maint_active_count(void) { + if (s_maint == NULL) { + return 0u; + } + return s_maint->active_count(); +} + +/* + * 函数功能:投一条操作记录留痕 + * 入口参数:desc - 描述串(ASCII 常量;NULL = 什么都不做) const char * + * 返回值:0 - 已投递;-1 - 未注入或 desc 为 NULL int + * 限定条件:uiTask 上下文 + * 函数说明:投递可能被队列丢弃(执行侧口径)——本层不因此把屏上的"操作成功" + * 翻成失败:记录通道故障不等于操作没发生,混起来只会让现场误判装置 + */ +int ui_data_maint_oper_note(const char *a_desc) { + if ((s_maint == NULL) || (a_desc == NULL)) { + return -1; + } + s_maint->oper_note(a_desc); + return 0; +} + +/* + * 函数功能:请求清除某类记录(清除记录页的执行出口) + * 入口参数:log_id - UI 侧记录源编号(UI_W_REC_LOG_*) uint8_t + * 返回值:0 - 已受理;-1 - 未注入或执行侧拒绝 int + * 限定条件:uiTask 上下文;"受理"≠"已清除",终态读 ui_data_maint_clear_state() + * 函数说明:不在本层判 log_id 上界——上界的真源是记录侧有几个文件(映射表在 + * 适配层),这里再抄一份长度就成了第二真源,记录加一类时必然漏改 + */ +int ui_data_maint_clear_req(uint8_t a_log_id) { + if (s_maint == NULL) { + return -1; + } + return s_maint->clear_req(a_log_id); +} + +/* + * 函数功能:查某类记录的清除状态 + * 入口参数:log_id - 记录源编号 uint8_t + * 返回值:UI_DATA_CLEAR_ST_NONE / _OK / _WAIT / _FAIL;未注入返回 NONE int + * 限定条件:页面绘制路径可直调 + * 函数说明:返回值直通执行侧、不做二次加工(与 ui_data_param_state() 同理: + * 两处解释同一件事迟早漂移);WAIT 是正常态,页面不得画成失败 + */ +int ui_data_maint_clear_state(uint8_t a_log_id) { + if (s_maint == NULL) { + return UI_DATA_CLEAR_ST_NONE; + } + return s_maint->clear_state(a_log_id); +} + +/* ── 时钟字段换算(本地域,无时区偏移)──────────────────────────────────── */ + +/* + * 私有宏定义区(本小节自带,不与其它小节共用): + * 算法 = Howard Hinnant 的 days_from_civil / civil_from_days(以 0000-03-01 + * 为纪元、400 年一周期),与 App/protocol/iec104/iec104_asdu.c 的正反两套实现 + * 同族;本工程年域 2000..2038,全程非负,用 uint32 就够,不引 int64 也不引 time.h + */ +#define CL_SECONDS_PER_DAY 86400u +#define CL_SECONDS_OF_DAY_MAX 11647u /* 0x7FFFFFFF % 86400 = 2038-01-19 的 03:14:07 */ +#define CL_DAYS_EPOCH_MAX 24855u /* 0x7FFFFFFF / 86400 = 2038-01-19(距 1970-01-01 天数) */ +#define CL_DAYS_TO_0000 719468u /* 1970-01-01 距 0000-03-01 纪元的天数(Hinnant 偏移量) */ +#define CL_DAYS_PER_ERA 146097u /* 400 年一周期的天数 */ + +/* + * 函数功能:公历日期 → 距 1970-01-01 的天数(调用方保证字段已判过合法性) + * 入口参数:year - 年(>= 1) uint16_t month - 月 1..12 uint8_t day - 日 1..31 uint8_t + * 返回值:天数(本工程年域内恒非负) uint32_t + * 限定条件:月/日未做合法性判定——**合法性判定在 to_epoch(),不在这里**, + * 换算函数保持"给什么算什么"才能与反向函数严格互逆 + * 函数说明:先做 1/2 月归到上一年的 11/12 月(y 相应减一),使 3 月成为岁首、 + * 闰日落在周期末端,之后每月天数变成 31 天/30.6 天的规整式子 + */ +static uint32_t ui_data_clock_days_from_civil(uint16_t a_year, uint8_t a_month, uint8_t a_day) { + uint32_t y = (a_month <= 2u) ? ((uint32_t)a_year - 1u) : (uint32_t)a_year; + uint32_t era = y / 400u; + uint32_t yoe = y - (era * 400u); /* [0, 399] */ + uint32_t mp = (a_month > 2u) ? ((uint32_t)a_month - 3u) : ((uint32_t)a_month + 9u); + uint32_t doy = ((153u * mp + 2u) / 5u) + (uint32_t)a_day - 1u; /* [0, 365] */ + uint32_t doe = (yoe * 365u) + (yoe / 4u) - (yoe / 100u) + doy; /* [0, 146096] */ + + return (era * CL_DAYS_PER_ERA) + doe - CL_DAYS_TO_0000; +} + +/* + * 函数功能:判断是否闰年(完整格里历规则) + * 入口参数:year - 年 uint16_t + * 返回值:1 - 闰年;0 - 平年 uint8_t + * 限定条件:与 App/util/sys_clock.c 的 unix_to_sd2506() 内联判据逐字同一条规则 + * (4 年 && 非 100 年 || 400 年);2100 不在允许年域内,无需讨论该例外 + * 函数说明:取 uint8_t 而不是 bool——本工程布尔判定允许 bool,但本函数参与 + * 天数计算,返回宽度明确的整型省掉一次隐式转换(同 ui_w_editor_key() + * 把"分发标志"写成 uint8_t 的口径) + */ +static uint8_t ui_data_clock_is_leap(uint16_t a_year) { + if ((((uint32_t)a_year % 4u) == 0u) && (((uint32_t)a_year % 100u) != 0u)) { + return 1u; + } + if (((uint32_t)a_year % 400u) == 0u) { + return 1u; + } + return 0u; +} + +/* + * 函数功能:取某年某月的天数(含闰年 2 月) + * 入口参数:year - 年 uint16_t month - 月 1..12 uint8_t + * 返回值:28 / 29 / 30 / 31;月份为 0 或 > 12 时返回 0(调用方以 0 判非法, + * 不开第二条错误码) uint8_t + * 限定条件:无(纯查表 + 闰年判定,零依赖) + * 函数说明:表序 = 1 月起(下标 0 是 1 月),与 sys_clock.c:145 那份 mdays[] + * 的"0 月起"排法**故意不同**——本表按屏上月的直觉排(下标 = 月 - 1), + * 少一次心算 -1 就少一次错位;两侧同一张表的等价性由主机单测逐月核对 + */ +uint8_t ui_data_clock_days_in_month(uint16_t a_year, uint8_t a_month) { + static const uint8_t k_mdays[12] = { + 31u, 28u, 31u, 30u, 31u, 30u, 31u, 31u, 30u, 31u, 30u, 31u + }; + + if ((a_month < 1u) || (a_month > 12u)) { + return 0u; + } + if ((a_month == 2u) && (ui_data_clock_is_leap(a_year) != 0u)) { + return 29u; + } + return k_mdays[a_month - 1u]; +} + +/* + * 函数功能:日历字段 → 本地域 epoch 秒,同时充当这组字段的合法性判据 + * 入口参数:p_in - 六个字段 const ui_data_clock_t * + * p_out - 秒 uint32_t * + * 返回值:0 - 合法并已输出;-1 - 参数为 NULL;-2 - 字段组合非法(任一段越界, + * 含 2 月 30 日、非闰年 2 月 29 日、月 13、时 24、分/秒 60); + * -3 - 字段各自合法但换算结果越界(> UI_DATA_CLOCK_EPOCH_MAX) int + * 限定条件:年段先判(2000..2038),因此天数计算全程在非负 uint32 域内 + * 函数说明:三段边界分开返码,是为了让页面能给出现场听得懂的话:"2 月 30 日" + * 是输入错(-2,光标回到出问题那一行),而"2038 年之后"是能力边界 + * (-3,说这一版装置的墙钟就到这儿)——两种情况混成一个 ERR, + * 用户会反复试一个永远试不出的日期 + * ⚠ 全程不含时区偏移(口径见 ui_data.h 本节注释与 + * App/task/ui_task.c 的 SOE 时标注记):加 28800 的后果是"屏上输 + * 08:00、存进去 16:00",且模拟器里往返照样自洽、照不出来 + */ +int ui_data_clock_to_epoch(const ui_data_clock_t *p_in, uint32_t *p_out) { + uint32_t days; + uint32_t sod; + uint32_t dmax; + + if ((p_in == NULL) || (p_out == NULL)) { + return -1; + } + if ((p_in->year < (uint16_t)UI_DATA_CLOCK_YEAR_MIN) || + (p_in->year > (uint16_t)UI_DATA_CLOCK_YEAR_MAX)) { + return -2; + } + if ((p_in->month < 1u) || (p_in->month > 12u)) { + return -2; + } + dmax = (uint32_t)ui_data_clock_days_in_month(p_in->year, p_in->month); + if ((p_in->day < 1u) || ((uint32_t)p_in->day > dmax)) { + return -2; + } + if (p_in->hour > 23u) { + return -2; + } + if ((p_in->minute > 59u) || (p_in->second > 59u)) { + return -2; + } + + days = ui_data_clock_days_from_civil(p_in->year, p_in->month, p_in->day); + sod = ((uint32_t)p_in->hour * 3600u) + ((uint32_t)p_in->minute * 60u) + + (uint32_t)p_in->second; + if ((days > CL_DAYS_EPOCH_MAX) || ((days == CL_DAYS_EPOCH_MAX) && (sod > CL_SECONDS_OF_DAY_MAX))) { + return -3; /* 2038-01-19 03:14:07 之后:uint32 秒域到头,不是用户输错 */ + } + + *p_out = (days * CL_SECONDS_PER_DAY) + sod; + return 0; +} + +/* + * 函数功能:本地域 epoch 秒 → 日历字段(ui_data_clock_to_epoch 的反函数) + * 入口参数:unix_epoch - 秒 uint32_t p_out - 输出 ui_data_clock_t * + * 返回值:0 - 已输出;-1 - p_out 为 NULL;-2 - 超出允许域(0 哨兵、年 < 2000、 + * 或 > UI_DATA_CLOCK_EPOCH_MAX) int + * 限定条件:与 to_epoch 严格互逆(主机单测按往返相等钉,见 test/host/ui_data); + * 刻意把 0 也判越界:0 = 1970-01-01,落在 RTC 年域(2000..2099)之外, + * 而它又恰好是"墙钟无效"在记录侧的占位起点,绝不允许被当真实时间显示 + */ +int ui_data_clock_from_epoch(uint32_t a_unix_epoch, ui_data_clock_t *p_out) { + uint32_t days; + uint32_t sod; + uint32_t z; + uint32_t era; + uint32_t doe; + uint32_t yoe; + uint32_t doy; + uint32_t mp; + uint32_t y; + uint32_t m; + + if (p_out == NULL) { + return -1; + } + if ((a_unix_epoch == 0u) || (a_unix_epoch > UI_DATA_CLOCK_EPOCH_MAX)) { + return -2; + } + + days = a_unix_epoch / CL_SECONDS_PER_DAY; + sod = a_unix_epoch % CL_SECONDS_PER_DAY; + z = days + CL_DAYS_TO_0000; /* 移到 0000-03-01 纪元 */ + era = z / CL_DAYS_PER_ERA; + doe = z - (era * CL_DAYS_PER_ERA); /* [0, 146096] */ + yoe = (doe - (doe / 1460u) + (doe / 36524u) - (doe / 146096u)) / 365u; /* [0, 399] */ + doy = doe - ((365u * yoe) + (yoe / 4u) - (yoe / 100u)); + mp = (5u * doy + 2u) / 153u; + + /* 3 月纪元月序转公历月序:mp < 10 落在 3..12 月,否则落在 1/2 月并归上一年 */ + y = yoe + (era * 400u); + if (mp < 10u) { + m = mp + 3u; + } else { + m = mp - 9u; + y += 1u; /* 1/2 月属于上一年 */ + } + + p_out->year = (uint16_t)y; + p_out->month = (uint8_t)m; + p_out->day = (uint8_t)(doy - ((153u * mp + 2u) / 5u) + 1u); + p_out->hour = (uint8_t)(sod / 3600u); + p_out->minute = (uint8_t)((sod / 60u) % 60u); + p_out->second = (uint8_t)(sod % 60u); + + if ((p_out->year < (uint16_t)UI_DATA_CLOCK_YEAR_MIN) || + (p_out->year > (uint16_t)UI_DATA_CLOCK_YEAR_MAX)) { + return -2; + } + return 0; +} + +/* + * 函数功能:解析定长时标串为日历字段 + * 入口参数:ts - "YYYY-MM-DD HH:MM:SS"(快照 rt.clock 的形状) const char * + * p_out - 输出 ui_data_clock_t * + * 返回值:0 - 成功;-1 - 参数为 NULL;-2 - 串型不对(不是 19 字符、分隔位不是 + * '-'/' '/':'、出现非数字)**或**型对但值非法 + * (组合再过 ui_data_clock_to_epoch() 把关) int + * 限定条件:空串(墙钟无效时快照里的取值,ui_data.h:149)走 -2; + * 本函数**不猜**:"1970-01-01 08:00:00"这类记录侧占位串在这里同样返 -2 + * (年 1970 出域),页面据此显示"无时钟"而不是把占位串画成真实时间 + * 函数说明:手写十进制累加而不用 sscanf——本模块是零 LVGL、零 App 的纯内存叶子, + * 拉进 stdio 就等于给主机单测加依赖、给 MCU 端加一份 printf 家族 + */ +int ui_data_clock_parse(const char *a_ts, ui_data_clock_t *p_out) { + ui_data_clock_t t; + uint32_t i; + uint32_t acc; + const char *p; + + if ((a_ts == NULL) || (p_out == NULL)) { + return -1; + } + + (void)memset(&t, 0, sizeof(t)); + + /* 五段数字:年 4 位、月/日/时/分/秒各 2 位;段间分隔位逐位核对 */ + p = a_ts; + acc = 0u; + for (i = 0u; i < 4u; i++) { + if ((p[i] < '0') || (p[i] > '9')) { + return -2; + } + acc = (acc * 10u) + (uint32_t)(p[i] - '0'); + } + t.year = (uint16_t)acc; + if (p[4] != '-') { + return -2; + } + p += 5u; + + { + uint8_t *const fields[5] = { &t.month, &t.day, &t.hour, &t.minute, &t.second }; + const char seps[4] = { '-', ' ', ':', ':' }; + + for (i = 0u; i < 5u; i++) { + if (((p[0] < '0') || (p[0] > '9')) || ((p[1] < '0') || (p[1] > '9'))) { + return -2; + } + *fields[i] = (uint8_t)(((uint32_t)(p[0] - '0') * 10u) + (uint32_t)(p[1] - '0')); + p += 2u; + if (i < 4u) { + if (*p != seps[i]) { + return -2; + } + p++; + } + } + } + + if (*p != '\0') { + return -2; /* 型对但尾部还有字符:不是本工程认的时标串 */ + } + { + uint32_t probe; + if (ui_data_clock_to_epoch(&t, &probe) != 0) { + return -2; + } + } + + *p_out = t; + return 0; +} + +/* + * 函数功能:日历字段 → 定长时标串(与 ui_data_clock_parse 同串型) + * 入口参数:p_in - 字段 const ui_data_clock_t * + * buf - 输出缓冲 char * + * len - 缓冲容量(含 NUL,至少 UI_DATA_CLOCK_TS_LEN + 1 = 20) uint32_t + * 返回值:0 - 已写出;-1 - 参数无效或容量不足(**不写半个串**,避免调用方 + * 把截断串当完整时标显示) int + * 限定条件:不判字段合法性(调用方刚从 parse/to_epoch 那条链出来,再判一遍 + * 就是第二条判据) + */ +int ui_data_clock_format(const ui_data_clock_t *p_in, char *a_buf, uint32_t a_len) { + uint8_t i; + char *p; + uint32_t v; + + if ((p_in == NULL) || (a_buf == NULL) || + (a_len < (UI_DATA_CLOCK_TS_LEN + 1u))) { + return -1; + } + + p = a_buf; + v = (uint32_t)p_in->year; + for (i = 0u; i < 4u; i++) { /* 年份 4 位补零 */ + p[3u - i] = (char)('0' + (uint32_t)(v % 10u)); + v /= 10u; + } + p[4] = '-'; + p += 5; + + { + const uint8_t fields[5] = { p_in->month, p_in->day, p_in->hour, + p_in->minute, p_in->second }; + const char seps[4] = { '-', ' ', ':', ':' }; + + for (i = 0u; i < 5u; i++) { + p[0] = (char)('0' + ((uint32_t)fields[i] / 10u)); + p[1] = (char)('0' + ((uint32_t)fields[i] % 10u)); + p += 2; + if (i < 4u) { + *p = seps[i]; + p++; + } + } + } + *p = '\0'; + return 0; +} diff --git a/Drivers/BSP/UI/ui_data.h b/Drivers/BSP/UI/ui_data.h index c378f5f0..df0ad921 100644 --- a/Drivers/BSP/UI/ui_data.h +++ b/Drivers/BSP/UI/ui_data.h @@ -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 diff --git a/Drivers/BSP/UI/ui_page_action.c b/Drivers/BSP/UI/ui_page_action.c index 6908d098..20ee6e3f 100644 --- a/Drivers/BSP/UI/ui_page_action.c +++ b/Drivers/BSP/UI/ui_page_action.c @@ -1,11 +1,19 @@ /* * 模块名称:UI 动作确认页(操作页共享实现)实现 - * 模块功能:见 ui_page_action.h 头注释(确认链状态机 + 投递回报 + 表单页自绘) + * 模块功能:见 ui_page_action.h 头注释——本文件是那三个钩子的实现体: + * init 复位确认链、draw 画问句/态标/观测行/键提示四行、key 走状态机并 + * 只投递不阻塞。实例由页面跳板持有,本文件不再有模块单例,也不建描述符 * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) * 作者:王建锋 * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道B task-5 三组件之一) + * v1.1 2026-10-26 DTU 项目组 道F task-3:删模块单例 s_act/s_desc 与 + * ui_page_action_open()(全仓零调用者),改出 + * ui_page_action_init/draw/key 三个收实例指针的 + * 钩子;新增只读拒绝(need_modify → 态标 LOCK、 + * 键提示 ESC=返回)与可选观测行(watch_label + + * watch_cb);返回值只留 uint8_t 分发标志 */ /* 头文件包含区 */ @@ -20,24 +28,19 @@ /* 私有宏定义区 */ #define AC_PROMPT_Y 44 /* 问句/结果行 放置 y(内容区上三分之一) */ -#define AC_STAGE_Y 66 /* 态标行("二次确认" / "ERR")放置 y */ +#define AC_STAGE_Y 66 /* 态标行("二次确认" / "ERR" / "LOCK")放置 y */ +#define AC_WATCH_Y 88 /* 可选观测行放置 y(实例没配观测行时这行留空) */ #define AC_HINT_Y 110 /* 键提示行 放置 y */ -/* 固定文案(用字均已入字体子集:二/次/确/认/取/消/返/回/越/界 及 ASCII) */ +/* 固定文案(用字均已入字体子集:二/次/确/认/取/消/返/回/越/界 及 ASCII; + * 道F task-3 起新增的"只读"两词沿用同一批用字,未扩字库) */ #define AC_STAGE2_TXT "二次确认" #define AC_HINT_DO "ENTER=确认 ESC=取消" #define AC_HINT_BACK "ENTER=返回" +#define AC_HINT_RO "ESC=返回" /* 只读:ENTER 无效,提示必须跟着改口 */ #define AC_ERR_TXT "ERR" - -/* 私有变量定义区 */ - -/* - * 模块单例:内容页框架全局单页(s_open 互斥),同一时刻至多一张操作页, - * 故钩子经"当前实例指针"取上下文即可成立(头注已载此约束;批 2 各跳板 - * 各自 static 实例、先后开页互不冲突) - */ -static ui_page_action_t *s_act; /* 当前实例(NULL = 未在用) */ -static content_page_def_t s_desc; /* 模板描述符(开页时按实例填充) */ +#define AC_RO_TXT "LOCK" /* 只读进入的态标(词表与整定保存行一致, + 都是 ASCII,见 UI_数据供给设计说明.md §6.3) */ /* 私有函数声明区 */ @@ -77,123 +80,158 @@ static void ac_draw_centered(lv_layer_t *a_layer, const char *a_text, ld.text_static = 1u; /* Flash 字面量:不拷内存 */ ld.color = UI_COL_FG; ld.ofs_x = (UI_SCREEN_W - - (int32_t)lv_text_get_width(a_text, (uint32_t)strlen(a_text), LV_FONT_DEFAULT, 0)) / 2; + (int32_t)lv_text_get_width(a_text, (uint32_t)strlen(a_text), LV_FONT_DEFAULT, 0)) / 2; ld.ofs_y = 0; lv_draw_label(a_layer, &ld, &t); } /* - * 函数功能:自绘(框架内置区块之后:问句/态标/键提示 三行居中) - * 入口参数:layer - 绘制层 lv_layer_t * - * clip - 重绘区 const lv_area_t * + * 函数功能:开页复位确认链(框架 init_cb 的实现体) + * 入口参数:a_inst - 操作页实例 ui_page_action_t * * 返回值:无 - * 限定条件:经描述符 draw_cb 挂接,页面打开中(s_act 有效) + * 限定条件:内容页框架开页链内调用(页面描述符的 init_cb 转发到此) + * 函数说明:stage 每次进页归零是**判据**不是收尾清洁:上一轮按到"二次确认"就 + * ESC 退出、下一轮进来看都不看再按 ENTER,如果 stage 跨次保留就直接 + * 执行了——破坏性动作不接受这种路径。err 一并清,否则上一次的投递 + * 失败会挂在新一页的态标行上(用户以为新页一进来就出错) */ -static void ac_draw_cb(lv_layer_t *layer, const lv_area_t *clip) { - if ((s_act == NULL) || (s_act->prompt == NULL)) { +void ui_page_action_init(ui_page_action_t *a_inst) { + if (a_inst == NULL) { + return; /* 防御:跳板没填实例(编译期就该发现,运行期只保不崩) */ + } + a_inst->stage = 0u; + a_inst->err = 0u; +} + +/* + * 函数功能:只读进入判定(本次开页是否"没有修改权限") + * 入口参数:a_inst - 操作页实例 const ui_page_action_t * + * 返回值:1 = 本页需要修改权限而本次只有查看权限;0 = 其余 uint8_t + * 限定条件:框架绘制/按键链内(开页时权限已装载,is_editable 读到的就是本次的) + * 函数说明:判据只在这一处——绘制与按键两侧各自判一次,早晚在"屏上能按但按不动" + * 这种半套状态上分家 + */ +static uint8_t ui_page_action_is_readonly(const ui_page_action_t *a_inst) { + if (a_inst->need_modify == 0u) { + return 0u; + } + return (ui_content_page_is_editable() == false) ? 1u : 0u; +} + +/* + * 函数功能:自绘(框架内置区块之后:问句 / 态标 / 可选观测行 / 键提示,四行居中) + * 入口参数:a_inst - 操作页实例 ui_page_action_t * + * a_layer - 绘制层 lv_layer_t * + * a_clip - 重绘区 const lv_area_t * + * 返回值:无 + * 限定条件:经页面描述符 draw_cb 挂接;实例或 prompt 为 NULL 时空操作 + * 函数说明:行 2 的优先级是刻意的——只读(LOCK) > 二次确认 > 投递失败(ERR): + * 没权限时画"二次确认"会让人继续按,比不画更坏 + */ +void ui_page_action_draw(ui_page_action_t *a_inst, lv_layer_t *a_layer, + const lv_area_t *a_clip) { + if ((a_inst == NULL) || (a_inst->prompt == NULL)) { return; } - /* 行 1:问句(stage 2 换成功结果行) */ - if (s_act->stage == 2u) { - if (s_act->done_text != NULL) { - ac_draw_centered(layer, s_act->done_text, AC_PROMPT_Y, clip); + /* 行 1:问句(stage 2 换成功结果行;done_text=NULL 时根本走不到 stage 2) */ + if (a_inst->stage == 2u) { + if (a_inst->done_text != NULL) { + ac_draw_centered(a_layer, a_inst->done_text, AC_PROMPT_Y, a_clip); } - } - else { - ac_draw_centered(layer, s_act->prompt, AC_PROMPT_Y, clip); + } else { + ac_draw_centered(a_layer, a_inst->prompt, AC_PROMPT_Y, a_clip); } - /* 行 2:态标(二次确认 / 投递失败 ERR) */ - if (s_act->stage == 1u) { - ac_draw_centered(layer, (s_act->err != 0u) ? AC_ERR_TXT : AC_STAGE2_TXT, - AC_STAGE_Y, clip); + /* 行 2:态标(只读 / 二次确认 / 投递失败) */ + if (ui_page_action_is_readonly(a_inst) != 0u) { + ac_draw_centered(a_layer, AC_RO_TXT, AC_STAGE_Y, a_clip); + } else if (a_inst->stage == 1u) { + ac_draw_centered(a_layer, + (a_inst->err != 0u) ? AC_ERR_TXT : AC_STAGE2_TXT, + AC_STAGE_Y, a_clip); + } else { + /* stage 0/2:这里没有要说的话——留空行比硬凑一个词诚实 */ } - /* 行 3:键提示 */ - ac_draw_centered(layer, (s_act->stage == 2u) ? AC_HINT_BACK : AC_HINT_DO, - AC_HINT_Y, clip); + /* 行 3:可选观测行(复归页用它把"保护动作 N"钉在确认同一屏上: + * N 从 >0 变 0 就是"动作位真的复归了"的屏上证据,不用翻页也不用接表) */ + if ((a_inst->watch_label != NULL) && (a_inst->watch_cb != NULL)) { + char watch[UI_BUF_STRLEN]; + + (void)lv_snprintf(watch, sizeof(watch), "%s %u", a_inst->watch_label, + (unsigned int)a_inst->watch_cb()); + /* 观测行是拼出来的栈串,不是 Flash 字面量:本函数同帧内绘制完即弃 */ + ac_draw_centered(a_layer, watch, AC_WATCH_Y, a_clip); + } + + /* 行 4:键提示(只读时 ENTER 无效,提示必须跟着改口——屏上说"ENTER=确认" + * 而按下去没反应,是现场投诉最多的"设备坏了"误判来源) */ + if (ui_page_action_is_readonly(a_inst) != 0u) { + ac_draw_centered(a_layer, AC_HINT_RO, AC_HINT_Y, a_clip); + } else { + ac_draw_centered(a_layer, + (a_inst->stage == 2u) ? AC_HINT_BACK : AC_HINT_DO, + AC_HINT_Y, a_clip); + } } /* - * 函数功能:按键处理(确认链状态机) - * 入口参数:key - LVGL 键值 lv_key_t - * 返回值:true = 已消费;false = 交回框架(其余键忽略) - * 限定条件:经描述符 key_cb 挂接(key_intercept=0:LEFT 直接关页 = 取消, - * 不进本函数;ESC 恒关页框架保证,同样不进本函数) - * 函数说明:ENTER:stage0 -> stage1(二次确认);stage1 -> 投递 ok_cb: - * 接受且有 done_text -> stage2 回报,接受且无 done_text -> 直接关页, - * 拒收 -> err=1 留在 stage1;stage2 -> 关页返回 + * 函数功能:按键处理(确认链状态机;框架 key_cb 的实现体) + * 入口参数:a_inst - 操作页实例 ui_page_action_t * + * a_key - LVGL 键值 lv_key_t + * 返回值:1 = 已消费(框架跳过内置语义);0 = 未消费(其余键交回框架) + * (键分发标志非错误通道,取 uint8_t——bsp-err/err-type) + * 限定条件:经页面描述符 key_cb 挂接;LEFT/ESC 不进本函数(框架恒关页 = 取消, + * 这是"任何一页都退得出去"的框架保证,本组件不得拦截) + * 函数说明:ENTER——stage0 转二次确认;stage1 调 ok_cb 投递:受理且有 done_text + * 转 stage2 回报、受理且无 done_text 直接关页、拒收置 err 留在 stage1 + * (再 ENTER 重走一次投递,不自动重试:连点两次就投两次, + * 清除记录这类动作要的是"我确实在两下之内只发了一次"); + * stage2 关页返回——回报行必须让用户自己确认看到了才走, + * 成功即关页的形态只在 done_text=NULL(没什么可回报的)时才成立。 + * 只读进入:先放行 stage2 的返回支(已完成的页总得关得掉), + * 其余 ENTER 一律消费但不推进、不投递 */ -static bool ac_key_cb(lv_key_t key) { - if (key != LV_KEY_ENTER) { - return false; +uint8_t ui_page_action_key(ui_page_action_t *a_inst, lv_key_t a_key) { + bool ok = false; + + if ((a_inst == NULL) || (a_inst->ok_cb == NULL)) { + return 0u; /* 防御:配置不全的实例不该出现在这里 */ } - if (s_act == NULL) { - return false; /* 防御 */ + if (a_key != LV_KEY_ENTER) { + return 0u; } - if (s_act->stage == 0u) { - s_act->stage = 1u; - s_act->err = 0u; + if (a_inst->stage == 2u) { + ui_content_page_close(); + return 1u; /* 已回报:ENTER = 收页回菜单 */ + } + + if (ui_page_action_is_readonly(a_inst) != 0u) { + return 1u; /* 只读:吃掉按键,态标行已在说 LOCK,不做任何动作 */ + } + + if (a_inst->stage == 0u) { + a_inst->stage = 1u; + a_inst->err = 0u; ui_content_page_invalidate(); - return true; + return 1u; } - if (s_act->stage == 1u) { - bool ok = false; - - if (s_act->ok_cb != NULL) { - ok = s_act->ok_cb(); /* 只投递请求,不等待执行结果 */ - } - if (!ok) { - s_act->err = 1u; - ui_content_page_invalidate(); - return true; /* ERR 显示,再 ENTER 重试 */ - } - if (s_act->done_text == NULL) { - ui_content_page_close(); /* 成功即关页回菜单(无回报行形态) */ - return true; - } - s_act->stage = 2u; - s_act->err = 0u; + /* stage 1:第二次 ENTER = 真执行(只投递,不等执行结果) */ + ok = a_inst->ok_cb(); + if (!ok) { + a_inst->err = 1u; ui_content_page_invalidate(); - return true; + return 1u; /* ERR 显示,下一次 ENTER 重新走投递 */ } - - /* stage 2:已回报,ENTER 返回 */ - ui_content_page_close(); - return true; -} - -/* - * 函数功能:开操作页(装载实例 + 复位 + 框架开页) - */ -bsp_err_t ui_page_action_open(ui_page_action_t *a_inst, uint8_t a_perm) { - if ((a_inst == NULL) || (a_inst->ok_cb == NULL) || (a_inst->prompt == NULL)) { - return BSP_ERR_PARAM; + if (a_inst->done_text == NULL) { + ui_content_page_close(); /* 成功即关页(无回报行形态) */ + return 1u; } - if (ui_content_page_is_open() != false) { - return BSP_ERR_STATE; /* 框架单页互斥的显式化(失败原因对跳板可判) */ - } - - s_act = a_inst; - s_act->stage = 0u; /* 每次进入固定从一次确认开始 */ - s_act->err = 0u; - - (void)memset(&s_desc, 0, sizeof(s_desc)); - s_desc.id = "action"; - s_desc.title = (a_inst->title != NULL) ? a_inst->title : a_inst->prompt; - s_desc.hide_deco = 1u; /* 表单/操作页形态:无浅 V(用户密钥页先例) */ - s_desc.hide_footer = 1u; /* 无页脚页码 */ - s_desc.selectable = 0u; /* 无行列表 */ - s_desc.key_intercept = 0u; /* LEFT 不拦截:关页 = 取消 */ - s_desc.draw_cb = ac_draw_cb; - s_desc.key_cb = ac_key_cb; - - if (ui_content_page_open(&s_desc, a_perm) == false) { - s_act = NULL; /* 开页失败不残留实例(钩子不会再被调) */ - return BSP_ERR_NO_SPACE; - } - return BSP_OK; + a_inst->stage = 2u; + a_inst->err = 0u; + ui_content_page_invalidate(); + return 1u; } diff --git a/Drivers/BSP/UI/ui_page_action.h b/Drivers/BSP/UI/ui_page_action.h index 1af4af16..2e226740 100644 --- a/Drivers/BSP/UI/ui_page_action.h +++ b/Drivers/BSP/UI/ui_page_action.h @@ -1,17 +1,21 @@ /* * 模块名称:UI 动作确认页(操作页共享实现,ui_page_action) - * 模块功能:可复用的"确认 -> 二次确认 -> 执行回报"操作页构建器—— - * 强制复归 / 清除记录 / 手动录波等叶子共用同一页实现, - * 各自以一份实例(标题/问句/结果行/执行回调)开页; - * 执行回调只投递请求不阻塞(跨任务交接由页面跳板实现), + * 模块功能:可复用的"确认 -> 二次确认 -> 执行回报"操作页**钩子三件套**—— + * 强制复归 / 手动录波等破坏性叶子共用同一份确认链实现,各自持一份 + * 实例(标题 / 问句 / 结果行 / 执行回调 / 可选观测行); + * 执行回调只投递请求不阻塞(跨任务交接由执行侧落定), * 成功经 done_text 回报后再 ENTER 返回,done_text=NULL 成功即关页; - * 投递失败显示 ERR,下一次 ENTER 重新走二次确认 + * 投递失败显示 ERR,下一次 ENTER 重新走二次确认; + * 只读权限(查看密码进入)可整页拒执行并显式说"没权限"(need_modify) * 适用范围:Drivers/BSP/UI(表单页形态:无浅 V 装饰、无页脚——用户密钥页 * 跟随实机先例;ESC 恒关页 = 取消,LEFT 亦关页 = 取消) - * 说明:同一时刻仅一张操作页在用(内容页框架本就全局单页),模块以 - * static 单例描述符 + 当前实例指针承载钩子上下文;页面跳板 - * (批 2 各 ui_page_xxx)持 ui_page_action_t 实例并在自己的入口 - * 调 ui_page_action_open() + * 说明:本组件**不建描述符也不开页**(v1.1 起的形态):内容页框架按描述符 + * **指针**开页,而描述符只能由"一叶一模块"的页面文件导出(真源 = + * ui_menu_tree.txt 的 #page 注记 + 生成物 ui_page_registry.c)—— + * 旧形态"组件自己 open()"决定了它永远不可能被注册表绑定(构建器不是 + * 描述符,全仓零调用者即其化石证据)。改成 init/draw/key 三个收实例的 + * 钩子后:确认链仍只有一份实现,而页面跳板薄到只剩"一份实例 + 一枚 + * 描述符"(见 ui_page_reset.c),多个操作页各自持实例、互不串态 * 作者:王建锋 * 创建日期:2026-10-25 * 修改记录: @@ -19,6 +23,14 @@ * 页面扩充详细方案.md §3.4 装置维护操作页 + * §4.2 ui_page_action bind(title, prompt, ok_cb) * 二次确认、ok_cb 只投递不阻塞) + * v1.1 2026-10-26 DTU 项目组 道F task-3(装置维护 3 页):改成钩子三件套 + * 形态(init/draw/key 收实例指针),删掉模块单例 + * 与 ui_page_action_open()——构建器开不了注册表 + * 绑定的页(框架按描述符指针开页),旧形态全仓 + * 零调用者;新增 need_modify(只读拒绝执行并显 + * LOCK)与可选观测行(watch_label + watch_cb, + * 复归页用它把"动作位复归"画在同一屏上); + * 错误通道由 bsp_err_t 改为无返回 + uint8_t 分发标志 */ #ifndef UI_PAGE_ACTION_H #define UI_PAGE_ACTION_H @@ -30,39 +42,76 @@ extern "C" { #include #include -#include "bsp_err.h" /* bsp_err_t(开页结果错误通道——BSP 公开 API 约定) */ +#include "lvgl.h" /* lv_key_t / lv_layer_t:钩子三件套的形参类型(本组件不建对象) */ /* 执行回调:只投递请求(事件位/消息队列),立即返回; * 返回值:true = 投递被接受(页面转成功回报),false = 拒收(页面显示 ERR) */ typedef bool (*ui_page_action_ok_fn)(void); -/* 操作页实例(配置字段 Flash 指针 + 运行态); - * 用法:页面跳板 static 一份、花括号初始化配置,开页经 ui_page_action_open */ +/* 操作页实例(配置字段 = Flash 指针与只读回调,运行态 = 本模块写的两个字节); + * 用法:页面跳板 static 一份、花括号初始化配置,把 init/draw/key 三个钩子直接 + * 转进本组件(v1.1 起本组件不再自己开页——内容页框架按描述符指针开页, + * 跳板就是那枚描述符的属主,见 ui_page_reset.c) */ typedef struct { - const char *title; /* Flash:页标题(框架头部居中) */ - const char *prompt; /* Flash:确认问句(如"确认复归所有保护?"—— - 问句用字须已入字体子集) */ + /* 页标题不在本结构里:描述符(连 title)由页面跳板 own —— 见 ui_page_reset.c; + * 两处各留一份标题就是两个真源,改一处屏上另一处不变那种 */ + const char *prompt; /* Flash:确认问句(如"确认复归保护?"—— + 问句用字须已入字体子集,否则显示空白方块) */ const char *done_text; /* Flash:成功后结果行(如"已请求复归"); NULL = 成功后直接关页回菜单 */ - ui_page_action_ok_fn ok_cb; /* 必填:执行投递 */ + ui_page_action_ok_fn ok_cb; /* 必填:执行投递(只投递,不等待执行结果) */ + uint8_t need_modify; /* 1 = 只读权限(查看密码进入)时拒绝执行: + 两次 ENTER 都不投递、态标位改显 LOCK。 + 破坏性动作必须置 1——只靠"屏上看着像能按" + 拦不住人,必须落在键路上 */ + const char *watch_label; /* Flash:可选观测行的前缀(NULL = 不画该行), + 如"保护动作"。配合 watch_cb 让"复归前 N>0、 + 复归后 N=0"在同一屏上看得见 */ + uint32_t (*watch_cb)(void); /* 可选:观测行取值(NULL = 不画该行;与 + watch_label 要么都给要么都不给) */ uint8_t stage; /* 运行态:0 一次确认 / 1 二次确认 / 2 完成回报 */ uint8_t err; /* 运行态:1 = 上轮投递被拒(显示 ERR) */ } ui_page_action_t; /* - * 函数功能:开操作页(装载实例 + 复位确认态 + 经内容页框架开页) - * 入口参数:inst - 操作页实例(配置字段须已填好;ok_cb 不得为 NULL) + * 函数功能:开页复位确认链(接框架的 init_cb;v1.1 起本组件不再自己开页) + * 入口参数:inst - 操作页实例(ok_cb 不得为 NULL;配置字段由跳板在编译期填好) * ui_page_action_t * - * perm - 授予权限(ui_perm_t;由跳板决定传什么权限, - * 组件不拦权限只拦 ok_cb 为空) - * 返回值:BSP_OK = 已开页;BSP_ERR_PARAM = 参数无效; - * BSP_ERR_STATE = 已有页面打开(框架单页互斥); - * BSP_ERR_NO_SPACE = 框架开页失败(池不足等) bsp_err_t - * 限定条件:uiTask 上下文;每次进入从一次确认开始(不跨次保留 stage) - * 函数说明:键语义——ENTER 推进确认链(0->1->执行->2),ESC/LEFT 关页取消; - * stage=2 时 ENTER 关页返回 + * 返回值:无 + * 限定条件:内容页框架开页链内调用(uiTask);实例为 NULL 时空操作(防御) + * 函数说明:每次进入固定从一次确认开始——"上一次按到一半"跨次保留下来就是 + * 第二次进页一按 ENTER 直接执行,那是操作票事故的形状,不是效率优化 */ -bsp_err_t ui_page_action_open(ui_page_action_t *a_inst, uint8_t a_perm); +void ui_page_action_init(ui_page_action_t *a_inst); + +/* + * 函数功能:自绘(接框架的 draw_cb:问句 / 态标 / 可选观测行 / 键提示,四行居中) + * 入口参数:inst - 操作页实例 ui_page_action_t * + * layer - 绘制层 lv_layer_t * + * clip - 重绘区 const lv_area_t * + * 返回值:无 + * 限定条件:框架绘制链末尾调用(本组件只画自己那几行,标题/装饰/页脚归框架); + * 实例或 prompt 为 NULL 时空操作 + * 函数说明:态标行优先级——只读且需修改权限 > 二次确认 > 投递失败 ERR; + * 只读那条排最前是刻意的:现场最常见的误读是"我按了没反应", + * 把"没权限"显出来才不会让人反复按 + */ +void ui_page_action_draw(ui_page_action_t *a_inst, lv_layer_t *a_layer, + const lv_area_t *a_clip); + +/* + * 函数功能:按键处理(接框架的 key_cb:确认链状态机) + * 入口参数:inst - 操作页实例 ui_page_action_t * + * key - LVGL 键值 lv_key_t + * 返回值:1 = 已消费(框架跳过内置语义);0 = 未消费(交回框架) + * (键分发标志非错误通道,取 uint8_t 不用 bool—— + * bsp-err/err-type 口径,同 ui_w_editor_key) + * 限定条件:经描述符 key_cb 挂接;LEFT/ESC 不进本函数(框架恒关页 = 取消) + * 函数说明:ENTER——stage0 转二次确认;stage1 调 ok_cb 投递:受理且有 done_text + * 转 stage2 回报、受理且无 done_text 直接关页、拒收置 err 留在 stage1; + * stage2 关页返回。只读且 need_modify 时任何 ENTER 都不推进也不投递 + */ +uint8_t ui_page_action_key(ui_page_action_t *a_inst, lv_key_t a_key); #ifdef __cplusplus } diff --git a/Drivers/BSP/UI/ui_page_clear.c b/Drivers/BSP/UI/ui_page_clear.c new file mode 100644 index 00000000..acc95b0b --- /dev/null +++ b/Drivers/BSP/UI/ui_page_clear.c @@ -0,0 +1,254 @@ +/* + * 模块名称:UI Page clear (清除记录内容页) + * 模块功能:装置维护 → 清除记录 叶子(node 27,#P 受密码保护)的内容页—— + * 七行清单(运行 / 自检 / 事故 / 保护告警 / 保护启动 / 操作 / 遥控记录), + * 值列 = 该类记录当前条数(0 条显"无记录"),逐行独立清除; + * 行内两段式确认:第一次 ENTER 该行值列变"确认?",第二次 ENTER 才投递, + * 之后该行按清除状态显 WAIT / OK / ERR,直到光标移开才收回; + * 条数只走 ui_w_records 的**同一枚注入 reader**(裸计数入口 + * ui_w_records_count_of),删除只发请求(执行侧 = syslog_request_clear), + * 真反馈 = 状态机 + 下一帧条数归零,不是"按了就算成功"; + * 只读进入(查看密码)整页不投递; + * 本文件零 App include、零文件系统触点(既不调 MCU 专属的 + * syslog_clear_apply(),也绝不自己 f_unlink / 碰卷锁——defaultTask 是 + * 记录唯一写者,uiTask 在 IWDG required_mask 内,给它一条最坏数百 ms + * 的存储路径就是拿误复位当代价) + * 为什么不做"一键清全部":七类记录分属不同事件面(运行/自检 vs 事故/告警/启动 vs + * 操作/遥控审计),一次点击清光七类 = 一次误操作毁掉全部审计面; + * 逐行清已完全覆盖判据(删后重建、计数归零、不存在时幂等) + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:DTU 项目组 + * 创建日期:2026-10-26 + * 修改记录: + * v1.0 2026-10-26 DTU 项目组 创建(道F task-3;判据 docs/任务规划/UI补齐.md + * §2 第 3 行「清除记录…不允许只做没有实际效果的按钮」; + * 删除 API 与请求/落地分离 = task-4,口径真源 + * App/log/README.md 与 App/log/sys_log.h) + */ + +/* 头文件包含区 */ +#include + +#include "ui_page_clear.h" + +#include "ui_content_page.h" +#include "ui_data.h" +#include "ui_tokens.h" +#include "ui_w_records.h" + +/* 私有宏定义区 */ + +#define CL_ROW_N UI_W_REC_LOG_N /* 行数 = 记录源个数(两端映射表长度同源) */ +#define CL_NONE 0xFFu /* "无行"哨兵(行号上限 7,绝不撞) */ +#define CL_TXT_NONE "无记录" /* 0 条(用字已在子集,与两条记录页同一词) */ +#define CL_TXT_CONFIRM "确认?" /* 第一段确认('?' 是 ASCII) */ +/* 结果词沿用全 ASCII 一排:WAIT 是正常态(MCU 端删除在 defaultTask 的 + * syslog_drain() 尾部落地),模拟器端同步删除永不出现 WAIT —— 两端都要能显示 */ +#define CL_TXT_WAIT "WAIT" +#define CL_TXT_OK "OK" +#define CL_TXT_ERR "ERR" + +/* 私有函数声明区 */ + +static const char *cl_name_of(uint16_t a_row); +static uint8_t cl_id_of(uint16_t a_row); + +/* 静态变量定义区 */ + +/* 行名表(Flash);顺序 = UI_W_REC_LOG_* 编号序,与两端映射表同一套下标 */ +static const char *const k_cl_name[CL_ROW_N] = { + "运行记录", "自检记录", "事故记录", "保护告警", "保护启动", "操作记录", "遥控记录" +}; +static const uint8_t k_cl_id[CL_ROW_N] = { + UI_W_REC_LOG_RUN, UI_W_REC_LOG_SELFTEST, UI_W_REC_LOG_ACCIDENT, UI_W_REC_LOG_ALARM, + UI_W_REC_LOG_START, UI_W_REC_LOG_OPER, UI_W_REC_LOG_YK +}; + +static uint8_t s_confirm; /* 待确认行(第一次 ENTER 已按下),CL_NONE = 无 */ +static uint8_t s_note; /* 已投递、要显示清除结果的行,CL_NONE = 无 */ +static uint8_t s_err; /* 投递被拒的行,CL_NONE = 无 */ +static uint16_t s_sel_seen; /* 上一次绘制时的选中行(移开光标即收回三个锁存) */ + +/* 函数定义区 */ + +/* + * 函数功能:取行名 + * 入口参数:row - 行号 uint16_t + * 返回值:名(Flash 字面量;越界回空串,不越表读) const char * + * 限定条件:无 + */ +static const char *cl_name_of(uint16_t a_row) { + if (a_row >= CL_ROW_N) { + return ""; + } + return k_cl_name[a_row]; +} + +/* + * 函数功能:取行对应的记录源编号 + * 入口参数:row - 行号 uint16_t + * 返回值:UI_W_REC_LOG_*;越界回 0(首号,调用方已在越界时不调本函数) uint8_t + * 限定条件:无 + * 函数说明:编号不透明——本页不知道也不该知道"3 号是 alarm.log",映射在两端 + * 适配层(App/task/ui_task.c 与 simulator/main.c),加一类记录时 + * 本表加一行、两端各加一条映射,屏上自动多一行 + */ +static uint8_t cl_id_of(uint16_t a_row) { + if (a_row >= CL_ROW_N) { + return (uint8_t)UI_W_REC_LOG_RUN; + } + return k_cl_id[a_row]; +} + +/* + * 函数功能:开页复位(框架 init_cb) + * 入口参数:无 + * 返回值:无 + * 限定条件:框架开页链内(此时本次授予权限已装载) + * 函数说明:三个锁存全部归无——清除状态是**按 id 的全局单值**(syslog 侧 + * 上电以来一直是 OK),开页直读会把"昨天清过操作记录"显示成"这次 + * 清成功了"(契约 §6.3 第 3 条同一件事:状态是全局的,页面必须锁存) + */ +static void pg_init(void) { + s_confirm = CL_NONE; + s_note = CL_NONE; + s_err = CL_NONE; + s_sel_seen = ui_content_page_selected_row(); +} + +/* + * 函数功能:逐行取数(框架 refresh;值列按"结果 > 确认 > 条数"优先级) + * 入口参数:a_row / a_name / a_value - 契约见 ui_content_page.h + * 返回值:无 + * 限定条件:框架绘制链内 + * 函数说明:已投递那一行的词来自 ui_data_maint_clear_state()(执行侧直通, + * FAIL 也照实显 ERR:原文件一个字节没少,可以再发一次请求); + * 没投递的行每帧现算条数——清 run.log 之后 sys_log 会往**操作记录** + * 补一条留痕,所以"操作记录"那行会从 0 变成 1,这是真的,页面不缓存 + * "我以为归零了"(清 oper.log 自己时刻意不补,App/log/README.md 已载) + */ +static void pg_refresh(uint16_t a_row, char *a_name, char *a_value) { + uint8_t id; + uint16_t n; + + (void)memset(a_value, 0, UI_BUF_STRLEN); + id = cl_id_of(a_row); + (void)memcpy(a_name, cl_name_of(a_row), strlen(cl_name_of(a_row)) + 1u); + + if ((uint8_t)a_row == s_note) { + int st = ui_data_maint_clear_state(id); + if (st == UI_DATA_CLEAR_ST_OK) { + (void)memcpy(a_value, CL_TXT_OK, sizeof(CL_TXT_OK)); + } else if (st == UI_DATA_CLEAR_ST_WAIT) { + (void)memcpy(a_value, CL_TXT_WAIT, sizeof(CL_TXT_WAIT)); + } else { + (void)memcpy(a_value, CL_TXT_ERR, sizeof(CL_TXT_ERR)); + } + return; + } + if ((uint8_t)a_row == s_err) { + (void)memcpy(a_value, CL_TXT_ERR, sizeof(CL_TXT_ERR)); + return; + } + if ((uint8_t)a_row == s_confirm) { + (void)memcpy(a_value, CL_TXT_CONFIRM, sizeof(CL_TXT_CONFIRM)); + return; + } + n = ui_w_records_count_of(id); /* 现算:不留"我以为归零了"的缓存 */ + if (n == 0u) { + (void)memcpy(a_value, CL_TXT_NONE, sizeof(CL_TXT_NONE)); + return; + } + (void)lv_snprintf(a_value, UI_BUF_STRLEN, "%u", (unsigned int)n); +} + +/* + * 函数功能:自绘(框架 draw_cb)——顺带承担"光标移开就收回锁存"的观测 + * 入口参数:a_layer / a_clip - 契约见 ui_content_page.h + * 返回值:无 + * 限定条件:框架绘制链内 + * 函数说明:为什么在 draw 里看选中行:↑↓ 移选是框架内置的(本页 key_cb 对 + * 非 ENTER 键一律交回框架,看不到它们),而每次移选框架都会 + * invalidate 并重绘——draw 是唯一必然发生、且总在状态变化之后的观测点。 + * 语义 = "用户已经看过程序结果 / 已经放弃这次确认",本页不再有 + * "上一次的结果挂在另一行上"的显示 + */ +static void pg_draw(lv_layer_t *a_layer, const lv_area_t *a_clip) { + uint16_t sel = ui_content_page_selected_row(); + + (void)a_layer; + (void)a_clip; + if (sel != s_sel_seen) { + s_sel_seen = sel; + s_confirm = CL_NONE; + s_note = CL_NONE; + s_err = CL_NONE; + } +} + +/* + * 函数功能:按键处理(行内两段式确认) + * 入口参数:a_key - LVGL 键值 lv_key_t + * 返回值:true = 本页已消费;false = 交回框架(↑↓ 移选、←/ESC 关页 = 取消) bool + * 限定条件:框架按键链内;描述符 edit_capable=1 + * 函数说明:只读进入一律交回框架(本页没有只读可做的动作,也没有保存行可显 + * LOCK——清单页没有态标位,加了反而像"可以按"); + * 第一次 ENTER 只是把该行标成待确认(不做任何投递),第二次才投; + * 投递被拒(含维护通道未注入)时置 s_err,光标一动就收回 + */ +static bool pg_key(lv_key_t a_key) { + uint16_t sel; + + if (a_key != LV_KEY_ENTER) { + return false; + } + if (ui_content_page_is_editable() == false) { + return false; /* 只读:清除是修改类动作,一条都不投 */ + } + sel = ui_content_page_selected_row(); + if (sel >= CL_ROW_N) { + return true; /* 越界防御:不投任何编号 */ + } + + if ((uint8_t)sel != s_confirm) { + s_confirm = (uint8_t)sel; /* 第一段:只标记,不投递 */ + s_note = CL_NONE; + s_err = CL_NONE; + ui_content_page_invalidate(); + return true; + } + + s_confirm = CL_NONE; + s_err = CL_NONE; + if (ui_data_maint_clear_req(cl_id_of(sel)) == 0) { + s_note = (uint8_t)sel; /* 已受理:接下来按状态机显 WAIT/OK/ERR */ + } else { + s_err = (uint8_t)sel; + } + ui_content_page_invalidate(); + return true; +} + +/* + * 页面描述符(外部链接:生成物 ui_page_registry.c 按菜单树 #page=clear 绑定引用) + * 七行 1 页(rows_per_page = UI_LIST_ROWS(8) > 7 ⇒ 不翻页);key_intercept=0: + * 本页没有"编辑到一半"的态要保,← 与 ESC 一律关页 = 取消 + */ +const content_page_def_t ui_page_clear_desc = { + .id = "clear", + .title = "清除记录", + .selectable = 1u, + .edit_capable = 1u, + .top_y = UI_LIST_TOP, + .pitch = UI_ROW_PITCH, + .band_h = UI_ROW_BAND_H, + .text_dy = 0, + .left_x = UI_LIST_LEFT_X, + .right_x = UI_LIST_RIGHT_X, + .rows_per_page = UI_LIST_ROWS, + .row_count = CL_ROW_N, + .init_cb = pg_init, + .refresh = pg_refresh, + .draw_cb = pg_draw, + .key_cb = pg_key, +}; diff --git a/Drivers/BSP/UI/ui_page_clear.h b/Drivers/BSP/UI/ui_page_clear.h new file mode 100644 index 00000000..0eabefbb --- /dev/null +++ b/Drivers/BSP/UI/ui_page_clear.h @@ -0,0 +1,29 @@ +/* + * 模块名称:UI Page clear (清除记录内容页) + * 模块功能:清除记录叶子的内容页声明——向框架 ui_content_page 提供页面描述符 + * (条数走 ui_w_records 的同一枚注入 reader、删除只发请求(syslog_request_clear 语义在 App 侧适配层)) + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:DTU 项目组 + * 创建日期:2026-10-26 + * 修改记录: + * v1.0 2026-10-26 DTU 项目组 创建(道F task-3「装置维护 3 页」,判据 = + * docs/任务规划/UI补齐.md §2 第 3 行;形态 = + * docs/UI设计/页面扩充详细方案.md §3.4) + */ +#ifndef UI_PAGE_CLEAR_H +#define UI_PAGE_CLEAR_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "ui_content_page.h" + +/* 页面描述符(定义在 ui_page_clear.c;生成物按菜单树 #page=clear 引用本符号) */ +extern const content_page_def_t ui_page_clear_desc; + +#ifdef __cplusplus +} +#endif + +#endif /* UI_PAGE_CLEAR_H */ diff --git a/Drivers/BSP/UI/ui_page_clock.c b/Drivers/BSP/UI/ui_page_clock.c new file mode 100644 index 00000000..d92fdd43 --- /dev/null +++ b/Drivers/BSP/UI/ui_page_clock.c @@ -0,0 +1,388 @@ +/* + * 模块名称:UI Page clock (时钟设置内容页) + * 模块功能:装置维护 → 时钟设置 叶子(node 24,#P 受密码保护)的内容页—— + * 六字段表单(年/月/日/时/分/秒)+ 保存行(仅可编辑态才有), + * 预填取自 ui_data 实时帧的 clock[20] + clock_ok(本文件不 include + * 任何 App 头,尤其**不碰 sys_clock**:模拟器没链 sys_clock,页面里 + * 用一次就是"MCU 编得过、模拟器链接期爆"),字段编辑复用 ui_w_editor + * (逐位 ±1、左右移光标、ENTER 提交、越界拒退并提示), + * 保存走 ui_data_maint_clock_set() 注入转发——执行侧真机实现 + * = sys_clock_set_unix() + 回读复核,返 0 才是终态; + * 无效值拒收:六字段先过 ui_data_clock_to_epoch() 整组校验 + * (含 2 月 30 日、非闰年 2 月 29 日这类"每段都合法、组合非法"), + * 不过则**一个字节都不写**、保存行显 ERR; + * 墙钟无效(clock_ok=0 或串解析失败):六行值列一律 "--" + 标题右侧 + * "无时钟"徽标,**绝不显示也绝不预填 1970**(口径见下方 CK_INVAL 注) + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:DTU 项目组 + * 创建日期:2026-10-26 + * 修改记录: + * v1.0 2026-10-26 DTU 项目组 创建(道F task-3;判据 docs/任务规划/UI补齐.md + * §2 第 3 行「时钟设置写入 sys_clock_set_unix() + * 并回读」;形态 = 页面扩充详细方案.md §3.4 表单 6 字段) + */ + +/* 头文件包含区 */ +#include + +#include "ui_page_clock.h" + +#include "ui_data.h" +#include "ui_tokens.h" +#include "ui_w_editor.h" + +/* 私有宏定义区 */ + +#define CK_FIELD_N 6u /* 字段行数:年月日时分秒 */ +#define CK_ROW_COUNT_MAX 7u /* 最多行数:6 字段 + 1 保存行 */ +#define CK_SAVE_ROW CK_FIELD_N /* 保存行下标(仅可编辑态存在) */ +#define CK_BADGE_X 7 /* 徽标左边界:与行名同一列 */ +#define CK_BADGE_Y 124 /* 徽标上边界:清单最后一行(108..121)与页脚带(140+) + 之间的空带,实测三条链都没占过它 */ +#define CK_NO_CLOCK_TXT "无时钟" /* 徽标(用字已在字库) */ +#define CK_INVAL_VAL "--" /* 无效墙钟的字段值列——**不是 1970**: + 记录侧那条 "1970-01-01 08:00:00" 是 + sys_log 写死占位串(App/log/sys_log_mcu.c + 落盘时 sys_clock_get_str 返 NULL 才用), + 把它抄来预填等于让用户"照抄保存", + 一次点击就把 1970 写进 RTC —— 比不显示更坏 */ + +/* 保存行值列词表:与整定保存行、网口设置保存行同一排 ASCII 词。 + * 没有 WAIT 这一档:维护通道是**同步转发**,返回值就是执行侧带回读结论的终态, + * 屏上挂一个永远等不到的 WAIT 比不挂更让人困惑(契约 §6.3 第 2 条同一口径) */ +#define CK_ST_ENTER "ENTER" +#define CK_ST_OK "OK" +#define CK_ST_ERR "ERR" + +/* + * 本地这条操作记录的描述串:ASCII 18 字节 < SYSLOG_DESC_MAX(28)。 + * 与远端 "clock set by 104"(App/task/iec104_serv.c)对称——面板与主站各一条、 + * 串不同,查"这装置什么时候被谁对过时"才分得开。只在保存成功那条腿投。 + */ +#define CK_OPER_NOTE "clock set by panel" + +/* 静态变量定义区 */ + +/* 字段区间:日的"当月真实上限"不在这里判(编辑器只有静态 min/max), + * 组合合法性统一由 ui_data_clock_to_epoch() 在保存时把关——少一处第二判据 */ +static const uint16_t k_ck_min[CK_FIELD_N] = { + (uint16_t)UI_DATA_CLOCK_YEAR_MIN, 1u, 1u, 0u, 0u, 0u +}; +static const uint16_t k_ck_max[CK_FIELD_N] = { + (uint16_t)UI_DATA_CLOCK_YEAR_MAX, 12u, 31u, 23u, 59u, 59u +}; +static const char *const k_ck_name[CK_FIELD_N] = { + "年", "月", "日", "时", "分", "秒" +}; + +/* 实时帧缓存:只在发布序号变过时整帧读(§5.4 行缓存纪律;本层不做逐行整帧拷贝) */ +static ui_data_rt_t s_rt; +static uint32_t s_rt_seq; + +static uint16_t s_val[CK_FIELD_N]; /* 工作值(编辑提交后落在这里;保存前不写任何数据源) */ +static uint8_t s_have; /* 1 = 最近一次预填来自一帧有效时标;0 = 无有效时标 */ +static uint8_t s_dirty; /* 1 = 用户改过工作值还没落钟:帧不得覆盖用户输入, + 值列也不写 "--"(否则编辑到一半被 200ms 节拍冲掉) */ +static uint8_t s_editing; /* 1 = 正在编辑(被编辑行 = 当前选中行) */ +static uint8_t s_st; /* 保存态锁存:0 未投过 / 1 OK / 2 ERR */ +static uint8_t s_ed_row; /* 被编辑行号(仅 s_editing=1 时有意义) */ + +static ui_w_editor_t s_ed; /* 数值编辑器实例(本页独占,勿跨页共用) */ + +/* 函数定义区 */ + +/* + * 函数功能:跟随发布序号取实时帧,并把时标拆成六个工作值 + * 入口参数:无 + * 返回值:无 + * 限定条件:uiTask(框架绘制/按键链内) + * 函数说明:解析失败(空串 = 墙钟无效;或快照里出现非本工程口径的串)一律 + * s_have=0 → 六行显 "--" + 徽标;**不猜**、不回填 0、不回填 1970 + */ +static void ck_sync(void) { + uint32_t seq = ui_data_rt_seq(); + ui_data_clock_t f; + + if ((seq != s_rt_seq) && (ui_data_rt(&s_rt) == 0)) { + s_rt_seq = seq; + } + if ((s_editing != 0u) || (s_dirty != 0u)) { + return; /* 用户正在改:200 ms 一帧的节拍不得冲掉他还没落钟的输入 */ + } + if ((s_rt.clock_ok == 0u) || (ui_data_clock_parse(s_rt.clock, &f) != 0)) { + s_have = 0u; + return; + } + /* 无未落钟改动 ⇒ 值列跟着墙钟走:保存成功后新时标进帧,这就是"回读校验 + * 真读回来了"的屏上证据,不必页面自己造一份"我以为写进去了"的显示 */ + s_val[0] = f.year; + s_val[1] = f.month; + s_val[2] = f.day; + s_val[3] = f.hour; + s_val[4] = f.minute; + s_val[5] = f.second; + s_have = 1u; +} + +/* + * 函数功能:取当前可编辑性(本次进入是否有修改权限) + * 入口参数:无 + * 返回值:1 = 可编辑;0 = 只读 uint8_t + * 限定条件:框架开页之后(init_cb 起,is_editable 读到的就是本次进入的权限) + * 函数说明:只读进入连保存行都不给(同整定四页的只读分支)——一个按不动的 + * 保存行只是徒增"是不是设备坏了"的误判面 + */ +static uint8_t ck_editable(void) { + return (ui_content_page_is_editable() != false) ? 1u : 0u; +} + +/* + * 函数功能:总行数(框架 count_cb) + * 入口参数:无 + * 返回值:6(只读)或 7(可编辑:6 字段 + 保存行) uint16_t + * 限定条件:框架绘制/翻页链内 + */ +static uint16_t pg_count(void) { + return (uint16_t)(CK_FIELD_N + ck_editable()); +} + +/* + * 函数功能:逐行取数(框架 refresh) + * 入口参数:a_row / a_name / a_value - 契约见 ui_content_page.h + * 返回值:无 + * 限定条件:框架绘制链内 + */ +static void pg_refresh(uint16_t a_row, char *a_name, char *a_value) { + uint16_t sel = ui_content_page_selected_row(); + + (void)memset(a_value, 0, UI_BUF_STRLEN); + + if (a_row >= CK_FIELD_N) { + (void)memcpy(a_name, "保存", sizeof("保存")); + (void)memcpy(a_value, (s_st == 0u) ? CK_ST_ENTER + : ((s_st == 1u) ? CK_ST_OK : CK_ST_ERR), + ((s_st == 0u) ? sizeof(CK_ST_ENTER) + : ((s_st == 1u) ? sizeof(CK_ST_OK) : sizeof(CK_ST_ERR)))); + return; + } + + (void)memcpy(a_name, k_ck_name[a_row], strlen(k_ck_name[a_row]) + 1u); + + if ((s_editing != 0u) && (sel == a_row)) { + ui_w_editor_format(&s_ed, a_value, UI_BUF_STRLEN); /* 编辑串与光标同规则 */ + return; + } + if ((s_have == 0u) && (s_dirty == 0u) && (s_editing == 0u)) { + (void)memcpy(a_value, CK_INVAL_VAL, sizeof(CK_INVAL_VAL)); + return; /* 墙钟无效且用户还没动手:显 "--",不显 1970、也不显 0 */ + } + if (a_row == 0u) { + (void)lv_snprintf(a_value, UI_BUF_STRLEN, "%04u", (unsigned int)s_val[0]); + } else { + (void)lv_snprintf(a_value, UI_BUF_STRLEN, "%02u", (unsigned int)s_val[a_row]); + } +} + +/* + * 函数功能:页脚带靠左画"无时钟"徽标(墙钟无效的屏上表达) + * 入口参数:a_layer / a_clip - 契约见 ui_content_page.h + * 返回值:无 + * 限定条件:框架绘制链内;只在 s_have=0 时落墨 + * 函数说明:UI_COL_FG、不做反显——反显在 1bpp 下表示"选中",徽标不是可选中项; + * 左边界/所在带见下方注释(实测过标题带会让"无时钟"和标题连成一句) + */ +static void ck_draw_badge(lv_layer_t *a_layer, const lv_area_t *a_clip) { + lv_area_t area; + lv_draw_label_dsc_t ld; + const char *txt = CK_NO_CLOCK_TXT; + + if ((s_have != 0u) || (s_dirty != 0u)) { + return; /* 已有有效时标;或用户正在填一个新时间(此时钟确实还没建立,但 + 屏上已经在看他输入的数,再压一个徽标只是抢注意力) */ + } + /* 位置实测两次才定下来,两次踩的坑都记在这里(都是"看起来放得下"): + * ① 标题带右侧(整定页"未标定"徽标那条线,右界 141):本标题"时钟设置"4 个字 + * 居中占到 x≈104,徽标 36 px 正好贴在它后面,一屏读成"时钟设置无时钟"一句 + * 话——整定页能那么放是因为它最长标题只有 3 个字(到 x≈98)。 + * ② 页脚带左侧 x=7:页脚"第 1 页,共 1 页"**不是居中的**,实测从 x=6 起排, + * 徽标直接压在"第 1 页"上(像素比对里表现为两边都缺一块)。 + * 现落点 = 清单最后一行(108..121)与页脚带(140 起)之间的空带:谁都不压, + * 且仍在"这一屏说的是时钟"的视线内 */ + area.x1 = CK_BADGE_X; + /* 该版本 lv_text_get_width:长度必须传 strlen(传 -1/0 会死循环或返回 0) */ + area.x2 = CK_BADGE_X + + (int32_t)lv_text_get_width(txt, (uint32_t)strlen(txt), LV_FONT_DEFAULT, 0); + area.y1 = CK_BADGE_Y; + area.y2 = CK_BADGE_Y + UI_TEXT_BAND_H; + if ((area.x1 > a_clip->x2) || (a_clip->x1 > area.x2) || + (area.y1 > a_clip->y2) || (a_clip->y1 > area.y2)) { + return; + } + lv_draw_label_dsc_init(&ld); + ld.base.layer = a_layer; + ld.text = txt; + ld.text_static = 1u; + ld.color = UI_COL_FG; + ld.ofs_x = 0; + ld.ofs_y = 0; + lv_draw_label(a_layer, &ld, &area); +} + +/* + * 函数功能:自绘(框架 draw_cb 末尾:编辑器光标反显 + 无时钟徽标) + * 入口参数:a_layer / a_clip - 契约见 ui_content_page.h + * 返回值:无 + * 限定条件:框架绘制链内 + */ +static void pg_draw(lv_layer_t *a_layer, const lv_area_t *a_clip) { + if (s_editing != 0u) { + uint16_t sel = ui_content_page_selected_row(); + int32_t row_top; + + /* 本页 ≤7 行、rows_per_page = UI_LIST_ROWS(8) ⇒ 恒第 1 页、窗口首行 = 0; + * 两个变量都只在编辑态有意义,就声明在编辑态这块里(cppcheck variableScope)*/ + row_top = UI_LIST_TOP + ((int32_t)sel * UI_ROW_PITCH); + ui_w_editor_draw(&s_ed, a_layer, a_clip, row_top, 0, UI_ROW_BAND_H, UI_LIST_RIGHT_X); + } + ck_draw_badge(a_layer, a_clip); +} + +/* + * 函数功能:保存——整组校验通过后投递墙钟设置,成功后补投本地操作记录 + * 入口参数:无 + * 返回值:无 + * 限定条件:可编辑态、保存行 ENTER;uiTask 上下文 + * 函数说明:三步顺序即判据:① to_epoch 整组校验(组合非法 → 一个字节都不写, + * 屏上 ERR,光标留在页面上,用户改的就是那一行不必重新找); + * ② 注入转发(真机 = sys_clock_set_unix + 回读复核;**sys_clock_set() + * 本身恒返 0**,RTC 写失败只打日志,所以回读是唯一真判据); + * ③ 只有返 0 才投操作记录——给一次没落地的对时留审计记录, + * 比少一条记录坏得多 + * 结果落在 s_st 上并**锁存**:状态是全局单值,直读会让用户动一下光标 + * 就看到别处留下的旧结果(契约 §6.3 第 3 条同一条理由) + */ +static void ck_save(void) { + ui_data_clock_t f; + uint32_t epoch = 0u; + int rc; + + f.year = (uint16_t)s_val[0]; + f.month = (uint8_t)s_val[1]; + f.day = (uint8_t)s_val[2]; + f.hour = (uint8_t)s_val[3]; + f.minute = (uint8_t)s_val[4]; + f.second = (uint8_t)s_val[5]; + + if (ui_data_clock_to_epoch(&f, &epoch) != 0) { + s_st = 2u; /* 组合非法:一条都不投 */ + return; + } + rc = ui_data_maint_clock_set(epoch); + if (rc == 0) { + s_st = 1u; + s_dirty = 0u; /* 已落钟:值列交还给实时帧,回读的新时标自己会上屏 */ + (void)ui_data_maint_oper_note(CK_OPER_NOTE); + } else { + s_st = 2u; + } +} + +/* + * 函数功能:开页重置运行态(框架 init_cb) + * 入口参数:无 + * 返回值:无 + * 限定条件:框架开页链内(此时本次授予权限已装载,ck_editable 读得到真值) + */ +static void pg_init(void) { + s_rt_seq = 0u; + s_have = 0u; + s_dirty = 0u; + s_st = 0u; + s_editing = 0u; + s_ed_row = 0u; + ui_w_editor_reset(&s_ed); + (void)memset(s_val, 0, sizeof(s_val)); + ck_sync(); + if (s_have == 0u) { + /* 无有效时标:起编值取各字段下限(不是 0、也不是 1970)——用户从 + * 2000-01-01 00:00:00 起步改,比从一片 0 里猜要少按十几次键 */ + s_val[0] = (uint16_t)UI_DATA_CLOCK_YEAR_MIN; + s_val[1] = 1u; + s_val[2] = 1u; + } +} + +/* + * 函数功能:按键转发(框架 key_cb) + * 入口参数:a_key - LVGL 键值 lv_key_t + * 返回值:true = 本页已消费;false = 交回框架(↑↓ 移选、← 关页) bool + * 限定条件:框架按键链内;描述符 edit_capable=1 + key_intercept=1 + * 函数说明:编辑中的 ←→ 是"移数字光标",必须先给页面(key_intercept=1 的用途), + * 否则框架会把 ← 当成关页,编辑到一半就跳出去;ESC 恒不到这里 + * (框架保证任何一页退得出去);只读进入 ENTER 一律忽略 + */ +static bool pg_key(lv_key_t a_key) { + uint16_t sel = ui_content_page_selected_row(); + + if (s_editing != 0u) { + if (ui_w_editor_key(&s_ed, a_key) == 0u) { + return false; /* 编辑器不认的键交回框架 */ + } + if (ui_w_editor_is_editing(&s_ed) == false) { + /* 编辑器已提交(ENTER 且范围通过):落工作值,作废上一次的保存结果 */ + if (s_ed_row < CK_FIELD_N) { + s_val[s_ed_row] = (uint16_t)ui_w_editor_value(&s_ed); + s_dirty = 1u; /* 工作值与墙钟从此两回事,别再让帧覆盖它 */ + } + s_editing = 0u; + s_st = 0u; /* 改了任何一位,上一次的保存结果就作废显示 */ + } + return true; + } + + if (a_key != LV_KEY_ENTER) { + return false; /* ↑↓ 等交回框架内置移选 */ + } + if (ck_editable() == 0u) { + return false; /* 只读:不编辑、不保存(保存行本来也不在屏上) */ + } + if (sel >= CK_FIELD_N) { + ck_save(); + ui_content_page_invalidate(); + return true; + } + /* 进字段编辑:初值取当前工作值;无有效时标时从下限起编 */ + s_ed_row = (uint8_t)sel; + s_editing = 1u; + ui_w_editor_begin(&s_ed, (int32_t)s_val[sel], (int32_t)k_ck_min[sel], + (int32_t)k_ck_max[sel], 0u); + ui_content_page_invalidate(); + return true; +} + +/* + * 页面描述符(外部链接:生成物 ui_page_registry.c 按菜单树 #page=clock 绑定引用) + * 列表页骨架(6~7 行 1 页,几何令牌同整定四页)+ 可编辑(edit_capable, + * 还要本次授予修改权限)+ ← 拦截(编辑中移数字光标)。 + * band_h 必须 >0:selectable 页选中行是黑字,没有白带就是黑底黑字看不见选中行 + */ +const content_page_def_t ui_page_clock_desc = { + .id = "clock", + .title = "时钟设置", + .selectable = 1u, + .edit_capable = 1u, + .key_intercept = 1u, + .top_y = UI_LIST_TOP, + .pitch = UI_ROW_PITCH, + .band_h = UI_ROW_BAND_H, + .text_dy = 0, + .left_x = UI_LIST_LEFT_X, + .right_x = UI_LIST_RIGHT_X, + .rows_per_page = UI_LIST_ROWS, + .row_count = CK_ROW_COUNT_MAX, + .init_cb = pg_init, + .count_cb = pg_count, + .refresh = pg_refresh, + .draw_cb = pg_draw, + .key_cb = pg_key, +}; diff --git a/Drivers/BSP/UI/ui_page_clock.h b/Drivers/BSP/UI/ui_page_clock.h new file mode 100644 index 00000000..58407d54 --- /dev/null +++ b/Drivers/BSP/UI/ui_page_clock.h @@ -0,0 +1,29 @@ +/* + * 模块名称:UI Page clock (时钟设置内容页) + * 模块功能:时钟设置叶子的内容页声明——向框架 ui_content_page 提供页面描述符 + * (字段编辑走 ui_w_editor、预填走 ui_data 实时帧、写方向只经 ui_data_maint_* 注入转发) + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:DTU 项目组 + * 创建日期:2026-10-26 + * 修改记录: + * v1.0 2026-10-26 DTU 项目组 创建(道F task-3「装置维护 3 页」,判据 = + * docs/任务规划/UI补齐.md §2 第 3 行;形态 = + * docs/UI设计/页面扩充详细方案.md §3.4) + */ +#ifndef UI_PAGE_CLOCK_H +#define UI_PAGE_CLOCK_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "ui_content_page.h" + +/* 页面描述符(定义在 ui_page_clock.c;生成物按菜单树 #page=clock 引用本符号) */ +extern const content_page_def_t ui_page_clock_desc; + +#ifdef __cplusplus +} +#endif + +#endif /* UI_PAGE_CLOCK_H */ diff --git a/Drivers/BSP/UI/ui_page_registry.c b/Drivers/BSP/UI/ui_page_registry.c index ee7616d6..eb7861a5 100644 --- a/Drivers/BSP/UI/ui_page_registry.c +++ b/Drivers/BSP/UI/ui_page_registry.c @@ -10,10 +10,13 @@ #include "ui_page_registry.h" #include "ui_page_ac.h" +#include "ui_page_clear.h" +#include "ui_page_clock.h" #include "ui_page_dc.h" #include "ui_page_device_info.h" #include "ui_page_net_cfg.h" #include "ui_page_password.h" +#include "ui_page_reset.h" #include "ui_page_runlog.h" #include "ui_page_selftest.h" #include "ui_page_soe.h" @@ -36,6 +39,9 @@ const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT] = { { 14u, &ui_page_view_set_desc }, /* node 14 定值 */ { 15u, &ui_page_view_word_desc }, /* node 15 控制字 */ { 16u, &ui_page_view_plate_desc }, /* node 16 软压板 */ + { 24u, &ui_page_clock_desc }, /* node 24 时钟设置 */ + { 25u, &ui_page_reset_desc }, /* node 25 强制复归 */ + { 27u, &ui_page_clear_desc }, /* node 27 清除记录 */ { 30u, &ui_page_net_cfg_desc }, /* node 30 网口设置 */ { 33u, &ui_page_soe_desc }, /* node 33 SOE记录 */ { 39u, &ui_page_selftest_desc }, /* node 39 自检记录 */ diff --git a/Drivers/BSP/UI/ui_page_registry.h b/Drivers/BSP/UI/ui_page_registry.h index 2c5c0c92..3cab046e 100644 --- a/Drivers/BSP/UI/ui_page_registry.h +++ b/Drivers/BSP/UI/ui_page_registry.h @@ -25,7 +25,7 @@ typedef struct { const content_page_def_t *desc; /* 页面描述符(Flash const) */ } ui_page_entry_t; -#define UI_PAGE_MAP_COUNT 16u /* 绑定了内容页的叶子数 */ +#define UI_PAGE_MAP_COUNT 19u /* 绑定了内容页的叶子数 */ extern const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT]; diff --git a/Drivers/BSP/UI/ui_page_reset.c b/Drivers/BSP/UI/ui_page_reset.c new file mode 100644 index 00000000..c3c516f4 --- /dev/null +++ b/Drivers/BSP/UI/ui_page_reset.c @@ -0,0 +1,153 @@ +/* + * 模块名称:UI Page reset (强制复归内容页) + * 模块功能:装置维护 → 强制复归 叶子(node 25,#P 受密码保护)的内容页—— + * 复用 ui_page_action 共享确认链(一次确认 → 二次确认 → 执行回报), + * 并在同一屏画一行"保护动作 N"(N = 当前处于动作态的保护元件个数): + * 复归前 N > 0、复归请求被 protectTask 消费后 N 归 0,这个变化就是 + * "动作位真的复归了"的屏上证据,不用翻页、不用外接表; + * 执行只做两件事:投复归请求 + 补一条本地操作记录留痕; + * 只读进入(查看密码)整页拒执行并显式说"没权限"(LOCK)—— + * 破坏性动作不给"看着像能按"的第二次机会; + * 本文件零 App include、零存储触点(分层方向由注入表守住) + * 为什么这么薄:确认链的形态(含二次确认与回报)是共享件,四个维护/操作类叶子 + * 共用一份实现;本文件只把"这一页问什么、执行什么、观测什么"三项 + * 配置接进框架的描述符骨架(同 ui_page_runlog 之于 ui_w_records) + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:DTU 项目组 + * 创建日期:2026-10-26 + * 修改记录: + * v1.0 2026-10-26 DTU 项目组 创建(道F task-3;判据 docs/任务规划/UI补齐.md + * §2 第 3 行「强制复归确认后动作位复归且有 SOE」) + */ + +/* 头文件包含区 */ +#include "ui_page_reset.h" + +#include "ui_content_page.h" +#include "ui_data.h" +#include "ui_page_action.h" + +/* 私有宏定义区 */ + +/* + * 本地这条操作记录的描述串:ASCII 20 字节 < SYSLOG_DESC_MAX(28)。 + * 与远端那条(App/protocol/iec104/iec104_pts.c 的 "force reset")**刻意不同串**—— + * 操作记录是审计面,两条一样就再也分不出"是主站下发的还是现场有人按面板", + * 而那正是查遥控事故时第一个要问的问题。 + * 为什么由页面投而不是由 prot_elm_request_reset() 投:那条函数在 algo 层, + * 在那里投会把 sys_log.h 拽进算法层(并把记录通道的依赖摊到 test/host/prot_dim + * 的 include 路径上);而"这是本地发起的"这个事实只有页面知道(口径真源 + * App/log/README.md 五类记录表 + task-10 结论)。 + */ +#define RS_OPER_NOTE "force reset by panel" + +/* 私有函数声明区(静态初始化器要引用下面两个函数的地址,故先声明) */ + +static bool rs_ok(void); +static uint32_t rs_watch(void); + +/* 静态变量定义区 */ + +/* + * 本页操作页实例(配置全为 Flash 字面量与函数地址;运行态两字节由组件写)。 + * 字段顺序 = ui_page_action_t 声明序(ARMCC v5 = C99 要求指定顺序一致)。 + */ +static ui_page_action_t s_act = { + "确认复归保护?", /* prompt:问句用字已在字库(确/认/复/归/保/护 + '?') */ + "已请求复归", /* done_text:回报"请求已受理",不谎称"已复归完成" */ + rs_ok, /* ok_cb */ + 1u, /* need_modify:只读进入一律拒执行 */ + "保护动作", /* watch_label(用字全在既有子集内;曾拟"动作元件", + 其中"元"不在子集 —— 见本行下方注释与终报) */ + rs_watch, /* watch_cb */ + 0u, /* stage(运行态初值) */ + 0u /* err (运行态初值) */ +}; + +/* 函数定义区 */ + +/* + * 函数功能:执行投递——请求复归,受理成功后补投本地操作记录 + * 入口参数:无 + * 返回值:true = 已受理(组件转成功回报);false = 拒收(组件显示 ERR) bool + * 限定条件:由 ui_page_action_key 在第二次 ENTER 时调用(uiTask 上下文) + * 函数说明:两条腿的次序即判据——**先请求后留痕**:请求被拒时一条记录都不投, + * 给一次没发生过的操作写审计记录,比少一条记录坏得多(同 + * App/task/iec104_serv.c 对时留痕"只在真改成功之后才投"那条纪律)。 + * 维护通道未注入(装配缺一半)也走拒收这条腿:屏上显 ERR, + * 比"按了像成功其实什么都没发生"诚实 + */ +static bool rs_ok(void) { + if (ui_data_maint_reset_req() != 0) { + return false; + } + (void)ui_data_maint_oper_note(RS_OPER_NOTE); + return true; +} + +/* + * 函数功能:取当前处于动作态的保护元件数(观测行 "保护动作 N" 的 N) + * 入口参数:无 + * 返回值:元件数(未注入时 0) uint32_t + * 限定条件:绘制路径可直调(一次转发,零 I/O、零锁) + * 函数说明:⚠ 台架判据的前提要写清:产品构建现处"未标定闭锁" + * (App/task/protect_task.c 停走判据),闭锁态下元件本就是复位态, + * "N 从 >0 变 0"这一眼要等两点标定解卡后才成立;解卡前该判据 + * 退化为"页面受理 + 操作记录落一条",不得把闭锁态报成复归无效 + */ +static uint32_t rs_watch(void) { + return ui_data_maint_active_count(); +} + +/* + * 函数功能:开页复位确认链(框架 init_cb) + * 入口参数:无 + * 返回值:无 + * 限定条件:框架开页链内调用 + * 函数说明:复位由组件做(stage/err 是它的运行态),本页不重复清一遍 + */ +static void pg_init(void) { + ui_page_action_init(&s_act); +} + +/* + * 函数功能:自绘(框架 draw_cb;四行居中全由组件画) + * 入口参数:a_layer / a_clip - 契约见 ui_content_page.h + * 返回值:无 + * 限定条件:框架绘制链末尾调用 + */ +static void pg_draw(lv_layer_t *a_layer, const lv_area_t *a_clip) { + ui_page_action_draw(&s_act, a_layer, a_clip); +} + +/* + * 函数功能:按键转发(框架 key_cb) + * 入口参数:a_key - LVGL 键值 lv_key_t + * 返回值:true = 本页已消费;false = 交回框架 bool + * 限定条件:框架按键链内 + * 函数说明:确认链的判定全在组件里(含只读拒执行),本页只如实透传返回值, + * 不在这层"顺手"加判据——两处各判一次早晚分家 + */ +static bool pg_key(lv_key_t a_key) { + return (ui_page_action_key(&s_act, a_key) != 0u); +} + +/* + * 页面描述符(外部链接:生成物 ui_page_registry.c 按菜单树 #page=reset 绑定引用) + * 表单/操作页形态:无浅 V、无页脚、无行列表(用户密钥页与网口设置页先例); + * edit_capable=1 是**能不能改**的那半,另半(本次进入授予的权限)由 + * ui_content_page_is_editable() 给——两半都在,组件才能判出"只读"; + * key_intercept=0:LEFT 不拦截,关页 = 取消(本链没有"编辑到一半"的状态要保) + */ +const content_page_def_t ui_page_reset_desc = { + .id = "reset", + .title = "强制复归", + .hide_deco = 1u, + .hide_footer = 1u, + .selectable = 0u, + .key_intercept = 0u, + .edit_capable = 1u, + .init_cb = pg_init, + .draw_cb = pg_draw, + .key_cb = pg_key, +}; diff --git a/Drivers/BSP/UI/ui_page_reset.h b/Drivers/BSP/UI/ui_page_reset.h new file mode 100644 index 00000000..b3777349 --- /dev/null +++ b/Drivers/BSP/UI/ui_page_reset.h @@ -0,0 +1,29 @@ +/* + * 模块名称:UI Page reset (强制复归内容页) + * 模块功能:强制复归叶子的内容页声明——向框架 ui_content_page 提供页面描述符 + * (确认链与观测行全部走 ui_page_action 共享实现,本模块零 App include、零存储触点) + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:DTU 项目组 + * 创建日期:2026-10-26 + * 修改记录: + * v1.0 2026-10-26 DTU 项目组 创建(道F task-3「装置维护 3 页」,判据 = + * docs/任务规划/UI补齐.md §2 第 3 行;形态 = + * docs/UI设计/页面扩充详细方案.md §3.4) + */ +#ifndef UI_PAGE_RESET_H +#define UI_PAGE_RESET_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "ui_content_page.h" + +/* 页面描述符(定义在 ui_page_reset.c;生成物按菜单树 #page=reset 引用本符号) */ +extern const content_page_def_t ui_page_reset_desc; + +#ifdef __cplusplus +} +#endif + +#endif /* UI_PAGE_RESET_H */ diff --git a/Drivers/BSP/UI/ui_w_records.c b/Drivers/BSP/UI/ui_w_records.c index 35eda51e..6f154fd3 100644 --- a/Drivers/BSP/UI/ui_w_records.c +++ b/Drivers/BSP/UI/ui_w_records.c @@ -31,6 +31,26 @@ void ui_w_records_bind(const ui_rec_reader_t *a_reader) { s_reader = a_reader; } +/* + * 函数功能:按记录源编号查条数(不经行缓存的裸计数) + * 入口参数:log_id - 记录源编号(UI_W_REC_LOG_*) uint8_t + * 返回值:条数;未注入 reader 或编号未知一律 0,即空态,调用方按"无记录"显示 + * uint16_t + * 限定条件:uiTask 上下文(记录侧绘制/按键链) + * 函数说明:为什么开这个口子——清除记录页要在 7 行上分行显示各类条数(点完 + * "清除"之后条数归零就是它唯一的真反馈),而 reader 是本组件私有 + * 的注入表;不开放一个只读入口,页面就只能自己去注第二份 reader, + * 那正好把 task-6 刚削掉的分层反向边又接回来(口径见 + * docs/UI设计/UI_数据供给设计说明.md §7.2/§7.3)。 + * 本函数不带行缓存:缓存属于"某一条记录的字段",与条数无关 + */ +uint16_t ui_w_records_count_of(uint8_t a_log_id) { + if (s_reader == NULL) { + return 0u; + } + return s_reader->count(a_log_id); +} + /* * 函数功能:总行数(动态;每条记录 2 行,空日志 = 单条伪行"无记录") * 入口参数:a_inst - 记录页实例 ui_w_records_t * diff --git a/Drivers/BSP/UI/ui_w_records.h b/Drivers/BSP/UI/ui_w_records.h index d62c5451..d26b0fd3 100644 --- a/Drivers/BSP/UI/ui_w_records.h +++ b/Drivers/BSP/UI/ui_w_records.h @@ -27,11 +27,25 @@ extern "C" { #include -/* 记录源不透明编号:UI 侧只认"0 号 / 1 号日志",App 侧适配层映射到 syslog_id_t +/* 记录源不透明编号:UI 侧只认"N 号日志",App 侧适配层映射到 syslog_id_t * (映射表见 App/task/ui_task.c 与 simulator/main.c 的绑定处;新增记录页 = - * 这里加编号 + 两端适配层各加一条映射) */ + * 这里加编号 + 两端适配层各加一条映射) + * ⚠ **顺序契约(Lead 裁定 2026-10-26,task-6 起 7 条)**:本表顺序必须逐字等于 + * App/log/sys_log.h 的 syslog_id_t 枚举序(RUN/SELFTEST/ACCIDENT/ALARM/START/ + * OPER/YK),且**只准尾部追加**——枚举序就是两端 s_paths[]/s_caps[] 的下标, + * 中间插值会安静地把事故写进告警文件(记录侧已有 TC-SYSLOG-130/131/131b 那条 + * 机械防线,UI 侧对应的那条 = test/host/ui_data 的 TC-UIDATA-024 逐项核对 + + * 两端适配层里的编译期数组守卫;编号"不透明"指的是**页面不得依赖**这个相等, + * 不是允许两者各数各的:适配层始终显式映射,从不把本编号强转成 syslog_id_t)。 + * 追加之外的任何改动(换序/删号/复用号)都会让已落盘的记录被读成别的类别。 */ #define UI_W_REC_LOG_RUN 0u /* 0 号:运行记录(MCU /log/run.log) */ #define UI_W_REC_LOG_SELFTEST 1u /* 1 号:自检记录(MCU /log/selftest.log) */ +#define UI_W_REC_LOG_ACCIDENT 2u /* 2 号:事故记录(/log/accid.log,跳闸型动作) */ +#define UI_W_REC_LOG_ALARM 3u /* 3 号:保护告警(/log/alarm.log,告警型动作) */ +#define UI_W_REC_LOG_START 4u /* 4 号:保护启动(/log/start.log,picked 上升沿) */ +#define UI_W_REC_LOG_OPER 5u /* 5 号:操作记录(/log/oper.log,人工/远端操作留痕) */ +#define UI_W_REC_LOG_YK 6u /* 6 号:遥控记录(/log/yk.log,104 选择/执行/拒绝) */ +#define UI_W_REC_LOG_N 7u /* 编号个数(上界;两端映射表的数组长度,不是合法编号) */ /* 读取缓冲容量(UI 侧定义;必须 >= sys_log 契约下限:时间戳 19+NUL、描述 28+NUL。 * sys_log 侧 SYSLOG_TS_LEN/SYSLOG_DESC_MAX 变化时这里同步加宽——容量不足时 @@ -91,6 +105,19 @@ typedef struct { */ void ui_w_records_bind(const ui_rec_reader_t *a_reader); +/* + * 函数功能:按记录源编号查条数(不经行缓存的裸计数;清除记录页逐行显示用) + * 入口参数:log_id - 记录源编号(UI_W_REC_LOG_*) uint8_t + * 返回值:条数;未注入 reader 或编号未知一律 0,即空态,调用方按"无记录"显示 + * uint16_t + * 限定条件:uiTask 上下文(绘制/按键链) + * 函数说明:reader 是本组件私有的注入表,不开放只读入口的话,想按行显示条数的 + * 页面只能自己去注第二份 reader——那正好把 task-6 刚削掉的分层反向边 + * 接回来(口径 docs/UI设计/UI_数据供给设计说明.md §7.2/§7.3); + * 本函数不带行缓存(缓存属于"某条记录的字段",与条数无关) + */ +uint16_t ui_w_records_count_of(uint8_t a_log_id); + /* * 函数功能:总行数(动态;每条记录 2 行,空日志 = 单条伪行"无记录") * 入口参数:a_inst - 记录页实例 ui_w_records_t * diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index b38b15e5..2045d455 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -3606,6 +3606,21 @@ 1 ..\Drivers\BSP\UI\ui_page_yx.c + + ui_page_clear.c + 1 + ..\Drivers\BSP\UI\ui_page_clear.c + + + ui_page_clock.c + 1 + ..\Drivers\BSP\UI\ui_page_clock.c + + + ui_page_reset.c + 1 + ..\Drivers\BSP\UI\ui_page_reset.c + diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index b95ba40d..97f7449f 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -111,6 +111,9 @@ set(UI_SOURCES ${UI_DIR}/ui_page_view_set.c ${UI_DIR}/ui_page_view_word.c ${UI_DIR}/ui_page_yx.c + ${UI_DIR}/ui_page_clear.c + ${UI_DIR}/ui_page_clock.c + ${UI_DIR}/ui_page_reset.c ) # 运行/自检记录引擎(双端共享核心 + 平台存储后端;MCU 端编 sys_log_mcu.c,见 Keil 工程) diff --git a/simulator/main.c b/simulator/main.c index 23404f9b..93cfd9bf 100644 --- a/simulator/main.c +++ b/simulator/main.c @@ -147,14 +147,20 @@ static void sim_time_cb(char *buf, size_t len) { * 入口参数:log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t * 返回值:记录条数;未知编号返回 0(页面空态) uint16_t */ +/* 镜像 App/task/ui_task.c 的 s_log_map:两端 UI 编号 → syslog_id_t 表同源同长; + * 顺序契约(== 枚举序、只准尾追加)在真机侧有编译期守卫,宿主侧由 + * test/host/ui_data TC-UIDATA-024 与下面的数组长度同时把住 */ +static const syslog_id_t s_sim_log_map[UI_W_REC_LOG_N] = { + SYSLOG_RUN, SYSLOG_SELFTEST, SYSLOG_ACCIDENT, SYSLOG_ALARM, + SYSLOG_START, SYSLOG_OPER, SYSLOG_YK +}; +typedef char sim_log_map_len_check_t[(UI_W_REC_LOG_N == (uint8_t)SYSLOG_COUNT) ? 1 : -1]; + static uint16_t sim_rec_count(uint8_t log_id) { - if (log_id == UI_W_REC_LOG_RUN) { - return (uint16_t)syslog_count(SYSLOG_RUN); + if (log_id >= (uint8_t)UI_W_REC_LOG_N) { + return 0u; } - if (log_id == UI_W_REC_LOG_SELFTEST) { - return (uint16_t)syslog_count(SYSLOG_SELFTEST); - } - return 0u; + return (uint16_t)syslog_count(s_sim_log_map[log_id]); } /* @@ -164,16 +170,103 @@ static uint16_t sim_rec_count(uint8_t log_id) { */ static int sim_rec_get(uint8_t log_id, uint32_t idx, char *ts, uint32_t ts_len, char *desc, uint32_t desc_len) { - if (log_id == UI_W_REC_LOG_RUN) { - return syslog_get_record(SYSLOG_RUN, idx, ts, (size_t)ts_len, desc, (size_t)desc_len); + if (log_id >= (uint8_t)UI_W_REC_LOG_N) { + return -1; } - if (log_id == UI_W_REC_LOG_SELFTEST) { - return syslog_get_record(SYSLOG_SELFTEST, idx, ts, (size_t)ts_len, desc, (size_t)desc_len); - } - return -1; + return syslog_get_record(s_sim_log_map[log_id], idx, ts, (size_t)ts_len, desc, + (size_t)desc_len); } static const ui_rec_reader_t s_rec_reader = { sim_rec_count, sim_rec_get }; + +/* + * ======================= 道F task-3:装置维护写通道假件(镜像 ui_task.c) + * + * 形状与真机完全同一张函数表(六件),语义差一处如实记在下面:清除请求在宿主端 + * 是同步落地的(simulator/sim/sim_syslog.c:本调用内就删完,状态直接 OK), + * 真机则先 WAIT 再等 defaultTask 落 —— 页面对两种时序都要显示正确, + * 所以清除记录页的值词表里 WAIT 与 OK 都留了位置(黄金参考只覆盖得到 OK)。 + */ + +static void sim_publish_data_frames(void); /* 前置声明:本文件下方定义 */ + +static int s_noclock; /* --noclock:把本机装成"墙钟无效" */ +static uint32_t s_sim_epoch; /* 面板设过的墙钟(0 = 没设过) */ +static uint32_t s_sim_active = 1u; /* 假"动作态元件数":1 → 复归请求后 0 */ +static char s_sim_note[32]; /* 最近一条面板留痕(页面上看不到, + 但自检/排障时是唯一的取证处) */ + +/* + * 函数功能:假"设置墙钟"——校验后落内存并立即重发实时帧 + * 入口参数:a_epoch - Unix 秒 uint32_t + * 返回值:0 - 已生效;-1 - 越界(与真机同一判据) int + * 限定条件:uiTask 等价上下文(注键链内) + * 函数说明:真机的"回读证据"来自 SD2506 回读 + 快照层 200ms 重发;模拟器没有 + * RTC 也没有周期发布(无头模式连 200ms 节拍都不走),这里用"当场重发 + * 一帧"补上那半条时间轴——与 sim_param_pump() 同一个理由。不这么做的话, + * 保存成功但屏幕上的时间纹丝不动,黄金参考就会把"页面显示旧值"误当成 + * "回读失败",而真机上这个组合根本不可能出现(那就是假故障) + */ +static int sim_maint_clock_set(uint32_t a_epoch) { + if ((a_epoch == 0u) || (a_epoch > (uint32_t)UI_DATA_CLOCK_EPOCH_MAX)) { + return -1; + } + s_sim_epoch = a_epoch; + sim_publish_data_frames(); + return 0; +} + +/* 函数功能:假"强制复归"——把动作态元件数清零(真机由 protectTask 下一拍清) */ +static int sim_maint_reset_req(void) { + s_sim_active = 0u; + return 0; +} + +/* 函数功能:假"动作态元件数"(复归页观测行 "保护动作 N") */ +static uint32_t sim_maint_active_count(void) { + return s_sim_active; +} + +/* + * 函数功能:假"面板留痕"——真投进宿主端的 /log/oper.log(不是只记账) + * 入口参数:a_desc - 描述串 const char * + * 返回值:无 + * 限定条件:uiTask 等价上下文 + * 函数说明:留痕必须**真的落盘**,否则清除记录页上"清完 oper.log 之后操作记录 + * 又从 0 变回 1"这条事实在模拟器里永远看不见,黄金参考也就白拍 + */ +static void sim_maint_oper_note(const char *a_desc) { + if (a_desc == NULL) { + return; + } + (void)snprintf(s_sim_note, sizeof(s_sim_note), "%s", a_desc); + syslog_post(SYSLOG_OPER, a_desc); /* 该 API 无格式串形态(本头就是 const char *desc) */ +} + +/* 函数功能:假"请求清除记录"(宿主端同步落地,返回即已是终态) */ +static int sim_maint_clear_req(uint8_t a_log_id) { + if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) { + return -1; + } + return (syslog_request_clear(s_sim_log_map[a_log_id]) == 0) ? 0 : -1; +} + +/* 函数功能:假"清除状态"(值直通,与真机同一不改写口径) */ +static int sim_maint_clear_state(uint8_t a_log_id) { + if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) { + return UI_DATA_CLEAR_ST_NONE; + } + return syslog_clear_state(s_sim_log_map[a_log_id]); +} + +static const ui_data_maint_ops_t s_maint_ops = { + sim_maint_clock_set, + sim_maint_reset_req, + sim_maint_active_count, + sim_maint_oper_note, + sim_maint_clear_req, + sim_maint_clear_state +}; /* * 函数功能:SOE 记录假源——固定 3 条(镜像 App/task/ui_task.c 真机绑定的形态) * 入口参数:无 @@ -327,8 +420,24 @@ static void sim_publish_data_frames(void) { rt.yx_dp = 0x0FULL; rt.freq_valid = 0u; /* eng=0 期间幅值门恒失效 → 无新鲜读数 */ rt.freq_hz = 0.0f; - rt.clock_ok = 1u; - (void)snprintf(rt.clock, sizeof(rt.clock), "%s", "2026-10-25 08:00:00"); + /* 墙钟两态(道F task-3):--noclock 把这台机器装成"RTC 异常/未对时"——只有 + * 这样才照得到时钟设置页的 "--" + "无时钟"徽标形态;面板设过时以面板值为准, + * 这就是页面上"回读证据"的唯一来源(真机那边是 SD2506 回读 + 快照层重发) */ + rt.clock_ok = (s_noclock == 0) ? 1u : 0u; + if (s_noclock != 0) { + rt.clock[0] = 0; /* 无效时标 = 空串(页面据此显 "--",绝不显 1970) */ + } else if (s_sim_epoch != 0u) { + ui_data_clock_t ck; + + if (ui_data_clock_from_epoch(s_sim_epoch, &ck) == 0) { + (void)ui_data_clock_format(&ck, rt.clock, sizeof(rt.clock)); + } else { + rt.clock[0] = 0; + rt.clock_ok = 0u; + } + } else { + (void)snprintf(rt.clock, sizeof(rt.clock), "%s", "2026-10-25 08:00:00"); + } (void)ui_data_publish_rt(&rt); /* 定值帧:出厂默认 + 封存(幂等),再按 prot_set 现状发一帧(见上面两个函数) */ @@ -470,6 +579,10 @@ static int run_headless(const char * keys, const char * path, int standby) { /* task-6 记录 reader 注入(镜像 ui_task.c:先于 ui_menu_init,两端同形) */ ui_w_records_bind(&s_rec_reader); + /* 装置维护写通道(道F task-3):与真机同一张表形状,假件见上方实现 */ + if (ui_data_maint_bind(&s_maint_ops) != 0) { + printf("[sim] UI maint bind: FAIL\n"); + } ui_w_soe_bind(&s_soe_reader); /* task-15:SOE 记录页假源(形态镜像真机绑定) */ /* task-14 数据快照:镜像 ui_task.c 的 ui_data_init + 首帧发布(先于建页, * 页面任何时刻打开都拿得到帧,不必等装配节拍) */ @@ -885,10 +998,19 @@ static int run_logtest(void) { * 函数功能:模拟器入口(解析模式开关 → 窗口模式或无头自检) * 入口参数:argc - 参数个数 int * argv - 参数数组(--screenshot / --keys / --standby / --cfgtest / - * --logtest / --idle) char ** + * --logtest / --idle / --noclock) char ** * 返回值:0 - 正常退出或自检 PASS;非 0 - 失败 int */ int main(int argc, char **argv) { + /* 形态旗标必须先扫(道F task-3):--keys / --standby 分支命中即 return, + * 放在它们后面的循环根本走不到 —— 这是旗标看似加了、实际只在无参窗口模式 + * 生效的那类"看起来做了"的坑,写在这里省一个人重新踩 */ + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--noclock") == 0) { + s_noclock = 1; /* 把这台机器装成"墙钟无效"(RTC 异常/未对时) */ + } + } + /* 截图/自检模式:--screenshot / --keys / --standby / --cfgtest / --logtest */ for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--screenshot") == 0 && (i + 1) < argc) { @@ -953,6 +1075,10 @@ int main(int argc, char **argv) { /* 构建菜单 UI(与 MCU 端共用 ui_menu.c) */ /* task-6 记录 reader 注入(镜像 ui_task.c:先于 ui_menu_init,两端同形) */ ui_w_records_bind(&s_rec_reader); + /* 装置维护写通道(道F task-3):与真机同一张表形状,假件见上方实现 */ + if (ui_data_maint_bind(&s_maint_ops) != 0) { + printf("[sim] UI maint bind: FAIL\n"); + } ui_w_soe_bind(&s_soe_reader); /* task-15:SOE 记录页假源(形态镜像真机绑定) */ /* task-14 数据快照:镜像 ui_task.c 的 ui_data_init + 首帧发布(先于建页, * 页面任何时刻打开都拿得到帧,不必等装配节拍) */ diff --git a/simulator/tests/check_content_pages.py b/simulator/tests/check_content_pages.py index 53ea409e..5c89a5c6 100644 --- a/simulator/tests/check_content_pages.py +++ b/simulator/tests/check_content_pages.py @@ -85,12 +85,113 @@ PAGES = [ ("netcfg_ro", "ddddde de e"), # 查看密码 000 → 只读态 ("netcfg_edit", "ddddde de uruuruuue e"), # 编辑模式:IP 首字节光标反显 ("netcfg_save", "ddddde de uruuruuue dddd e"), # 保存行 ENTER → 值列 OK + # ---- 道F task-3 装置维护三页(根菜单第 5 项 = 4 下;子项 0/1/3 = 时钟/复归/清除) + # 键序读法与上面同源:e 进子菜单后再 e 打开叶子(叶子带 #P → 先落密码页), + # "uruuruuue" = 修改口令 123(可编辑态),单一个 e = 查看口令 000(只读态)。 + # 空格是分组占位:模拟器键映射把空格当可读分隔跳过(simulator/main.c 键映射) + ("clock", "dddd ee uruuruuue"), + ("clock_ro", "dddd ee e"), + ("clock_edit", "dddd ee uruuruuue e"), + ("clock_save", "dddd ee uruuruuue ddddd e u e d e"), # 秒行 +1 → 保存 + ("clock_noclock", "dddd ee uruuruuue"), # 同 clock 键序 + --noclock + ("reset", "dddd ede uruuruuue"), + ("reset_2nd", "dddd ede uruuruuue e"), + ("reset_done", "dddd ede uruuruuue ee"), + ("reset_ro", "dddd ede e"), + ("clear", "dddd eddde uruuruuue"), + ("clear_2nd", "dddd eddde uruuruuue e"), + ("clear_done", "dddd eddde uruuruuue ee"), + ("clear_ro", "dddd eddde e"), ] # 文本级断言表:页名 -> [(名称, 期望串, y 带, 是否反显)] # 只断言确定的内容;标签与值分列断言(两列 x 不同,不能拼成一串)。 # 覆盖本工程新增/易缺字的关键文案(网口设置/地址/掩码/逗号/保存/OK 等)。 TEXTS = { + # ---- 道F task-3 装置维护三页。行槽沿用列表页口径(首行 24+14i), + # 操作页四行 y = 44/66/88/110(Drivers/BSP/UI/ui_page_action.c 的 AC_*_Y)。 + # 只断言"这一屏此刻该有什么",不拿参考图当断言(参考图会跟着实现一起错) + "clock": [ + ("标题", "时钟设置", (0, 24), False), + ("r0名", "年", (24, 38), True), ("r0值", "2026", (24, 38), True), + ("r1名", "月", (38, 52), False), ("r1值", "10", (38, 52), False), + ("r2名", "日", (52, 66), False), ("r2值", "25", (52, 66), False), + ("r3名", "时", (66, 80), False), ("r3值", "08", (66, 80), False), + ("r4名", "分", (80, 94), False), ("r4值", "00", (80, 94), False), + ("r5名", "秒", (94, 108), False), ("r5值", "00", (94, 108), False), + ("r6名", "保存", (108, 122), False), ("r6值", "ENTER", (108, 122), False), + ("页脚", "第 1 页,共 1 页", (140, 160), False), + ], + "clock_ro": [ + ("标题", "时钟设置", (0, 24), False), + ("r0名", "年", (24, 38), True), ("r0值", "2026", (24, 38), True), + ("r5名", "秒", (94, 108), False), ("r5值", "00", (94, 108), False), + ("页脚", "第 1 页,共 1 页", (140, 160), False), + ], + "clock_edit": [ + ("标题", "时钟设置", (0, 24), False), + ("r0名", "年", (24, 38), True), + ], + "clock_save": [ + ("标题", "时钟设置", (0, 24), False), + ("r0名", "年", (24, 38), False), ("r0值", "2026", (24, 38), False), + ("r5名", "秒", (94, 108), False), ("r5值改后", "01", (94, 108), False), + ("r6名", "保存", (108, 122), True), ("r6值", "OK", (108, 122), True), + ], + "clock_noclock": [ + ("标题", "时钟设置", (0, 24), False), + ("无时钟徽标", "无时钟", (122, 140), False), + ("页脚未被徽标压住", "第 1 页,共 1 页", (140, 160), False), + ("r0名", "年", (24, 38), True), ("r0值空", "--", (24, 38), True), + ("r6名", "保存", (108, 122), False), + ], + "reset": [ + ("标题", "强制复归", (0, 24), False), + ("问句", "确认复归保护?", (38, 60), False), + ("观测行", "保护动作", (82, 102), False), + ("键提示", "ENTER=确认 ESC=取消", (104, 126), False), + ], + "reset_2nd": [ + ("标题", "强制复归", (0, 24), False), + ("问句", "确认复归保护?", (38, 60), False), + ("二次确认", "二次确认", (60, 82), False), + ("观测行", "保护动作", (82, 102), False), + ], + "reset_done": [ + ("标题", "强制复归", (0, 24), False), + ("结果行", "已请求复归", (38, 60), False), + ("观测行归零", "保护动作", (82, 102), False), + ("返回提示", "ENTER=返回", (104, 126), False), + ], + "reset_ro": [ + ("标题", "强制复归", (0, 24), False), + ("问句", "确认复归保护?", (38, 60), False), + ("只读态标", "LOCK", (60, 82), False), + ("只读提示", "ESC=返回", (104, 126), False), + ], + "clear": [ + ("标题", "清除记录", (0, 24), False), + ("r0名", "运行记录", (24, 38), True), + ("r1名", "自检记录", (38, 52), False), + ("r3名", "保护告警", (66, 80), False), + ("r5名", "操作记录", (94, 108), False), + ("r6名", "遥控记录", (108, 122), False), + ("页脚", "第 1 页,共 1 页", (140, 160), False), + ], + "clear_2nd": [ + ("标题", "清除记录", (0, 24), False), + ("确认问", "确认?", (24, 38), True), + ], + "clear_done": [ + ("标题", "清除记录", (0, 24), False), + ("结果", "OK", (24, 38), True), + ("页脚", "第 1 页,共 1 页", (140, 160), False), + ], + "clear_ro": [ + ("标题", "清除记录", (0, 24), False), + ("r0名", "运行记录", (24, 38), True), + ("r6名", "遥控记录", (108, 122), False), + ], # v2.0(道E task-15 回炉):6 行 Flash 字面量 -> 8 行真值;标签去全角冒号(要给值列 # 让出 MAC 的 102 px),行槽 = 38 + 13i。值全部来自模拟器固定帧(simulator/main.c 的 # sim_publish_data_frames)与 net_cfg 出厂副本 —— 逐行断言等于把这两处口径钉在像素侧。 @@ -383,6 +484,53 @@ TEXTS = { # · 徽标只属于整定态(可改值但判据停走),不属于只读态,也不属于四个查看页; # · 只读态没有"保存"这件事(无 commit → 组件不画保存行)。 ABSENT = { + # ---- 道F task-3:反向断言钉的是"规矩本身",不依赖参考图 ---- + # 1970 那条是本页最值钱的反向断言:记录侧的占位串一旦漏进时钟页, + # 用户一次"照抄保存"就把 1970 写进 RTC(口径见 ui_page_clock.c 的 CK_INVAL 注) + "clock_noclock": [ + ("无效时标不显 1970", "1970", (0, 160), False), + ("无效时标不显旧帧值", "2026", (0, 160), False), + ("无效时标不显 08:00", "08:00", (0, 160), False), + # 徽标压页脚这件事**不用反向断言钉**:实测本脚本的字形匹配把 y 带判得 + # 比字面宽(徽标在 125..136、页脚在 144..155,写 ("无时钟", (140,160)) 反 + # 而报"出现了")——拿它当"位置不重叠"的证据会假红。真正的防线在 TEXTS 那 + # 一侧:"页脚未被徽标压住"这条正向断言在压字的版本上实测过不了(就是 + # 上一轮那次 FAIL),它才是这条位置规矩的机器防线。 + ], + "clock": [ + ("有效时标不该挂无时钟徽标", "无时钟", (122, 140), False), + ], + "clock_ro": [ + ("只读页没有保存这件事", "保存", (0, 140), False), + ], + "clock_save": [ + ("保存成功后保存行不再提示 ENTER", "ENTER", (108, 122), True), + ], + # 操作页 hide_footer=1:页脚/页码都不该出现(出现 = 描述符没关掉内置区块) + "reset": [ + ("操作页无页脚", "第 1 页", (138, 160), False), + ("未确认前不投递", "已请求复归", (0, 160), False), + ], + "reset_2nd": [ + ("停在二次确认就没投递", "已请求复归", (0, 160), False), + ("操作页无页脚", "第 1 页", (138, 160), False), + ], + "reset_done": [ + ("回报后问句让位", "确认复归保护?", (38, 60), False), + ], + "reset_ro": [ + ("只读不进二次确认", "二次确认", (0, 160), False), + ("只读一条都不投", "已请求复归", (0, 160), False), + ("只读不显示动作结果", "OK", (60, 82), False), + ], + "clear": [ + ("没按过就不该有确认问", "确认?", (0, 160), False), + ("没投递就不该有结果词", "OK", (0, 140), False), + ], + "clear_ro": [ + ("只读不进确认态", "确认?", (0, 160), False), + ("只读不投递", "WAIT", (0, 160), False), + ], "sparam_ro": [ ("只读页无未标定徽标", "未标定", (0, 24), False), ("只读页无保存行", "保存", (0, 140), False), @@ -416,7 +564,15 @@ STRIDE = W // 8 # I1:20 字节/行 POOL_AFTER_ROUNDTRIP = 5448 -def run_sim(seq, out, cwd): +# 每页额外的模拟器旗标(道F task-3):clock_noclock 需要一台"墙钟无效"的机器, +# 而 rt 帧只有 --noclock 这一条路能装出来(真机侧对应"RTC 异常/未对时")。 +# 不为它另造一套假数据:假的是"时钟有没有"这件事,页面与显示链全是真的。 +EXTRA = { + "clock_noclock": ["--noclock"], +} + + +def run_sim(seq, out, cwd, extra=()): """在指定工作目录里跑一次无头截图。 模拟器按**工作目录**读写 `sim_config.ini`:目录里残留一份配置(尤其 @@ -424,7 +580,7 @@ def run_sim(seq, out, cwd): 拒绝保存 → 网口设置页保存行显示 ERR → 与"工厂默认态"的黄金参考对不上。 因此一律在空临时目录里跑,回到"无配置文件 = 工厂默认"这一确定态。 """ - r = subprocess.run([str(EXE), "--keys", seq, out], capture_output=True, + r = subprocess.run([str(EXE), "--keys", seq, out] + list(extra), capture_output=True, cwd=str(cwd)) if r.returncode != 0: raise RuntimeError("lvgl_sim --keys %r rc=%d: %s" @@ -589,7 +745,7 @@ def main(): shots = {} for name, seq in PAGES: shot = os.path.join(tmp, "shot_%s.bin" % name) - run_sim(seq, shot, tmp) + run_sim(seq, shot, tmp, EXTRA.get(name, ())) shots[name] = open(shot, "rb").read() n_pos = 0 diff --git a/simulator/tests/ref/shot_clear.bin b/simulator/tests/ref/shot_clear.bin new file mode 100644 index 00000000..54c26dce Binary files /dev/null and b/simulator/tests/ref/shot_clear.bin differ diff --git a/simulator/tests/ref/shot_clear_2nd.bin b/simulator/tests/ref/shot_clear_2nd.bin new file mode 100644 index 00000000..995bfe8b Binary files /dev/null and b/simulator/tests/ref/shot_clear_2nd.bin differ diff --git a/simulator/tests/ref/shot_clear_done.bin b/simulator/tests/ref/shot_clear_done.bin new file mode 100644 index 00000000..67a18121 Binary files /dev/null and b/simulator/tests/ref/shot_clear_done.bin differ diff --git a/simulator/tests/ref/shot_clear_ro.bin b/simulator/tests/ref/shot_clear_ro.bin new file mode 100644 index 00000000..68bbe2af Binary files /dev/null and b/simulator/tests/ref/shot_clear_ro.bin differ diff --git a/simulator/tests/ref/shot_clock.bin b/simulator/tests/ref/shot_clock.bin new file mode 100644 index 00000000..1cff8c8e Binary files /dev/null and b/simulator/tests/ref/shot_clock.bin differ diff --git a/simulator/tests/ref/shot_clock_edit.bin b/simulator/tests/ref/shot_clock_edit.bin new file mode 100644 index 00000000..44213124 Binary files /dev/null and b/simulator/tests/ref/shot_clock_edit.bin differ diff --git a/simulator/tests/ref/shot_clock_noclock.bin b/simulator/tests/ref/shot_clock_noclock.bin new file mode 100644 index 00000000..0c77396a Binary files /dev/null and b/simulator/tests/ref/shot_clock_noclock.bin differ diff --git a/simulator/tests/ref/shot_clock_ro.bin b/simulator/tests/ref/shot_clock_ro.bin new file mode 100644 index 00000000..d842c116 Binary files /dev/null and b/simulator/tests/ref/shot_clock_ro.bin differ diff --git a/simulator/tests/ref/shot_clock_save.bin b/simulator/tests/ref/shot_clock_save.bin new file mode 100644 index 00000000..ecd634a1 Binary files /dev/null and b/simulator/tests/ref/shot_clock_save.bin differ diff --git a/simulator/tests/ref/shot_reset.bin b/simulator/tests/ref/shot_reset.bin new file mode 100644 index 00000000..cc7c2212 Binary files /dev/null and b/simulator/tests/ref/shot_reset.bin differ diff --git a/simulator/tests/ref/shot_reset_2nd.bin b/simulator/tests/ref/shot_reset_2nd.bin new file mode 100644 index 00000000..a10dc0b5 Binary files /dev/null and b/simulator/tests/ref/shot_reset_2nd.bin differ diff --git a/simulator/tests/ref/shot_reset_done.bin b/simulator/tests/ref/shot_reset_done.bin new file mode 100644 index 00000000..34854bd0 Binary files /dev/null and b/simulator/tests/ref/shot_reset_done.bin differ diff --git a/simulator/tests/ref/shot_reset_ro.bin b/simulator/tests/ref/shot_reset_ro.bin new file mode 100644 index 00000000..ebdfd55e Binary files /dev/null and b/simulator/tests/ref/shot_reset_ro.bin differ diff --git a/test/host/ui_data/ui_data_test.c b/test/host/ui_data/ui_data_test.c index 98108e94..6f2cb806 100644 --- a/test/host/ui_data/ui_data_test.c +++ b/test/host/ui_data/ui_data_test.c @@ -60,6 +60,21 @@ * 已生效的前几项不回滚(prot_set 无回滚接口,谎称整批未写更坏) * TC-UIDATA-021 v2 帧扩展:UI_DATA_VER == 2、既有字段偏移逐字节见证(只允许尾 * 追加)、cal_block 两种态(未标定/已标定)整帧往返 + * ── 以下 5 组 = 装置维护通道与时钟换算(道F task-3, + * docs/任务规划/UI补齐.md §2 第 3 行「装置维护 3 页」的地基)── + * TC-UIDATA-022 时钟换算与"无效值拒收":12 条锚点正向换算 + 反函数互逆 + + * **时区反证**(不得含 28800 偏移)+ 逐月天数(闰/平)+ 非法组合 + * 逐条 -2 + epoch 0/越域反算 -2(1970 绝不被反算出来) + * TC-UIDATA-023 时标串 parse/format:正常串、空串(无效时钟)、记录侧占位串 + * "1970-01-01 08:00:00" 必须解析失败、分隔位/长度/尾字符/型对值非法 + * TC-UIDATA-024 跨层编号顺序契约:UI_W_REC_LOG_* 逐字等于 syslog_id_t 枚举序 + + * UI 侧缓冲容量与 SYSLOG 契约同宽 + UI_DATA_CLEAR_ST_* 与 + * SYSLOG_CLEAR_* 逐值相等 + 面板两条留痕串各自不超描述上限 + * ("与远端串不同"不在这里判:字面量互比是恒真空断言,被 + * test-cppcheck 抓过,真判据落在 TC-UIDATA-026s) + * TC-UIDATA-025 维护通道未注入防御:六口安全返回 + 残表整表拒绝且不覆盖好表 + * TC-UIDATA-026 注入后转发言行:epoch 0 哨兵不下发、reset_req 自行投操作记录=0 次 + * (谁触发谁投,防"远端 + 本地"双投)、clear 状态码直通不改码 * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 @@ -84,6 +99,8 @@ #include "ui_w_soe.c" #include "soe_rec.h" /* soe_type_t:事件类型编号真源 */ #include "prot_elm.h" /* prot_elm_id_t:元件编号真源 */ +#include "sys_log.h" /* syslog_id_t / SYSLOG_CLEAR_*:记录编号与清除状态真源 */ +#include "ui_w_records.h" /* UI_W_REC_LOG_*:UI 侧记录源编号(同一 TU 里核对顺序契约) */ /* ===================== 断言框架 ===================== */ @@ -886,6 +903,396 @@ static void tc_param_frame_v2(void) CHECK(got.cal_block == 0u, "TC-UIDATA-021j zero-initialized frame means not blocked"); } +/* ============ 装置维护写通道与时钟换算(道F task-3,UI 补齐 §2 第 3 行)============ */ + +/* + * 维护执行侧桩件:只回答"哪个口被调了几次、最后一次收到什么",不做任何业务判断。 + * 计数而不是布尔:多处偷投同一条操作记录(本道最怕的"远端 + 本地各投一次") + * 只有靠次数才照得出来。 + */ +static int s_stub_clock_rc; +static int s_stub_clock_calls; +static uint32_t s_stub_clock_last; +static int s_stub_reset_rc; +static int s_stub_reset_calls; +static uint32_t s_stub_active; +static int s_stub_note_calls; +static char s_stub_note_last[32]; +static int s_stub_clear_calls; +static uint8_t s_stub_clear_last_id; +static int s_stub_clear_state_calls; +static uint8_t s_stub_clear_state_id; +static int s_stub_clear_state_rc; + +static int stub_clock_set(uint32_t a_epoch) +{ + s_stub_clock_calls++; + s_stub_clock_last = a_epoch; + return s_stub_clock_rc; +} + +static int stub_reset_req(void) +{ + s_stub_reset_calls++; + return s_stub_reset_rc; +} + +static uint32_t stub_active_count(void) +{ + return s_stub_active; +} + +static void stub_oper_note(const char *a_desc) +{ + s_stub_note_calls++; + if (a_desc != NULL) { + (void)strncpy(s_stub_note_last, a_desc, sizeof(s_stub_note_last) - 1u); + s_stub_note_last[sizeof(s_stub_note_last) - 1u] = 0; + } +} + +static int stub_clear_req(uint8_t a_id) +{ + s_stub_clear_calls++; + s_stub_clear_last_id = a_id; + return 0; +} + +static int stub_clear_state(uint8_t a_id) +{ + s_stub_clear_state_calls++; + s_stub_clear_state_id = a_id; + return s_stub_clear_state_rc; +} + +static void stub_reset_all(void) +{ + s_stub_clock_rc = 0; + s_stub_clock_calls = 0; + s_stub_clock_last = 0u; + s_stub_reset_rc = 0; + s_stub_reset_calls = 0; + s_stub_active = 0u; + s_stub_note_calls = 0; + s_stub_note_last[0] = '\0'; + s_stub_clear_calls = 0; + s_stub_clear_last_id = 0u; + s_stub_clear_state_calls = 0; + s_stub_clear_state_id = 0u; + s_stub_clear_state_rc = UI_DATA_CLEAR_ST_NONE; +} + +static const ui_data_maint_ops_t s_maint_stub = { + stub_clock_set, stub_reset_req, stub_active_count, + stub_oper_note, stub_clear_req, stub_clear_state +}; + +/* 故意缺一条(clear_state 为 NULL)的残表:注入必须整表拒绝,不能半途接住 */ +static const ui_data_maint_ops_t s_maint_broken = { + stub_clock_set, stub_reset_req, stub_active_count, + stub_oper_note, stub_clear_req, NULL +}; + +/* 日历用例:字段 → 期望秒(期望值由独立实现(Python/JS 格里历)算出,不自我复制) */ +typedef struct { + uint16_t y; + uint8_t mo; + uint8_t d; + uint8_t h; + uint8_t mi; + uint8_t s; + uint32_t epoch; +} clock_case_t; + +static const clock_case_t k_clock_cases[] = { + { 2000u, 1u, 1u, 0u, 0u, 0u, 946684800u }, /* 年域下限(RTC 年字段起点) */ + { 2000u, 2u, 29u, 0u, 0u, 0u, 951782400u }, /* 千禧闰年 2/29 */ + { 2000u, 3u, 1u, 0u, 0u, 0u, 951868800u }, /* 闰日次日:月序跨界 */ + { 2001u, 2u, 28u, 23u, 59u, 59u, 983404799u }, /* 平年 2 月末 + 当日最大秒 */ + { 2004u, 2u, 29u, 12u, 0u, 0u, 1078056000u }, /* 4 年闰规则 */ + { 2016u,12u, 31u, 0u, 0u, 0u, 1483142400u }, /* 年末 */ + { 2020u, 6u, 15u, 6u, 30u, 45u, 1592202645u }, /* 任意中段 */ + { 2024u, 2u, 29u, 12u, 34u, 56u, 1709210096u }, /* 近一个闰日 */ + { 2025u, 2u, 28u, 0u, 0u, 1u, 1740700801u }, /* 平年 2/28 */ + { 2026u,10u, 25u, 8u, 0u, 0u, 1792915200u }, /* 模拟器固定帧那一刻 */ + { 2037u,12u, 31u, 23u, 59u, 59u, 2145916799u }, /* 上界前一天 */ + { 2038u, 1u, 19u, 3u, 14u, 7u, 2147483647u } /* uint32 秒域上界 = 0x7FFFFFFF */ +}; + +/* + * TC-UIDATA-022 时钟换算与"无效值拒收" + * a) 12 条锚点正向换算(期望值来自独立格里历实现,不是本函数的自我复述) + * b) 反函数逐条回原文(互逆性:屏上显示的时间 = 存进去的时间,不多不少一小时) + * c) **时区反证**:2026-10-25 08:00:00 必须是 1792915200,而不是它 +28800; + * 本工程墙钟 epoch 域就是本地时间,谁在这里补了 8 小时,模拟器照样自洽、 + * 只有真机上"屏上输 08:00、存进去 16:00"(App/task/ui_task.c 的 SOE 时标注记 + * 是同一条纪律的另一处防线) + * d) 逐月天数(含闰年 2 月 29 / 平年 2 月 28 / 出域月返 0) + * e) 非法组合逐条拒(2 月 30、非闰 2 月 29、月 0/13、日 0/32、时 24、分秒 60、 + * 年 1999/2039),且 -2 与"越能力上界"的 -3 可分 + * f) 边界外秒域反算(epoch 0 与 0x80000000 都拒;0 是记录侧占位起点,绝不能 + * 被反算成 1970 显示到屏上) + */ +static void tc_clock_convert(void) +{ + ui_data_clock_t t; + uint32_t e; + uint32_t i; + int rc; + int ok_fwd = 1; + int ok_rev = 1; + int n = (int)(sizeof(k_clock_cases) / sizeof(k_clock_cases[0])); + + for (i = 0u; i < (uint32_t)n; i++) { + (void)memset(&t, 0, sizeof(t)); + t.year = k_clock_cases[i].y; + t.month = k_clock_cases[i].mo; + t.day = k_clock_cases[i].d; + t.hour = k_clock_cases[i].h; + t.minute = k_clock_cases[i].mi; + t.second = k_clock_cases[i].s; + e = 0u; + rc = ui_data_clock_to_epoch(&t, &e); + if ((rc != 0) || (e != k_clock_cases[i].epoch)) { + ok_fwd = 0; + printf(" case %u: rc=%d got=%lu want=%lu\n", (unsigned)i, rc, + (unsigned long)e, (unsigned long)k_clock_cases[i].epoch); + } + (void)memset(&t, 0xFF, sizeof(t)); + rc = ui_data_clock_from_epoch(k_clock_cases[i].epoch, &t); + if ((rc != 0) || (t.year != k_clock_cases[i].y) || (t.month != k_clock_cases[i].mo) || + (t.day != k_clock_cases[i].d) || (t.hour != k_clock_cases[i].h) || + (t.minute != k_clock_cases[i].mi) || (t.second != k_clock_cases[i].s)) { + ok_rev = 0; + printf(" rev case %u: rc=%d got=%u-%u-%u %u:%u:%u\n", (unsigned)i, rc, + (unsigned)t.year, (unsigned)t.month, (unsigned)t.day, + (unsigned)t.hour, (unsigned)t.minute, (unsigned)t.second); + } + } + CHECK(ok_fwd, "TC-UIDATA-022a fields to epoch over 12 anchors"); + CHECK(ok_rev, "TC-UIDATA-022b epoch to fields is the exact inverse"); + + /* 时区反证:本函数不得含任何时区偏移 */ + (void)memset(&t, 0, sizeof(t)); + t.year = 2026u; t.month = 10u; t.day = 25u; t.hour = 8u; t.minute = 0u; t.second = 0u; + e = 0u; + (void)ui_data_clock_to_epoch(&t, &e); + CHECK(e == 1792915200u, "TC-UIDATA-022c no timezone shift baked in (local epoch domain)"); + CHECK(e != (1792915200u + 28800u), "TC-UIDATA-022c2 must not be plus 8 hours"); + + /* 逐月天数(闰/平两年 + 出域月) */ + { + static const uint8_t k_leap[12] = { 31u,29u,31u,30u,31u,30u,31u,31u,30u,31u,30u,31u }; + static const uint8_t k_flat[12] = { 31u,28u,31u,30u,31u,30u,31u,31u,30u,31u,30u,31u }; + int ok_dim = 1; + for (i = 1u; i <= 12u; i++) { + if (ui_data_clock_days_in_month(2000u, (uint8_t)i) != k_leap[i - 1u]) ok_dim = 0; + if (ui_data_clock_days_in_month(2025u, (uint8_t)i) != k_flat[i - 1u]) ok_dim = 0; + } + CHECK(ok_dim, "TC-UIDATA-022d days in month (leap 2000 / flat 2025)"); + CHECK(ui_data_clock_days_in_month(2000u, 0u) == 0u, "TC-UIDATA-022d2 month 0 means invalid"); + CHECK(ui_data_clock_days_in_month(2000u, 13u) == 0u, "TC-UIDATA-022d3 month 13 means invalid"); + } + + /* 非法组合逐条:一律 -2(输入错),不是 -3(能力边界) */ + { + static const clock_case_t k_bad[] = { + { 2025u, 2u, 29u, 0u, 0u, 0u, 0u }, /* 非闰年 2/29 */ + { 2026u, 2u, 30u, 0u, 0u, 0u, 0u }, /* 2 月 30 日 */ + { 2026u, 4u, 31u, 0u, 0u, 0u, 0u }, /* 4 月无 31 日 */ + { 2026u, 0u, 1u, 0u, 0u, 0u, 0u }, /* 月 0 */ + { 2026u,13u, 1u, 0u, 0u, 0u, 0u }, /* 月 13 */ + { 2026u,10u, 0u, 0u, 0u, 0u, 0u }, /* 日 0 */ + { 2026u,10u, 32u, 0u, 0u, 0u, 0u }, /* 日 32 */ + { 2026u,10u, 25u,24u, 0u, 0u, 0u }, /* 时 24 */ + { 2026u,10u, 25u, 0u,60u, 0u, 0u }, /* 分 60 */ + { 2026u,10u, 25u, 0u, 0u,60u, 0u }, /* 秒 60 */ + { 1999u, 1u, 1u, 0u, 0u, 0u, 0u }, /* 年 < RTC 年域 */ + { 2039u, 1u, 1u, 0u, 0u, 0u, 0u } /* 年 > uint32 秒域 */ + }; + int ok_bad = 1; + for (i = 0u; i < (uint32_t)(sizeof(k_bad) / sizeof(k_bad[0])); i++) { + /* 逐字段搬(两种结构体尺寸不同,靠强转按字节拷是给自己埋雷) */ + t.year = k_bad[i].y; + t.month = k_bad[i].mo; + t.day = k_bad[i].d; + t.hour = k_bad[i].h; + t.minute = k_bad[i].mi; + t.second = k_bad[i].s; + rc = ui_data_clock_to_epoch(&t, &e); + if (rc != -2) { + ok_bad = 0; + printf(" bad case %u: rc=%d (want -2)\n", (unsigned)i, rc); + } + } + CHECK(ok_bad, "TC-UIDATA-022e invalid field combos rejected with -2"); + CHECK(ui_data_clock_to_epoch(NULL, &e) == -1, "TC-UIDATA-022e2 NULL input is -1"); + CHECK(ui_data_clock_to_epoch(&t, NULL) == -1, "TC-UIDATA-022e3 NULL output is -1"); + } + + /* 反算越界:0 是"墙钟无效"的占位起点,绝不能显示成 1970-01-01 */ + CHECK(ui_data_clock_from_epoch(0u, &t) == -2, "TC-UIDATA-022f epoch 0 rejected (never show 1970)"); + CHECK(ui_data_clock_from_epoch(946684799u, &t) == -2, "TC-UIDATA-022f2 epoch below 2000 rejected"); + CHECK(ui_data_clock_from_epoch(0x80000000u, &t) == -2, "TC-UIDATA-022f3 epoch above uint32 domain"); + CHECK(ui_data_clock_from_epoch(0u, NULL) == -1, "TC-UIDATA-022f4 NULL output is -1"); +} + +/* + * TC-UIDATA-023 时标串 parse / format + * 快照 rt.clock 的串型(19 字符)是页面预填的唯一来源;无效时钟时它是空串, + * 记录侧的占位串 "1970-01-01 08:00:00" 同样必须解析失败——否则页面会把 + * 占位串当真实时间预填,用户照抄保存就等于把 1970 写进 RTC。 + */ +static void tc_clock_string(void) +{ + ui_data_clock_t t; + char buf[24]; + uint32_t e; + + (void)memset(&t, 0, sizeof(t)); /* 解析失败按契约不写输出,先给个确定的初值 */ + e = 0u; + + CHECK(ui_data_clock_parse("2026-10-25 08:00:00", &t) == 0, "TC-UIDATA-023a parse well-formed ts"); + if (t.year == 2026u && t.month == 10u && t.day == 25u && t.hour == 8u) { + (void)ui_data_clock_to_epoch(&t, &e); + CHECK(e == 1792915200u, "TC-UIDATA-023b parsed ts maps to same epoch as fields"); + } else { + CHECK(0, "TC-UIDATA-023b parsed fields wrong"); + } + + CHECK(ui_data_clock_parse("", &t) == -2, "TC-UIDATA-023c empty ts (clock invalid) is -2"); + CHECK(ui_data_clock_parse("1970-01-01 08:00:00", &t) == -2, + "TC-UIDATA-023d record placeholder ts must not parse (no 1970 on screen)"); + CHECK(ui_data_clock_parse("2026-10-25T08:00:00", &t) == -2, "TC-UIDATA-023e wrong separator"); + CHECK(ui_data_clock_parse("2026-10-25 08:00:0", &t) == -2, "TC-UIDATA-023f short ts"); + CHECK(ui_data_clock_parse("2026-10-25 08:00:001", &t) == -2, "TC-UIDATA-023g trailing junk"); + CHECK(ui_data_clock_parse("2O26-10-25 08:00:00", &t) == -2, "TC-UIDATA-023h non digit year"); + CHECK(ui_data_clock_parse("2026-02-30 00:00:00", &t) == -2, "TC-UIDATA-023i shaped but invalid day"); + CHECK(ui_data_clock_parse("20260101 00:00:00", &t) == -2, "TC-UIDATA-023j missing dashes"); + CHECK(ui_data_clock_parse(NULL, &t) == -1, "TC-UIDATA-023k NULL ts is -1"); + CHECK(ui_data_clock_parse("2026-10-25 08:00:00", NULL) == -1, "TC-UIDATA-023l NULL out is -1"); + + (void)memset(buf, 0x7A, sizeof(buf)); + CHECK(ui_data_clock_format(&t, buf, sizeof(buf)) == 0, "TC-UIDATA-023m format ok into 24B buffer"); + CHECK(strcmp(buf, "2026-10-25 08:00:00") == 0, "TC-UIDATA-023n formatted string round-trips"); + (void)memset(buf, 0x7A, sizeof(buf)); + CHECK(ui_data_clock_format(&t, buf, 19u) == -1, "TC-UIDATA-023o capacity 19 refused (need 20)"); + CHECK(buf[0] == (char)0x7A, "TC-UIDATA-023p refused format writes nothing"); + CHECK(ui_data_clock_format(NULL, buf, sizeof(buf)) == -1, "TC-UIDATA-023q NULL in is -1"); +} + +/* + * TC-UIDATA-024 跨层编号顺序契约(Lead 裁定:UI 侧编号逐字等于 syslog_id_t 枚举序, + * 只准尾部追加)+ 清除状态码逐值相等 + 墙钟字段容量与串型一致。 + * 适配层始终显式映射(从不强转),这条断言钉的是"两套编号今天确实相等"这件事 + * 本身——枚举中间插值会安静地把事故读成告警,只有在这里才会响。 + */ +static void tc_record_codes(void) +{ + CHECK(UI_W_REC_LOG_RUN == (uint8_t)SYSLOG_RUN, "TC-UIDATA-024a RUN order"); + CHECK(UI_W_REC_LOG_SELFTEST == (uint8_t)SYSLOG_SELFTEST, "TC-UIDATA-024b SELFTEST order"); + CHECK(UI_W_REC_LOG_ACCIDENT == (uint8_t)SYSLOG_ACCIDENT, "TC-UIDATA-024c ACCIDENT order"); + CHECK(UI_W_REC_LOG_ALARM == (uint8_t)SYSLOG_ALARM, "TC-UIDATA-024d ALARM order"); + CHECK(UI_W_REC_LOG_START == (uint8_t)SYSLOG_START, "TC-UIDATA-024e START order"); + CHECK(UI_W_REC_LOG_OPER == (uint8_t)SYSLOG_OPER, "TC-UIDATA-024f OPER order"); + CHECK(UI_W_REC_LOG_YK == (uint8_t)SYSLOG_YK, "TC-UIDATA-024g YK order"); + CHECK(UI_W_REC_LOG_N == (uint8_t)SYSLOG_COUNT, "TC-UIDATA-024h count matches sentinel (append only below)"); + CHECK(UI_DATA_CLOCK_TS_LEN == SYSLOG_TS_LEN, "TC-UIDATA-024i ts field length same contract on both sides"); + CHECK(UI_W_REC_TS_LEN == (SYSLOG_TS_LEN + 1u), "TC-UIDATA-024j UI ts buffer covers SYSLOG_TS_LEN + NUL"); + CHECK(UI_W_REC_DESC_LEN == (SYSLOG_DESC_MAX + 1u), "TC-UIDATA-024k UI desc buffer covers max + NUL"); + CHECK(UI_DATA_CLEAR_ST_NONE == SYSLOG_CLEAR_NONE, "TC-UIDATA-024l clear NONE value"); + CHECK(UI_DATA_CLEAR_ST_OK == SYSLOG_CLEAR_OK, "TC-UIDATA-024m clear OK value"); + CHECK(UI_DATA_CLEAR_ST_WAIT == SYSLOG_CLEAR_WAIT, "TC-UIDATA-024n clear WAIT value"); + CHECK(UI_DATA_CLEAR_ST_FAIL == SYSLOG_CLEAR_FAIL, "TC-UIDATA-024o clear FAIL value"); + /* 操作记录描述上限:本地/远端两条留痕串都必须装得下,超了会被投递侧静默截断 */ + CHECK(strlen("force reset by panel") <= (size_t)SYSLOG_DESC_MAX, + "TC-UIDATA-024p panel reset note fits desc limit"); + CHECK(strlen("clock set by panel") <= (size_t)SYSLOG_DESC_MAX, + "TC-UIDATA-024q panel clock note fits desc limit"); + /* 这里曾写 CHECK(strcmp("force reset by panel", "force reset") != 0, ...): + * 两个字面量互比 = 恒真,test-cppcheck 一眼抓出(钉口径的断言永不可能失败, + * 比根本没有断言更坏——它让人以为这条已经被管住了)。同一判据挪到 + * TC-UIDATA-026s:拿实际落进执行侧缓冲的串去比远端那条文案 */ +} + +/* + * TC-UIDATA-025 维护通道未注入时的防御:六个口一律安全返回、不崩、ready 返 0; + * 残表(某成员 NULL)整表拒绝且不覆盖已生效的好表——半途接住会在"按下才崩"的位置崩。 + */ +static void tc_maint_not_bound(void) +{ + (void)ui_data_maint_bind(NULL); + stub_reset_all(); + + CHECK(ui_data_maint_ready() == 0u, "TC-UIDATA-025a not bound before bind"); + CHECK(ui_data_maint_clock_set(1792915200u) == -1, "TC-UIDATA-025b clock_set not bound is -1"); + CHECK(ui_data_maint_reset_req() == -1, "TC-UIDATA-025c reset_req not bound is -1"); + CHECK(ui_data_maint_active_count() == 0u, "TC-UIDATA-025d active_count not bound is 0"); + CHECK(ui_data_maint_oper_note("x") == -1, "TC-UIDATA-025e oper_note not bound is -1"); + CHECK(ui_data_maint_clear_req(UI_W_REC_LOG_RUN) == -1, "TC-UIDATA-025f clear_req not bound is -1"); + CHECK(ui_data_maint_clear_state(UI_W_REC_LOG_RUN) == UI_DATA_CLEAR_ST_NONE, + "TC-UIDATA-025g clear_state not bound is NONE"); + CHECK(ui_data_maint_bind(&s_maint_broken) == -1, "TC-UIDATA-025h incomplete table refused"); + CHECK(ui_data_maint_ready() == 0u, "TC-UIDATA-025i refused table left channel unbound"); + CHECK(ui_data_maint_bind(&s_maint_stub) == 0, "TC-UIDATA-025j complete table accepted"); + CHECK(ui_data_maint_ready() == 1u, "TC-UIDATA-025k bound after accept"); + CHECK(ui_data_maint_bind(&s_maint_broken) == -1, "TC-UIDATA-025l broken table refused again"); + CHECK(ui_data_maint_ready() == 1u, "TC-UIDATA-025m refusal keeps the good table active"); +} + +/* + * TC-UIDATA-026 注入后的转发言行(页面侧全部判据的地基): + * - clock_set(0) 是本层保留的"未初始化"哨兵,必须挡在下发之前(桩计数为 0) + * - reset_req **不得**自己投操作记录:谁触发谁投,这条留痕属页面; + * 在转发层偷投 = 同一次操作两个真源,也正是"远端那条已投过"会被重复投的形态 + * - clear_state 返回值直通不改码(WAIT/FAIL 都是正常显示态) + * - oper_note(NULL) 不投递、不崩 + */ +static void tc_maint_forwarding(void) +{ + (void)ui_data_maint_bind(&s_maint_stub); + stub_reset_all(); + + CHECK(ui_data_maint_clock_set(0u) == -2, "TC-UIDATA-026a epoch 0 sentinel refused locally"); + CHECK(s_stub_clock_calls == 0, "TC-UIDATA-026b sentinel never reaches executor"); + s_stub_clock_rc = 0; + CHECK(ui_data_maint_clock_set(1792915200u) == 0, "TC-UIDATA-026c clock_set forwarded"); + CHECK((s_stub_clock_calls == 1) && (s_stub_clock_last == 1792915200u), + "TC-UIDATA-026d executor got the exact epoch"); + s_stub_clock_rc = -1; + CHECK(ui_data_maint_clock_set(1792915200u) == -1, "TC-UIDATA-026e executor failure propagates"); + + CHECK(ui_data_maint_reset_req() == 0, "TC-UIDATA-026f reset accepted"); + CHECK(s_stub_reset_calls == 1, "TC-UIDATA-026g executor reset called once"); + CHECK(s_stub_note_calls == 0, "TC-UIDATA-026h forwarding layer must NOT post the oper record"); + (void)ui_data_maint_oper_note("force reset by panel"); + CHECK((s_stub_note_calls == 1) && (strcmp(s_stub_note_last, "force reset by panel") == 0), + "TC-UIDATA-026i page-side note reaches executor verbatim"); + /* 面板这条与远端 YK11 那条(App/protocol/iec104/iec104_pts.c 投的 + * "force reset")必须是不同串,否则审计面再也分不出"主站下发的"还是 + * "现场有人按了面板"。比的是执行侧缓冲里的实际内容,不是两个常量互比 */ + CHECK(strcmp(s_stub_note_last, "force reset") != 0, + "TC-UIDATA-026s posted note differs from the remote YK11 wording"); + CHECK(ui_data_maint_oper_note(NULL) == -1, "TC-UIDATA-026j NULL note refused"); + CHECK(s_stub_note_calls == 1, "TC-UIDATA-026k NULL note not posted"); + s_stub_reset_rc = -1; + CHECK(ui_data_maint_reset_req() == -1, "TC-UIDATA-026l rejected reset propagates"); + + s_stub_active = 3u; + CHECK(ui_data_maint_active_count() == 3u, "TC-UIDATA-026m active count forwarded (proof of reset)"); + + CHECK((ui_data_maint_clear_req(UI_W_REC_LOG_YK) == 0) && (s_stub_clear_last_id == UI_W_REC_LOG_YK), + "TC-UIDATA-026n clear req id passed through unchanged"); + s_stub_clear_state_rc = UI_DATA_CLEAR_ST_WAIT; + CHECK(ui_data_maint_clear_state(UI_W_REC_LOG_OPER) == UI_DATA_CLEAR_ST_WAIT, + "TC-UIDATA-026o WAIT passed through (normal state, not failure)"); + s_stub_clear_state_rc = UI_DATA_CLEAR_ST_FAIL; + CHECK(ui_data_maint_clear_state(UI_W_REC_LOG_OPER) == UI_DATA_CLEAR_ST_FAIL, + "TC-UIDATA-026p FAIL passed through unchanged"); + CHECK(s_stub_clear_state_id == UI_W_REC_LOG_OPER, "TC-UIDATA-026q state queried the asked id"); + CHECK(s_stub_clear_state_calls == 2, "TC-UIDATA-026r state queried once per call"); +} + /* ===================== main ===================== */ int main(void) @@ -930,6 +1337,16 @@ int main(void) tc_param_apply_fail(); printf("-- param frame v2 (cal_block) --\n"); tc_param_frame_v2(); + printf("-- clock convert --\n"); + tc_clock_convert(); + printf("-- clock ts string --\n"); + tc_clock_string(); + printf("-- record id order contract --\n"); + tc_record_codes(); + printf("-- maint channel: not bound --\n"); + tc_maint_not_bound(); + printf("-- maint channel: forwarding --\n"); + tc_maint_forwarding(); printf("\n=== UI Data Snapshot Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); diff --git a/ui_menu_tree.txt b/ui_menu_tree.txt index 7f5dd3df..e30ba7b3 100644 --- a/ui_menu_tree.txt +++ b/ui_menu_tree.txt @@ -40,10 +40,10 @@ 遥信防抖 #P 双点遥信 #P 装置维护 - 时钟设置 #P - 强制复归 #P + 时钟设置 #P #page=clock + 强制复归 #P #page=reset 手动录波 #P - 清除记录 #P + 清除记录 #P #page=clear 通讯参数 通讯设置 #P 网口设置 #P #page=net_cfg