diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 140877b9..3b9c0a8c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -639,7 +639,7 @@ repos: # 疑似 MSBuild 节点争用;此后 3 次干净树连跑均绿。判据:单次失败先复跑一次再看, # 连续两次失败才当真(不许直接 --no-verify 绕过)。 - id: ui-golden - name: UI 像素回归(菜单 8 状态 + 内容页 11 页 黄金参考) + name: UI 像素回归(菜单 8 状态 + 内容页 27 页 黄金参考) entry: python scripts/check_ui_golden.py language: system files: ^(Drivers/BSP/UI/|App/task/ui_task\.(c|h)$|simulator/(tests|sim|src)/|simulator/main\.c$|simulator/CMakeLists\.txt$|simulator/lv_conf\.h$|scripts/check_ui_golden\.py$|\.pre-commit-config\.yaml$) diff --git a/App/algo/prot_elm.c b/App/algo/prot_elm.c index 9a0b676c..ee0f2f9e 100644 --- a/App/algo/prot_elm.c +++ b/App/algo/prot_elm.c @@ -18,12 +18,20 @@ typedef struct { uint8_t over; /* 1 = 过量(越上限动作),0 = 欠量(越下限动作) */ uint8_t src; /* 测量源:0 = phasor 基波有效值,1 = freq 测频 */ prot_set_id_t en_id; /* 投退定值 */ + uint8_t trip_ctl; /* 跳闸投退定值项(PS_*_TRIP);PROT_ELM_NO_TRIP = + * 该元件没有这一项(告警类)→ 动作只出告警记录。 + * 用 uint8_t 而不是 prot_set_id_t 就是因为要能表达 + * "无此项"这个第 41 种取值(PS_ID_COUNT 是数组长度, + * 拿去喂 prot_set_get_ctl() 会越界读) */ prot_set_id_t set_id; /* 动作定值 */ prot_set_id_t dly_id; /* 延时定值(ms) */ prot_set_id_t ret_id; /* 返回系数定值(RET_OVER / RET_UNDER) */ const char *pprefix; /* 相量实例名前缀("C01"…);src=1 时为 0(不用) */ } pe_cfg_t; +/* k_pe[].trip_ctl 的"无此定值项"取值(见上方字段注释) */ +#define PROT_ELM_NO_TRIP 0xFFu + /* 测量源枚举(pe_cfg_t.src) */ #define PE_SRC_PHASOR 0u #define PE_SRC_FREQ 1u @@ -31,18 +39,24 @@ typedef struct { /* freq 无有效读数时给欠量元件的"必不启动"填充值(远大于任何欠量定值上限) */ #define PE_MEAS_BIG 1e30f -static const pe_cfg_t k_pe[PE_COUNT] = { - { "OC1-IA", 1u, PE_SRC_PHASOR, PS_OC1_EN, PS_OC1_CUR, PS_OC1_DLY, PS_RET_OVER, "C01" }, - { "OC2-IA", 1u, PE_SRC_PHASOR, PS_OC2_EN, PS_OC2_CUR, PS_OC2_DLY, PS_RET_OVER, "C01" }, - { "OC3-IA", 1u, PE_SRC_PHASOR, PS_OC3_EN, PS_OC3_CUR, PS_OC3_DLY, PS_RET_OVER, "C01" }, - { "OL-IA", 1u, PE_SRC_PHASOR, PS_OL_EN, PS_OL_CUR, PS_OL_DLY, PS_RET_OVER, "C01" }, - { "OV-UA", 1u, PE_SRC_PHASOR, PS_OV_EN, PS_OV_VOL, PS_OV_DLY, PS_RET_OVER, "C02" }, - { "UV-UA", 0u, PE_SRC_PHASOR, PS_UV_EN, PS_UV_VOL, PS_UV_DLY, PS_RET_UNDER, "C02" }, - { "3U0-UN", 1u, PE_SRC_PHASOR, PS_3U0_EN, PS_3U0_VOL, PS_3U0_DLY, PS_RET_OVER, "C03" }, +/* 列序 = pe_cfg_t 字段序(name, over, src, en_id, trip_ctl, set_id, dly_id, ret_id, + * pprefix)。⚠ trip_ctl 紧跟 en_id 插入,新增元件必须照这一列的口径填:有跳闸投退 + * 定值项的填 PS_*_TRIP,没有的填 PROT_ELM_NO_TRIP(= 该元件动作只出告警记录)。*/ +static const pe_cfg_t k_pe[PE_COUNT] = { /* ← 表头注释见上,行内列序与之一致 */ + { "OC1-IA", 1u, PE_SRC_PHASOR, PS_OC1_EN, PS_OC1_TRIP, PS_OC1_CUR, PS_OC1_DLY, PS_RET_OVER, "C01" }, + { "OC2-IA", 1u, PE_SRC_PHASOR, PS_OC2_EN, PS_OC2_TRIP, PS_OC2_CUR, PS_OC2_DLY, PS_RET_OVER, "C01" }, + { "OC3-IA", 1u, PE_SRC_PHASOR, PS_OC3_EN, PS_OC3_TRIP, PS_OC3_CUR, PS_OC3_DLY, PS_RET_OVER, "C01" }, + { "OL-IA", 1u, PE_SRC_PHASOR, PS_OL_EN, PS_OL_TRIP, PS_OL_CUR, PS_OL_DLY, PS_RET_OVER, "C01" }, + /* ↓ 三项 + 下面两项频率元件:参考系统的告警段(0x8027~0x802A)本就没有「跳闸 + 投退」这一档,g_prot_set_meta 也无对应项 → PROT_ELM_NO_TRIP,动作一律归告警。 + 这不是兜底猜测,是照实反映("哪些元件算跳闸"的真源始终是这张表 + 定值表)。*/ + { "OV-UA", 1u, PE_SRC_PHASOR, PS_OV_EN, PROT_ELM_NO_TRIP, PS_OV_VOL, PS_OV_DLY, PS_RET_OVER, "C02" }, + { "UV-UA", 0u, PE_SRC_PHASOR, PS_UV_EN, PROT_ELM_NO_TRIP, PS_UV_VOL, PS_UV_DLY, PS_RET_UNDER, "C02" }, + { "3U0-UN", 1u, PE_SRC_PHASOR, PS_3U0_EN, PROT_ELM_NO_TRIP, PS_3U0_VOL, PS_3U0_DLY, PS_RET_OVER, "C03" }, /* 过频/低频(L3-5,B8):测量量 = freq 层测得频率(Hz),返回系数独立 * (RET_OF/RET_UF——频率滞回带与电气量不同量级,勿共用 0.95/1.05) */ - { "OF-F", 1u, PE_SRC_FREQ, PS_OF_EN, PS_OF_HZ, PS_OF_DLY, PS_RET_OF, 0 }, - { "UF-F", 0u, PE_SRC_FREQ, PS_UF_EN, PS_UF_HZ, PS_UF_DLY, PS_RET_UF, 0 }, + { "OF-F", 1u, PE_SRC_FREQ, PS_OF_EN, PROT_ELM_NO_TRIP, PS_OF_HZ, PS_OF_DLY, PS_RET_OF, 0 }, + { "UF-F", 0u, PE_SRC_FREQ, PS_UF_EN, PROT_ELM_NO_TRIP, PS_UF_HZ, PS_UF_DLY, PS_RET_UF, 0 }, }; /* 运行时状态(picked/asserted 的对外快照 + 延时起点 + 相量实例索引缓存) */ @@ -259,6 +273,50 @@ uint32_t prot_elm_active_mask(void) { return mask; } +/* + * 函数功能:聚合启动位图 + * 入口参数:无 + * 返 回 值:bit i = 元件 i picked(越限电平,早于/等于 asserted) + * 限定条件:任意上下文(只读快照,无锁;与 prot_elm_active_mask 同口径) + * 函数说明:调用方是 protectTask 的「保护启动」记录边沿检测(task-10)。本函数 + * **只聚合电平**,不造边沿——边沿属观测者状态(s_prev_pick 在那一侧), + * 判据核里再存一份"上一拍"会让 eval 不再幂等。 + */ +uint32_t prot_elm_pick_mask(void) { + uint32_t mask = 0u; + uint32_t i; + + for (i = 0u; i < (uint32_t)PE_COUNT; i++) { + if (s_rt[i].picked != 0u) { + mask |= (1uL << i); + } + } + return mask; +} + +/* + * 函数功能:判定该元件动作上升沿应归哪一类记录(见 prot_elm.h 同号声明) + * 入口参数:id - 元件编号 prot_elm_id_t + * 返 回 值:PROT_REC_TRIP / PROT_REC_ALARM / PROT_REC_NONE(id 越界) prot_rec_kind_t + * 限定条件:任意上下文;只读,不改任何状态 + * 函数说明:判据唯一来源 = k_pe[id].trip_ctl 指向的那枚 PS_*_TRIP 投退定值。 + * trip_ctl == PROT_ELM_NO_TRIP 时**先返回告警**,绝不把 0xFF 喂给 + * prot_set_get_ctl()(那是数组越界读,会读到定值区外的 RAM—— + * 与"未标定拿 ADC 引脚电压当二次值"同类的静默错数据形状)。 + */ +prot_rec_kind_t prot_elm_record_kind(prot_elm_id_t id) { + uint8_t trip_ctl; + + if (id >= PE_COUNT) { + return PROT_REC_NONE; + } + trip_ctl = k_pe[(uint32_t)id].trip_ctl; + if (trip_ctl == (uint8_t)PROT_ELM_NO_TRIP) { + return PROT_REC_ALARM; + } + return (prot_set_get_ctl((prot_set_id_t)trip_ctl) != 0u) ? PROT_REC_TRIP : PROT_REC_ALARM; +} + /* * 函数功能:取实例名(日志用) * 入口参数:id - 元件编号 diff --git a/App/algo/prot_elm.h b/App/algo/prot_elm.h index 6d75fb5b..fb4dc1fd 100644 --- a/App/algo/prot_elm.h +++ b/App/algo/prot_elm.h @@ -100,6 +100,43 @@ void prot_elm_state(prot_elm_id_t id, prot_elm_state_t *out); /* 动作位图(bit i = 元件 i asserted),供 L4 出口 / 心跳日志 */ uint32_t prot_elm_active_mask(void); +/* 启动位图(bit i = 元件 i picked),供「保护启动」记录边沿(task-10,2026-10-26): + * picked 是越限滞回后的电平,早于(或同拍于)asserted——只看 active_mask 会让 + * "启动了但没到点"这一整段在记录里彻底消失,而它正是"保护启动"页要看的东西。 */ +uint32_t prot_elm_pick_mask(void); + +/* + * 元件动作边沿的记录归属(task-10「五类记录」)。 + * ⚠ 本枚举刻意**不**用 syslog_id_t:那会把 App/log 的头拽进 algo 层,而 + * App/algo/prot_elm.c 同时被 test/host/prot_dim 编进测试单元(那个运行器的 + * include 路径里没有 App/log,且不在本道写域)→ 一改就在别人的单测上爆错。 + * "元件 → 哪一类"是判据域事实(属主在本文件),"哪一类 → 哪个日志文件"是 + * 记录域映射(属主在 App/task/protect_task.c 的一行三元),两边各自单源。 + */ +typedef enum { + PROT_REC_TRIP = 0u, /* 跳闸型动作 → 事故记录 */ + PROT_REC_ALARM, /* 告警型动作 → 保护告警记录 */ + PROT_REC_NONE /* id 越界 → 调用方一条都不许落 */ +} prot_rec_kind_t; + +/* + * 函数功能:判定"该元件若发生动作上升沿,应归哪一类记录" + * 入口参数:id - 元件编号 prot_elm_id_t + * 返回值:PROT_REC_TRIP / PROT_REC_ALARM / PROT_REC_NONE(越界) prot_rec_kind_t + * 限定条件:任意上下文(只读 k_pe[] + prot_set RAM,不落任何状态) + * 函数说明:判据**唯一来源** = 该元件的跳闸投退定值项 g_prot_set_meta 里的 + * PS_*_TRIP(其枚举注释即"0 告警 / 1 跳闸")。元件→投退项的对应关系 + * 放在实例表 k_pe[] 的 trip_ctl 列(与 en_id/set_id/dly_id 同一张表, + * 新增元件只加一行);**本函数里没有第二份"哪些元件算跳闸"的名单**, + * 所以它不会与 g_prot_set_meta / k_pe[] / 整定视图门禁互相打架。 + * 过压/欠压/零序过压/过频/低频五项本就没有 TRIP 投退项(告警类)→ + * trip_ctl = PROT_ELM_NO_TRIP → 恒 PROT_REC_ALARM,这是照实反映, + * 不是兜底猜测。 + * 定值巡检闭锁(prot_set_locked)不影响本判定:闭锁态下元件会被复位、 + * 根本走不到动作上升沿,调用方无需在此重复判闭锁。 + */ +prot_rec_kind_t prot_elm_record_kind(prot_elm_id_t id); + /* 实例名(ROM 常量,日志用) */ const char *prot_elm_name(prot_elm_id_t id); diff --git a/App/app/ui_data_feed.c b/App/app/ui_data_feed.c index 877fad7c..cec6ef47 100644 --- a/App/app/ui_data_feed.c +++ b/App/app/ui_data_feed.c @@ -5,6 +5,11 @@ * (实时量 rt / 参数 par / 装置信息 info),经 Drivers/BSP/UI/ui_data.h * 的发布 API 推给 UI 层;本模块是快照的**唯一填充者**,不含任何布局、 * 格式化与 LVGL 调用(显示规格全在页面行表,同文档 §8) + * 写方向(道F task-1):同一条 tick 顺带把页面投递的定值整定请求落定—— + * ui_data_param_drain() 每轮取一条,执行动作经本文件的 s_param_ops + * (locked = prot_set_locked / check = g_prot_set_meta 上下限 / + * apply = prot_set_set_sealed)注入给 BSP;值域与落盘事实都留在 App + * 侧,BSP 只有队列与状态机(契约 §6.1"UI 不能直写"的落地形态) * 量纲诚实性:eng 字段按**真值**填——docs/任务规划/数据可信.md §2.6 把承诺边界定在 * "引脚电压",故未做两点标定前 eng = UI_DATA_ENG_PIN(值 = ADC 引脚电压 V, * 不是端子二次值);电气 8 路 adc_cal_flags() 的 VALID 全置位才升 @@ -15,6 +20,10 @@ * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-14) + * v1.1 2026-10-26 DTU 项目组 道F task-1:① tick 前置 ui_data_param_drain() + * (定值写路径出队 → 闭锁/越界预检 → set_sealed, + * 见 s_param_ops);② 参数帧尾填 cal_block(未标定 + * 闭锁徽标,三路口径同 protect_task.c,不用 eng) */ /* 头文件包含区 */ @@ -93,11 +102,18 @@ * (契约 docs/UI设计/UI_数据供给设计说明.md §9.4 第 1/2 条) */ typedef char feed_chk_yc_n_t[(UI_DATA_YC_N == (uint32_t)ADC_SEM_COUNT) ? 1 : -1]; typedef char feed_chk_set_n_t[(UI_DATA_SET_N == (uint32_t)PS_ID_COUNT) ? 1 : -1]; +/* 写路径注入表的 id 形参是 uint8_t(BSP 侧不透明编号宽度,位图只有 64 位): + * 定值项数一旦破 64/255,request 的 mask 与 check/apply 的编号都会静默截断, + * 故在写侧也钉一道(BSP 侧同一条守卫在 Drivers/BSP/UI/ui_data.c) */ +typedef char feed_chk_set_u8_t[((uint32_t)PS_ID_COUNT <= 64u) ? 1 : -1]; typedef char feed_chk_fuser_t[(UI_DATA_FUSER_LEN >= FTP_CFG_USER_MAX) ? 1 : -1]; /* 静态变量定义区 */ -/* 组装缓冲:三帧串行填充,共用一块存储(取最大者 = 参数帧),省两份 RAM */ +/* 组装缓冲:三帧串行填充,共用一块存储,省两份 RAM。 + * 实际最大者是**实时帧**(208 B > par 192 B > info),故给 par 尾追加字段 + * 只要不超过 rt 就一字节 RAM 都不多花(这条由主机单测 TC-UIDATA-021g/h 钉住, + * 尺寸一变这里就是唯一会撒谎的地方) */ static union { ui_data_rt_t rt; ui_data_par_t par; @@ -109,10 +125,17 @@ static union { static int8_t s_yc_phasor[ADC_SEM_COUNT]; static uint8_t s_map_done; -/* 发布节拍与参数变化检测状态 */ +/* 发布节拍与参数变化检测状态 + * par 帧有**三个各自独立**的变化源,任一变了就要重发一帧(详见 tick 里的门控注释): + * s_par_sum 定值改动(校验和) + * s_par_locked 定值闭锁(L5-1 自检失配,校验和不动) + * s_par_cal_block 未标定闭锁(卡 12 标定完成,校验和也不动) + * 后两个各占一字节——省下来就是"屏上状态与真值脱拍"这笔债 */ static uint32_t s_last_rt_ms; static uint32_t s_par_sum; static uint8_t s_par_sum_valid; +static uint8_t s_par_locked; +static uint8_t s_par_cal_block; static uint8_t s_first_done; /* 函数定义区 */ @@ -386,8 +409,96 @@ static void feed_fill_rt(ui_data_rt_t *p_snap, uint32_t tick_ms) { } } +/* ── 写路径(契约 §6.2):把 prot_set 的三个动作包成注入给 BSP 的函数表 ────── */ + /* - * 函数功能:组装参数快照(40 项定值 Q10 + 校验和 + 封存态 + 越界位图) + * 函数功能:写路径判定"定值闭锁中吗"(BSP 经 ui_data_param_ops_t 来问) + * 入口参数:无 + * 返 回 值:1 - 闭锁(定值校验和自检失败态,禁一切定值写入);0 - 可写 uint8_t + * 限定条件:uiTask 上下文(只读 prot_set 状态位) + * 函数说明:这里的"闭锁"是**定值闭锁**(= par.locked = YX31 遥信),**不是**未标定 + * 闭锁(= par.cal_block):后者仍可改定值、只是判据停走,两者在屏上不得 + * 互认(三词三源口径 docs/任务规划/数据可信.md §4.3 + 落地简报 §7-6) + */ +static uint8_t feed_param_locked(void) { + return prot_set_locked(); +} + +/* + * 函数功能:写路径整批预检——单项是否落在 meta 上下限内 + * 入口参数:a_id - 定值项编号(位图位序;uint8_t 是 BSP 侧不透明编号的宽度) + * a_q10 - 待写 Q10 定点值 + * 返 回 值:0 - 合法;-1 - 编号越界或值越界 int + * 限定条件:只读 g_prot_set_meta(ROM 表),任意上下文安全 + * 函数说明:上下限**只此一处取**——BSP 行表(Drivers/BSP/UI/ui_w_setview.c)是它的 + * 换算镜像,由 setview-check 钩子核一致;本函数再抄一份数字就是第二真相 + * ("查看合法、整定越界"两套边界就是这么来的) + */ +static int feed_param_check(uint8_t a_id, int32_t a_q10) { + if (a_id >= (uint8_t)PS_ID_COUNT) { + return -1; + } + if ((a_q10 < g_prot_set_meta[a_id].min) || (a_q10 > g_prot_set_meta[a_id].max)) { + return -1; + } + return 0; +} + +/* + * 函数功能:写路径落值——单项**原子**写入 + 重封校验和 + 置脏 + * 入口参数:a_id - 定值项编号;a_q10 - Q10 定点值 + * 返 回 值:0 - 已写入;-1 - 编号越界或 prot_set 拒写(原值不动) int + * 限定条件:uiTask 上下文;调用前 BSP 已整批预检通过(两条件与本函数一致) + * 函数说明:用 prot_set_set_sealed()(影子拷贝→改→重封→单字交换,**每项自带置脏**, + * 落盘由 defaultTask 50 ms 圈扫脏写 /prot_set.bin,本模块不碰 FatFs)。 + * ⚠ 不得改成 prot_set_set() + 批末 prot_set_seal():两条路径之间有 + * "已改未封存"窗口,prot_set.c 明令不得交错,撞上就被 L5-1 自检判成 + * "定值被非法改写"→ 整台保护闭锁 + */ +static int feed_param_apply(uint8_t a_id, int32_t a_q10) { + if (a_id >= (uint8_t)PS_ID_COUNT) { + return -1; + } + return prot_set_set_sealed((prot_set_id_t)a_id, a_q10); +} + +/* 注入给 ui_data_param_drain() 的执行侧实现(BSP 不保存副本,每次调用现传) */ +static const ui_data_param_ops_t s_param_ops = { + feed_param_locked, + feed_param_check, + feed_param_apply +}; + +/* + * 函数功能:未标定闭锁判定(整定页"未标定"徽标的数据源) + * 入口参数:无 + * 返 回 值:1 - 未标定闭锁(UA/UN/IA 三路至少一路标定 VALID 未置位);0 - 三路全 + * VALID(保护判据正在跑) uint8_t + * 限定条件:uiTask 上下文(adc_cal_flags 纯读,每 200 ms 一次,代价可忽略) + * 函数说明:判据与 App/task/protect_task.c 的 s_cal_locked() **逐字同构**——那是 + * 停走判据的真源,本函数只是把它搬到显示通道上;两处的例外通道也必须 + * 一起改(DTU_BENCH 台架旁路,仅本地台架自行定义、产品恒强制)。 + * ⚠ 已知重复:本仓没有导出该判据的公开 API(protect_task.h 只有线程 + * 入口),故这里镜像一份;将来 protect_task 导出访问器后应收敛成一处 + * (Lead 补充 A 明确本任务按此实现,不改 protect_task.*) + * ⚠ 不得用 rt.eng 反推:eng 要求电气 8 路全 VALID,V0.1 双转换器下 + * UB/UC/IB/IC/IN 永不置位 → eng 恒为引脚档,照它画徽标就永不解锁 + */ +static uint8_t feed_cal_block(void) { + /* 形态与 protect_task.c 同:用 #ifndef 把产品默认路径留在主分支(而非 #ifdef + * 双分支)——cppcheck 对未定义宏取真分支,双分支会把它引到旁路上报假信号 */ +#ifndef DTU_BENCH + if (((adc_cal_flags(ADC_SEM_UA) & ADC_CAL_F_VALID) == 0u) || + ((adc_cal_flags(ADC_SEM_UN) & ADC_CAL_F_VALID) == 0u) || + ((adc_cal_flags(ADC_SEM_IA) & ADC_CAL_F_VALID) == 0u)) { + return 1u; + } +#endif + return 0u; +} + +/* + * 函数功能:组装参数快照(40 项定值 Q10 + 校验和 + 封存态 + 越界位图 + 未标定闭锁) * 入口参数:p_snap - 目标帧(调用方已清零) ui_data_par_t * * sum - 本次发布的 prot_set_checksum() 值 uint32_t * 返回值:无 @@ -406,6 +517,9 @@ static void feed_fill_par(ui_data_par_t *p_snap, uint32_t sum) { /* 越界位图非 0 = 上一次整表装载/提交有越界项(prot_set_bad_mask 的既有口径), * 页面据此反显;为 0 时无意义位图不参与显示 */ p_snap->bad_valid = (p_snap->bad_mask != 0u) ? 1u : 0u; + /* v2 尾追加:未标定闭锁徽标(三路口径,与判据停走同源;与上面的 locked 是 + * 两件事——locked 禁编辑,cal_block 只说明"改了也不会动作") */ + p_snap->cal_block = feed_cal_block(); for (i = 0u; i < (uint8_t)PS_ID_COUNT; i++) { p_snap->q10[i] = prot_set_get((prot_set_id_t)i); } @@ -442,23 +556,40 @@ static void feed_fill_info(ui_data_info_t *p_snap, uint32_t tick_ms) { } /* - * 函数功能:装配节拍入口——实时帧与信息帧满 200 ms 发布一次,参数帧仅在校验和 - * 变化时发布;首轮立即发布(免启动初期出现一闪的 "--") + * 函数功能:装配节拍入口——先落定一条定值整定请求(写方向),再发布实时帧与 + * 信息帧(满 200 ms 一次)、参数帧(仅校验和变化时);首轮立即发布 + * (免启动初期出现一闪的 "--") * 入口参数:无 * 返回值:无 * 限定条件:uiTask 上下文,主循环每轮一次(UI 循环周期 10 ms) - * 函数说明:每轮未到期时代价 = 一次 osKernelGetTickCount + 两次减法比较; - * 差值法吸收 tick 回绕(同 App/algo/adc_health.h 的 now_ms 口径) + * 函数说明:每轮未到期时代价 = 一次 osKernelGetTickCount + 两次减法比较 + + * 一次空队列判断(drain 无请求时只是一次计数比较); + * 差值法吸收 tick 回绕(同 App/algo/adc_health.h 的 now_ms 口径)。 + * 写路径失败态(ERR_RANGE/ERR_LOCKED/ERR_BUSY)走屏上通道,不打日志 + * (每条 DBG 约 90 B Flash + 216 B 栈,AGENTS「日志不是免费的」) */ void ui_data_feed_tick(void) { - uint32_t now = osKernelGetTickCount(); - uint32_t sum = prot_set_checksum(); + uint32_t now; + uint32_t sum; uint8_t due_rt; + now = osKernelGetTickCount(); + if (s_map_done == 0u) { feed_map_phasor(); } + /* 写路径:先落定整定请求,**再**取校验和——本轮刚写进 prot_set 的定值就进 + * 本轮"校验和变化才发"那道门,页面保存行的 OK 与新值同一帧回来,不必多等 + * 一轮(10 ms)。顺序反了不出错,只是慢一轮。 + * 每轮最多处理一条(ui_data_param_drain 的限定条件):整定是人工低频事件, + * 一轮一条已经把最坏耗时钉住,不必排空整个队列 + * ⚠ 返回值必须显式丢弃:本函数不消费"处理了几条",但 bsp-err 的 + * consume-result 只认接住或 (void) 两种写法(实测编译器与 cppcheck 都不报) */ + (void)ui_data_param_drain(&s_param_ops); + + sum = prot_set_checksum(); + due_rt = 0u; if ((s_first_done == 0u) || ((uint32_t)(now - s_last_rt_ms) >= FEED_RT_PERIOD_MS)) { due_rt = 1u; @@ -475,13 +606,28 @@ void ui_data_feed_tick(void) { (void)ui_data_publish_info(&s_scr.info); } - /* 参数帧:校验和门控——定值变化是低频事件,不随 200 ms 白拷 184 B - * (docs/UI设计/UI_数据供给设计说明.md §5.2) */ - if ((s_par_sum_valid == 0u) || (sum != s_par_sum)) { - (void)memset(&s_scr, 0, sizeof(s_scr)); - feed_fill_par(&s_scr.par, sum); - (void)ui_data_publish_par(&s_scr.par); - s_par_sum = sum; - s_par_sum_valid = 1u; + /* 参数帧门控:定值变化是低频事件,不随 200 ms 白拷一帧(契约 §5.2)。 + * 但"校验和没变"不等于"页面该看的没变"——本帧里还有两个与校验和无关的事实: + * · locked(定值闭锁):自检失配时置位,校验和一字不变, + * 而整定页保存行要显示 LOCK 并禁编辑; + * · cal_block(未标定闭锁):卡 12 两点标定写完 [adc_cal] 的 valid 就翻转, + * 校验和同样不动,而标题上的"未标定"徽标要当场消失。 + * 只盯校验和,这两件事都到不了屏上(真机现象:标定做完了屏上徽标永远挂着, + * 直到某次定值改动才被顺带刷掉——那是把两件不相干的事当同步点)。 + * 代价:每轮多两次字节比较(无变化时零拷贝),换来屏上状态与真值同拍 */ + { + uint8_t locked_now = prot_set_locked(); + uint8_t cal_now = feed_cal_block(); + + if ((s_par_sum_valid == 0u) || (sum != s_par_sum) + || (locked_now != s_par_locked) || (cal_now != s_par_cal_block)) { + (void)memset(&s_scr, 0, sizeof(s_scr)); + feed_fill_par(&s_scr.par, sum); + (void)ui_data_publish_par(&s_scr.par); + s_par_sum = sum; + s_par_locked = locked_now; + s_par_cal_block = cal_now; + s_par_sum_valid = 1u; + } } } diff --git a/App/app/ui_data_feed.h b/App/app/ui_data_feed.h index 9ff8fcc0..1012e344 100644 --- a/App/app/ui_data_feed.h +++ b/App/app/ui_data_feed.h @@ -17,6 +17,8 @@ * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-14) + * v1.1 2026-10-26 DTU 项目组 道F task-1:tick 兼任定值写路径的落定节拍 + * (契约 §6.2;实现见 .c 的 s_param_ops) */ #ifndef UI_DATA_FEED_H @@ -27,15 +29,21 @@ extern "C" { #endif /* - * 函数功能:装配节拍入口——实时帧与信息帧满 200 ms 发布一次,参数帧仅在校验和 - * 变化时发布;首轮立即发布(免启动后出现一闪的 "--") + * 函数功能:装配节拍入口——先落定一条定值整定请求(写方向),再发布实时帧与 + * 信息帧(满 200 ms 一次)、参数帧(仅校验和变化时);首轮立即发布 + * (免启动后出现一闪的 "--") * 入口参数:无 * 返回值:无 - * 限定条件:uiTask 上下文,主循环每轮一次(UI 循环周期 10 ms) - * 函数说明:本函数每轮代价 = 一次 tick 差值比较 + 一次校验和比较;真正的取数 - * 与组装只在到期时发生(docs/UI设计/UI_数据供给设计说明.md §5.2)。 + * 限定条件:uiTask 上下文,主循环每轮一次(UI 循环周期 10 ms);页面"保存"按键 + * 与本页节拍同上下文,故写路径的投递/落定之间不需要任何锁 + * (Drivers/BSP/UI/ui_data.h 写路径节) + * 函数说明:本函数每轮代价 = 一次 tick 差值比较 + 一次校验和比较 + 一次 + * ui_data_param_drain()(队列空时只是一次计数判断);真正的取数与组装 + * 只在到期时发生(docs/UI设计/UI_数据供给设计说明.md §5.2)。 + * ⚠ drain 排在取校验和**之前**:本轮写入的定值要在本轮就被"校验和变化 + * 才发"那道门看见,页面才能在同一帧里同时显示 OK 与新值 * 内部零阻塞、零堆分配、零日志(日志每条约 90 B Flash + 216 B 栈, - * 200 ms 周期打不起) + * 200 ms 周期打不起);整定失败态走屏上通道,不打日志 */ void ui_data_feed_tick(void); diff --git a/App/log/README.md b/App/log/README.md index 80ea9e78..c1035650 100644 --- a/App/log/README.md +++ b/App/log/README.md @@ -7,7 +7,7 @@ | 文件 | 作用 | |---|---| -| `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录写入 `/log/run.log`·`selftest.log` | +| `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录,七类各一个 `/log` 文件(run / selftest / accid / alarm / start / oper / yk)| | `sys_log_mcu.c` | `sys_log` 的 MCU 端后端实现(NAND + FatFs) | | `console_log.c/h` | **控制台单写者日志**:多任务日志经队列汇入唯一写者,避免串口输出交错 | | `soe_rec.c/h` | **SOE 记录纯编解码**(B9):64B 定长记录(CP56Time2a 时标 + 类型 + 元件号 + 单调序号 + 值 + sum8 撕裂校验),零 I/O 零 OS 依赖,主机单测覆盖 | @@ -23,6 +23,67 @@ 内核启动后由 `syslog_drain` 惰性创建真队列并排空 - ⚠️ 写盘必须校验 `f_write` 返回的**字节数**(该 FatFs 移植在簇分配失败时返回 `FR_OK` + 短写, 不报错误码);追加写必须 `FA_WRITE|FA_OPEN_APPEND`(`FA_OPEN_APPEND` 单独用**不含写位**) +- **清除记录 = 请求 / 落地分离**:非写者上下文(uiTask 等)只能 `syslog_request_clear(id)` + 置一个标志(零盘触点、零卷锁),真正的 `f_unlink` 在 **defaultTask** 的 `syslog_drain()` + **尾部**执行——顺序也是判据的一部分:排在条目排空之后,否则本轮刚排空的旧记录会被 + 写回去、`syslog_count()` 不归零。结果读 `syslog_clear_state(id)` + (`NONE/OK/WAIT/FAIL`),事实复核读 `syslog_count()`。 + 落地函数 `syslog_clear_apply(id)` 是 MCU 后端专属(模拟器不实现 → 共享代码误调在 + 链接期就报错):卷锁一律 try,卷占用返 -1 且**不动原文件**;幂等只认 `FR_NO_FILE`, + `FR_NO_PATH` 如实返 -1 且**不做任何卷级动作**(不补目录、不重格式化) +- 删除的作用域 = 上表七个记录文件(`syslog_request_clear(id)` 逐 id 独立请求);取证文件 + `wdev.log` 与 `soe.log` 各有各的属主与通道,不在本 API 作用域,想清它们要另开 API。 + 清除成功会往**操作记录**里落一条 `clear <文件>`——但清 `oper.log` 自己时**刻意不投**, + 否则"我清除了记录"这条会把刚删空的文件又写成 1 条,直接砸掉「删除后 `syslog_count()` + 归零」这条判据(`TC-SYSLOG-133b` 钉住) +- ⚠ 宿主后端(`simulator/sim/sim_syslog.c`)与 MCU 端**判据相同、时序不同**:模拟器单线程, + 七类对应工作目录下七份宿主文件(`sim_accid.log` 等),`syslog_post_quiet()` 在本端 + 等价于 `syslog_post()`(宿主进程没有那条栈硬线,也没有队列与丢弃); + `syslog_request_clear()` 调用内就删完了,因此 `WAIT` 态在模拟器上永不出现 + (同 `syslog_post` 在两端"有队列 / 直写无队列"的差异) + +### 五类记录与「记录类型 = 日志 ID」(道F task-10,2026-10-26;task-11 记录 5 页按此做) + +**决策一:记录类型就是 `syslog_id_t` 本身,一类记录 = `/log` 下一个严格 8.3 短名的文件。** +64B 定长记录体里**没有类型字段位**:`[0..18]` 时标 / `[19]` 空格 / `[20..29]` epoch / +`[30]` 空格 / `[31..58]` 描述 / 尾部补零,这个布局被 UI 记录页与主机解码工具按**固定偏移** +读了了很久(run.log / selftest.log 是既有文件,既有读者)。塞类型字节要么挪走 epoch +(上电/重启分类就断),要么挪走描述(屏上少字),且两端读者同批改——所以 +"这条记录是什么类型"由**它在哪个文件里**回答。`TC-SYSLOG-136` 把这条钉成机器断言: +同一条内容在七类之间逐字节相同,记录体不编码类型。 + +**决策二:不动 `soe_type_t`(二进制 SOE 通道保持现有 8 型)。** 加 SOE 类型必须同步 UI 侧 +`ui_w_soe.c` 的类型名表(那是另一位队友的写域),而 SOE 页与这五页本就是两套读者 +(二进制解码器 vs 文本列表)。**别把新类型顺手加进 SOE**:那会同时造出 +"同一个事件两个真源"。 + +| 记录 | 日志 ID / 文件 | 容量上限 | 写入点 | 触发判据 | +|---|---|---|---|---| +| 事故 | `SYSLOG_ACCIDENT` / `accid.log` | 512 | `App/task/protect_task.c:181` | `asserted` 上升沿**且**该元件跳闸投退项 = 1 | +| 保护告警 | `SYSLOG_ALARM` / `alarm.log` | 1024 | 同文件 `184` | `asserted` 上升沿**且**跳闸投退项 = 0 或该元件无此项 | +| 保护启动 | `SYSLOG_START` / `start.log` | 1024 | 同文件 `176` | `picked` 上升沿(越限电平,早于/等于动作) | +| 操作 | `SYSLOG_OPER` / `oper.log` | 512 | `App/protocol/iec104/iec104_pts.c:574/602`、`App/task/default_task.c:242`、`App/log/sys_log_mcu.c:646`(宿主 `sim_syslog.c:245`) | 强制复归受理 / 软压板投退落地 / 定值**真落盘成功** / 清除记录成功 | +| 遥控 | `SYSLOG_YK` / `yk.log` | 256 | `App/protocol/iec104/iec104_pts.c:573/576/582/586/596/598/609` | 104 双点命令的选择段、执行段、非法 DPC、未知 IOA 各一条 | + +- **"哪些元件算跳闸"只有一个真源**:定值表 `g_prot_set_meta` 里的 `PS_*_TRIP`(枚举注释 + 即"0 告警 / 1 跳闸")+ 实例表 `k_pe[]` 的 `trip_ctl` 列。分类入口 + `prot_elm_record_kind()`(`App/algo/prot_elm.c`)里**没有第二份元件名单**, + 没有该投退项的五项(OV/UV/3U0/OF/UF)填 `PROT_ELM_NO_TRIP=0xFF` → 恒告警; + `TC-PROTELEM-015` 逐元钉住"逐项独立 / 改定值即改类别 / 无此项者不受别人定值影响"。 +- **描述只用 ROM 常量或 `prot_elm_name()` 直传**:所有写入点零 `snprintf`、零拼接。 + 最长一条 20 字节 < `SYSLOG_DESC_MAX`(28);`TC-IEC104-030` 逐条量长度(超 28 会被 + 投递侧静默截断,屏上少几个字,没人会去数)。 +- ⚠️ **两个投递口,按调用者的栈预算选**:`syslog_post()` 的两个"满态丢弃留痕"分支 + 内联着日志行缓冲(每处约 216 B 栈),因此 `syslog_post_quiet()` 给静态最坏链吃紧的 + 任务用(**protectTask、iec104Task 必须用它**),丢弃只累加 `syslog_post_dropped()`、 + 由唯一写者 defaultTask 在 `syslog_drain()` 里按差值补一条留痕——同 `soe_store_post` + 当年那条 `872 → 1192 B FAIL` 的实证纪律。收/丢判据两个口完全一致。 +- **读侧契约(给 task-11)**:`syslog_count(id)` + `syslog_get_record(id, idx, ts, ts_len, + desc, desc_len)` 与运行/自检记录**同一枚 reader**,"最近 20 条"= 从 `count-20` 起读; + `count()==0` 就是空态,页面显示"无记录"。**不要**在共享代码里调 MCU 专属的 + `syslog_clear_apply()`(模拟器不实现 → 链接期报错是故意的);清除一律走 + `syslog_request_clear()` + `syslog_clear_state()`,并把 `WAIT` 当正常态看(模拟器端 + 永不出现 `WAIT`,见下条)。 ### console_log(控制台单写者) diff --git a/App/log/sys_log.h b/App/log/sys_log.h index 2f34d1d7..5fa2f3a1 100644 --- a/App/log/sys_log.h +++ b/App/log/sys_log.h @@ -7,6 +7,13 @@ * 创建日期:2026-09-14 * 修改记录: * v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4) + * v1.2 2026-10-26 王建锋 新增「记录删除」API(UI 装置维护「清除记录」的前置, + * 道F task-4;判据 docs/任务规划/UI补齐.md §2 第 3 行 + * 「不允许只做一个没有实际效果的按钮」): + * 共享层 syslog_request_clear() / syslog_clear_state() + * (双端都实现,UI 侧只准用这两个)+ MCU 后端专属 + * syslog_clear_apply()(落地半边,仅 defaultTask)。 + * 请求与落地分离的理由与执行点写在下面声明处 * v1.1 2026-10-24 王建锋 仅契约注释更正,与 App/log/sys_log_mcu.c 的 C/F 两项 * 整改同步(无签名、无行为改动): * syslog_get_record 写死容量要求(ts_len > SYSLOG_TS_LEN、 @@ -28,10 +35,28 @@ extern "C" { /* * 日志 ID:决定落盘文件与容量上限 + * + * ⚠ **记录类型 = 日志 ID 本身**(一类记录 = /log 下一个严格 8.3 短名的文件), + * 记录体内**没有**类型字段位(Lead 裁定 2026-10-26,task-10):64B 定长格式被 + * UI 记录页(文本读者)与主机解码工具同时按固定偏移读,塞类型字节等于当场砸掉 + * 这两批既有读者;而运行/自检记录已经在这份格式里跑了很久,不能为五类新记录 + * 改版本位。于是"这条记录是什么类型"由它在哪个文件里回答。 + * 同理二进制 SOE 通道的 soe_type_t 本轮**不动**:改它必须同步 UI 侧的类型名表 + * (类型名表在 UI 侧 ,不在本道写域),而且 SOE + * 页与这五页是两套读者。 + * 口径与五个写入点已登记进 App/log/README.md(task-11「记录 5 页」按此做)。 */ typedef enum { SYSLOG_RUN = 0u, /* 运行记录 /log/run.log:上电/重启事件 */ SYSLOG_SELFTEST, /* 自检记录 /log/selftest.log:开机自检故障(仅故障才写) */ + /* ↓ 五类记录(docs/UI设计/页面扩充详细方案.md §3.6 的五行,task-10 落写入点) + * ⚠ 只准**尾部追加**:id 值即枚举序 = 两端 s_paths[]/s_caps[] 的下标, + * 中间插值会让老文件被新 id 读成别的类型(静默错类别,比崩更难查)。 */ + SYSLOG_ACCIDENT, /* 事故记录 /log/accid.log:跳闸投退=1 的元件动作 */ + SYSLOG_ALARM, /* 保护告警 /log/alarm.log:跳闸投退=0 或该项不存在的元件动作 */ + SYSLOG_START, /* 保护启动 /log/start.log:元件 picked 上升沿 */ + SYSLOG_OPER, /* 操作记录 /log/oper.log:强制复归 / 清除记录 / 对时 / 定值保存 */ + SYSLOG_YK, /* 遥控记录 /log/yk.log:104 遥控的选择 / 执行 / 拒绝 */ SYSLOG_COUNT /* 哨兵值,非有效 ID */ } syslog_id_t; @@ -53,9 +78,21 @@ typedef enum { /* * 容量上限(条数;满时删除重建,设计文档 §4.3) + * + * ⚠ **上限是逻辑上限,不是预分配**:文件按追加写自然增长,一条不写就不占簇; + * 到顶才 unlink 重建(照 run.log 先例)。七类全满的名义峰值 = + * (1024+512+512+1024+1024+512+256) × 64B = **304 KB**,NAND 2 GB、且这些 + * 记录只在事件边沿才写(判据动作/遥控/人工操作),不构成容量或磨损瓶颈。 + * 告警/启动给到 1024 是因为一次系统故障会连片触发多个元件,别让最想了解 + * 故障过程的两类记录最先被截掉。 */ #define SYSLOG_RUN_MAX 1024u /* /log/run.log:64KB */ #define SYSLOG_SELFTEST_MAX 512u /* /log/selftest.log:32KB */ +#define SYSLOG_ACCIDENT_MAX 512u /* /log/accid.log:32KB(跳闸稀有,换长历史) */ +#define SYSLOG_ALARM_MAX 1024u /* /log/alarm.log:64KB */ +#define SYSLOG_START_MAX 1024u /* /log/start.log:64KB */ +#define SYSLOG_OPER_MAX 512u /* /log/oper.log:32KB */ +#define SYSLOG_YK_MAX 256u /* /log/yk.log:16KB(低频,与事故同稀有) */ /* * 上电/重启分类窗口(秒):run.log 末条 epoch 距 now < 该值 → "reboot",否则 "power on" @@ -83,6 +120,34 @@ typedef enum { */ void syslog_post(syslog_id_t id, const char *desc); +/* + * 函数功能:投递一条日志——**调用链上不带日志行缓冲**的那一个版本 + * 入口参数:id - 日志 ID syslog_id_t + * desc - 描述串(NUL 结尾;超过 SYSLOG_DESC_MAX 截断),同 syslog_post + * 返回值:无 + * 限定条件:任意上下文;MCU 端不碰 FatFs、不碰卷锁、不阻塞 + * 函数说明:为什么要有第二个投递口(不是洁癖):每个 DBG_* 调用点在调用者栈上 + * 内联约 216 B 行缓冲,而 syslog_post 的两个"满态丢弃留痕"分支就躺在 + * 它自己的帧里——于是"调一次 post"在静态最坏链上等于"带上这两处缓冲"。 + * protectTask 的静态最坏链是 B9 写进 DoD 的硬线(872 B / 57%,当年在 + * post 里加一条 DBG_* 实测垫到 1192 B = stack_report FAIL),iec104Task + * 同样是深链任务,所以这五类记录的写入点一律走本函数。 + * 代价是丢弃时不当场留痕:只累加 syslog_post_dropped(),由唯一写者 + * defaultTask 在 syslog_drain() 里按差值补一条(照 soe_store 的先例)。 + * 与 syslog_post 的差别**只有留痕路径**,收/丢的判据逐条相同。 + * 模拟器后端没有队列也没有栈预算问题,本函数等价于 syslog_post。 + */ +void syslog_post_quiet(syslog_id_t id, const char *desc); + +/* + * 函数功能:取 syslog_post_quiet() 通道的累计丢弃条数(可观测性) + * 入口参数:无 + * 返回值:MCU 端 = 队列满 / 启动前数组满累计次数;模拟器端恒 0(直写不丢) uint32_t + * 函数说明:与 soe_store_dropped() 同款定位。留痕由 drain 按差值上报,本函数是 + * 给单测与台架读数用的,不参与任何判定路径。 + */ +uint32_t syslog_post_dropped(void); + /* * 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1)) * 入口参数:id - 日志 ID syslog_id_t @@ -118,6 +183,58 @@ int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, c */ int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch); +/* + * 记录删除(「清除记录」):请求 → 落地分离 + * + * 为什么必须分两步:defaultTask 是本模块**唯一写者**(存储架构说明 §10.1 第 7 条), + * uiTask 直接 f_unlink 会与写者的追加写/「容量满 unlink 重建」交错,而且删除要走 + * 卷锁 + FAT 回写——uiTask 在 IWDG required_mask 内、每 100 ms 喂一次心跳, + * 给它一条最坏数百 ms 的存储路径就是拿误复位当代价。故 UI 侧只登记请求, + * 真正的删除在 defaultTask 的 syslog_drain() 尾部执行 + * (范式同 App/algo/prot_elm.c 的 prot_elm_request_reset():单字节标志 + 消费者落地)。 + * + * 状态码(syslog_clear_state 返回值;写成宏而不是枚举,与本头既有 SYSLOG_* 常量同族) + */ +#define SYSLOG_CLEAR_NONE 0 /* 从未请求过清除(上电初值) */ +#define SYSLOG_CLEAR_OK 1 /* 已清空:文件不在盘上,同拍 syslog_count() 必为 0 */ +#define SYSLOG_CLEAR_WAIT (-1) /* 请求排队中:等 defaultTask 下一轮 drain(含卷占用顺延、卷未就绪挂起) */ +#define SYSLOG_CLEAR_FAIL (-2) /* 最近一次落地失败:原文件未被删除,可再发一次请求 */ + +/* + * 函数功能:登记「清除某日志全部记录」的请求(任意上下文;不碰存储、不阻塞) + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:0 - 请求已登记(同一 id 重复登记合并为一次,幂等) + * -1 - id 越界(请求未登记,状态一个字节都没动) int + * 限定条件:非唯一写者的上下文(uiTask / netTask / iec104Task)**只能**用本函数发起 + * 删除,不得直调 MCU 后端的 syslog_clear_apply() + * 函数说明:本函数**不等于"已清除"**,只代表"已受理"——MCU 端的删除动作发生在 + * defaultTask 的下一次 syslog_drain()(50 ms 节拍),结果读 + * syslog_clear_state(),事实复核读 syslog_count()。 + * ⚠ 两端时序有别(同 syslog_post 的队列/直写差异,那边写在 post 的头注释里): + * MCU 端返回后状态通常是 SYSLOG_CLEAR_WAIT;模拟器端单线程、本调用内 + * 同步落地,返回后立即是 OK/FAIL,**永远不会读到 WAIT**。UI 侧要按 + * "WAIT 也正常"写判断,别把 WAIT 当失败。 + * 刻意**不**在删除成功后补一条 "records cleared" 留痕:判据要求 + * syslog_count() 归零,写了立刻变 1;要留痕归操作记录/SOE + * (docs/任务规划/UI补齐.md 第 6 项的写入方),不是本 API 的职责。 + */ +int syslog_request_clear(syslog_id_t id); + +/* + * 函数功能:查「清除记录」的状态(给 UI 出真实反馈,不是"按了就算成功") + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:SYSLOG_CLEAR_NONE(0) / SYSLOG_CLEAR_OK(1) / SYSLOG_CLEAR_WAIT(-1) / + * SYSLOG_CLEAR_FAIL(-2);id 越界按 NONE 返回(等价"从没请求过") int + * 限定条件:任意上下文(MCU 端只读两个 volatile 单字节,Cortex-M 上原子,无锁) + * 函数说明:排队位优先于结果字节——否则连点两次会先把上一次的结果当成这次的, + * 还在排队的请求被显示成"已清空"。 + * 无锁交接的最坏竞态只有两种,都不产生错误数据:① 写者已销账、尚未写 + * 结果的那几个指令周期里读 → 读到上一次的结果(同向,多为 OK); + * ② 上一次失败被新请求清零 → 那次失败不再显示(用户已在重新发起, + * 语义正确)。为它上临界区要引 FreeRTOS 头 + 宿主桩,不值。 + */ +int syslog_clear_state(syslog_id_t id); + /* * MCU 后端专属 API(sys_log_mcu.c;模拟器端无对应实现,勿在共享代码调用) */ @@ -143,9 +260,34 @@ void syslog_mcu_mounted(void); * 入口参数:无 * 返回值:无 * 限定条件:仅 defaultTask(唯一写者)调用;卷未就绪时直接返回(条目滞留待重试) + * 本函数尾部同时消费「清除记录」请求(syslog_request_clear 的落地半边, + * 排在条目排空之后——删除必须是本轮对这两个文件的最后一次盘上动作, + * 先删后写会把刚排空的旧记录又落回去,"清除"就变成没效果的按钮) */ void syslog_drain(void); +/* + * 函数功能:立即删除某日志的全部记录(「清除记录」的落地半边,MCU 后端专属) + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:0 - 记录已清空(含"文件本就不存在"的幂等成功,见下) + * -1 - 未清空:卷被占用或删除失败,**原文件一个字节都没少** int + * 限定条件:仅 defaultTask(syslog 唯一写者)上下文,且调用处**不得持卷锁** + * (卷锁非递归锁);卷锁一律 try(vol_lock_wait(0),本任务 = 看门狗 + * 喂狗点,阻塞等锁 = 停喂 = IWDG 误复位) + * 函数说明:其它上下文请调 syslog_request_clear()(本头文件上方);本函数在 + * MCU 后端之外**没有任何实现**,共享代码误调会在链接期报错, + * 这比运行时断言硬。产品路径上唯一的调用者是 syslog_drain 尾部的 + * 请求消费循环,直接调用只用于板级套件与主机单测。 + * 幂等口径:只有 FR_NO_FILE 算"已经没有什么可删了"→ 返 0; + * FR_NO_PATH(/log 目录不在)**不算**幂等——记录确实读不到,但成因是 + * 目录没了而不是"被清空",补目录归 syslog_mcu_mounted() 每 2s 的重试, + * 这里如实返 -1,不把卷异常洗成"清除成功"。 + * 名字/参数类错误码(FR_INVALID_NAME 等)一律只返 -1 不做任何补救动作: + * 本函数压根没有卷级动作(无 f_mkdir / 无 f_mkfs / 不重试 unlink), + * 这正是存储架构说明 §10.1 第 10 条在删除路径上的落地形态。 + */ +int syslog_clear_apply(syslog_id_t id); + /* * 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;变长行,不走 64B 定长格式) * 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char * diff --git a/App/log/sys_log_mcu.c b/App/log/sys_log_mcu.c index 0eb704e1..38dd1e3f 100644 --- a/App/log/sys_log_mcu.c +++ b/App/log/sys_log_mcu.c @@ -1,11 +1,33 @@ /* * 模块名称:System Log (MCU 后端) — 运行/自检记录落盘 - * 模块功能:FatFS /log 目录日志文件(run.log/selftest.log)单写者落盘;启动前静态待发数组 + FreeRTOS 消息队列, + * 模块功能:FatFS /log 目录**七类**定长 64B 记录(run/selftest/accid/alarm/start/oper/yk, + * 一类记录 = 一个文件 = 一种记录类型)单写者落盘;启动前静态待发数组 + FreeRTOS 消息队列, * defaultTask(唯一写者)排空落盘;容量满删除重建;上电/重启分类 * 适用平台:STM32F407ZGTx * 作者:王建锋 * 创建日期:2026-09-14 * 修改记录: + * v1.7 2026-10-26 王建锋 「记录 5 页」的数据面(task-10):syslog_id_t 尾部追加 + * SYSLOG_ACCIDENT / ALARM / START / OPER / YK 五枚,本文件 + * s_paths[]/s_caps[] 同步到七项(**必须与枚举同序**,越界 + * 守卫只挡 id>=SYSLOG_COUNT,序错会静默把事故写进告警文件)。 + * 记录体内不加类型字段——文件即类型(决策与理由见 sys_log.h + * 的 syslog_id_t 注释、App/log/README.md)。 + * 成本:.bss **+40 B**(s_rd_gen 4B×5 + s_requeue_reported/ + * s_clear_req/s_clear_st/s_fault_reported 各 1B×5), + * .const **+111 B**(5 枚路径指针 20B + 5 项容量 20B + + * 5 条 8.3 路径串 71B);零新增缓冲、零堆分配、零栈增量。 + * 写入点全部在别处(protectTask / iec104 / 配置保存 / + * 本模块 request_clear),本文件只多七张表的五项。 + * v1.6 2026-10-26 王建锋 新增「记录删除」(道F task-4,UI 装置维护「清除记录」 + * 的前置):请求 syslog_request_clear() / 状态 + * syslog_clear_state() / 落地 syslog_clear_apply()。 + * 仍然只有 defaultTask 碰盘——非写者上下文只置一个 + * volatile 单字节,f_unlink 在 syslog_drain() 尾部执行 + * (范式同 prot_elm_request_reset);卷锁一律 try, + * 本任务 = 看门狗喂狗点绝不阻塞。 + * 新增静态 4 B .bss(s_clear_req[2] + s_clear_st[2]), + * 零新增缓冲、零堆分配。 * v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4.4) * v1.1 2026-09-21 王建锋 消息队列改为内核启动后由 syslog_drain 惰性创建 * (修复 osKernelStart 前创建 FreeRTOS 内核对象导致的启动失败) @@ -58,8 +80,30 @@ /* 静态变量定义区 */ -static const char *const s_paths[SYSLOG_COUNT] = { "/log/run.log", "/log/selftest.log" }; -static const uint32_t s_caps[SYSLOG_COUNT] = { SYSLOG_RUN_MAX, SYSLOG_SELFTEST_MAX }; +/* ⚠ 两张表的下标 = syslog_id_t 枚举序,**逐项同序追加**,中间不得插值(错序 = 静默 + * 把某类记录写进别的文件,越界守卫拦不住)。文件名一律严格 8.3(FF_USE_LFN=0: + * 主名 ≤8 字符、扩展 ≤3、不得以点开头),由 TC-SYSLOG-081 逐条机器核。 + * 七项合计 .const ≈ 111 B(含 5 条路径串 71 B);容量是**逻辑上限非预分配**。 */ +static const char *const s_paths[SYSLOG_COUNT] = { + "/log/run.log", "/log/selftest.log", "/log/accid.log", "/log/alarm.log", + "/log/start.log", "/log/oper.log", "/log/yk.log" +}; +static const uint32_t s_caps[SYSLOG_COUNT] = { + SYSLOG_RUN_MAX, SYSLOG_SELFTEST_MAX, SYSLOG_ACCIDENT_MAX, SYSLOG_ALARM_MAX, + SYSLOG_START_MAX, SYSLOG_OPER_MAX, SYSLOG_YK_MAX +}; + +/* 「清除记录」成功后的操作记录文案(task-10 的第五个操作写入点)。一类一枚 ROM + * 常量、零拼接:投递发生在 defaultTask 的 drain 尾部,那里不该为一条日志再放 + * 格式化缓冲;最长 20 字符 << SYSLOG_DESC_MAX(28)。 + * ⚠ SYSLOG_OPER 自己那一格**永远不会被用到**:清 oper.log 时不投递操作记录, + * 否则"我清除了记录"这条会把刚删空的 oper.log 又写成 1 条,直接砸掉 task-4 + * 定死、UI 页面照它出反馈的判据「删除成功后 syslog_count() 归零」(与 + * sys_log.h 里刻意不写 "records cleared" 留痕是同一条理由的两个面)。 */ +static const char *const s_clear_rec[SYSLOG_COUNT] = { + "clear run.log", "clear selftest.log", "clear accid.log", "clear alarm.log", + "clear start.log", "clear oper.log", "clear yk.log" +}; #define SYSLOG_QUEUE_MAX 8u @@ -108,6 +152,16 @@ static uint8_t s_requeue_reported[SYSLOG_COUNT]; static volatile uint8_t s_post_qfull_reported; /* 队列满丢弃:已留痕? */ static volatile uint8_t s_post_pboot_full_reported;/* 启动前数组满丢弃:已留痕? */ +/* syslog_post_quiet() 通道(task-10,2026-10-26):给"静态最坏链放不下日志内联 + * 行缓冲"的任务用的投递口(protectTask 872 B / 57% 是 B9 的 DoD 硬线,当年在 + * post 里加一条 DBG_* 实测把它垫到 1192 B = stack_report FAIL)。本通道丢弃时 + * **只累加计数**,留痕挪到 syslog_drain()(defaultTask)按差值上报——与 + * App/log/soe_store.c 的 s_drop_cnt + drain 侧差值上报完全同构。 + * RAM:两枚 uint32 = **8 B .bss**;volatile 单字自增在 Cortex-M 上原子,最坏 + * 少报一条,不会漏计到永久静默(水位只在本函数读到差值时才前进)。 */ +static volatile uint32_t s_quiet_drop; /* quiet 通道累计丢弃条数 */ +static uint32_t s_quiet_reported; /* drain 侧已上报水位(唯一写者私有) */ + /* 日志读写放大收口(2026-10-15,读侧):8 条滑动读窗口 + 写者代数。 * 窗口 512 B 走静态(不吃任务栈;defaultTask/uiTask 最坏链见 stack_report.py, * 预算余量见 docs/内存管理/RAM预算台账.md)。s_rd_gen 由写者自增、读者比对, @@ -120,6 +174,21 @@ static uint32_t s_rd_win_cnt; /* 窗口内有效条数 */ static uint32_t s_rd_win_gen; /* 填充时看到的写者代数 */ static volatile uint32_t s_rd_gen[SYSLOG_COUNT]; /* 每次成功落盘 / unlink 后自增 */ +/* 「清除记录」的请求 / 落地交接(道F task-4)。两枚各 SYSLOG_COUNT 字节 = **共 4 B + * .bss**(本模块唯一的新增静态;无缓冲、无堆,MicroLIB C 堆只有 512 B): + * s_clear_req[id] —— 1 = 有未落地的删除请求。非写者上下文(uiTask)置 1, + * defaultTask 落地时先清 0 再执行 → 期间到达的新请求会 + * 重新置 1,**下一次排空再删一次**,不会因为"正在删"而丢; + * s_clear_st[id] —— 最近一次落地的结果,取值 SYSLOG_CLEAR_NONE/OK/FAIL + * (WAIT 不落在这里,由 s_clear_req 现场推,见 syslog_clear_state)。 + * 为什么拆两枚而不是合成一个四态状态机:单字节状态机在"写者读完状态到写回结果" + * 之间收到的新请求会被结果覆盖掉(用户的第二次点击被无声吞掉)。拆开后各自的写者 + * 分明(req:任意上下文置 1 / 写者清 0;st:只有写者写),单字节写在 Cortex-M 上 + * 原子,剩下的竞态只有"读到上一次结果"这一种同向偏差 → 无锁,代价换来 0 个临界区、 + * 0 个头文件依赖(本文件不 include FreeRTOS.h)。 */ +static volatile uint8_t s_clear_req[SYSLOG_COUNT]; /* 1 = 待落地的清除请求 */ +static volatile int8_t s_clear_st[SYSLOG_COUNT]; /* SYSLOG_CLEAR_NONE / OK / FAIL */ + /* 函数定义区 */ /* @@ -484,6 +553,102 @@ static int s_read_raw(syslog_id_t id, uint32_t idx, uint8_t *p_rec) { return 0; } +/* + * 函数功能:落地执行"删掉本模块自己的日志文件"(记录删除的落地半边,内部三态版) + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:0 - 已删除,或文件本就不存在(FR_NO_FILE = 幂等成功); + * -1 - 删除真失败(原文件一个字节都没少); + * -2 - 卷被占用(本轮顺延,非故障,与 s_append_rec 的 -2 同一含义) int + * 限定条件:仅 defaultTask(syslog 唯一写者)上下文;调用处不得持卷锁 + * (卷锁非递归锁,重入即自死锁,见 App/fs/vol_lock.h 使用约定) + * 函数说明:本函数只做一件事——对自己那一个文件 f_unlink 一次。因此存储架构说明 + * §10.1 第 10 条(名字/参数类错误码严禁升级成卷操作)在这里的形态是 + * "压根没有卷级动作":不 f_mkdir、不 f_mkfs、不重开、不重试, + * FR_INVALID_NAME / FR_NO_PATH / FR_DENIED 一律如实返 -1。 + * 幂等只认 FR_NO_FILE(文件不在 = 没有记录可删,目标已达成); + * FR_NO_PATH 刻意不算幂等,理由写在 syslog_clear_apply() 的头注释里。 + * 只有真删成功(含幂等路径)才让 s_rd_gen[id] 自增:文件确实不在盘上了, + * 读者的 8 条窗口必须作废;失败路径内容没变,窗口仍是有效数据, + * 不去做无谓的失效(与 s_append_rec"失败也一律失效"不同——那边失败 + * 伴随过 unlink/重建,本函数失败时盘上什么都没动)。 + * 刻意不在删除成功后补一条 "records cleared":判据要求 syslog_count() + * 归零,写了立刻变 1;要留痕归操作记录/SOE(UI补齐.md 第 6 项)。 + */ +static int s_clear_try(syslog_id_t id) { + FRESULT fr; + int rc; + + if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { + return -1; /* 守卫在取锁之前:非法入参一个字节都不碰盘,也不占用卷 */ + } + /* 卷锁 try:本任务 = 看门狗喂狗点,绝不阻塞等锁(与 s_append_rec 同一条铁律) */ + if (vol_lock_wait(0u) == 0u) { + return -2; + } + fr = f_unlink(s_paths[id]); + if ((fr == FR_OK) || (fr == FR_NO_FILE)) { + s_rd_gen[id]++; /* 文件已不在盘上:读者的 8 条窗口作废重填 */ + rc = 0; + } else { + rc = -1; + } + vol_lock_release(); + if (rc != 0) { + /* 一次用户动作至多一条留痕(失败不重试,见 s_clear_service),不会刷屏, + * 所以这里不需要像 B/F 项那样另立节流位。 + * ⚠ 留这条的成本约 90 B Flash(AGENTS「日志与工具链」);留它的理由: + * "清除失败但盘上还有记录"在串口之外没有任何可观测面(UI 只有一个整数状态)。 */ + DBG_ERROR("clear %s fail (%d)", s_paths[id], (int)fr); + } + return rc; +} + +/* + * 函数功能:消费「清除记录」请求(syslog_request_clear 的请求 → 落地执行点) + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 syslog_drain() 调用 = defaultTask(唯一写者)上下文,且不持卷锁; + * 卷未就绪 / 队列未建成时 syslog_drain 在本函数之前就已早退, + * 请求原地保持挂起(与日志条目滞留同一处置),状态一直是 WAIT + * 函数说明:两个决定都是刻意的: + * 1) 排在条目排空**之后**(调用位置见 syslog_drain):删除必须是本轮对 + * run.log/selftest.log 的最后一次盘上动作,否则本轮刚排空的旧记录 + * 会在删除之后又落回去,count 不归零,"清除"就成了没实际效果的按钮 + * (判据出处 docs/任务规划/UI补齐.md §2 第 3 行)。 + * 副作用照实登记:**先排空再删** = 请求受理时仍在队列/preboot 里的 + * 条目也一并被删掉(用户按下的就是"把现在这些记录清掉"), + * 只有删除之后新投递的记录才会留下。 + * 2) 卷占用(-2)把请求**挂回**而不是判失败:与 A 项整改后的条目 + * "整批回投、静默顺延"同一纪律(本函数每 50 ms 被排空调一次, + * 一打日志就自己刷屏);真失败(-1)则销账 + 置 FAIL,绝不自动重试 + * ——名字/参数类错误码反复 unlink 自己的文件 = §10.1 第 10 条老账。 + */ +static void s_clear_service(void) { + uint32_t i; + + for (i = 0u; i < (uint32_t)SYSLOG_COUNT; i++) { + int rc; + + if (s_clear_req[i] == 0u) { + continue; + } + s_clear_req[i] = 0u; /* 先销账再执行:期间的新请求留到下轮,不丢 */ + rc = s_clear_try((syslog_id_t)i); + if (rc == -2) { + s_clear_req[i] = 1u; /* 卷占用:挂回请求,状态继续是 WAIT,静默 */ + } else { + s_clear_st[i] = (rc == 0) ? (int8_t)SYSLOG_CLEAR_OK : (int8_t)SYSLOG_CLEAR_FAIL; + /* 清除是一次真实的运维动作 → 操作记录(为何只在成功时记、为何 + * 独独跳过 SYSLOG_OPER,见上面 s_clear_rec 的表头注释)。 + * 失败不记:那一次什么都没改,记了反而让"操作记录"里出现 + * 一个没发生过的动作。 */ + if ((rc == 0) && (i != (uint32_t)SYSLOG_OPER)) { + syslog_post_quiet(SYSLOG_OPER, s_clear_rec[i]); + } + } + } +} + /* * 函数功能:日志引擎初始化(内核启动前安全空操作) * 入口参数:无 @@ -671,12 +836,42 @@ void syslog_drain(void) { break; /* 本轮到此为止,下轮再试 */ } } + /* quiet 通道的丢弃留痕(task-10):投递侧不许打日志(见上面 s_quiet_drop 的 + * 存在理由),所以由唯一写者按差值补一条。与 soe_store 的 dropped 上报同款, + * 也是"日志自己不许变成负载"的既有纪律:只在计数真的前进时打。 */ + if (s_quiet_drop != s_quiet_reported) { + s_quiet_reported = s_quiet_drop; + DBG_ERROR("quiet drop total=%lu", (unsigned long)s_quiet_drop); + } + /* 「清除记录」的落地半边——必须在上面的条目排空**之后**(顺序是判据的一部分: + * 先删后写会把本轮刚排空的旧记录又落回去,count 不归零 = 没实际效果的按钮), + * 也在 soe_store_drain() 之前:本函数自带卷锁 try,绝不在此持锁状态下调用。 + * 卷未就绪/队列未建时上面已经早退,请求保持挂起(状态 WAIT),不会丢。 */ + s_clear_service(); /* B9:SOE 落盘挂本函数尾部——同一唯一写者(defaultTask)、同一卷就绪门 (上方 s_fs_ready 早退蕴含 /log 已建)、同一卷锁 try 语义;protectTask 只投递不写盘(动作路径零 NAND I/O,DoD 硬约束)。 */ soe_store_drain(); } +/* + * 函数功能:立即删除某日志的全部记录(「清除记录」的落地半边,MCU 后端专属) + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:0 - 记录已清空(含"文件本就不存在"的幂等成功) + * -1 - 未清空:卷被占用或删除失败,**原文件未被删除** int + * 限定条件:仅 defaultTask(syslog 唯一写者)上下文,且调用处不得持卷锁; + * 非写者上下文一律走 syslog_request_clear()(本文件不实现"跨上下文删除", + * 那是把 uiTask 直接接上 FAT 回写的开始) + * 函数说明:内部三态里"卷占用 = -2(顺延)"在本 API 折成 -1 —— 对调用方来说 + * "没删掉、记录还在"是同一个事实,该不该重试由调用方定; + * 请求侧的顺延重试策略(-2 挂回请求、-1 判失败不重试)在 s_clear_service(), + * 不外溢成第三种返回值。逐码依据与幂等口径见 sys_log.h 里本函数声明处的 + * 头注释,以及 s_clear_try() 的实现注释。 + */ +int syslog_clear_apply(syslog_id_t id) { + return (s_clear_try(id) == 0) ? 0 : -1; +} + /* * 函数功能:投递一条日志(任意上下文;非阻塞,队列满则丢弃) * 入口参数:id - 日志 ID syslog_id_t @@ -690,55 +885,192 @@ void syslog_drain(void) { * 串口就按业务投递频率刷"queue full, drop",日志自己变成负载。 * 抑制位是 volatile 单字节、由任意上下文读写:竞态最坏只多打/少打一行。 */ -void syslog_post(syslog_id_t id, const char *desc) { +/* 投递结果编码(s_put 返回值)。分通道返回是**必须的**,不是讲究:F 项那两个节流 + * 抑制位按通道分开(队列 / 启动前数组),塌成一个"失败"就会把"数组满"报成"队列 + * 满",还会把另一通道的抑制位误复位(TC-SYSLOG-105..107c 逐条钉着这条)。 */ +#define SYSLOG_PUT_Q_OK 0u /* 队列收下 */ +#define SYSLOG_PUT_PB_OK 1u /* 启动前待发数组收下 */ +#define SYSLOG_PUT_Q_FULL 2u /* 队列满:本条丢弃 */ +#define SYSLOG_PUT_PB_FULL 3u /* 数组满:本条丢弃 */ + +/* + * 函数功能:填一条待发条目——投递时就拷贝描述(post / post_quiet 共用,内部) + * 入口参数:p_e - 输出条目(不可为 NULL) syslog_entry_t * + * id - 日志 ID(调用方已核过界) syslog_id_t + * desc - 描述串,NUL 结尾;静态串或调用方的临时缓冲都可以 const char * + * 返 回 值:无 + * 函数说明:拷贝之后待发链不再引用调用方的缓冲区——这条纪律是 B 方案整改用真机 + * 实测换来的(旧实现只存指针,复用栈缓冲时一整批 12 条全部别名到最后一次 + * 写入的内容),见本文件上方 syslog_entry_t 的注释。 + */ +static void s_entry_fill(syslog_entry_t *p_e, syslog_id_t id, const char *desc) { + size_t dl = strlen(desc); + + p_e->id = (uint8_t)id; + if (dl > SYSLOG_DESC_MAX) { + dl = SYSLOG_DESC_MAX; + } + memcpy(p_e->desc, desc, dl); + p_e->desc[dl] = '\0'; +} + +/* + * 函数功能:把条目送进消息队列;队列不存在或内核未跑则进启动前待发数组(内部) + * 入口参数:p_e - 已填好的条目(不可为 NULL) const syslog_entry_t * + * 返 回 值:SYSLOG_PUT_Q_OK / SYSLOG_PUT_PB_OK / SYSLOG_PUT_Q_FULL / + * SYSLOG_PUT_PB_FULL uint8_t + * 限定条件:⚠ **本函数内禁止出现任何 DBG_***(照 soe_store_post 的同款禁令) + * 函数说明:它是 post 与 post_quiet 的共同落点,而 post_quiet 存在的唯一理由就是 + * "调用方任务的静态最坏链放不下日志的内联行缓冲"(每处约 216 B 栈)。 + * 这里一旦出现日志,quiet 就退化成 post 的别名,protectTask / iec104Task + * 的记录投递又会把链垫回 stack_report 的 FAIL 区——B9 当年实测 + * 872 B -> 1192 B 就是这条,别用"这里只有一条"说服自己。 + * 留痕因此分两侧:post 的调用侧节流位 + drain 的 quiet 差值上报。 + */ +static uint8_t s_put(const syslog_entry_t *p_e) { + if (s_queue != NULL && osKernelGetState() == osKernelRunning) { + return (osMessageQueuePut(s_queue, p_e, 0u, 0u) == osOK) ? + (uint8_t)SYSLOG_PUT_Q_OK : (uint8_t)SYSLOG_PUT_Q_FULL; + } + if (s_preboot_cnt < SYSLOG_QUEUE_MAX) { + s_preboot[s_preboot_cnt] = *p_e; + s_preboot_cnt = (uint8_t)(s_preboot_cnt + 1u); + return (uint8_t)SYSLOG_PUT_PB_OK; + } + return (uint8_t)SYSLOG_PUT_PB_FULL; +} + +/* + * 函数功能:投递一条日志(零日志缓冲版本;收/丢语义与 syslog_post 完全一致) + * 入口参数:id - 日志 ID syslog_id_t + * desc - 描述串(NUL 结尾,静态/常量串;超长截断) const char * + * 返 回 值:无 + * 限定条件:任意上下文;不碰 FatFs、不碰卷锁、不阻塞。**专为静态最坏链吃紧的 + * 任务准备**(本仓当前 = protectTask、iec104Task) + * 函数说明:与 syslog_post 的差别只有两处:① 本调用链上不内联任何 DBG 行缓冲, + * 丢弃时只累加 s_quiet_drop,由 defaultTask 在 syslog_drain() 里按差值 + * 补一条留痕;② 不参与 F 项那两个节流位——那两个位描述的是 syslog_post + * 自己的历史,混用会让"post 已恢复"被 quiet 的成功投递误复位。 + * 丢弃计数读 syslog_post_dropped()。其它任务请继续用 syslog_post: + * 即时留痕,可观测性更好,别为不存在的栈压力付两倍的认知成本。 + */ +void syslog_post_quiet(syslog_id_t id, const char *desc) { syslog_entry_t e; - size_t dl; + uint8_t res; if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) { return; } - e.id = (uint8_t)id; - /* 投递即拷贝(含截断):待发条目不再引用调用方缓冲区,栈缓冲/临时格式化结果 - * 都能安全投递(见上方 syslog_entry_t 注释的台架实证) */ - dl = strlen(desc); - if (dl > SYSLOG_DESC_MAX) { - dl = SYSLOG_DESC_MAX; - } - memcpy(e.desc, desc, dl); - e.desc[dl] = '\0'; - if (s_queue != NULL && osKernelGetState() == osKernelRunning) { - if (osMessageQueuePut(s_queue, &e, 0u, 0u) != osOK) { - /* F 项:只报"从正常转入故障"那一次;期间条目照常丢弃(丢弃语义不变, - * 变的只是留痕次数)。下一次投递成功即复位。 */ - if (s_post_qfull_reported == 0u) { - s_post_qfull_reported = 1u; - DBG_ERROR("queue full, drop: %s", desc); - } - } else { - /* 恢复正常:清抑制位,下一次故障重新留痕。这里刻意无条件写 - * (曾试成"仅置位时才清",被 cppcheck duplicateConditionalAssign - * 判成等价赋值并拦提交闸;本函数上方已做过一次条目拷贝,多一条 - * STR 可以忽略,别为它加 inline-suppr)。 */ - s_post_qfull_reported = 0u; + s_entry_fill(&e, id, desc); + res = s_put(&e); + if ((res == (uint8_t)SYSLOG_PUT_Q_FULL) || (res == (uint8_t)SYSLOG_PUT_PB_FULL)) { + if (s_quiet_drop < UINT32_MAX) { + s_quiet_drop = s_quiet_drop + 1u; /* 留痕在 drain 侧,理由见函数头 */ } - } else { - uint8_t n = s_preboot_cnt; - if (n < SYSLOG_QUEUE_MAX) { - s_preboot[n] = e; - s_preboot_cnt = n + 1u; - s_post_pboot_full_reported = 0u; /* 恢复正常:同上,无条件复位 */ - } else if (s_post_pboot_full_reported == 0u) { - /* 同一纪律的另一通道:启动前数组只有 SYSLOG_QUEUE_MAX 槽,挂载慢 + - * 开机事件密集时会在整个"未就绪窗口"里每条投递打一行(比队列通道更早 - * 发生、也更密集)。数组重新收下第一条即视为恢复。 */ + } +} + +/* + * 函数功能:取 quiet 通道累计丢弃条数(可观测性;照 soe_store_dropped 同款) + * 入口参数:无 + * 返 回 值:累计丢弃条数 uint32_t + */ +uint32_t syslog_post_dropped(void) { + return s_quiet_drop; +} + +void syslog_post(syslog_id_t id, const char *desc) { + syslog_entry_t e; + uint8_t res; + + if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) { + return; + } + s_entry_fill(&e, id, desc); + res = s_put(&e); + switch (res) { + case SYSLOG_PUT_Q_OK: + /* 恢复正常:清抑制位,下一次故障重新留痕。这里刻意无条件写 + * (曾试成"仅置位时才清",被 cppcheck duplicateConditionalAssign + * 判成等价赋值并拦提交闸;本函数上方已做过一次条目拷贝,多一条 + * STR 可以忽略,别为它加 inline-suppr)。 */ + s_post_qfull_reported = 0u; + break; + case SYSLOG_PUT_PB_OK: + s_post_pboot_full_reported = 0u; /* 恢复正常:同上,无条件复位 */ + break; + case SYSLOG_PUT_Q_FULL: + /* F 项:只报"从正常转入故障"那一次;期间条目照常丢弃(丢弃语义不变, + * 变的只是留痕次数)。下一次投递成功即复位。 */ + if (s_post_qfull_reported == 0u) { + s_post_qfull_reported = 1u; + DBG_ERROR("queue full, drop: %s", desc); + } + break; + default: + /* SYSLOG_PUT_PB_FULL:同一纪律的另一通道——启动前数组只有 + * SYSLOG_QUEUE_MAX 槽,挂载慢 + 开机事件密集时会在整个"未就绪窗口"里 + * 每条投递打一行(比队列通道更早发生、也更密集)。数组重新收下第一条 + * 即视为恢复(上面 PB_OK 已复位)。故障期间静默,条目照常丢弃。 */ + if (s_post_pboot_full_reported == 0u) { s_post_pboot_full_reported = 1u; DBG_ERROR("preboot full, drop: %s", desc); - } else { - /* 故障期间静默,条目照常丢弃 */ } + break; } } +/* + * 函数功能:登记「清除某日志全部记录」的请求(任意上下文;不碰存储、不阻塞) + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:0 - 请求已登记(同一 id 重复登记合并为一次,幂等) + * -1 - id 越界(该 id 的请求位与结果字节一个都没动) int + * 限定条件:任意任务上下文(含 uiTask);零 FatFs 触点、零卷锁触点, + * 因此**不会**把调用者的喂狗节拍拖进存储路径 + * 函数说明:MCU 端本函数只代表"已受理",真正的 f_unlink 在 defaultTask 的 + * syslog_drain() 尾部(s_clear_service)执行,结果读 syslog_clear_state()。 + * 先清结果再置请求,是为了让 UI 在"排队中"这一拍不会拿上一次的 + * OK/FAIL 当本次的结论(顺序反了就会出现"按了按钮仍显示上次失败")。 + * 竞态面(写在 sys_log.h 同一位置,勿只信这里):置请求与写者清请求都是 + * 单字节写,Cortex-M 上原子;最坏是写者已清账、本函数又置回 1 → + * 下一轮多删一次(unlink 幂等),不会丢请求、也不会误清别人的记录。 + */ +int syslog_request_clear(syslog_id_t id) { + if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { + return -1; + } + s_clear_st[id] = (int8_t)SYSLOG_CLEAR_NONE; + s_clear_req[id] = 1u; + return 0; +} + +/* + * 函数功能:查「清除记录」的状态(给 UI 出真实反馈,不是"按了就算成功") + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:SYSLOG_CLEAR_NONE(0) / SYSLOG_CLEAR_OK(1) / SYSLOG_CLEAR_WAIT(-1) / + * SYSLOG_CLEAR_FAIL(-2);id 越界按 NONE 返回 int + * 限定条件:任意上下文(只读两个 volatile 单字节,无锁) + * 函数说明:排队位优先于结果字节 —— 否则连点两次时会先把上一次的结果当成本次的 + * 结论,还在排队的请求被显示成"已清空"。两个字节都是 defaultTask 与 + * uiTask 之间的无锁交接,最坏竞态与为什么不加临界区,见 sys_log.h + * syslog_clear_state() 的头注释(同一事实只在那一处详述)。 + */ +int syslog_clear_state(syslog_id_t id) { + if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { + return (int)SYSLOG_CLEAR_NONE; + } + if (s_clear_req[id] != 0u) { + return (int)SYSLOG_CLEAR_WAIT; + } + if (s_clear_st[id] == (int8_t)SYSLOG_CLEAR_OK) { + return (int)SYSLOG_CLEAR_OK; + } + if (s_clear_st[id] == (int8_t)SYSLOG_CLEAR_FAIL) { + return (int)SYSLOG_CLEAR_FAIL; + } + return (int)SYSLOG_CLEAR_NONE; +} + /* * 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1)) * 入口参数:id - 日志 ID syslog_id_t diff --git a/App/protocol/iec104/iec104_pts.c b/App/protocol/iec104/iec104_pts.c index 8cd3eda8..8fd731a8 100644 --- a/App/protocol/iec104/iec104_pts.c +++ b/App/protocol/iec104/iec104_pts.c @@ -32,6 +32,7 @@ #include "run_par.h" /* 卡 10:死区真源 + 运行参数段 104 读写路由 */ #include "prot_elm.h" /* 板端源:遥信 = 元件状态 */ #include "prot_set.h" /* 板端源:定值 + 闭锁 */ +#include "sys_log.h" /* task-10:遥控记录(SYSLOG_YK)/ 操作记录(SYSLOG_OPER) */ #include "adc_input.h" /* adc_semantic_t(phasor 实例按语义绑定) */ /* ---------------- YC 表(§1.5 点号 4011+;死区列 = ROM 默认值) ------------ */ @@ -544,25 +545,68 @@ static uint64_t s_src_yx_valid(void) { * YK12 走 prot_set_set_sealed(原子写+封存,B7);protectTask 下一拍 * 读到压板态即复位元件并停判据(protect_task.c)。 */ +/* 遥控记录的文案表(task-10)。**全部 ROM 常量、零拼接**:本回调跑在 + * iec104Task,而 iec104_serv.c 的日志缓冲已经因为"深链模块"被收到 128 B, + * 这里再 snprintf 一条"YK ioa=0x600B dpc=2 sel=0 rc=0"就是往那条链上加缓冲; + * 记录类型由 SYSLOG_YK 这个文件本身回答,屏上话术归 UI(App/log/README.md 口径)。 + * 最长一条 20 字符 < SYSLOG_DESC_MAX(28)。 */ +#define YK_REC_YK11_SEL "YK11 select" +#define YK_REC_YK11_EXEC "YK11 exec" +#define YK_REC_YK12_SEL "YK12 select" +#define YK_REC_YK12_ON "YK12 exec on" +#define YK_REC_YK12_OFF "YK12 exec off" +#define YK_REC_YK12_FAIL "YK12 exec fail" +#define YK_REC_DPC_BAD "YK12 bad dpc" +#define YK_REC_UNKNOWN "YK unknown ioa" + static int s_src_yk_exec(uint32_t ioa, uint8_t dpc, uint8_t select) { switch (ioa) { case 0x600Au: /* YK11 信号复归 → 元件状态复位(异步一拍) */ (void)dpc; /* 复归与双点值无关 */ if (select == 0u) { prot_elm_request_reset(); + /* 遥控记录(执行段已受理)+ 操作记录(本地/远端强制复归)。 + * 两条不是重复留痕:前者回答"主站下了什么命令、结果如何",后者 + * 回答"装置被谁改动了状态"——UI 的遥控记录页与操作记录页各读一份。 + * YK11 没有可失败的下游(request_reset 只置单字节标志),所以它 + * 只有受理态,没有 fail 分支。 */ + syslog_post_quiet(SYSLOG_YK, YK_REC_YK11_EXEC); + syslog_post_quiet(SYSLOG_OPER, "force reset"); + } else { + syslog_post_quiet(SYSLOG_YK, YK_REC_YK11_SEL); } return 0; - case 0x600Bu: /* YK12 全局软压板(PS_GLOBAL_EN)两段式 */ + case 0x600Bu: { /* YK12 全局软压板(PS_GLOBAL_EN)两段式 */ + int rc; if ((dpc != (uint8_t)IEC104_DPC_ON) && (dpc != (uint8_t)IEC104_DPC_OFF)) { + syslog_post_quiet(SYSLOG_YK, YK_REC_DPC_BAD); return -1; /* 非法双点命令:选择段即拒 */ } if (select != 0u) { + syslog_post_quiet(SYSLOG_YK, YK_REC_YK12_SEL); return 0; /* 选择段 = 只校验 */ } - return prot_set_set_sealed(PS_GLOBAL_EN, - (dpc == (uint8_t)IEC104_DPC_ON) - ? PROT_SET_Q10(1) : PROT_SET_Q10(0)); + /* 执行段:set_sealed 自带范围校验与封存,失败 = 值被拒、RAM 未改。 + * 这里**必须接住返回值再落记录**(bsp-err:错误码不得裸丢弃; + * 原实现是 return 直出,现在多一条记录后仍然原值返回,语义不变)。 */ + rc = prot_set_set_sealed(PS_GLOBAL_EN, + (dpc == (uint8_t)IEC104_DPC_ON) + ? PROT_SET_Q10(1) : PROT_SET_Q10(0)); + if (rc != 0) { + syslog_post_quiet(SYSLOG_YK, YK_REC_YK12_FAIL); + } else { + syslog_post_quiet(SYSLOG_YK, + (dpc == (uint8_t)IEC104_DPC_ON) ? YK_REC_YK12_ON + : YK_REC_YK12_OFF); + /* 压板投退是一次真实的运维动作(保护退出/恢复服务)→ 同时进操作记录 */ + syslog_post_quiet(SYSLOG_OPER, + (dpc == (uint8_t)IEC104_DPC_ON) ? "plate in" + : "plate out"); + } + return rc; + } default: + syslog_post_quiet(SYSLOG_YK, YK_REC_UNKNOWN); return -1; /* 其余 YK:无出口硬件/无对应能力 → 拒绝 */ } } diff --git a/App/task/default_task.c b/App/task/default_task.c index 34991ea8..301dd9a3 100644 --- a/App/task/default_task.c +++ b/App/task/default_task.c @@ -40,6 +40,7 @@ #include "cfg_file.h" #include "run_par.h" /* 运行参数段落盘(卡 10,配置引擎属主 = 本任务) */ #include "prot_set_store.h" +#include "prot_set.h" /* task-10:prot_set_dirty_peek(区分"无待写"与"真落盘") */ #include "sys_log.h" #include "sys_events.h" /* 深调用链模块(本任务 = 存储 bring-up + 配置引擎属主 + syslog 唯一写者, @@ -230,8 +231,16 @@ void default_task_func(void *argument) { task_wd_seg(TASK_WD_SEG_CFGSAVE); net_cfg_save_run(); /* 保护定值落盘(B7):内部消费脏标志,无改动 = 空操作; - * 写失败脏回置 → iec104Task 下轮 poll 再投递事件 = 天然重试 */ - (void)prot_set_store_save(); + * 写失败脏回置 → iec104Task 下轮 poll 再投递事件 = 天然重试。 + * task-10「操作记录」写入点之一:⚠ 不能按 prot_set_store_save() 的返回值 + * 记——它对"无待写"同样返 0,照返回值记就是每 50 ms 刷一条**没发生过**的 + * 操作(正是"不许用静态演示数据充数"的反面)。先 peek 脏位(非消费, + * iec104_serv.c 通知属主用的就是同一枚),只有"确实有待写且这趟写成功" + * 才算一次整定操作。失败不记(判据 = 动作成功后写一条),脏位保留、 + * 下轮重试成功后才留痕。 */ + if ((prot_set_dirty_peek() != 0u) && (prot_set_store_save() == 0)) { + syslog_post(SYSLOG_OPER, "settings saved"); + } /* 运行参数段落盘(卡 10):同款脏语义;save = 引擎整档外科写 * ([run_par] 9 键 + 被 adc_cal_set 联动改写的 [adc_cal] 两段一趟落) */ (void)run_par_store_save(); diff --git a/App/task/iec104_serv.c b/App/task/iec104_serv.c index 37cc3af5..9f93cf2a 100644 --- a/App/task/iec104_serv.c +++ b/App/task/iec104_serv.c @@ -18,6 +18,7 @@ #include "prot_set.h" /* 定值脏标志 peek(B7 落盘通知) */ #include "run_par.h" /* 运行参数脏 peek(卡 10 落盘通知,同款语义) */ #include "sys_clock.h" +#include "sys_log.h" /* task-19:对时成功落一条操作记录(SYSLOG_OPER) */ #include "sys_events.h" #include "task_watchdog.h" #include "dbg_log.h" @@ -85,19 +86,34 @@ static void s_send_apdu(void *ctx, const uint8_t *apdu, uint16_t len) { } /* - * 函数功能:link 层对时回调——epoch 毫秒落系统墙钟(含 SD2506 RTC 写回) + * 函数功能:link 层对时回调——epoch 毫秒落系统墙钟(含 SD2506 RTC 写回), + * 成功时顺带落一条操作记录(道F task-19:「时钟设置」是四处操作记录 + * 写入点之一,另外三处见 App/log/README.md 的五类记录对照表) * 入口参数:ctx 未用;epoch_ms = C_CS 解析出的 UTC 时刻 * 返回值:0 成功 / -1 失败(link 层回 actcon+PN) - * 函数说明:uint32 秒域上限 2106-02-07,越界显式拒绝(CP56 域到 2127) + * 函数说明:uint32 秒域上限 2106-02-07,越界显式拒绝(CP56 域到 2127)。 + * **只在真正改成功之后才投记录**:越界拒绝与 sys_clock_set_unix 失败两条腿 + * 一条都不投——操作记录是审计面,写进去一条没发生过的"对时"比少一条更坏 + * (TC-I104SRV-050b / 051 分别钉这两条腿)。 + * 投递用 syslog_post_quiet():iec104Task 是深链任务(本文件顶部已把 + * DBG_LINE_BUF_MAX 收到 128 就是同一件事的证据),不许在它的调用链上 + * 内联日志行缓冲;两个投递口的收/丢判据一致,差别只在留痕位置 + * (口径与理由见 App/log/README.md「两个投递口」)。 + * 文案是 ROM 常量、17 字节 < SYSLOG_DESC_MAX(28):iec104Task 里不 snprintf。 */ static int s_clock_sync(void *ctx, uint64_t epoch_ms) { uint64_t secs = epoch_ms / 1000ULL; + int rc; (void)ctx; if (secs > 0xFFFFFFFFULL) { return -1; } - return sys_clock_set_unix((uint32_t)secs); + rc = sys_clock_set_unix((uint32_t)secs); + if (rc == 0) { + syslog_post_quiet(SYSLOG_OPER, "clock set by 104"); + } + return rc; } /* diff --git a/App/task/protect_task.c b/App/task/protect_task.c index 7ca019fe..e4155780 100644 --- a/App/task/protect_task.c +++ b/App/task/protect_task.c @@ -51,6 +51,7 @@ #include "prot_elm.h" #include "prot_set.h" #include "soe_store.h" /* B9:动作/复归边沿 → SOE 投递(零 FS,队列异步) */ +#include "sys_log.h" /* task-10:事故 / 保护告警 / 保护启动 三类文本记录 */ #include "task_watchdog.h" #include "tpafe5160.h" /* tpafe5160_frame_seq():帧序号(BUSY EXTI 写) */ @@ -74,6 +75,7 @@ static adc_health_age_t s_age_tp; /* TPAFE 车道 */ static adc_health_age_t s_age_a1; /* STM32 车道 */ static uint32_t s_prev_active; /* 上拍动作位图(边沿日志用) */ +static uint32_t s_prev_pick; /* 上拍启动位图(「保护启动」记录边沿用,task-10) */ static uint8_t s_prev_hf_lock; /* 上拍采样闭锁态(边沿日志用) */ static uint8_t s_prev_plate_out; /* 上拍全局软压板退出态(边沿日志用,B7) */ static uint8_t s_prev_cal_lock; /* 上拍未标定闭锁态(边沿日志用,卡 5) */ @@ -138,6 +140,61 @@ static void s_log_active_edge(uint32_t active) { s_prev_active = active; } +/* + * 函数功能:把元件状态边沿落成三类文本记录(事故 / 保护告警 / 保护启动) + * 入口参数:active - 本拍 prot_elm_active_mask()(asserted 位图) uint32_t + * pick - 本拍 prot_elm_pick_mask()(picked 位图) uint32_t + * 返 回 值:无 + * 限定条件:仅本任务上下文;**只投递、不碰盘**(唯一写者是 defaultTask), + * 投递走 syslog_post_quiet()——本任务静态最坏链是 B9 写进 DoD 的硬线 + * (872 B / 57%),syslog_post 的两个满态留痕分支各内联约 216 B 行缓冲, + * 在这里调它等于把链垫回 stack_report 的 FAIL 区(同本文件不用 + * snprintf/DBG 拼记录的纪律,见 s_log_active_edge 函数头) + * 函数说明:三类记录的**类型就是目标文件**(一类一个 /log 文件,记录体内没有 + * 类型字段位;决策与理由见 App/log/README.md)。判据: + * 1) picked 上升沿 → SYSLOG_START("保护启动"):越限电平一到就记, + * 不等定时限到点——只记动作会让"启动了但没到点"那段在记录里消失; + * 2) asserted上升沿 → 归 事故 还是 告警 由 prot_elm_record_kind() 判, + * 其判据唯一来源是定值表里的 PS_*_TRIP(0 告警 / 1 跳闸);本函数 + * 不另立"哪些元件算跳闸"的名单,避免与 g_prot_set_meta / k_pe[] 打架。 + * 返回沿(asserted/picked 掉落)**不记**:复归留痕已由 s_log_active_edge + * 的 SOE 通道承担,再写一份就是同一事件两个真源。 + * 同拍既启动又动作(延时段 0ms / 速断)→ 两条都记,顺序先启动后动作, + * 与物理事件次序一致。 + * desc 直接用 prot_elm_name() 的 ROM 常量("OC1-IA" 等,最长 6 字节 + * << SYSLOG_DESC_MAX=28),零格式化、零栈缓冲。 + */ +static void s_log_record_edge(uint32_t active, uint32_t pick) { + uint32_t p_chg = pick ^ s_prev_pick; + uint32_t a_chg = active ^ s_prev_active; + uint32_t i; + + for (i = 0u; (i < (uint32_t)PE_COUNT) && (((p_chg | a_chg) != 0u)); i++) { + const uint32_t bit = 1u << i; + + if (((p_chg & bit) != 0u) && ((pick & bit) != 0u)) { + syslog_post_quiet(SYSLOG_START, prot_elm_name((prot_elm_id_t)i)); + } + if (((a_chg & bit) != 0u) && ((active & bit) != 0u)) { + switch (prot_elm_record_kind((prot_elm_id_t)i)) { + case PROT_REC_TRIP: + syslog_post_quiet(SYSLOG_ACCIDENT, prot_elm_name((prot_elm_id_t)i)); + break; + case PROT_REC_ALARM: + syslog_post_quiet(SYSLOG_ALARM, prot_elm_name((prot_elm_id_t)i)); + break; + default: + /* PROT_REC_NONE:元件号越界。位图来自 prot_elm 自己的表,正常 + * 走不到这里;真走到了就是表与位图不一致,一条都不能落—— + * 落错类别的审计记录比少一条记录更坏。 */ + break; + } + } + } + s_prev_pick = pick; + s_prev_active = active; +} + /* * 函数功能:protectTask 线程入口 * 入口参数:argument - 未用 @@ -242,6 +299,8 @@ void protect_task(void *argument) { } s_log_active_edge(prot_elm_active_mask()); + /* 三类文本记录(task-10):放在 SOE 边沿之后、喂狗之前,纯 RAM 投递 */ + s_log_record_edge(prot_elm_active_mask(), prot_elm_pick_mask()); task_wd_beat(TASK_WD_PROTECT); osDelay(PROTECT_TASK_CYCLE_MS); diff --git a/Drivers/BSP/UI/ui_data.c b/Drivers/BSP/UI/ui_data.c index a723b008..c19e10be 100644 --- a/Drivers/BSP/UI/ui_data.c +++ b/Drivers/BSP/UI/ui_data.c @@ -10,11 +10,23 @@ * 发布者,保护收在本模块内部(双槽 + 每槽 seq + 单字节活动下标,照 * App/algo/phasor.c 双区先例),页面代码不受影响 * (判据 docs/UI设计/UI_数据供给设计说明.md §5.1) + * 写路径(定值整定请求队列)同理:生产者是页面"保存"按键钩子、消费者是 + * 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 写路径节) * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) * 作者:王建锋 * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-14) + * v1.1 2026-10-26 DTU 项目组 道F task-1:新增定值写路径环形队列(深度 4、满返 -1) + * 与状态机(IDLE/WAIT/OK/ERR_RANGE/ERR_LOCKED/ERR_BUSY), + * 执行经注入函数表回调 App 侧;参数帧新增 cal_block + * 字段随 publish 原样入存(本层不解释含义) */ /* 头文件包含区 */ @@ -39,15 +51,42 @@ static uint32_t s_rt_cnt; static uint32_t s_par_cnt; static uint32_t s_info_cnt; +/* ── 写路径:定值整定请求队列(深度 UI_DATA_PARAM_QD 的静态环形数组)──────── */ + +/* + * mask 合法位集合 = 低 UI_DATA_SET_N 位(40 项 → 0x000000FFFFFFFFFF)。 + * 用"全 1 右移"而不是"1 左移 40 位再减 1":前者在 UI_DATA_SET_N == 64 时仍是 + * 良定义(移 0 位),后者会退化成立即未定义行为(移位数 == 位宽)。 + * 项数一旦超过 64,下面的编译期守卫当场拦下——位图放不下就是表示法该换, + * 绝不能静默把高 24 项变成"投不进去也不报错" + */ +#define UI_DATA_PARAM_MASK_VALID ((uint64_t)(~(uint64_t)0u) >> (64u - UI_DATA_SET_N)) + +typedef char ui_data_chk_mask_t[((uint32_t)UI_DATA_SET_N <= 64u) ? 1 : -1]; + +/* 环形队列:head = 出队下标,cnt = 在队条数(0..UI_DATA_PARAM_QD) + * 为什么用 head+cnt 而不是 head+tail:cnt 直接就是"还有几条没落定", + * state() 判 WAIT 不必再做 (tail - head) 取模减法,少一处可算错的地方 */ +static ui_data_param_req_t s_pq[UI_DATA_PARAM_QD]; +static uint8_t s_pq_head; +static uint8_t s_pq_cnt; + +/* 写路径状态字与越界位图(页面只读;int/uint64 单字读写在 M4 上不撕裂) */ +static int s_pq_state = UI_DATA_PARAM_ST_IDLE; +static uint64_t s_pq_err_mask; + /* 函数定义区 */ /* - * 函数功能:快照层初始化——三通道清零(valid=0、seq=0),页面即按"无首帧"渲染 + * 函数功能:快照层初始化——三通道清零(valid=0、seq=0)+ 写路径队列复位, + * 页面即按"无首帧/未投递"渲染 * 入口参数:无 * 返回值:无 * 限定条件:内核启动前可调(纯内存操作,零 OS 依赖);可重复调用(幂等) * 函数说明:静态存储上电本已清零,本函数的意义是运行期复位(例如"恢复出厂后 - * 重开 UI"这类后续路径),以及让宿主侧测试能随时造出"首帧之前"的状态 + * 重开 UI"这类后续路径),以及让宿主侧测试能随时造出"首帧之前"的状态。 + * 写路径一并丢弃在队请求是**有意**的:旧请求带的是旧配置下的编辑叠层, + * 复位后还把它落进 prot_set,等于拿陈旧草稿覆盖新装载的定值 */ void ui_data_init(void) { (void)memset(&s_rt, 0, sizeof(s_rt)); @@ -56,6 +95,11 @@ void ui_data_init(void) { s_rt_cnt = 0u; s_par_cnt = 0u; s_info_cnt = 0u; + (void)memset(s_pq, 0, sizeof(s_pq)); + s_pq_head = 0u; + s_pq_cnt = 0u; + s_pq_state = UI_DATA_PARAM_ST_IDLE; + s_pq_err_mask = 0u; } /* @@ -194,3 +238,185 @@ uint32_t ui_data_par_seq(void) { uint32_t ui_data_info_seq(void) { return s_info.seq; } +/* + * 函数功能:投递一条定值写入请求(页面"保存"行的唯一出口——只投递,不执行) + * 入口参数:a_req - 请求(本层整条拷入环形队列,调用方可复用其栈/叠层空间) + * const ui_data_param_req_t * + * 返回值:0 - 已入队(状态转 WAIT);或 mask 为 0 的空请求(无改动可保存, + * 状态直接转 OK,不占槽位) + * -1 - 队列满(状态转 ERR_BUSY,在队请求一条不动); + * -2 - 参数无效:NULL 或 mask 含 UI_DATA_SET_N 以上的位 + * (状态与队列都不动——把调用方的编程错误落成屏上"错误"会误导现场) int + * 限定条件:uiTask 上下文(页面按键钩子);与 drain 同上下文串行 → 无锁 + * 函数说明:本层不校验值域(越界与否属执行侧事实,落在 drain 的整批预检), + * 不 include App 头、不碰 prot_set、不碰 FatFs(UI 直写定值会把校验和 + * 打成中间态、直写盘会撞 defaultTask 单写者纪律——契约 §6.1) + * 空请求按成功处理是刻意的:保存行 ENTER 时"一个都没改"是正常操作, + * 报 ERR 会让用户以为设备有问题 + */ +int ui_data_param_request(const ui_data_param_req_t *a_req) { + uint8_t slot; + + if (a_req == NULL) { + return -2; + } + if ((a_req->mask & ~UI_DATA_PARAM_MASK_VALID) != 0u) { + return -2; + } + if (a_req->mask == 0u) { + s_pq_state = UI_DATA_PARAM_ST_OK; + s_pq_err_mask = 0u; + return 0; + } + if (s_pq_cnt >= (uint8_t)UI_DATA_PARAM_QD) { + s_pq_state = UI_DATA_PARAM_ST_ERR_BUSY; + return -1; + } + + slot = (uint8_t)(((uint32_t)s_pq_head + (uint32_t)s_pq_cnt) % UI_DATA_PARAM_QD); + (void)memcpy(&s_pq[slot], a_req, sizeof(s_pq[slot])); + s_pq_cnt += 1u; + s_pq_state = UI_DATA_PARAM_ST_WAIT; + return 0; +} + +/* + * 函数功能:取写路径状态(页面保存行显示) + * 入口参数:无 + * 返回值:UI_DATA_PARAM_ST_IDLE / _WAIT / _OK / _ERR_RANGE / _ERR_LOCKED / + * _ERR_BUSY 之一 int + * 限定条件:无(只读单字,任意上下文安全) + * 函数说明:本函数就是 s_pq_state 的直读,不做"pending 优先"之类的二次加工—— + * 三条写路径(投递成功→WAIT、投递被拒→ERR_BUSY、drain 落定→终态或 + * 残留则 WAIT)已把顺序语义收在写侧,读侧再判一次等于两处解释同一件事 + */ +int ui_data_param_state(void) { + return s_pq_state; +} + +/* + * 函数功能:取最近一条请求的越界项位图(页面高亮越界行) + * 入口参数:无 + * 返回值:bit i = 1 → 第 i 项越界;最近终态不是 ERR_RANGE 时恒 0 uint64_t + * 限定条件:无(只读单字) + * 函数说明:与 ui_data_par_t.bad_mask 两条来源不同(本函数 = 本次提交被预检挡下 + * 的项;bad_mask = prot_set 整表装载/解析留下的越界位图),页面在 + * ERR_RANGE 那一帧用本函数,混用会把上一次装载的越界显示成本次的 + */ +uint64_t ui_data_param_err_mask(void) { + return s_pq_err_mask; +} + +/* + * 函数功能:整批预检一条请求(位图内置位的项逐个问执行侧 check()) + * 入口参数:a_ops - 执行侧函数表(调用方已核非 NULL) + * a_req - 待检请求 + * 返回值:越界项位图(bit i = 第 i 项越界;0 = 本批全部在界内) uint64_t + * 限定条件:只调 check(),不写任何东西("预检"与"落值"分成两个函数, + * 正是为了让 all-or-nothing 有地方落脚:整批扫完才决定写不写) + * 函数说明:一次扫全 40 位而不是"遇到第一个越界就返回"——err_mask 要给页面 + * **全部**越界行反显,只报第一行等于让用户改完一行再撞下一行 + */ +static uint64_t ui_data_param_scan(const ui_data_param_ops_t *a_ops, + const ui_data_param_req_t *a_req) { + uint64_t bad = 0u; + uint64_t bit = 1u; + uint8_t i; + + for (i = 0u; i < (uint8_t)UI_DATA_SET_N; i++) { + if ((a_req->mask & bit) != 0u) { + if (a_ops->check(i, a_req->q10[i]) != 0) { + bad |= bit; + } + } + bit <<= 1u; + } + return bad; +} + +/* + * 函数功能:逐项落值一条请求(调用前 ui_data_param_scan() 已确认整批在界内) + * 入口参数:a_ops - 执行侧函数表 + * a_req - 待写请求 + * 返回值:0 - 位图内全部写入;-1 - 某项被执行侧拒绝(*p_bad 输出该项) int + * 限定条件:apply() 的副作用由执行侧定义(App 实现 = prot_set_set_sealed, + * 每项自带置脏);本函数不回滚已写项——prot_set 没有回滚接口 + * 函数说明:走到这里还失败 = 执行侧与预检不一致(两个真源漂移),理论不可达; + * 如实报失败项比谎称"整批未写"更诚实,故**可能已有前几项生效** + */ +static int ui_data_param_write(const ui_data_param_ops_t *a_ops, + const ui_data_param_req_t *a_req, + uint64_t *p_bad) { + uint64_t bit = 1u; + uint8_t i; + + for (i = 0u; i < (uint8_t)UI_DATA_SET_N; i++) { + if ((a_req->mask & bit) != 0u) { + if (a_ops->apply(i, a_req->q10[i]) != 0) { + *p_bad = bit; + return -1; + } + } + bit <<= 1u; + } + *p_bad = 0u; + return 0; +} + +/* + * 函数功能:处理一条定值写入请求(App 装配层节拍入口:出队 → 闭锁 → 预检 → 落定) + * 入口参数:a_ops - 执行侧函数表(locked/check/apply 三者都不得为 NULL) + * const ui_data_param_ops_t * + * 返回值:1 - 处理并提交了一条(状态已转终态,或队列仍有残留则转 WAIT); + * 0 - 队列空(状态与队列都不动); + * -1 - 参数无效(a_ops 或其成员为 NULL,状态与队列都不动) int + * 限定条件:uiTask 上下文(App/app/ui_data_feed.c 的 tick 每轮一次);每轮最多 + * 处理一条——整定是低频人工事件,一轮一条已够,且把最坏耗时钉在 + * 2 × 40 次回调(预检一遍 + 写入一遍),不随队列堆积抖动 + * 函数说明:四步顺序即判据(契约 §6.2),判定本体拆成 scan/write 两个静态函数 + * (见其函数说明)—— + * ① 取队首;② locked() → ERR_LOCKED 并**丢弃本条**(闭锁是业务态,不做 + * "排队等解锁",否则屏上 WAIT 无限期挂着,用户以为设备卡死); + * ③ scan() 任一项越界 → ERR_RANGE 且**一字不写**(all-or-nothing,与 + * prot_set_load_raw 同纪律:半张表落进值区就是"看着保存成功、其实混着 + * 新旧两套定值");④ write() 全成 → OK。 + * ⚠ 顺序不可调:先判闭锁再判越界——闭锁态下 check() 的语义仍成立,但 + * 页面要的是"定值闭锁"这个更高优先级的原因(契约 §6.3 表序) + */ +int ui_data_param_drain(const ui_data_param_ops_t *a_ops) { + const ui_data_param_req_t *p_req; + uint64_t bad; + int settled; + + if ((a_ops == NULL) || (a_ops->locked == NULL) || + (a_ops->check == NULL) || (a_ops->apply == NULL)) { + return -1; + } + if (s_pq_cnt == 0u) { + return 0; + } + + p_req = &s_pq[s_pq_head]; + bad = 0u; + + if (a_ops->locked() != 0u) { + settled = UI_DATA_PARAM_ST_ERR_LOCKED; + } else { + settled = UI_DATA_PARAM_ST_OK; + bad = ui_data_param_scan(a_ops, p_req); + if (bad != 0u) { + settled = UI_DATA_PARAM_ST_ERR_RANGE; /* 预检挡下:一字未写 */ + } else { + if (ui_data_param_write(a_ops, p_req, &bad) != 0) { + settled = UI_DATA_PARAM_ST_ERR_RANGE; /* 执行侧拒写:见函数说明 */ + } + /* 两关都过 → settled 保持 OK */ + } + } + + s_pq_err_mask = bad; + s_pq_head = (uint8_t)(((uint32_t)s_pq_head + 1u) % UI_DATA_PARAM_QD); + s_pq_cnt -= 1u; + s_pq_state = (s_pq_cnt != 0u) ? UI_DATA_PARAM_ST_WAIT : settled; + return 1; +} diff --git a/Drivers/BSP/UI/ui_data.h b/Drivers/BSP/UI/ui_data.h index 205ac511..c378f5f0 100644 --- a/Drivers/BSP/UI/ui_data.h +++ b/Drivers/BSP/UI/ui_data.h @@ -25,6 +25,13 @@ * v1.0 2026-10-25 王建锋 创建(道E task-14:契约 §4 快照层落地;相对契约 * 两通道新增第三条 info,划分理由见本头"通道划分"注 * 与契约 §4.4) + * v1.1 2026-10-26 DTU 项目组 道F task-1(docs/任务规划/UI补齐.md §2 第 1 行): + * 新增定值写路径(param_request/param_state/ + * param_drain,契约 §6.2 深度 4 环形队列)+ 参数帧尾 + * 追加 cal_block(UI_DATA_VER 1→2,整定页"未标定"徽标 + * 的数据源);写路径仍不引入任何 App 依赖(执行事实 + * 经 ui_data_param_ops_t 由 App 侧注入,同契约 §7.2 + * 记录页注入式 reader 的路子) */ #ifndef UI_DATA_H @@ -41,7 +48,9 @@ extern "C" { * (禁止删除或重排字段——页面按名字取,重排是静默错位; * 口径 docs/UI设计/UI_数据供给设计说明.md §4.3) */ -#define UI_DATA_VER 1u +/* v2(2026-10-26,道F task-1):ui_data_par_t 尾追加 cal_block——版本号是页面与 + * 写侧唯一的对齐凭据,追加字段不升版 = 老页面按新帧长 memcpy 时静默错位 */ +#define UI_DATA_VER 2u /* ── 通道数量 ─────────────────────────────────────────────────────────────── */ @@ -157,6 +166,16 @@ typedef struct { uint8_t rsv; /* 预留 */ uint64_t bad_mask; /* 40 位:置 1 = 该定值项越界(页面反显该行) */ int32_t q10[UI_DATA_SET_N]; /* Q10 原值,下标 = prot_set_id_t 枚举序 */ + /* v2 尾追加:未标定闭锁(整定页"未标定"徽标的数据源)。 + * 1 = UA/UN/IA 三路里至少一路的标定 VALID 未置位 → 保护判据当前停走,页面必须 + * 让用户看见"改了定值也不会动作";0 = 三路全 VALID(判据在跑)。 + * ⚠ 口径**不是** rt.eng:eng 是整帧量纲档(要求电气 8 路全 VALID),V0.1 双 + * 转换器下 UB/UC/IB/IC/IN 永不置位 → eng 恒为引脚档,照 eng 画徽标就成了 + * "标定做完屏上仍永不解锁"。三路口径的权威实现 = App/task/protect_task.c + * 的 s_cal_locked()(写侧 App/app/ui_data_feed.c 按同一条判据现算,本层只存 + * 真值、不判断)。与上面 locked(定值校验和自检闭锁 = 禁编辑)是两件事, + * 屏上不得互认(三词三源见 docs/任务规划/数据可信.md §4.3) */ + uint8_t cal_block; } ui_data_par_t; /* ── 通道三:装置信息(静态身份 + 通讯参数 + 运行时长)───────────────────── */ @@ -276,6 +295,137 @@ uint32_t ui_data_par_seq(void); */ uint32_t ui_data_info_seq(void); +/* ── 写路径:定值整定请求(页面只投递,执行在 App 侧)───────────────────── */ + +/* + * 请求队列深度(契约 docs/UI设计/UI_数据供给设计说明.md §6.2 定死 4) + * 为什么是 4 不是 1:整定页"保存"是用户按键,一条请求要等 App 侧 drain 才落定; + * 深度 1 会让连按两次保存把第二条判成"忙",而 4 格 = 本层 .bss 多 + * 4 × sizeof(ui_data_param_req_t)(168 B/格,主机单测 TC-UIDATA-016 钉住尺寸, + * 内存台账 docs/内存管理/RAM使用分析与优化方案.md)。加深 = 纯 RAM 代价, + * 本层不做任何按内存自适应 + */ +#define UI_DATA_PARAM_QD 4u + +/* + * 一条定值写入请求 = "本次保存改了哪些项 + 它们的 Q10 新值" + * + * 为什么是「位图 + 定长 40 槽」而不是「项数 + 变长 id 数组」:表现层编辑叠层本就 + * 是一张改动位图(uint64_t)+ 一张工作值表(int32_t[UI_DATA_SET_N]),本形状与它 + * 逐位同构 → 提交侧一次 memcpy、执行侧逐项按位取,两侧都不必再维护第三种表示; + * 定长还让队列退化成 O(1) 静态环形数组(零堆、零变长拷贝、零 malloc 门禁风险)。 + * + * ⚠ mask 只有 64 位 → 定值项数硬上限 64;越界位由 ui_data.c 的编译期守卫拦住, + * 将来项数破 64 必须换表示(不会静默丢项)。 + * ⚠ **mask 未置位的槽本次不提交**:本层与执行侧都不读它——调用方可以只填 mask 内 + * 的槽(整帧 memcpy 入队会把 mask 外的字节一并存下来,但那字节永不被读,不影响 + * 任何判定;先 memset 再填也完全可以,两种写法都安全)。 + * 绝不允许"从显示值回提全表"——Q10 存不下 0.95 这类返回系数(真存 972/1024), + * 按显示域往返会把用户没动的项顺手改掉 + * (docs/任务规划/UI补齐.md §2 第 2 行判据"值不落盘"的前提)。 + */ +typedef struct { + uint64_t mask; /* bit i = 1 → 第 i 项随本请求提交 */ + int32_t q10[UI_DATA_SET_N]; /* Q10 新值;仅 mask 置位的槽有意义 */ +} ui_data_param_req_t; + +/* + * 写路径状态(页面保存行显示;语义表 = 契约 §6.3) + * IDLE - 从未投递 → 页面显 ENTER + * WAIT - 已投递未落定(队列里还有请求在路上)→ 页面显 WAIT + * OK - 最近一条已逐项写入并置脏 → 页面显 OK + * ERR_RANGE - 最近一条整批拒绝:任一项越界 → 一字未写 → 页面显 ERR 并高亮 + * 越界行(越界项经 ui_data_param_err_mask() 取) + * ERR_LOCKED - 最近一条被定值闭锁拒(写侧自检失败态)→ 页面显 LOCK 并禁编辑 + * ERR_BUSY - 最近一次投递被拒:队列满 → 页面显 ERR、页脚"忙,请重试" + * 状态变量的三条写入路径(本层唯二写者 = request 与 drain,同在 uiTask): + * 投递成功 → WAIT;投递被拒(满)→ ERR_BUSY;drain 落定 → 终态,但队列仍有 + * 残留时改写回 WAIT。故"满被拒"看得见(ERR_BUSY),随后逐步 drain 又回到 WAIT + */ +#define UI_DATA_PARAM_ST_IDLE 0 +#define UI_DATA_PARAM_ST_WAIT 1 +#define UI_DATA_PARAM_ST_OK 2 +#define UI_DATA_PARAM_ST_ERR_RANGE 3 +#define UI_DATA_PARAM_ST_ERR_LOCKED 4 +#define UI_DATA_PARAM_ST_ERR_BUSY 5 + +/* + * 执行侧函数表(由 App 在 ui_data_feed_tick() 每次调用时传入;本层不保存副本) + * + * 为什么用注入而不是直接调 prot_set:分层方向硬约束——Drivers/BSP 不得 include + * App 头(scripts/check_layer_deps.py 拦提交),而"什么算越界、写了要不要置脏" + * 全在 App/algo/prot_set 那侧。同契约 §7.2 记录页的注入式 reader:BSP 声明抽象 + * 函数表,App 给实现,主机单测给桩 → 队列与状态机的**生产代码**能在 PC 上跑 + * 全表(越界不改值 / 闭锁拒写 / 正常往返三条判据都覆盖到真代码,不是副本)。 + * ⚠ 本表不含任何"范围数值"——上下限真源仍是 App/algo/prot_set.c 的 + * g_prot_set_meta,本层只按 check() 的返回值决定整批写不写(不新增第二真源) + */ +typedef struct { + uint8_t (*locked)(void); /* 1 = 定值闭锁中 */ + int (*check)(uint8_t a_id, int32_t a_q10); /* 0 合法 / -1 越界 */ + int (*apply)(uint8_t a_id, int32_t a_q10); /* 0 成功 / -1 失败 */ +} ui_data_param_ops_t; + +/* + * 函数功能:投递一条定值写入请求(页面"保存"行的唯一出口——只投递,不执行) + * 入口参数:a_req - 请求(mask 与 q10 整条拷入队列,调用方可复用其栈空间) + * const ui_data_param_req_t * + * 返回值:0 - 已入队(状态转 WAIT);mask 为 0 的空请求也返回 0,但按"无改动可 + * 保存"直接落终态 OK 且**不占槽位**(保存行 ENTER 时一个都没改 + * 是正常操作,报 ERR 会让现场以为设备有问题); + * -1 - 队列满(状态转 ERR_BUSY,在队请求一条不动); + * -2 - 参数无效:NULL、或 mask 含 UI_DATA_SET_N 以上的位 + * (状态与队列都不动:调用方的编程错误不该落成屏上的"越界") int + * 限定条件:uiTask 上下文(页面按键钩子);与 drain 同上下文 → 无锁 + * 函数说明:本函数**不**校验值域(越界与否是执行侧事实,在 drain 里整批预检), + * 也不碰 prot_set、不碰文件系统(落盘归 defaultTask,AGENTS 模块约定) + */ +int ui_data_param_request(const ui_data_param_req_t *a_req); + +/* + * 函数功能:取写路径状态(页面保存行显示) + * 入口参数:无 + * 返回值:UI_DATA_PARAM_ST_* 之一 int + * 限定条件:无(只读单字,任意上下文安全) + * 函数说明:ERR_TIMEOUT(契约 §6.3 的"3 s 未取走")本层**不产出**——uiTask 每 + * 10 ms 就 drain 一次,真出现"永不落定"说明 App 侧没接线,属装配缺陷; + * 页面若要兜底可自己按投递时刻计时(docs/UI设计/UI_数据供给设计说明.md + * §6.3 已把它列为页面本地可选项) + */ +int ui_data_param_state(void); + +/* + * 函数功能:取最近一条请求的越界项位图(页面高亮越界行) + * 入口参数:无 + * 返回值:bit i = 1 → 第 i 项越界;最近终态不是 ERR_RANGE 时恒 0 uint64_t + * 限定条件:无(只读单字) + * 函数说明:与 ui_data_par_t.bad_mask 是两条不同来源——bad_mask 是 prot_set 整表 + * 装载/解析留下的越界位图(App 侧事实),本函数是**本次提交**里被预检 + * 挡下的项;页面在 ERR_RANGE 那一帧用本函数,别把两者混成一份 + */ +uint64_t ui_data_param_err_mask(void); + +/* + * 函数功能:处理一条定值写入请求(App 装配层节拍入口;出队 → 判定 → 落定) + * 入口参数:a_ops - 执行侧函数表(三个函数指针都不得为 NULL) + * const ui_data_param_ops_t * + * 返回值:1 - 处理并提交了一条(状态已落终态或转 WAIT);0 - 队列空(状态不动); + * -1 - 参数无效(ops 或其函数指针为 NULL,队列与状态都不动) int + * 限定条件:uiTask 上下文,紧跟页面之后调(App/app/ui_data_feed.c 的 tick); + * 每轮最多处理一条——整定是低频人工事件,一轮一条已够,且把最坏耗时 + * 钉在 40 次 check + 40 次 apply,不会随队列堆积而抖动 + * 函数说明:固定四步(顺序即判据,契约 §6.2)—— + * ① 取队首一条;② ops->locked() 非 0 → ERR_LOCKED,**本条丢弃**(闭锁 + * 是业务态,用户解闭锁后重新提交;不做"排队等解锁",那会让屏上的 WAIT + * 无限期挂着);③ 按 mask 逐项 ops->check() 整批预检,任一项越界 → + * ERR_RANGE 且**一字不写**(all-or-nothing,与 prot_set_load_raw 同纪律), + * 越界项记入 err_mask;④ 预检全过再逐项 ops->apply(),全成 → OK。 + * ④ 中途失败(预检已过仍失败 = 执行侧与预检不一致,理论不可达)不回滚: + * prot_set 无回滚接口,谎称"整批未写"比如实报 ERR_RANGE 更坏,故按实际 + * 失败项置 err_mask 后报 ERR_RANGE,并在注释里写明可能部分已生效 + */ +int ui_data_param_drain(const ui_data_param_ops_t *a_ops); + #ifdef __cplusplus } #endif diff --git a/Drivers/BSP/UI/ui_menu_tab.h b/Drivers/BSP/UI/ui_menu_tab.h index f82b4c25..c17e7700 100644 --- a/Drivers/BSP/UI/ui_menu_tab.h +++ b/Drivers/BSP/UI/ui_menu_tab.h @@ -53,7 +53,7 @@ uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max); * 入口参数:node - 节点下标 uint8_t 0 - 表长-1 * 返回值:true - 有子项(显示为菜单,带 ▶);false - 无子项(叶子,可进内容页) * 限定条件:纯查询,无副作用;node 越界返回 false - * 函数说明:这是**谓词**(bool 合法),不是成败码——err-type 检查里白名单放行的形态。 + * 函数说明:这是**谓词**(bool 合法),不是成败码——bsp-err 的 err-type 检查里白名单放行的形态。 */ bool ui_menu_tab_has_children(uint8_t node); diff --git a/Drivers/BSP/UI/ui_page_registry.c b/Drivers/BSP/UI/ui_page_registry.c index 84c0e44a..ee7616d6 100644 --- a/Drivers/BSP/UI/ui_page_registry.c +++ b/Drivers/BSP/UI/ui_page_registry.c @@ -28,6 +28,10 @@ const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT] = { { 3u, &ui_page_ac_desc }, /* node 3 交流量 */ { 4u, &ui_page_dc_desc }, /* node 4 直流量 */ { 5u, &ui_page_yx_desc }, /* node 5 遥信量 */ + { 8u, &ui_page_view_param_desc }, /* node 8 参数 */ + { 9u, &ui_page_view_set_desc }, /* node 9 定值 */ + { 10u, &ui_page_view_word_desc }, /* node 10 控制字 */ + { 11u, &ui_page_view_plate_desc }, /* node 11 软压板 */ { 13u, &ui_page_view_param_desc }, /* node 13 参数 */ { 14u, &ui_page_view_set_desc }, /* node 14 定值 */ { 15u, &ui_page_view_word_desc }, /* node 15 控制字 */ diff --git a/Drivers/BSP/UI/ui_page_registry.h b/Drivers/BSP/UI/ui_page_registry.h index eeabbbcf..2c5c0c92 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 12u /* 绑定了内容页的叶子数 */ +#define UI_PAGE_MAP_COUNT 16u /* 绑定了内容页的叶子数 */ extern const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT]; diff --git a/Drivers/BSP/UI/ui_page_view_param.c b/Drivers/BSP/UI/ui_page_view_param.c index 20284505..39236c9b 100644 --- a/Drivers/BSP/UI/ui_page_view_param.c +++ b/Drivers/BSP/UI/ui_page_view_param.c @@ -2,7 +2,13 @@ * 模块名称:UI Page view_param (参数查看内容页) * 模块功能:参数定值 → 查看 → 参数 的内容页——8 行数值表(PT/CT 一次二次额定 + 4 个返回系数),每页 8 行、1 页; * 数据 = 参数快照(Q10)按行表小数位换算,越界项由 bad 回调反显; - * 本文件零 App include、零写路径(查看页没有"保存"这件事) + * 本文件零 App include;写方向只有一个出口:整定态的"保存"经 + * ui_data_param_request() 投递(执行与落盘在 App 侧,本层不碰 prot_set/FatFs) + * 两态一页(道F task-2:整定 4 叶绑定本描述符):同一份描述符既服务"查看"叶子 + * (只读——不绑 commit → 无保存行、按键全交回框架 → 像素与绑定前逐位一致), + * 也服务"整定"叶子(123 授权 → 编辑叠层 + 保存行 + 未标定徽标)。 + * 分派点只有 pg_init 一处(按 ui_content_page_is_editable() 判), + * 两态共用行表/取数/反显,不存在"查看一套边界、整定另一套边界" * 为什么这么薄:行表与取数在 ui_w_setview(四个查看页共用一份视图模型), * 渲染与反显在 ui_w_setlist(与整定页同一组件)——本文件只把视图编号 * 和标准骨架几何令牌接进框架的跳板,改一次口径四处一致 @@ -11,6 +17,11 @@ * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-15 批 3) + * v1.1 2026-10-26 DTU 项目组 道F task-2:接整定态——pg_init 按权限分派 + * attach/attach_edit、新增 pg_key 转发按键、 + * pg_draw 追加"未标定"徽标、描述符补 + * edit_capable/key_intercept/key_cb 三项 + * (只读分支行为逐字不变) */ /* 头文件包含区 */ #include "ui_page_view_param.h" @@ -32,11 +43,21 @@ static ui_w_setlist_t s_sl; * 入口参数:无 * 返回值:无 * 限定条件:框架开页链内调用(uiTask) - * 函数说明:每次开页都重新 attach——组件实例内残留的编辑态/游标会跨页带过来, - * 开页复位比"信任上一次已经清干净"可靠 + * 函数说明:1. 每次开页都重新 attach——组件实例内残留的编辑态/游标会跨页带过来, + * 开页复位比"信任上一次已经清干净"可靠 + * 2. **只读 / 整定两态在这里分派一次**,之后所有钩子只看已绑好的回调 + * (组件用 commit==NULL 判视图类型,不再回头看权限) + * 3. 时序依据(为什么 init_cb 里问 is_editable() 拿到的就是本次进入的权限): + * 框架 open() 先装 s_perm 与 s_open、最后才调 init_cb + * (ui_content_page.c 的 open:s_perm=perm → s_open=desc → init_cb), + * 所以这里读到的是"本次进入"的真值,不是上一次的残留 */ static void pg_init(void) { - ui_w_setview_attach(&s_sl, UI_W_SETVIEW_PARAM); + if (ui_content_page_is_editable()) { + ui_w_setview_attach_edit(&s_sl, UI_W_SETVIEW_PARAM); /* 整定:编辑 + 保存行 + 徽标 */ + } else { + ui_w_setview_attach(&s_sl, UI_W_SETVIEW_PARAM); /* 查看:与绑定前完全同一条路径 */ + } } /* @@ -60,26 +81,49 @@ static void pg_refresh(uint16_t a_row, char *a_name, char *a_value) { } /* - * 函数功能:自绘层(越界行反显;框架内置区块画完后调用) + * 函数功能:自绘层(越界行反显 + 未标定徽标;框架内置区块画完后调用) * 入口参数:a_layer / a_clip - 契约见 ui_content_page.h * 返回值:无 * 限定条件:框架绘制链内 + * 函数说明:徽标函数自己判"是不是整定视图 + 帧在不在 + cal_block 置没置", + * 页面这里无条件调它——四页少写四个 if,也就少四次写漏的机会; + * 查看页三个条件第一条就不成立,一个像素都不落(vparam~vplate + * 四张黄金参考仍逐位一致) */ static void pg_draw(lv_layer_t *a_layer, const lv_area_t *a_clip) { ui_w_setlist_draw(&s_sl, a_layer, a_clip); + ui_w_setview_draw_cal_badge(a_layer, a_clip); +} + +/* + * 函数功能:按键转发(框架 key_cb) + * 入口参数:a_key - LVGL 键值 lv_key_t + * 返回值:true - 本页已消费(框架跳过内置);false - 交回框架(↑↓ 移选、← 关页) + * 限定条件:框架按键链内;描述符须 edit_capable=1 + key_intercept=1 + * 函数说明:两态行为的不同**不写在这里**——ui_w_setview_key() 先看视图类型 + * (查看页未绑 commit → 直接交回框架,等于本页没有 key_cb), + * 再看参数帧在不在。所以这里必须原样透传返回值,不得就地"或"上什么 + */ +static bool pg_key(lv_key_t a_key) { + return (ui_w_setview_key(&s_sl, a_key) != 0u); } /* * 页面描述符(外部链接:生成物 ui_page_registry.c 按菜单树 #page=view_param 绑定引用) - * edit_capable=0 + 无 key_cb + key_intercept=0:查看页不接受任何写交互, - * ↑↓ 移选与 ← 关页全走框架内置(§3.2.7 表里"权限不足 ENTER 一律忽略"在 - * 查看页等价于"根本没有 ENTER 这条路") + * edit_capable=1 + key_cb + key_intercept=1:同一份描述符两态共用。 + * · 整定叶子(123 授权 UI_PERM_MODIFY)→ is_editable() 真 → attach_edit + + * 编辑叠层 + 保存行;key_intercept=1 是**必需**的:编辑中的 ←→ 移位光标 + * 要先给页面,否则框架拿 ← 当"关页",编辑到一半就跳出去 + * · 查看叶子(无 #P,恒 UI_PERM_VIEW)→ is_editable() 假 → 仍走 attach, + * ui_w_setview_key() 第一条门就把键全部交回框架,↑↓ 移选与 ← 关页全走内置 + * ——即"绑了 key_cb 的查看页"与"没有 key_cb 的查看页"行为逐字等价 + * (§3.2.7:"权限不足 ENTER 一律忽略") */ const content_page_def_t ui_page_view_param_desc = { .id = "view_param", .title = "参数", .selectable = 1u, - .edit_capable = 0u, + .edit_capable = 1u, /* 两态页:能不能真编辑还要看本次进入的权限(is_editable) */ .top_y = UI_LIST_TOP, .pitch = UI_ROW_PITCH, .band_h = UI_ROW_BAND_H, @@ -87,8 +131,10 @@ const content_page_def_t ui_page_view_param_desc = { .left_x = UI_LIST_LEFT_X, .right_x = UI_LIST_RIGHT_X, .rows_per_page = UI_LIST_ROWS, + .key_intercept = 1u, /* ← 先交 key_cb:编辑中是"移位光标",非编辑中才轮到关页 */ .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_view_plate.c b/Drivers/BSP/UI/ui_page_view_plate.c index 425994d7..8fd908b3 100644 --- a/Drivers/BSP/UI/ui_page_view_plate.c +++ b/Drivers/BSP/UI/ui_page_view_plate.c @@ -2,7 +2,13 @@ * 模块名称:UI Page view_plate (软压板查看内容页) * 模块功能:参数定值 → 查看 → 软压板 的内容页——10 行二态表(9 个段投退 + 保护总投:投入/退出),每页 8 行、2 页; * 数据 = 参数快照(Q10)按行表小数位换算,越界项由 bad 回调反显; - * 本文件零 App include、零写路径(查看页没有"保存"这件事) + * 本文件零 App include;写方向只有一个出口:整定态的"保存"经 + * ui_data_param_request() 投递(执行与落盘在 App 侧,本层不碰 prot_set/FatFs) + * 两态一页(道F task-2:整定 4 叶绑定本描述符):同一份描述符既服务"查看"叶子 + * (只读——不绑 commit → 无保存行、按键全交回框架 → 像素与绑定前逐位一致), + * 也服务"整定"叶子(123 授权 → 编辑叠层 + 保存行 + 未标定徽标)。 + * 分派点只有 pg_init 一处(按 ui_content_page_is_editable() 判), + * 两态共用行表/取数/反显,不存在"查看一套边界、整定另一套边界" * 为什么这么薄:行表与取数在 ui_w_setview(四个查看页共用一份视图模型), * 渲染与反显在 ui_w_setlist(与整定页同一组件)——本文件只把视图编号 * 和标准骨架几何令牌接进框架的跳板,改一次口径四处一致 @@ -11,6 +17,11 @@ * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-15 批 3) + * v1.1 2026-10-26 DTU 项目组 道F task-2:接整定态——pg_init 按权限分派 + * attach/attach_edit、新增 pg_key 转发按键、 + * pg_draw 追加"未标定"徽标、描述符补 + * edit_capable/key_intercept/key_cb 三项 + * (只读分支行为逐字不变) */ /* 头文件包含区 */ #include "ui_page_view_plate.h" @@ -32,11 +43,21 @@ static ui_w_setlist_t s_sl; * 入口参数:无 * 返回值:无 * 限定条件:框架开页链内调用(uiTask) - * 函数说明:每次开页都重新 attach——组件实例内残留的编辑态/游标会跨页带过来, - * 开页复位比"信任上一次已经清干净"可靠 + * 函数说明:1. 每次开页都重新 attach——组件实例内残留的编辑态/游标会跨页带过来, + * 开页复位比"信任上一次已经清干净"可靠 + * 2. **只读 / 整定两态在这里分派一次**,之后所有钩子只看已绑好的回调 + * (组件用 commit==NULL 判视图类型,不再回头看权限) + * 3. 时序依据(为什么 init_cb 里问 is_editable() 拿到的就是本次进入的权限): + * 框架 open() 先装 s_perm 与 s_open、最后才调 init_cb + * (ui_content_page.c 的 open:s_perm=perm → s_open=desc → init_cb), + * 所以这里读到的是"本次进入"的真值,不是上一次的残留 */ static void pg_init(void) { - ui_w_setview_attach(&s_sl, UI_W_SETVIEW_PLATE); + if (ui_content_page_is_editable()) { + ui_w_setview_attach_edit(&s_sl, UI_W_SETVIEW_PLATE); /* 整定:编辑 + 保存行 + 徽标 */ + } else { + ui_w_setview_attach(&s_sl, UI_W_SETVIEW_PLATE); /* 查看:与绑定前完全同一条路径 */ + } } /* @@ -60,26 +81,49 @@ static void pg_refresh(uint16_t a_row, char *a_name, char *a_value) { } /* - * 函数功能:自绘层(越界行反显;框架内置区块画完后调用) + * 函数功能:自绘层(越界行反显 + 未标定徽标;框架内置区块画完后调用) * 入口参数:a_layer / a_clip - 契约见 ui_content_page.h * 返回值:无 * 限定条件:框架绘制链内 + * 函数说明:徽标函数自己判"是不是整定视图 + 帧在不在 + cal_block 置没置", + * 页面这里无条件调它——四页少写四个 if,也就少四次写漏的机会; + * 查看页三个条件第一条就不成立,一个像素都不落(vparam~vplate + * 四张黄金参考仍逐位一致) */ static void pg_draw(lv_layer_t *a_layer, const lv_area_t *a_clip) { ui_w_setlist_draw(&s_sl, a_layer, a_clip); + ui_w_setview_draw_cal_badge(a_layer, a_clip); +} + +/* + * 函数功能:按键转发(框架 key_cb) + * 入口参数:a_key - LVGL 键值 lv_key_t + * 返回值:true - 本页已消费(框架跳过内置);false - 交回框架(↑↓ 移选、← 关页) + * 限定条件:框架按键链内;描述符须 edit_capable=1 + key_intercept=1 + * 函数说明:两态行为的不同**不写在这里**——ui_w_setview_key() 先看视图类型 + * (查看页未绑 commit → 直接交回框架,等于本页没有 key_cb), + * 再看参数帧在不在。所以这里必须原样透传返回值,不得就地"或"上什么 + */ +static bool pg_key(lv_key_t a_key) { + return (ui_w_setview_key(&s_sl, a_key) != 0u); } /* * 页面描述符(外部链接:生成物 ui_page_registry.c 按菜单树 #page=view_plate 绑定引用) - * edit_capable=0 + 无 key_cb + key_intercept=0:查看页不接受任何写交互, - * ↑↓ 移选与 ← 关页全走框架内置(§3.2.7 表里"权限不足 ENTER 一律忽略"在 - * 查看页等价于"根本没有 ENTER 这条路") + * edit_capable=1 + key_cb + key_intercept=1:同一份描述符两态共用。 + * · 整定叶子(123 授权 UI_PERM_MODIFY)→ is_editable() 真 → attach_edit + + * 编辑叠层 + 保存行;key_intercept=1 是**必需**的:编辑中的 ←→ 移位光标 + * 要先给页面,否则框架拿 ← 当"关页",编辑到一半就跳出去 + * · 查看叶子(无 #P,恒 UI_PERM_VIEW)→ is_editable() 假 → 仍走 attach, + * ui_w_setview_key() 第一条门就把键全部交回框架,↑↓ 移选与 ← 关页全走内置 + * ——即"绑了 key_cb 的查看页"与"没有 key_cb 的查看页"行为逐字等价 + * (§3.2.7:"权限不足 ENTER 一律忽略") */ const content_page_def_t ui_page_view_plate_desc = { .id = "view_plate", .title = "软压板", .selectable = 1u, - .edit_capable = 0u, + .edit_capable = 1u, /* 两态页:能不能真编辑还要看本次进入的权限(is_editable) */ .top_y = UI_LIST_TOP, .pitch = UI_ROW_PITCH, .band_h = UI_ROW_BAND_H, @@ -87,8 +131,10 @@ const content_page_def_t ui_page_view_plate_desc = { .left_x = UI_LIST_LEFT_X, .right_x = UI_LIST_RIGHT_X, .rows_per_page = UI_LIST_ROWS, + .key_intercept = 1u, /* ← 先交 key_cb:编辑中是"移位光标",非编辑中才轮到关页 */ .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_view_set.c b/Drivers/BSP/UI/ui_page_view_set.c index 055a4605..4c3343b1 100644 --- a/Drivers/BSP/UI/ui_page_view_set.c +++ b/Drivers/BSP/UI/ui_page_view_set.c @@ -2,7 +2,13 @@ * 模块名称:UI Page view_set (定值查看内容页) * 模块功能:参数定值 → 查看 → 定值 的内容页——18 行数值表(三段过流/过负荷/过压/低压/零序/过频/低频 定值与延时),每页 8 行、3 页; * 数据 = 参数快照(Q10)按行表小数位换算,越界项由 bad 回调反显; - * 本文件零 App include、零写路径(查看页没有"保存"这件事) + * 本文件零 App include;写方向只有一个出口:整定态的"保存"经 + * ui_data_param_request() 投递(执行与落盘在 App 侧,本层不碰 prot_set/FatFs) + * 两态一页(道F task-2:整定 4 叶绑定本描述符):同一份描述符既服务"查看"叶子 + * (只读——不绑 commit → 无保存行、按键全交回框架 → 像素与绑定前逐位一致), + * 也服务"整定"叶子(123 授权 → 编辑叠层 + 保存行 + 未标定徽标)。 + * 分派点只有 pg_init 一处(按 ui_content_page_is_editable() 判), + * 两态共用行表/取数/反显,不存在"查看一套边界、整定另一套边界" * 为什么这么薄:行表与取数在 ui_w_setview(四个查看页共用一份视图模型), * 渲染与反显在 ui_w_setlist(与整定页同一组件)——本文件只把视图编号 * 和标准骨架几何令牌接进框架的跳板,改一次口径四处一致 @@ -11,6 +17,11 @@ * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-15 批 3) + * v1.1 2026-10-26 DTU 项目组 道F task-2:接整定态——pg_init 按权限分派 + * attach/attach_edit、新增 pg_key 转发按键、 + * pg_draw 追加"未标定"徽标、描述符补 + * edit_capable/key_intercept/key_cb 三项 + * (只读分支行为逐字不变) */ /* 头文件包含区 */ #include "ui_page_view_set.h" @@ -32,11 +43,21 @@ static ui_w_setlist_t s_sl; * 入口参数:无 * 返回值:无 * 限定条件:框架开页链内调用(uiTask) - * 函数说明:每次开页都重新 attach——组件实例内残留的编辑态/游标会跨页带过来, - * 开页复位比"信任上一次已经清干净"可靠 + * 函数说明:1. 每次开页都重新 attach——组件实例内残留的编辑态/游标会跨页带过来, + * 开页复位比"信任上一次已经清干净"可靠 + * 2. **只读 / 整定两态在这里分派一次**,之后所有钩子只看已绑好的回调 + * (组件用 commit==NULL 判视图类型,不再回头看权限) + * 3. 时序依据(为什么 init_cb 里问 is_editable() 拿到的就是本次进入的权限): + * 框架 open() 先装 s_perm 与 s_open、最后才调 init_cb + * (ui_content_page.c 的 open:s_perm=perm → s_open=desc → init_cb), + * 所以这里读到的是"本次进入"的真值,不是上一次的残留 */ static void pg_init(void) { - ui_w_setview_attach(&s_sl, UI_W_SETVIEW_SET); + if (ui_content_page_is_editable()) { + ui_w_setview_attach_edit(&s_sl, UI_W_SETVIEW_SET); /* 整定:编辑 + 保存行 + 徽标 */ + } else { + ui_w_setview_attach(&s_sl, UI_W_SETVIEW_SET); /* 查看:与绑定前完全同一条路径 */ + } } /* @@ -60,26 +81,49 @@ static void pg_refresh(uint16_t a_row, char *a_name, char *a_value) { } /* - * 函数功能:自绘层(越界行反显;框架内置区块画完后调用) + * 函数功能:自绘层(越界行反显 + 未标定徽标;框架内置区块画完后调用) * 入口参数:a_layer / a_clip - 契约见 ui_content_page.h * 返回值:无 * 限定条件:框架绘制链内 + * 函数说明:徽标函数自己判"是不是整定视图 + 帧在不在 + cal_block 置没置", + * 页面这里无条件调它——四页少写四个 if,也就少四次写漏的机会; + * 查看页三个条件第一条就不成立,一个像素都不落(vparam~vplate + * 四张黄金参考仍逐位一致) */ static void pg_draw(lv_layer_t *a_layer, const lv_area_t *a_clip) { ui_w_setlist_draw(&s_sl, a_layer, a_clip); + ui_w_setview_draw_cal_badge(a_layer, a_clip); +} + +/* + * 函数功能:按键转发(框架 key_cb) + * 入口参数:a_key - LVGL 键值 lv_key_t + * 返回值:true - 本页已消费(框架跳过内置);false - 交回框架(↑↓ 移选、← 关页) + * 限定条件:框架按键链内;描述符须 edit_capable=1 + key_intercept=1 + * 函数说明:两态行为的不同**不写在这里**——ui_w_setview_key() 先看视图类型 + * (查看页未绑 commit → 直接交回框架,等于本页没有 key_cb), + * 再看参数帧在不在。所以这里必须原样透传返回值,不得就地"或"上什么 + */ +static bool pg_key(lv_key_t a_key) { + return (ui_w_setview_key(&s_sl, a_key) != 0u); } /* * 页面描述符(外部链接:生成物 ui_page_registry.c 按菜单树 #page=view_set 绑定引用) - * edit_capable=0 + 无 key_cb + key_intercept=0:查看页不接受任何写交互, - * ↑↓ 移选与 ← 关页全走框架内置(§3.2.7 表里"权限不足 ENTER 一律忽略"在 - * 查看页等价于"根本没有 ENTER 这条路") + * edit_capable=1 + key_cb + key_intercept=1:同一份描述符两态共用。 + * · 整定叶子(123 授权 UI_PERM_MODIFY)→ is_editable() 真 → attach_edit + + * 编辑叠层 + 保存行;key_intercept=1 是**必需**的:编辑中的 ←→ 移位光标 + * 要先给页面,否则框架拿 ← 当"关页",编辑到一半就跳出去 + * · 查看叶子(无 #P,恒 UI_PERM_VIEW)→ is_editable() 假 → 仍走 attach, + * ui_w_setview_key() 第一条门就把键全部交回框架,↑↓ 移选与 ← 关页全走内置 + * ——即"绑了 key_cb 的查看页"与"没有 key_cb 的查看页"行为逐字等价 + * (§3.2.7:"权限不足 ENTER 一律忽略") */ const content_page_def_t ui_page_view_set_desc = { .id = "view_set", .title = "定值", .selectable = 1u, - .edit_capable = 0u, + .edit_capable = 1u, /* 两态页:能不能真编辑还要看本次进入的权限(is_editable) */ .top_y = UI_LIST_TOP, .pitch = UI_ROW_PITCH, .band_h = UI_ROW_BAND_H, @@ -87,8 +131,10 @@ const content_page_def_t ui_page_view_set_desc = { .left_x = UI_LIST_LEFT_X, .right_x = UI_LIST_RIGHT_X, .rows_per_page = UI_LIST_ROWS, + .key_intercept = 1u, /* ← 先交 key_cb:编辑中是"移位光标",非编辑中才轮到关页 */ .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_view_word.c b/Drivers/BSP/UI/ui_page_view_word.c index 36a18934..3f765865 100644 --- a/Drivers/BSP/UI/ui_page_view_word.c +++ b/Drivers/BSP/UI/ui_page_view_word.c @@ -2,7 +2,13 @@ * 模块名称:UI Page view_word (控制字查看内容页) * 模块功能:参数定值 → 查看 → 控制字 的内容页——4 行二态表(*_TRIP:告警/跳闸),每页 8 行、1 页; * 数据 = 参数快照(Q10)按行表小数位换算,越界项由 bad 回调反显; - * 本文件零 App include、零写路径(查看页没有"保存"这件事) + * 本文件零 App include;写方向只有一个出口:整定态的"保存"经 + * ui_data_param_request() 投递(执行与落盘在 App 侧,本层不碰 prot_set/FatFs) + * 两态一页(道F task-2:整定 4 叶绑定本描述符):同一份描述符既服务"查看"叶子 + * (只读——不绑 commit → 无保存行、按键全交回框架 → 像素与绑定前逐位一致), + * 也服务"整定"叶子(123 授权 → 编辑叠层 + 保存行 + 未标定徽标)。 + * 分派点只有 pg_init 一处(按 ui_content_page_is_editable() 判), + * 两态共用行表/取数/反显,不存在"查看一套边界、整定另一套边界" * 为什么这么薄:行表与取数在 ui_w_setview(四个查看页共用一份视图模型), * 渲染与反显在 ui_w_setlist(与整定页同一组件)——本文件只把视图编号 * 和标准骨架几何令牌接进框架的跳板,改一次口径四处一致 @@ -11,6 +17,11 @@ * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-15 批 3) + * v1.1 2026-10-26 DTU 项目组 道F task-2:接整定态——pg_init 按权限分派 + * attach/attach_edit、新增 pg_key 转发按键、 + * pg_draw 追加"未标定"徽标、描述符补 + * edit_capable/key_intercept/key_cb 三项 + * (只读分支行为逐字不变) */ /* 头文件包含区 */ #include "ui_page_view_word.h" @@ -32,11 +43,21 @@ static ui_w_setlist_t s_sl; * 入口参数:无 * 返回值:无 * 限定条件:框架开页链内调用(uiTask) - * 函数说明:每次开页都重新 attach——组件实例内残留的编辑态/游标会跨页带过来, - * 开页复位比"信任上一次已经清干净"可靠 + * 函数说明:1. 每次开页都重新 attach——组件实例内残留的编辑态/游标会跨页带过来, + * 开页复位比"信任上一次已经清干净"可靠 + * 2. **只读 / 整定两态在这里分派一次**,之后所有钩子只看已绑好的回调 + * (组件用 commit==NULL 判视图类型,不再回头看权限) + * 3. 时序依据(为什么 init_cb 里问 is_editable() 拿到的就是本次进入的权限): + * 框架 open() 先装 s_perm 与 s_open、最后才调 init_cb + * (ui_content_page.c 的 open:s_perm=perm → s_open=desc → init_cb), + * 所以这里读到的是"本次进入"的真值,不是上一次的残留 */ static void pg_init(void) { - ui_w_setview_attach(&s_sl, UI_W_SETVIEW_WORD); + if (ui_content_page_is_editable()) { + ui_w_setview_attach_edit(&s_sl, UI_W_SETVIEW_WORD); /* 整定:编辑 + 保存行 + 徽标 */ + } else { + ui_w_setview_attach(&s_sl, UI_W_SETVIEW_WORD); /* 查看:与绑定前完全同一条路径 */ + } } /* @@ -60,26 +81,49 @@ static void pg_refresh(uint16_t a_row, char *a_name, char *a_value) { } /* - * 函数功能:自绘层(越界行反显;框架内置区块画完后调用) + * 函数功能:自绘层(越界行反显 + 未标定徽标;框架内置区块画完后调用) * 入口参数:a_layer / a_clip - 契约见 ui_content_page.h * 返回值:无 * 限定条件:框架绘制链内 + * 函数说明:徽标函数自己判"是不是整定视图 + 帧在不在 + cal_block 置没置", + * 页面这里无条件调它——四页少写四个 if,也就少四次写漏的机会; + * 查看页三个条件第一条就不成立,一个像素都不落(vparam~vplate + * 四张黄金参考仍逐位一致) */ static void pg_draw(lv_layer_t *a_layer, const lv_area_t *a_clip) { ui_w_setlist_draw(&s_sl, a_layer, a_clip); + ui_w_setview_draw_cal_badge(a_layer, a_clip); +} + +/* + * 函数功能:按键转发(框架 key_cb) + * 入口参数:a_key - LVGL 键值 lv_key_t + * 返回值:true - 本页已消费(框架跳过内置);false - 交回框架(↑↓ 移选、← 关页) + * 限定条件:框架按键链内;描述符须 edit_capable=1 + key_intercept=1 + * 函数说明:两态行为的不同**不写在这里**——ui_w_setview_key() 先看视图类型 + * (查看页未绑 commit → 直接交回框架,等于本页没有 key_cb), + * 再看参数帧在不在。所以这里必须原样透传返回值,不得就地"或"上什么 + */ +static bool pg_key(lv_key_t a_key) { + return (ui_w_setview_key(&s_sl, a_key) != 0u); } /* * 页面描述符(外部链接:生成物 ui_page_registry.c 按菜单树 #page=view_word 绑定引用) - * edit_capable=0 + 无 key_cb + key_intercept=0:查看页不接受任何写交互, - * ↑↓ 移选与 ← 关页全走框架内置(§3.2.7 表里"权限不足 ENTER 一律忽略"在 - * 查看页等价于"根本没有 ENTER 这条路") + * edit_capable=1 + key_cb + key_intercept=1:同一份描述符两态共用。 + * · 整定叶子(123 授权 UI_PERM_MODIFY)→ is_editable() 真 → attach_edit + + * 编辑叠层 + 保存行;key_intercept=1 是**必需**的:编辑中的 ←→ 移位光标 + * 要先给页面,否则框架拿 ← 当"关页",编辑到一半就跳出去 + * · 查看叶子(无 #P,恒 UI_PERM_VIEW)→ is_editable() 假 → 仍走 attach, + * ui_w_setview_key() 第一条门就把键全部交回框架,↑↓ 移选与 ← 关页全走内置 + * ——即"绑了 key_cb 的查看页"与"没有 key_cb 的查看页"行为逐字等价 + * (§3.2.7:"权限不足 ENTER 一律忽略") */ const content_page_def_t ui_page_view_word_desc = { .id = "view_word", .title = "控制字", .selectable = 1u, - .edit_capable = 0u, + .edit_capable = 1u, /* 两态页:能不能真编辑还要看本次进入的权限(is_editable) */ .top_y = UI_LIST_TOP, .pitch = UI_ROW_PITCH, .band_h = UI_ROW_BAND_H, @@ -87,8 +131,10 @@ const content_page_def_t ui_page_view_word_desc = { .left_x = UI_LIST_LEFT_X, .right_x = UI_LIST_RIGHT_X, .rows_per_page = UI_LIST_ROWS, + .key_intercept = 1u, /* ← 先交 key_cb:编辑中是"移位光标",非编辑中才轮到关页 */ .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_w_setlist.c b/Drivers/BSP/UI/ui_w_setlist.c index eae8b7aa..38b79269 100644 --- a/Drivers/BSP/UI/ui_w_setlist.c +++ b/Drivers/BSP/UI/ui_w_setlist.c @@ -7,6 +7,12 @@ * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道B task-5 三组件之一) + * v1.1 2026-10-26 DTU 项目组 道F task-2:① 保存行新增 LOCK 词 + 闭锁态 + * ENTER 不消费(禁编辑);② 二态行 ENTER **取模回绕** + * (原实现落在最后一项时写出 opts 之外的下标:屏上 + * 看着没变、叠层已是越界值,保存必被预检挡回; + * task-2 是本页第一个消费者,此前无人按过这条键); + * 查看视图(commit/state 未绑)路径逐字未变 */ /* 头文件包含区 */ @@ -172,6 +178,11 @@ void ui_w_setlist_refresh(ui_w_setlist_t *a_inst, uint16_t a_row, case UI_W_SETLIST_ST_FAIL: (void)lv_snprintf(a_value, UI_BUF_STRLEN, "%s", "ERR"); break; + case UI_W_SETLIST_ST_LOCK: + /* 定值闭锁:写不进去这件事必须写在保存行上(页脚/徽标都不算, + * 用户按保存之前看到的就是这一格);词用 ASCII 与 ENTER/WAIT 同排 */ + (void)lv_snprintf(a_value, UI_BUF_STRLEN, "%s", "LOCK"); + break; default: (void)lv_snprintf(a_value, UI_BUF_STRLEN, "%s", "ENTER"); break; @@ -211,6 +222,16 @@ uint8_t ui_w_setlist_key(ui_w_setlist_t *a_inst, lv_key_t a_key) { if (ui_content_page_is_editable() == false) { return 0u; /* 只读权限 ENTER 一律忽略(框架内置也无 ENTER 语义) */ } + /* 定值闭锁(state 回调回 LOCK)→ ENTER 一律不消费:不进数值编辑、不切二态、 + * 不投保存。权限门(上一条)说的是"这人不能写",这一条说的是"这台装置现在 + * 写不进"——两件事都必须落在键路上,只靠屏显词会让用户白改一轮。 + * ⚠ state() 回调可能带收口副作用(视图模型用它锁存落定结果),这里调它 + * 是幂等的:同一次落定只会被消费一次(见 ui_w_setview sv_state 的 s_pending) + * ⚠ 已在编辑中的那一格不强行走这条路(编辑分支在上面已返回),其值只落在 + * RAM 工作副本,投递口已被本门堵住,落不了盘 */ + if ((a_inst->state != NULL) && (a_inst->state() == UI_W_SETLIST_ST_LOCK)) { + return 0u; + } if (sel < a_inst->count) { const ui_w_setlist_row_t *r = &a_inst->rows[sel]; @@ -222,14 +243,24 @@ uint8_t ui_w_setlist_key(ui_w_setlist_t *a_inst, lv_key_t a_key) { ui_content_page_invalidate(); return 1u; } - /* 二态行:循环切换(控制字 告警<->跳闸、软压板 投入<->退出) */ + /* 二态行:循环切换(控制字 告警<->跳闸、软压板 投入<->退出) + * ⚠ **必须取模回绕**(道F task-2 实测坑):原写法 nxt = cur + 1 不回绕, + * 落在最后一项时写出 opts 之外的下标 n——显示侧按 [0, n-1] 钳位, + * 于是"按 ENTER 屏幕上什么也没变",而叠层里存的已是越界下标, + * 随保存投出去就被 App 侧整批预检判成 ERR_RANGE:用户按了两下切换, + * 结果是整页保存被"越界"挡回。本组件在 task-2 之前没有整定页消费者, + * 这条只有接上写路径才会暴露(二态行 = 控制字 4 行 + 软压板 10 行, + * 初始值全为"跳闸/投入"= 最后一项,所以是**必现**而不是边角) + */ { int32_t cur = a_inst->get(r->id); uint8_t n = sl_opts_count(r->opts); - int32_t nxt = cur + 1; + int32_t nxt; if ((cur < 0) || (cur >= (int32_t)n)) { - nxt = 0; + nxt = 0; /* 值区里是 opts 之外的脏值:回指第一项,不把脏值往下传 */ + } else { + nxt = (int32_t)(((uint32_t)cur + 1u) % (uint32_t)n); } a_inst->set(r->id, nxt); ui_content_page_invalidate(); diff --git a/Drivers/BSP/UI/ui_w_setlist.h b/Drivers/BSP/UI/ui_w_setlist.h index 356f66eb..64c2beae 100644 --- a/Drivers/BSP/UI/ui_w_setlist.h +++ b/Drivers/BSP/UI/ui_w_setlist.h @@ -23,6 +23,10 @@ * §3.2 四视图表/§3.2.6 查看与整定差别/§3.2.7 编辑 * 交互/§4.2 bind 要点;保存行 ENTER/WAIT/OK/ERR * 沿用 ui_page_net_cfg 交接显示先例) + * v1.1 2026-10-26 DTU 项目组 道F task-2(整定 4 页):态枚举尾追加 LOCK + * (值列显 LOCK + ENTER 不消费 = 闭锁禁编辑); + * 查看视图不绑 commit/state,走不到新分支, + * 四张查看页像素零变化(黄金用例 vparam~vplate 为证) */ #ifndef UI_W_SETLIST_H #define UI_W_SETLIST_H @@ -37,12 +41,20 @@ extern "C" { #include "ui_content_page.h" /* lvgl(lv_key_t/lv_layer_t)+ 框架类型 */ #include "ui_w_editor.h" -/* 保存交接显示态(值列 ENTER/WAIT/OK/ERR,与 net_cfg 保存行同字面) */ +/* 保存交接显示态(值列 ENTER/WAIT/OK/ERR/LOCK,与 net_cfg 保存行同字面) + * ⚠ 新增态一律**尾追加**:前四个值按序被各页与先例使用,中间插入会把后续态的 + * 数值整体挪位——数值挪位在 C 里不报错,只会让屏上的词换成另一个词 */ typedef enum { UI_W_SETLIST_ST_IDLE = 0, /* 空闲:保存行显示 ENTER */ UI_W_SETLIST_ST_WAIT, /* 请求已投递、属主任务处理中:WAIT */ UI_W_SETLIST_ST_OK, /* 上轮提交成功:OK */ - UI_W_SETLIST_ST_FAIL /* 上轮提交失败:ERR */ + UI_W_SETLIST_ST_FAIL, /* 上轮提交失败:ERR */ + UI_W_SETLIST_ST_LOCK /* 定值闭锁(= par.locked,校验和自检失败):值列 LOCK + * 且**禁编辑**(组件见 LOCK 即不消费 ENTER)。 + * ⚠ 与「未标定」是两件事、屏上两条通道:未标定 + * (= par.cal_block)仍可改定值、只是判据停走, + * 用标题右侧徽标表达;LOCK 才是"写不进去"。 + * 三词三源 → docs/任务规划/数据可信.md §4.3 */ } ui_w_setlist_st_t; /* 行种类 */ @@ -174,6 +186,11 @@ void ui_w_setlist_refresh(ui_w_setlist_t *a_inst, uint16_t a_row, * 整表提交口径见 页面补齐规划.md §3.2) * 3. 只读权限 ENTER 一律不消费;ESC 恒关页(框架,未提交编辑丢弃, * 已经 ENTER 提交到工作副本的调整保留、不落盘——net_cfg 先例) + * 4. 整定视图 state()==LOCK(定值闭锁)→ ENTER 同样一律不消费: + * 不进数值编辑、不切二态、不投保存——**为什么闭锁不由组件解释**, + * 它只按 state 回调给的事实收住写交互(闭锁 vs 未标定的分工见 + * ui_w_setlist_st_t 的 LOCK 注);已在编辑中的那一格不强制打断, + * 但其值只落在工作副本,投递口已被 LOCK 堵住,落不了盘 */ uint8_t ui_w_setlist_key(ui_w_setlist_t *a_inst, lv_key_t a_key); diff --git a/Drivers/BSP/UI/ui_w_setview.c b/Drivers/BSP/UI/ui_w_setview.c index fe391e9e..6d7c2dea 100644 --- a/Drivers/BSP/UI/ui_w_setview.c +++ b/Drivers/BSP/UI/ui_w_setview.c @@ -1,12 +1,20 @@ /* * 模块名称:UI 定值查看视图模型(ui_w_setview) * 模块功能:参数 / 定值 / 控制字 / 软压板四张视图表与它们的取数、换算、越界反显—— - * 供四个查看页薄跳板共用;声明与分层理由见 ui_w_setview.h + * 四个查看页与四个整定页**共用同一批表**(attach = 只读,attach_edit = + * 可写:另绑 set/commit/state + 编辑叠层);声明与分层理由见 ui_w_setview.h * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码) * 作者:王建锋 * 创建日期:2026-10-25 * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-15 批 3) + * v1.1 2026-10-26 DTU 项目组 道F task-2:整定侧落地——编辑叠层(改动位图 + + * 工作值,只提交改动 id)、sv_commit 经 + * ui_data_param_request() 投递、sv_state 锁存落定 + * 并映射 LOCK、bad 回调加"本次提交挡下项"第二来源、 + * 统一按键门 ui_w_setview_key()、标题"未标定"徽标; + * 新增 .bss ≈190 B(叠层 160 B + 位图/态), + * 查看路径与四张行表逐字未变 */ /* 头文件包含区 */ #include @@ -113,6 +121,21 @@ static const uint8_t s_dec[UI_DATA_SET_N] = { /* 无参数帧时值列的占位串(与全仓"读不到就 --"同一口径) */ #define SV_NO_DATA "--" +/* 参数帧/落定结果轮询节拍(ms):见 sv_poll_cb 的"为什么必须有"。 + * 100 ms 是"用户看不出延迟"与"每拍一次比较"的折中,比 rt 帧的 200 ms 更细一档, + * 这样保存落定与主站写值最迟 100 ms 上屏 */ +#define SV_POLL_MS 100u + +/* 轮询定时器(LVGL 池对象,首张定值页 attach 时建、只建不删;NULL 初始化是 + * coding-standard CODE-RULE-15 对本层唯一文件作用域指针的要求) */ +static lv_timer_t *s_poll_timer = NULL; + +/* 未标定徽标(整定页标题右侧):三字已在字库里(交流量页标定行、SOE type7 在用), + * 故本次不新增字面字符;词与 par.cal_block 的屏上口径一致,别换成"标定失败" + * 之类没登记过的说法(font-charset 与像素断言都按这个词钉) */ +#define SV_CAL_BADGE "未标定" +#define SV_BADGE_RIGHT_X 141 /* 文字右边界:让开"浅 V"右腿(y7~17 占 x≈147~158) */ + /* 参数帧缓存(四个查看页共用一份:同屏只开一页,无并发读者) */ static ui_data_par_t s_par; static uint32_t s_par_seq = 0xFFFFFFFFu; /* 与任何合法发布序号都不同 */ @@ -122,6 +145,22 @@ static uint8_t s_par_ok; static const ui_w_setlist_row_t *s_cur_rows; static uint16_t s_cur_n; /* 当前视图行数(bad 回调的边界) */ +/* + * 整定视图状态(道F task-2) + * ⚠ 与 s_cur_rows 同一假设:**同屏只开一页**,故这些状态是模块级单份而非按实例存 + * (查看页四份缓存同一条理由)。将来要并排开两页,得连 s_cur_rows 一起收进实例 + * + * RAM 台账:s_wq 160 B + 三个位图 24 B + 态与标志 ≈ **190 B .bss** + *(落 RW_IRAM1;口径 docs/内存管理/RAM使用分析与优化方案.md,回报里已单独列给 Lead) + */ +static int32_t s_wq[UI_DATA_SET_N]; /* 工作值(工程量 × 10^dec,下标 = id) */ +static uint64_t s_wch; /* 改动位图:bit=1 → 该行本次要提交 */ +static uint64_t s_commit_mask; /* 在飞请求覆盖的位(落定后按它清叠层) */ +static uint64_t s_bad_mask; /* 本次提交被挡下的越界项(bad 回调第二个来源) */ +static ui_w_setlist_st_t s_disp; /* 保存行显示态(锁存,见 sv_state 的注) */ +static uint8_t s_pending; /* 1 = 已投递未落定 */ +static uint8_t s_edit; /* 1 = 当前 attach 是整定视图(徽标/按键门都看它) */ + /* 行表长度(编译期算,别手写数字——手写数字与表长不一致是最难查的错) */ #define SV_ROW_N(tbl) ((uint16_t)(sizeof(tbl) / sizeof((tbl)[0]))) @@ -162,6 +201,44 @@ static int32_t sv_q10_to_scaled(int32_t a_q10, uint8_t a_dec) { return r; } +/* + * 函数功能:显示值(工程量 × 10^dec)→ Q10 定点(整定提交方向,sv_q10_to_scaled 的逆) + * 入口参数:a_scaled - 显示域整数值 int32_t + * a_dec - 该行小数位 0..3 uint8_t + * 返回值:Q10 定点值(工程量 × 1024) int32_t + * 限定条件:a_dec <= 3;a_scaled 非负(组件编辑器契约 min>=0,本工程定值全非负) + * 函数说明:q10 = (scaled×1024 + den/2) / den,四舍五入,**纯 32 位整数**—— + * 不用 float、更不用 __aeabi_ldiv(64 位除),理由与 sv_q10_to_scaled 同一条 + * (MicroLIB 代价,模拟器照不出来)。溢出界已核:最大显示值 PT一次额定 + * 500000(dec=0)→ 500000×1024 = 512,000,000 < int32 满 + * ⚠ 往返精度:这条换算与正向量程不是逐位可逆的(Q10 存 972 → 显示 95 → + * 再写回 973,差 1 个 LSB)。所以**只提交改动位图内的 id**:用户没动的 + * 行根本不进这个函数,也就不会被"顺手改掉"(docs/UI设计/UI_数据供给设计 + * 说明.md §6.2 的 mask 语义;越界值不落盘这条判据也建在它上面) + */ +static int32_t sv_scaled_to_q10(int32_t a_scaled, uint8_t a_dec) { + static const int32_t p10[4] = { 1, 10, 100, 1000 }; + int32_t den = p10[a_dec]; + + return (a_scaled * 1024 + den / 2) / den; +} + +/* + * 函数功能:判断两区域相交(与本目录各绘制文件同实现:static 局部) + * 入口参数:a - 区域 1 const lv_area_t * + * b - 区域 2 const lv_area_t * + * 返回值:true = 相交 bool + * 限定条件:无 + */ +static bool sv_areas_intersect(const lv_area_t *a, const lv_area_t *b) { + int32_t x1 = (a->x1 > b->x1) ? a->x1 : b->x1; + int32_t y1 = (a->y1 > b->y1) ? a->y1 : b->y1; + int32_t x2 = (a->x2 < b->x2) ? a->x2 : b->x2; + int32_t y2 = (a->y2 < b->y2) ? a->y2 : b->y2; + + return (x1 <= x2 && y1 <= y2); +} + /* * 函数功能:读某定值项的显示值(组件 get 回调) * 入口参数:a_id - 行表登记的 id(= 枚举序) uint8_t @@ -191,6 +268,144 @@ static void sv_set_unused(uint8_t a_id, int32_t a_val) { (void)a_val; } +/* + * 函数功能:读某定值项的**编辑态**显示值(整定视图的 get 回调) + * 入口参数:a_id - 行表登记的 id(= prot_set 枚举序) uint8_t + * 返回值:改过 → 叠层工作值;未改 → 参数帧换算值 int32_t + * 限定条件:整定视图(attach_edit)专用;帧未就位时由页面值列打 "--",走不到这里 + * 函数说明:叠层优先——用户改到一半的数必须以叠层为准,否则每帧重绘都会把 + * 未提交的编辑冲回帧值("改完光标一动就跳回原值"就是这种实现写反了); + * 未改的行反过来一律跟随帧,主站 C_SE 写进来的新值 ≤200 ms 自己上屏 + */ +static int32_t sv_get_edit(uint8_t a_id) { + if ((a_id < (uint8_t)UI_DATA_SET_N) && ((s_wch & ((uint64_t)1u << a_id)) != 0u)) { + return s_wq[a_id]; + } + return sv_get(a_id); +} + +/* + * 函数功能:写某定值项的编辑叠层(整定视图的 set 回调;不落任何数据源) + * 入口参数:a_id - 行 id uint8_t + * a_val - 新显示值(工程量 × 10^dec) int32_t + * 返回值:无 + * 限定条件:整定视图专用;组件只在 ENTER 提交/二态切换时调 + * 函数说明:只置位不改帧——真正的写入发生在保存行 ENTER → sv_commit → 快照层队列 + * → App 侧 prot_set_set_sealed()。顺手清掉该行的"上次越界"反显: + * 用户已经改过的行还挂着上一次失败的 redflag,是在说一件已经不成立的事 + */ +static void sv_set_edit(uint8_t a_id, int32_t a_val) { + if (a_id >= (uint8_t)UI_DATA_SET_N) { + return; + } + s_wq[a_id] = a_val; + s_wch |= ((uint64_t)1u << a_id); + s_bad_mask &= ~((uint64_t)1u << a_id); +} + +/* + * 函数功能:提交本次改动(整定视图的 commit 回调——只投递,不执行、不等待) + * 入口参数:无 + * 返回值:无(结果经 sv_state 回读,组件保存行显示) + * 限定条件:uiTask 按键链内(保存行 ENTER);整定视图专用 + * 函数说明:请求形状 = 改动位图 + 按位换算的 Q10(ui_data_param_req_t,168 B 栈帧)。 + * 本函数**不**判断越界、不判闭锁、更不碰 FatFs:那三件事分别属于 + * prot_set 的 meta、prot_set 的自检态与 defaultTask(契约 §6.1)。 + * 队列满(-1)时叠层原样保留:用户直接再按一次保存即可,不必重输 + */ +static void sv_commit(void) { + ui_data_param_req_t req; + uint64_t bit = 1u; + uint8_t i; + int ret; + + (void)memset(&req, 0, sizeof(req)); + req.mask = s_wch; + for (i = 0u; i < (uint8_t)UI_DATA_SET_N; i++) { + if ((s_wch & bit) != 0u) { + req.q10[i] = sv_scaled_to_q10(s_wq[i], s_dec[i]); + } + bit <<= 1u; + } + s_bad_mask = 0u; /* 新一轮提交:上一次的越界认定作废 */ + ret = ui_data_param_request(&req); + if (ret == 0) { + s_commit_mask = s_wch; + s_pending = 1u; + s_disp = UI_W_SETLIST_ST_WAIT; + } else { + s_pending = 0u; + s_commit_mask = 0u; + s_disp = UI_W_SETLIST_ST_FAIL; /* 忙/参数无效 → 页脚口径"忙,请重试" */ + } +} + +/* + * 函数功能:回读保存行显示态(整定视图的 state 回调;顺带收口一次落定) + * 入口参数:无 + * 返回值:ui_w_setlist_st_t(IDLE/WAIT/OK/FAIL/LOCK) + * 限定条件:uiTask(组件绘制保存行时调用;ui_w_setlist_key 的闭锁门也调它) + * 函数说明:为什么要把落定"锁存"进 s_disp 而不是每次直读快照层状态—— + * 快照层的 ui_data_param_state() 是**全局单值**,落定后一直停在那儿; + * 直读会让用户移动一下光标(触发整页重绘)就看到别的页留下的旧结果, + * 甚至把本次 + * OK 显示成 ENTER。这里改成事件锁存:本页投过一次才显示那次的答案, + * 开页 attach_edit 复位成 ENTER。 + * 优先级(从高到低,逐条都有各自的"为什么"): + * ① 帧里 locked=1 → LOCK:装置现在写不进去,比任何历史结果都该先说; + * ② 在飞请求落定 → WAIT/OK/FAIL/LOCK(ERR_LOCKED 也映射成 LOCK: + * 帧可能还没刷到 locked 位,但执行侧刚说过的"闭锁"是当下一手事实); + * ③ 否则回显锁存值(ENTER/OK/ERR 等) + */ +static ui_w_setlist_st_t sv_state(void) { + ui_w_setview_sync(); + + if ((s_par_ok != 0u) && (s_par.locked != 0u)) { + return UI_W_SETLIST_ST_LOCK; + } + if (s_pending != 0u) { + int p = ui_data_param_state(); + + if (p == UI_DATA_PARAM_ST_WAIT) { + s_disp = UI_W_SETLIST_ST_WAIT; + } else { + s_pending = 0u; /* 一次落定只消费一次 */ + if (p == UI_DATA_PARAM_ST_OK) { + s_wch &= ~s_commit_mask; /* 这批已进值区:叠层让位给新帧 */ + s_bad_mask = 0u; + s_disp = UI_W_SETLIST_ST_OK; + } else { + s_bad_mask = (p == UI_DATA_PARAM_ST_ERR_RANGE) + ? ui_data_param_err_mask() : 0u; + s_disp = (p == UI_DATA_PARAM_ST_ERR_LOCKED) + ? UI_W_SETLIST_ST_LOCK : UI_W_SETLIST_ST_FAIL; + } + s_commit_mask = 0u; + } + } + return s_disp; +} + +/* + * 函数功能:复位整定侧状态(attach / attach_edit 开页时都调) + * 入口参数:无 + * 返回值:无 + * 限定条件:uiTask(开页 attach 链内,先于组件 reset) + * 函数说明:**每次开页都清稿**是刻意的:改动位图与工作值只在本页这一次打开里有意义, + * 带进下一次打开会出现"没改任何东西却显示着上次的编辑值"(更糟的是下次 + * 保存会把那份陈旧草稿投出去)。ESC 关页天然弃稿(不投递),与本函数 + * 合起来正好是 net_cfg 那套"改到一半退出 = 什么都没发生"的语义 + */ +static void sv_view_reset(void) { + (void)memset(s_wq, 0, sizeof(s_wq)); + s_wch = 0u; + s_commit_mask = 0u; + s_bad_mask = 0u; + s_disp = UI_W_SETLIST_ST_IDLE; + s_pending = 0u; + s_edit = 0u; +} + /* * 函数功能:某数据行是否越界(组件 bad 回调 → 反显高亮) * 入口参数:a_row - 数据行下标(不含保存行) uint16_t @@ -205,7 +420,7 @@ static bool sv_bad(uint16_t a_row) { const ui_w_setlist_row_t *r; uint64_t bit; - if ((s_cur_rows == NULL) || (s_par_ok == 0u) || (s_par.bad_valid == 0u)) { + if (s_cur_rows == NULL) { return false; } if (a_row >= s_cur_n) { @@ -216,9 +431,74 @@ static bool sv_bad(uint16_t a_row) { return false; } bit = (uint64_t)1u << r->id; + /* + * 两个来源,任一命中即反显(整定批次加了①,查看页②逐字不变): + * ① s_bad_mask —— **本次提交**被 App 侧整批预检挡下的项(ui_data_param_err_mask() + * 经 sv_state 收进来,只在 ERR_RANGE 那一轮非 0;用户再改该行、或重新提交 + * 即清)。这是"越界行高亮"判据的屏上落点:整批一字未写,但必须说清是哪几行 + * 把人挡住的,否则用户只能逐行猜。 + * ② s_par.bad_mask —— prot_set 整表装载/解析留下的越界位图,且必须 bad_valid=1 + * 才参与(bad_valid=0 时位图无意义,把"没数据"画成"这行有问题"是假告警) + */ + if ((s_bad_mask & bit) != 0u) { + return true; + } + if ((s_par_ok == 0u) || (s_par.bad_valid == 0u)) { + return false; + } return (s_par.bad_mask & bit) != 0u; } +/* + * 函数功能:参数帧/落定结果轮询(LVGL 定时器,100 ms 一拍) + * 入口参数:a_timer - 定时器句柄(未使用) lv_timer_t * + * 返回值:无 + * 限定条件:uiTask 上下文(LVGL 定时器在 lv_timer_handler 内跑,与页面同一线程) + * 函数说明:**为什么必须有这条定时器**——LVGL 只重绘被 invalidate 的区域,而本页 + * 的数据是**异步**变的: + * ① 参数帧由 App 侧节拍发布(保存落定后校验和变、主站 C_SE 写进来、 + * 标定完成让 cal_block 翻转),页面自己无从察觉; + * ② 整定的落定结果更隐蔽:ERR_RANGE / ERR_LOCKED **不改校验和**, + * 也就不会发布新帧——只有 ui_data_param_state() 变了。 + * 没有这条轮询,用户按完"保存"屏上会一直停在 WAIT,越界行的反显也要 + * 等下一次按键才出现(判据"越界行高亮"在真机上就落不了地)。 + * 代价:一拍一次 uint32 比较 + 一次 bool 判断,无变化时零重绘、零拷贝; + * 页面没打开时直接返回(菜单屏/待机屏一像素都不动)。 + * 与 net_cfg 的差别:net_cfg 的保存行状态至今只在按键触发的重绘里更新 + * (先例的既有局限),本页不能沿用——整定的 OK/ERR/LOCK 是用户判断 + * "这台装置到底吃了我改的值没有"的唯一凭据 + */ +static void sv_poll_cb(lv_timer_t *a_timer) { + (void)a_timer; + + if (ui_content_page_is_open() == false) { + return; + } + if (ui_data_par_seq() != s_par_seq) { + ui_content_page_invalidate(); /* 新参数帧:整页重绘(refresh 里同步序号) */ + return; + } + if ((s_pending != 0u) && (ui_data_param_state() != UI_DATA_PARAM_ST_WAIT)) { + ui_content_page_invalidate(); /* 落定但不改校验和(ERR_RANGE/ERR_LOCKED) */ + } +} + +/* + * 函数功能:确保轮询定时器已建(首张定值相关页面 attach 时建一次,之后不再建) + * 入口参数:无 + * 返回值:无 + * 限定条件:uiTask(页面 open 链内) + * 函数说明:只建不删(同 ui_standby_init 的 sb_idle_timer_cb 先例:模块生命周期 = + * UI 生命周期);回调自带"没开页就返回"的门,停在菜单屏时它只是一次比较。 + * 建失败 = LVGL 池耗尽,此时页面退回"按键才重绘"的旧行为——不另外报错, + * 因为那条错误在屏上没有任何可显示的通道(池耗尽本身早已是更大的问题) + */ +static void sv_poll_ensure(void) { + if (s_poll_timer == NULL) { + s_poll_timer = lv_timer_create(sv_poll_cb, SV_POLL_MS, NULL); + } +} + /* * 函数功能:按发布序号取参数帧快照(接各查看页 refresh 前调用) * 入口参数:无 @@ -348,34 +628,125 @@ void ui_w_setview_attach(ui_w_setlist_t *a_inst, ui_w_setview_t a_view) { return; } (void)memset(a_inst, 0, sizeof(*a_inst)); - switch (a_view) { - case UI_W_SETVIEW_PARAM: - a_inst->rows = s_rows_param; - a_inst->count = SV_ROW_N(s_rows_param); - break; - case UI_W_SETVIEW_SET: - a_inst->rows = s_rows_set; - a_inst->count = SV_ROW_N(s_rows_set); - break; - case UI_W_SETVIEW_WORD: - a_inst->rows = s_rows_word; - a_inst->count = SV_ROW_N(s_rows_word); - break; - case UI_W_SETVIEW_PLATE: - a_inst->rows = s_rows_plate; - a_inst->count = SV_ROW_N(s_rows_plate); - break; - default: - a_inst->rows = NULL; - a_inst->count = 0u; - break; - } - s_cur_rows = a_inst->rows; - s_cur_n = a_inst->count; - a_inst->get = sv_get; - a_inst->set = sv_set_unused; - a_inst->commit = NULL; /* NULL = 查看视图:无保存行、禁编辑(§3.2.6) */ - a_inst->state = NULL; - a_inst->bad = sv_bad; + /* 行表取自 ui_w_setview_table()(本模块唯一的行表出口):attach 与 attach_edit + * 共用同一张表,才会出现"查看合法、整定也合法"——两处各自 switch 就是两份口径 */ + a_inst->rows = ui_w_setview_table(a_view, &a_inst->count); + s_cur_rows = a_inst->rows; + s_cur_n = a_inst->count; + a_inst->get = sv_get; + a_inst->set = sv_set_unused; + /* NULL = 查看视图:无保存行、禁编辑(§3.2.6);state 同 NULL → 保存行也没得显示 */ + a_inst->commit = NULL; + a_inst->state = NULL; + a_inst->bad = sv_bad; + /* 整定侧状态一律复位:查看页既不该看见上一次的改动位图,也不该在下次开整定页 + * 时把它带过去(s_edit=0 同时关掉"未标定"徽标——查看页像素零变化的硬条件) */ + sv_view_reset(); + sv_poll_ensure(); /* 帧是异步变的:页面必须能自己发现新帧/落定(见 sv_poll_cb) */ ui_w_setlist_reset(a_inst); } + +/* + * 函数功能:把某个**整定**视图绑到定值表实例(行表与 attach 同一批,另绑写回调) + * 入口参数:a_inst - 页面持有的实例(NULL 直接返回) ui_w_setlist_t * + * a_view - 视图编号 ui_w_setview_t + * 返回值:无 + * 限定条件:页面 init_cb 内、且 ui_content_page_is_editable() 为真时才调 + * (只读权限进来调这个等于给只读用户开了保存行) + * 函数说明:与 attach 的差别只有 get/set/commit/state 四个钩子 + s_edit 标志, + * 行表、行数、bad 回调、越界反显全部复用——**同一张行表**是这一版 + * 设计的关键:上下限的镜像只有一份,漂移由 setview-check 钩子拦 + */ +void ui_w_setview_attach_edit(ui_w_setlist_t *a_inst, ui_w_setview_t a_view) { + if (a_inst == NULL) { + return; + } + (void)memset(a_inst, 0, sizeof(*a_inst)); + a_inst->rows = ui_w_setview_table(a_view, &a_inst->count); + s_cur_rows = a_inst->rows; + s_cur_n = a_inst->count; + a_inst->get = sv_get_edit; + a_inst->set = sv_set_edit; + a_inst->commit = sv_commit; /* 非 NULL = 整定视图:组件画保存行并放开编辑 */ + a_inst->state = sv_state; + a_inst->bad = sv_bad; + sv_view_reset(); + s_edit = 1u; + sv_poll_ensure(); /* 同 attach:整定页更要看到"落定"这一刻 */ + ui_w_setlist_reset(a_inst); +} + +/* + * 函数功能:两态统一按键转发(页面 key_cb 一行转过来) + * 入口参数:a_inst - 已 attach 的实例 ui_w_setlist_t * + * a_key - LVGL 键值 lv_key_t + * 返回值:1 - 本页已消费(框架跳过内置);0 - 交回框架(↑↓ 移选、← 关页) + * (键分发标志非错误通道,取 uint8_t——BSP err-type 约定) + * 限定条件:uiTask 按键链内(框架 key_cb;描述符须 edit_capable=1 + key_intercept=1) + * 函数说明:两道门都在本函数里,四页共用一条规矩(写在页面里就会长出不一致的四个版本): + * 1. 只读视图(commit==NULL)直接交回框架:查看页绑了 key_cb 也**没有 + * 任何行为变化**——这是"绑 key_cb 不改像素"的依据,vparam~vplate + * 四张黄金参考仍逐位一致就是它的机器证明 + * 2. 参数帧未就位时不接受写交互:值列此刻是 "--",拿 "--" 进编辑器等于 + * 把"还不知道"当成 0 让人改,改完还能投出去("无数据不许打 0"的同一条 + * 诚实性纪律,见 ui_w_setview_refresh 的注释) + * ⚠ 判"就位"之前必须先 ui_w_setview_sync():s_par_ok 原本**只在绘制路径** + * 里被置位,而"画过没有"是 LVGL 的事、与按键不同步。模拟器无头回归 + * 两次注键之间根本不重绘 → 开页后的第一个 ENTER 永远被这道门挡掉; + * 真机上开页 10 ms 内会重绘,于是这条坑只在测试侧咬人("代码没错、 + * 时序有错"最难查)。sync 在帧没变时只是一次 seq 比较,多调零代价 + */ +uint8_t ui_w_setview_key(ui_w_setlist_t *a_inst, lv_key_t a_key) { + if ((a_inst == NULL) || (a_inst->commit == NULL)) { + return 0u; + } + ui_w_setview_sync(); + if (ui_w_setview_ready() == 0u) { + return 0u; + } + return ui_w_setlist_key(a_inst, a_key); +} + +/* + * 函数功能:整定页标题右侧画"未标定"徽标(未标定闭锁的屏上表达) + * 入口参数:a_layer - 绘制层 lv_layer_t * + * a_clip - 重绘区 const lv_area_t * + * 返回值:无 + * 限定条件:页面 draw_cb 内(框架内置区块之后);三条件全满足才落墨:整定视图 + + * 帧已就位 + par.cal_block 非 0 + * 函数说明:几何为什么这样取:标题带只有 y=2..17 这一条,而"浅 V"右腿在 y7~17 + * 占掉 x≈147..158(ui_deco.c 的点集),所以徽标右边界取 141、左边界按 + * 实宽左推(3 个汉字 = 36 px → x≈105..141),既不压装饰也不压居中的标题 + * (最长标题"软压板/控制字" 3 字到 x≈98 为止)。字色取 UI_COL_FG, + * 与标题同一极性;不做反显——反显在 1bpp 下表示"选中",徽标不是可选中项 + */ +void ui_w_setview_draw_cal_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 = SV_CAL_BADGE; + size_t len; + + if ((s_edit == 0u) || (s_par_ok == 0u) || (s_par.cal_block == 0u)) { + return; + } + len = strlen(txt); + area.x2 = UI_SCREEN_W - 1; + area.y1 = UI_TITLE_Y; + area.y2 = UI_TITLE_Y + UI_TEXT_BAND_H; + /* 右对齐:先用与框架标题同一套测宽(lv_text_get_width + strlen, + * 这里 length 必须传 strlen——该版本传 -1/0 要么死循环要么返回 0) */ + area.x1 = SV_BADGE_RIGHT_X + - (int32_t)lv_text_get_width(txt, (uint32_t)len, LV_FONT_DEFAULT, 0); + + if (!sv_areas_intersect(&area, a_clip)) { + return; + } + lv_draw_label_dsc_init(&ld); + ld.base.layer = a_layer; + ld.text = txt; + ld.text_static = 1u; /* Flash 字面量:不拷内存(与框架标题同法) */ + ld.color = UI_COL_FG; + ld.ofs_x = 0; + ld.ofs_y = 0; + lv_draw_label(a_layer, &ld, &area); +} diff --git a/Drivers/BSP/UI/ui_w_setview.h b/Drivers/BSP/UI/ui_w_setview.h index cf88adbc..8859bf68 100644 --- a/Drivers/BSP/UI/ui_w_setview.h +++ b/Drivers/BSP/UI/ui_w_setview.h @@ -2,8 +2,12 @@ * 模块名称:UI 定值查看视图模型(ui_w_setview) * 模块功能:参数 / 定值 / 控制字 / 软压板**四张视图表**与它们的取数、越界反显—— * 把"40 项定值怎么分组上屏、Q10 怎么换算成带小数位的显示值"收在一处, - * 供四个查看页薄跳板(ui_page_view_*)共用;整定页(下一批)复用同一批表, - * 只是再绑 set/commit/state 三个回调 + * 供四个查看页薄跳板(ui_page_view_*)共用;**同一批表**再经 + * ui_w_setview_attach_edit() 供整定页复用(道F task-2 已落:查看与整定 + * 只差绑不绑 set/commit/state)。行表只有一份,界内界外就不会两套口径 + * 写方向:整定页的"保存"经 Drivers/BSP/UI/ui_data.h 的 param_request 投递 + * (位图 + Q10 批量请求,队列深度 4),执行与落盘在 App 侧——本模块 + * 仍然零 App include:越界与否、写不写得进去都由快照层回读 * 分层:本模块只读 Drivers/BSP/UI/ui_data.h 的参数快照,**不 include 任何 App 头** * (行表里的 id 是**裸数字** + 注释标注 PS_* 名,因为 prot_set_id_t 属 App 层) * ——id 与 App/algo/prot_set.h 枚举序的一致性由 test/host/ui_data 的主机单测 @@ -17,6 +21,10 @@ * 修改记录: * v1.0 2026-10-25 王建锋 创建(道E task-15 批 3;规格 = * docs/UI设计/页面扩充详细方案.md §3.2.1~§3.2.3/§3.2.6) + * v1.1 2026-10-26 DTU 项目组 道F task-2:新增整定侧三件事——attach_edit + * (改动位图叠层 + 只提交改动 id + 保存态锁存)、 + * key 转发(两态统一门)、标题"未标定"徽标; + * 四张行表与查看路径逐字未变(像素零变化) */ #ifndef UI_W_SETVIEW_H #define UI_W_SETVIEW_H @@ -52,6 +60,58 @@ typedef enum { */ void ui_w_setview_attach(ui_w_setlist_t *a_inst, ui_w_setview_t a_view); +/* + * 函数功能:把一个**整定**视图绑到定值表实例(行表与 attach 同一批,另绑 + * set/commit/state 三个回调 —— 组件据此画保存行并放开编辑) + * 入口参数:a_inst - 页面持有的实例 ui_w_setlist_t * + * a_view - 视图编号 ui_w_setview_t + * 返回值:无 + * 限定条件:页面 init_cb 内调用,且**只在 ui_content_page_is_editable() 为真时**调 + * (只读权限走 attach,本函数根本不该被绑上) + * 函数说明:整定与查看的唯一差别就是绑不绑这三个回调(§3.2.6 口径)。三个新增点: + * 1. 编辑叠层:改动位图 s_wch + 工作值表 s_wq(工程量 × 10^dec), + * get 优先读叠层、未改的行跟随参数帧 —— 用户改到一半退出页面即弃稿 + * (ESC 关页不投递,同 net_cfg);开页 attach 一律清稿,不带上次的改动 + * 2. 提交只投**改动位图内的 id**(绝不从显示值回提全表):Q10 存不下 + * 0.95 这类返回系数(真存 972/1024),按显示域往返会把用户没动的项 + * 顺手改掉——"越界值不落盘"与"改哪条只落哪条"两条判据都靠这个 mask + * 3. 保存行显示态由 sv_state() 锁存(见 .c 内 s_disp 的注), + * 闭锁 → LOCK(组件据此禁 ENTER),未标定 → 标题右侧徽标(下面那个) + */ +void ui_w_setview_attach_edit(ui_w_setlist_t *a_inst, ui_w_setview_t a_view); + +/* + * 函数功能:整定/查看两态统一的按键转发(页面 key_cb 一行转过来即可) + * 入口参数:a_inst - 已 attach 的实例 ui_w_setlist_t * + * a_key - LVGL 键值 lv_key_t + * 返回值:1 - 本页已消费(框架跳过内置);0 - 交回框架(↑↓ 移选、← 关页) + * (键分发标志非错误通道,取 uint8_t——BSP 公开 API 的 err-type 约定) + * 限定条件:uiTask 按键链内(框架 key_cb) + * 函数说明:门在这里而不是散在四个页面文件里,为的是"四页同一条规矩": + * 1. 只读视图(未绑 commit)一律交回框架 —— 查看页按键行为逐字不变, + * 这是"绑了 key_cb 也不会动像素"的依据(黄金用例 vparam~vplate 仍绿) + * 2. 参数帧未就位时**不接受任何写交互**:值列此刻打的是 "--", + * 拿 "--" 去 begin 编辑等于把"还不知道"当成 0 让人改,改完还能投出去 + */ +uint8_t ui_w_setview_key(ui_w_setlist_t *a_inst, lv_key_t a_key); + +/* + * 函数功能:整定页标题右侧画"未标定"徽标(未标定闭锁的屏上表达) + * 入口参数:a_layer - 绘制层 lv_layer_t * + * a_clip - 重绘区 const lv_area_t * + * 返回值:无 + * 限定条件:页面 draw_cb 内调用(框架内置区块之后);三个条件全满足才落墨: + * 当前是整定视图(attach_edit)+ 参数帧已就位 + par.cal_block 非 0 + * 函数说明:为什么值得占标题带一个角:整定页的存在就是"让用户改保护定值", + * 而产品构建下 UA/UN/IA 未标定 → 判据恒停走 → **改完也不会动作**。 + * 不写这件事,页面就是在暗示"改了就算数"(UI补齐.md §4 末段"保护闭锁 + * 必须可见";口径 docs/任务规划/数据可信.md §4.3)。 + * ⚠ 与保存行的 LOCK 是两件事:LOCK = 定值闭锁(写不进去、禁编辑), + * 徽标 = 未标定(仍可改,只是判据停走)——合成一个词就等于互相冒充。 + * ⚠ 查看页不画(s_edit=0):那四页是既有黄金参考,像素零变化是硬约束 + */ +void ui_w_setview_draw_cal_badge(lv_layer_t *a_layer, const lv_area_t *a_clip); + /* * 函数功能:按发布序号取参数帧快照(页面 refresh 前调用) * 入口参数:无 diff --git a/docs/UI设计/UI_数据供给设计说明.md b/docs/UI设计/UI_数据供给设计说明.md index d9e0d382..1dac0ffb 100644 --- a/docs/UI设计/UI_数据供给设计说明.md +++ b/docs/UI设计/UI_数据供给设计说明.md @@ -96,13 +96,15 @@ flowchart TB F --> SRC end subgraph BSP["Drivers/BSP/UI(表现层)"] - D["ui_data.c/h
快照存储 + 只读访问器"] - P["ui_page_ac / ui_page_yx / ui_page_setlist ...
只读快照 + 格式化 + 布局"] + D["ui_data.c/h
快照存储 + 只读访问器
+ 定值写入队列与状态机"] + P["ui_page_ac / ui_page_yx / ui_w_setview+ui_w_setlist ...
只读快照 + 格式化 + 布局(整定页另加写投递)"] P --> D + P -- "整定保存:ui_data_param_request(&req)" --> D end T["App/task/ui_task.c
调度:每轮调 feed_tick()"] --> F T --> P F -- "ui_data_publish_rt/par(&帧)" --> D + F -- "落定:ui_data_param_drain(&ops),执行侧由 App 注入" --> D ``` **include 方向**(全部合法): @@ -116,13 +118,17 @@ flowchart TB **`ui_data.c` 不 include 任何 App 头,也不 include 任何页面头**——它是叶子。 +写路径没有给上表新增任何一行:它走的是**反向调用**(BSP 声明函数表、App 提供实现并按节拍 +把表传进来,见 §6.2),所以 include 方向不变、`layer-deps` 基线不变。这类"下层定义抽象、 +上层实现并注入"的第三条腿在 §7.2 的记录页 reader 上已经用过一次,两次都不新增 `ALLOW` 条目。 + ### 3.2 三个模块的职责边界 | 模块 | 位置 | 只做 | 绝不做 | |---|---|---|---| -| `ui_data` | `Drivers/BSP/UI/` | 存最新一帧、给只读访问、记发布序号 | 取数、格式化、判断业务含义 | -| `ui_data_feed` | `App/app/` | 按节拍从各 App 模块取数、组装、发布;变化检测 | 布局、字符串格式化、LVGL 调用 | -| 页面 `ui_page_*` | `Drivers/BSP/UI/` | 读快照、按行表格式化、交给框架绘制 | 直接调 App、自己算业务量 | +| `ui_data` | `Drivers/BSP/UI/` | 存最新一帧、给只读访问、记发布序号;**存定值写入请求队列、跑那台状态机**(越界与闭锁的判定按注入的 ops 问,自己一条业务规则都不持有) | 取数、格式化、判断业务含义、碰 `prot_set` 与文件系统 | +| `ui_data_feed` | `App/app/` | 按节拍从各 App 模块取数、组装、发布;变化检测;**每轮 drain 一条定值请求并注入执行侧实现** | 布局、字符串格式化、LVGL 调用 | +| 页面 `ui_page_*` | `Drivers/BSP/UI/` | 读快照、按行表格式化、交给框架绘制;整态把改动攒在**编辑叠层**里(改动位图 + 工作值,`ui_w_setview.c` 模块级一份——同屏只开一页,与各查看页的行缓存同一假设),只在按"保存"时投递一次 | 直接调 App、自己算业务量、自己判越界(行内上下限只是**镜像**,用于早拦一手;真源在 App,见 §9.4 一致性测试) | --- @@ -188,21 +194,41 @@ typedef struct { ```c #define UI_DATA_SET_N 40u /* 定值项数(与 prot_set 枚举同序,见 §9.2 一致性测试) */ +#define UI_DATA_VER 2u /* 契约版本;字段只尾追加、追加即升版(§4.3) */ typedef struct { uint32_t seq; uint32_t checksum; /* 与 prot_set_checksum() 同值:页面据此判"参数是否变过" */ uint8_t valid; - uint8_t locked; /* 定值闭锁(校验和自检失败等) */ + uint8_t locked; /* 定值闭锁(校验和自检失败等)—— 定值**写不进去** */ uint8_t bad_valid; /* 越界位图有效 */ uint8_t rsv; uint64_t bad_mask; /* 40 位:置 1 = 该定值项越界 */ int32_t q10[UI_DATA_SET_N]; /* Q10 原值;页面按行表的小数位换算显示 */ -} ui_data_par_t; + uint8_t cal_block; /* v2 尾追加:未标定闭锁(判据停走)—— 定值**仍可改** */ +} ui_data_par_t; /* 当前 192 B(v2 前 184 B;尾字节由 8 字节对齐补齐) */ ``` **为什么存 Q10 而不是 float**:定值是**整表校验和覆盖的定点值**,快照存浮点会引入"显示值与校验和口径不一致"的二义性。Q10 → 显示值的小数位换算属表现层(§8)。 +**`locked` 与 `cal_block` 是两个事实,屏上是两条通道、两个词,不得合并**(判据口径 +`docs/任务规划/数据可信.md` §4.3): + +| 字段 | 真源 | 说的是什么事 | 屏上落点 | 还能改定值吗 | +|---|---|---|---|---| +| `locked` | `prot_set_locked()`(L5-1 校验和自检失配) | 这台装置现在**写不进去** | 整定页保存行值列 `LOCK`,且 ENTER 一律不消费(禁编辑) | ❌ | +| `cal_block` | `adc_cal_flags()` 的 `ADC_CAL_F_VALID`(UA/UN/IA 三路,判据侧同源 `App/task/protect_task.c`) | 定值能改,但**改了也不会动作**(判据停走) | 整定页标题右侧「未标定」徽标 | ✅ | + +合成一个词就等于互相冒充:现场看到 `LOCK` 会以为"改了白改"而不去标定,看到「未标定」 +会以为"这台装置写不进定值"而放弃正当的整定操作。两个字段的取数都在 `App/app/ui_data_feed.c` +完成,页面零 App include(分层方向由 `scripts/check_layer_deps.py` 守住)。 + +> 【v2 落地注(2026-10-26,道F task-1)】本次只尾追加 `cal_block`,帧长 184 → 192 B; +> 组装缓冲 `s_scr` 是 rt/par/info 的联合体,而最大者是 **rt(208 B)**,所以这笔追加 +> 实际**没多花一字节 RAM**——这条"谁兜住了谁"的关系由主机单测的尺寸断言钉住 +> (帧长一变,那处注释就是唯一会撒谎的地方)。升 `UI_DATA_VER` 是必须的:版本号是 +> 页面与写侧唯一的对齐凭据,追加字段不升版 = 老页面按新帧长 memcpy 时静默错位。 + ### 4.3 扩展规则 - **只允许尾部追加字段**,结构体开头保留 `seq/valid`。 @@ -249,9 +275,9 @@ typedef struct { | 通道 | 节拍 | 理由 | |---|---|---| | `rt` | **200 ms**(内部计数分频,UI tick 10 ms × 20) | 与遥测链目标刷新率一致;人眼在 160×160 单色屏上感知不到更快 | -| `par` | **变化触发**:每 200 ms 比一次 `prot_set_checksum()`,变则重发 | 避免每拍拷 160 B;定值变化是低频事件 | +| `par` | **变化触发**:每轮比三个各自独立的变化源——`prot_set_checksum()`(定值改动)、`prot_set_locked()`(自检闭锁)、未标定判定(标定完成),**任一翻转就重发一帧** | 定值变化是低频事件,只盯校验和能省下每拍的 192 B 拷贝;但 `locked` 与 `cal_block` 都**不改动校验和**,只盯校验和会让保存行的 `LOCK` 与标题的「未标定」永远停在旧值上(真机现象:标定做完了徽标还挂着,直到某次定值改动被顺带刷掉——那是把两件不相干的事当同步点) | -`ui_data_feed_tick()` 每轮调用是**廉价**的:一次 tick 差值判断 + 一次 uint32 比较。真正的组装只在到期时发生。 +`ui_data_feed_tick()` 每轮调用是**廉价**的:一次 tick 差值判断 + 一次 uint32 比较 + 两次字节比较(`locked`/`cal_block` 上一帧值)。真正的组装只在到期或有源翻转时发生,无变化时零拷贝。 ### 5.3 启动时序 @@ -301,36 +327,90 @@ static void pg_refresh(uint16_t row, char *name, char *value) { ### 6.2 请求 / 状态交接(复用 `net_cfg` 范式) +落地形态(道F task-1 落本层队列与状态机,task-2 落页面侧消费者,2026-10-26): + ``` -ui_page_setlist(编辑态) - │ 用户按"保存" +ui_page_view_param / _set / _word / _plate(整定态:123 授权 → attach_edit 绑 set/commit/state) + │ 选中"保存"行按 ENTER(组件 ui_w_setlist_key 先过两道门:可写权限 + state()!=LOCK) ▼ -ui_data_param_request(&req) ← BSP 侧只投递,不执行 - │ (小环形队列,深度 4;满则返回 -1,页面提示"忙") +sv_commit():req.mask = 改动位图;req.q10[] 只按 mask 置位的槽换算(显示值 → Q10) ▼ -ui_data_feed_tick()(App 侧,uiTask 上下文) - │ 取出请求 → 校验(调 prot_set 的只读接口预检) +ui_data_param_request(&req) ← 本层只投递,不执行、不判越界、不碰文件系统 + │ 静态环形队列,深度 4(UI_DATA_PARAM_QD);满 → -1(页面显 ERR + 页脚"忙") + │ 入队 = 168 B 整条拷入(.bss 4 × 168 = 672 B;零堆、零变长拷贝) ▼ -prot_set_set_sealed() / prot_set_set() + prot_set_seal() - │ 成功 → prot_set_dirty_mark() → defaultTask 落盘 +ui_data_feed_tick()(App 侧,uiTask 上下文;先 drain 再比校验和,顺序即判据) + │ ui_data_param_drain(&s_param_ops):每轮最多落定一条 + │ ② ops->locked() == 1 → ERR_LOCKED,**本条丢弃**(不排队等解锁) + │ ③ ops->check() 按 mask 逐项整批预检,任一项越界 → ERR_RANGE + err_mask,一字不写 + │ ④ 预检全过再逐项 ops->apply() → prot_set_set_sealed()(只写 mask 内的 id)→ OK + │ 值真的变了 → 同轮重发 par 帧(页面按 seq 失效缓存) ▼ -ui_data_param_state(OK / ERR_RANGE / ERR_LOCKED / ERR_BUSY) - │ - ▼ -ui_page_setlist 显示"保存成功 / 越界:Ⅲ段定值" +ui_data_param_state() / ui_data_param_err_mask()(保存行显词 + 越界行反显) ``` -**这套"UI 只投递、属主任务执行、状态回读"的结构,与 `net_cfg` 的 `net_cfg_save_request` / `net_cfg_save_state` 完全同构**(`Drivers/BSP/NETCFG/net_cfg.h`),不引入新范式——这是"低耦合"的另一个含义:**项目里同类问题只有一种解法**。 +四个公开函数(声明 `Drivers/BSP/UI/ui_data.h`、实现 `ui_data.c`,全在本层,零 App include): + +| 函数 | 作用 | 返回值口径 | +|---|---|---| +| `ui_data_param_request()` | 投递一条请求(页面"保存"的唯一出口) | `0` 已入队;**`mask=0` 的空请求也返 0**,但不占槽位、直接落终态 OK(一个都没改就按保存是正常操作,报 ERR 会让现场以为装置有问题);`-1` 队列满;`-2` 参数无效(NULL,或 `mask` 含 `UI_DATA_SET_N` 以上的位——调用方的编程错误不该落成屏上的"越界") | +| `ui_data_param_state()` | 回读写路径状态 | `UI_DATA_PARAM_ST_{IDLE,WAIT,OK,ERR_RANGE,ERR_LOCKED,ERR_BUSY}` | +| `ui_data_param_err_mask()` | 最近一条**被本次预检挡下**的项位图 | bit i = 1 → 第 i 项越界;最近终态不是 `ERR_RANGE` 时恒 0 | +| `ui_data_param_drain()` | 落定一条(App 装配节拍入口) | `1` 处理并提交了一条;`0` 队列空(状态不动);`-1` ops 或其函数指针为 NULL | + +**"算不算越界"与"现在能不能写"都是 App 侧事实,本层一份都不复制**:执行能力经 +`ui_data_param_ops_t`(`locked` / `check` / `apply` 三个函数指针)由 `App/app/ui_data_feed.c` +**每次调 drain 时注入**,本层不保存副本。原因有两条,都不含糊:① 分层方向硬约束—— +`Drivers/BSP/**` 不得 include `App/**`(`scripts/check_layer_deps.py` 拦提交),而上下限真源 +在 `App/algo/prot_set.c` 的 `g_prot_set_meta`;② 注入表**刻意不含任何范围数值**,多传一份 +上下限就是第二真源、就会漂。同 §7.2 记录页的注入式 reader 一条路子,额外的好处是主机单测 +可以把三个指针换成桩件,于是**队列与状态机的生产代码**(不是副本)能在 PC 上把"满队列 / +越界不改值 / 闭锁拒写 / 正常往返"跑全(`test/host/ui_data/`)。 + +**请求形状 = 「位图 + 定长 40 槽」(168 B)而不是「项数 + 变长 id 数组」**:表现层的编辑叠层 +本就是一张改动位图 + 一张工作值表,本形状与它逐位同构 → 提交侧一次赋值、执行侧逐项按位取, +两侧都不必维护第三种表示;定长还让队列退化成 O(1) 静态数组。边界与代价:`mask` 只有 64 位 +→ 定值项数硬上限 64(`ui_data.c` 有编译期守卫,破 64 是编译失败而不是静默丢项);队列 672 B ++ 控制字 16 B 是买"整批原子 + 零变长 + 零堆"的价钱(`docs/内存管理/RAM使用分析与优化方案.md`)。 + +> ⚠ **提交侧必须只提交 `mask` 内置位的 id,绝不"从显示值回提全表"**:Q10 存不下 0.95 这类 +> 返回系数(实际存 972/1024),按显示域往返会把用户没动的项顺手改掉。判据"越界值不落盘"与 +> "改哪条只落哪条"都建在这条上(`docs/任务规划/UI补齐.md` §2 第 2 行)。 +> 换算的往返精度因此也**不需要**逐位可逆:没动的行根本不进换算函数。 + +**这套结构与 `net_cfg` 的 `net_cfg_save_request` / `net_cfg_save_state` 完全同构** +(`Drivers/BSP/NETCFG/net_cfg.h`),不引入新范式——这是"低耦合"的另一个含义: +**项目里同类问题只有一种解法**。 ### 6.3 失败语义 -| 返回 | 含义 | 页面表现 | -|---|---|---| -| `OK` | 已封存并置脏 | 行值刷新,页脚短暂"已保存" | -| `ERR_RANGE` | 某项越界 | 高亮越界行(用 `bad_mask`),页脚"越界" | -| `ERR_LOCKED` | 定值闭锁中(自检失败) | 页脚"定值闭锁",禁止编辑 | -| `ERR_BUSY` | 请求队列满 | 页脚"忙,请重试" | -| `ERR_TIMEOUT` | 3 s 未取走 | 页脚"无响应" | +落定结果只有 5 个值(`UI_DATA_PARAM_ST_*`),页面侧的**词与位置**由整定视图统一映射, +四张整定页共用一套(组件按 `state()` 回调给的值画保存行值列,页面不参与解释): + +| 状态 | 含义 | 保存行值列 | 页面附带动作 | 产出方 | +|---|---|---|---|---| +| `IDLE` | 本次打开页面从未投递过 | `ENTER` | — | 本层初值 | +| `WAIT` | 已投递、队列里还有请求在路上 | `WAIT` | 编辑叠层**保留**(不清稿) | `request` 受理 / `drain` 未排空 | +| `OK` | 已逐项写入并置脏 | `OK` | 按 `commit` 位图清掉叠层,让位给新帧 | `drain` 第④步 | +| `ERR_RANGE` | 整批预检有任一项越界 → **一字未写** | `ERR` | 用 `ui_data_param_err_mask()` 反显越界行 | `drain` 第③步 | +| `ERR_LOCKED` | 投递时定值闭锁 → **本条丢弃** | `LOCK` | 反显同 `ERR_RANGE`;`drain` 不回滚(`prot_set` 无回滚接口,谎称"整批未写"比如实报更坏) | `drain` 第②步 | +| `ERR_BUSY` | 投递被拒:队列满 | `ERR` | 页脚"忙,请重试";**在队请求一条不动**,用户直接再按一次保存 | `request` 返 -1 | + +三条实现侧口径,都是原稿没写死的: + +1. **`LOCK` 还有一个与状态机无关的来源**:帧里 `par.locked == 1` 时保存行**直接显 `LOCK` + 并禁 ENTER**(优先级高于历史结果)。闭锁是持续状态,不该只在"某次投递被拒"那一帧才说一次; + 禁编辑必须落在键路上,只靠屏上词会让用户白改一轮。 +2. **`ERR_TIMEOUT`(原稿的"3 s 未取走")本层不产出**——uiTask 每 10 ms 就 drain 一次, + 真出现"永不落定"是 App 侧没接线的装配缺陷,不是运行期状态。页面若要兜底,可自己按投递 + 时刻计时(属页面本地可选项,当前没做:没有消费者就别先加一条无人走的分支)。 +3. **状态是全局单值,页面必须锁存显示**:`ui_data_param_state()` 落定后一直停在那个值上, + 直读会让用户移动一下光标就看到别的页留下的旧结果。整定视图把它锁存成"本页投过一次 + 才显示那次的答案、开页复位成 `ENTER`"(`ui_w_setview.c` 的 `s_disp`)。 + +> 词表刻意用 ASCII(`ENTER`/`WAIT`/`OK`/`ERR`/`LOCK`)与 `net_cfg` 保存行同排; +> 中文「未标定」徽标是**另一件事**(`cal_block`),见 §4.2 的两源两通道表,不要为了 +> 省一个词把它并进 `LOCK`。 --- @@ -446,6 +526,18 @@ typedef struct { > 2. 固定帧里的定值**不手抄 40 个数字**,而是把 `App/algo/prot_set.c`(零平台依赖的纯算法,双端共享核心)链进模拟器、`prot_set_init()` 后取出厂默认——抄表等于造第二真相(同 `scripts/check_font_charset.py` 的"单一规则源"口径); > 3. `build_date` 用**固定串**而不是 `__DATE__`:否则跨天重建会把装置信息页的黄金参考刷掉,而黄金参考是判据本身。 > 4. 原稿设的 `--live` 合成正弦模式**尚未实现**(当前无消费者,等动态刷新真的需要人眼观察时再加)。 +> 5. **定值写路径在模拟器侧有一份同构的执行实现**(`sim_param_locked/check/apply` + +> `sim_param_pump()`):真机那段在 `App/app/ui_data_feed.c` 的 tick 里,而模拟器不链接装配器, +> 所以在 `main.c` 里镜像一份——`check()` 用的仍是同一份 `g_prot_set_meta` 真源(链进来的 +> `App/algo/prot_set.c`),因此"模拟器里越界被拒"与"真机上越界被拒"是同一个判定,不是两份规矩。 +> 固定帧的 `cal_block` 显式填 1:**真机现在就是**未标定态(`rt.eng` 也按同一条口径钉在引脚档), +> 屏上的话必须与本机一致,模拟器才配当像素基准。 +> 6. 落定与"页面自己发现数据变了"的分工:`sim_param_pump()` 在无头模式排在注键之后、截图之前 +> 同步排空(真机由每 10 ms 的 tick 排);整定视图另有一条 100 ms 的 LVGL 轮询定时器, +> 负责把"主站写进来的新值 / 标定完成 / 落定但不改校验和的 ERR"推上屏。**这条定时器在无头 +> 回归里一次都不会触发**——`run_headless()` 严禁调 `lv_timer_handler()`(I1+FULL 全屏刷新会 +> 进渲染死循环),所以黄金像素与它无关;两次连跑逐字节一致即为证据(`simulator/README.md` +> 的同条注)。窗口模式才用它。 ### 9.3 与"虚拟注入"的关系 @@ -469,18 +561,29 @@ typedef struct { ## 10. 内存与性能 -| 项 | 量 | 说明 | +| 项 | 量(`.map` 实测) | 说明 | |---|---|---| -| `ui_data` rt 快照 | ≈ 132 B | 9×8 + 位图 + 时钟串 | -| `ui_data` par 快照 | ≈ 180 B | 40×4 + 头部 | -| 状态/队列 | ≈ 60 B | 请求环 4 条 + 状态字 | -| **合计(RW_IRAM1)** | **≈ 380 B** | 当前 RW_IRAM1 余 57,504 B → 无压力 | -| 页面行缓存 | 每页 ≈ 132 B(仅数据页) | 页面私有静态,随页数线性;若 6 个数据页 ≈ 800 B | -| LVGL 池 | **0** | 快照不进池(不是 LVGL 对象) | -| 每 200 ms 拷贝 | ≈ 132 B | 一次 memcpy,微秒级 | -| 每帧(10 ms) | 0 次拷贝(比 seq 后只在变化时拷) | 见 §5.4 | +| `rt` 帧 | 208 B | 遥测 9 路 + 相量槽带 + 位图 + 时钟串;三帧串行组装用的 `s_scr` 是联合体,**取最大者 = rt** | +| `par` 帧 | 192 B | 40×4 Q10 + 头部 + `cal_block`(v2);追加这 8 B 由 rt 兜住,**净增 0**(尺寸关系由主机单测钉住,见 §4.2 落地注) | +| `info` 帧 | 随字符串字段定长 | 与 rt 同节拍发布,同样落在联合体里,不额外占 | +| `ui_data.o` 全部静态 | `.bss` 1,168 B | 含三帧存储、`s_scr` 联合体、**定值请求队列 4×168 = 672 B + 控制字 16 B** | +| `ui_w_setview.o` 静态 | `.bss` 352 B / `.data` 48 B | 四视图行缓存 + **整定编辑叠层 190 B**(`int32_t[40]` + 三张位图 + 锁存态) | +| 页面行缓存 | 每页 ≈ 52 B(`ui_page_view_*.o` 各 `.bss` 52 B) | 定值相关页共用一份视图缓存(同屏只开一页),所以四张整定页**没有**再各存一份 192 B | +| LVGL 池 | 快照 0 B;轮询定时器一只 | 快照不进池(不是 LVGL 对象);定时器是 LVGL 池对象,只建一次不删 | +| 每 200 ms 拷贝 | 一帧(≤208 B),且只在变化时 | 一次 memcpy,微秒级 | +| 每轮(10 ms) | 0 次拷贝 | 比 seq / 比三个变化源;见 §5.2、§5.4 | -**结论**:代价是"几百字节 + 每 200 ms 一次 memcpy",换来的是整个 UI 层的数据可及性。 +栈侧(同一份 `.map` 的调用图,改前改后各跑一次 `scripts/stack_report.py`):uiTask 静态最坏链 +**1336 B / 配置 16 KB(余 15,048 B,91%)在接入写路径前后逐字节不变**——新链路自身都不深 +(`sv_commit` 帧 192 B 含 168 B 请求体、链深 208 B;「未标定」徽标帧 160 B、链深 536 B;按键链 +`pg_key` 起算 480 B),最坏链仍是 LVGL 建树那条,**没有换人**。这是"页面 commit 会在 uiTask 栈上 +放 168 B 局部量"这类担心的实测答案,也是本表值得写实测数而不写"约几百字节"的理由。 + +**结论**:代价 = 约 1.5 KB 静态 + 每 200 ms 一次 memcpy,换来整个 UI 层的数据可及性与一条 +可测的写路径。区域水位(RW_IRAM1 / RW_CCM / Flash)**不在本文复制**——那是受管快照, +由 `scripts/ram_baseline.py` 从每次 `@build` 的 `.map` 生成,第二处写死就是一份会过期的 +真源(本仓已有一条门禁在钉这个,口径见 `docs/内存管理/RAM使用分析与优化方案.md` §2.1 与 +`docs/内存管理/RAM预算台账.md`)。本次新增约 1.5 KB 静态 + 一条 LVGL 池定时器,量级无压力。 --- @@ -492,7 +595,7 @@ typedef struct { | **步骤2** | `App/app/ui_data_feed.c` + `ui_task.c` 接线(每轮调 tick) | 串口打印首帧(临时锚点);`@build` 0/0 | 步骤1 | | **步骤3** | 交流量 / 直流量回炉(接快照 + 行表加 `src/dot/unit`) | 模拟器固定帧 golden 更新;台架看真实码值变化 | 步骤2 | | **步骤4** | 遥信量 + 定值/控制字/软压板查看(4 页) | 主站改定值 → 页面 200 ms 内变 | 步骤3 | -| **步骤5** | 写路径(请求/状态)+ 整定 4 页 | 就地改定值 → 保存 → 掉电重启 → 值保持 | 步骤4 | +| **步骤5** | 写路径(请求/状态)+ 整定 4 页 | 就地改定值 → 保存 → 掉电重启 → 值保持 | 步骤4。**已落**(道F task-1 落本层队列与状态机、task-2 落整定 4 页与模拟器侧,2026-10-26);「掉电重启值保持」只能上板收口,见 §12 判据 6 | | **步骤6** | 记录 reader 注入 + 删 3 条 layer-deps 基线 | `check_layer_deps.py` 基线由 6 → 3 | 可与步骤3 并行 | **步骤1~步骤2 是关键路径**:它们不产出任何用户可见效果,但决定后面所有页面是否可信。**不要跳过步骤1 直接改页面**——那会退化成"页面里塞假数据 2.0"。 @@ -533,6 +636,26 @@ typedef struct { > 「当前是否已过密码」既不真实也不该被推断。判据侧的过法只有一套(查看密码 = 出厂 000 / > 修改密码 = 123,键序与黄金回归的网口设置编辑态完全一致),口径与纪律见 > `docs/UI设计/UI_自动化测试流程.md` §7.5;本节只登记这条事实,避免两份文档各说各话。 +> +> 【落地注·六(写路径消费者已上屏,2026-10-26,道F task-2)】整定 4 页(参数 / 定值 / +> 控制字 / 软压板)绑到与查看列**同一批行表、同一个 `ui_w_setlist` 组件**:同一份页面 +> 描述符按 `ui_content_page_is_editable()` 分派只读 / 可写两态,两态的差别只有绑不绑 +> `set/commit/state` 三个回调。于是本节契约里原本只是示意图的那条链路第一次真的被按到: +> **越界值不落盘**(提交只带改动位图)、**闭锁禁编辑**(`state()` 回 LOCK 或帧里 +> `locked=1` 就直接挡住 ENTER,而不是只换个屏上词)、**落定要自己上屏**(`ERR_RANGE` 与 +> `ERR_LOCKED` 都不改校验和,页面收不到新帧,靠的是整定视图那条 100 ms 轮询,见 §9.2 注 6)。 +> +> 这一批还暴露了两条「只有第一个消费者才会暴露」的缺陷,都已修(为什么必现写在 +> `docs/UI设计/页面扩充详细方案.md` §3.2 落地注):二态行 ENTER 不回绕(写出 opts 之外的 +> 下标:屏上看着没变、叠层里已是越界值,保存必被预检挡回);「参数帧已就位」原先只在 +> **绘制路径**里被置位,而按键门读它 → 无头回归里开页后的第一个 ENTER 永远被挡(真机开页 +> 10 ms 内就重绘,症状只在测试侧出现)。第二条的修法是把「数据就位」与「画过没有」解耦: +> 判就位之前先按 seq 做一次 sync(帧没变时只是一次 uint32 比较)。 +> +> 另记一条同批暴露、与本契约无关的事实:菜单树重跑时 `ui_menu_tab.h` 有一行注释 +> 跟着生成器的模板文字变了 —— 说明 HEAD 上「生成器模板」与「已提交生成物」本就不同步 +> (改模板那笔没重跑生成器)。生成物以生成器输出为准即可,但这条滞后该补一条门禁, +> 已登记为待办「生成器幂等性检查」。 --- @@ -545,8 +668,9 @@ typedef struct { | 3 | 交流量页显示真实相量(非写死值) | 台架:注入已知信号 → 页面值随信号变 | | 4 | 未标定时页面明确标识(不把码值当伏特) | 目检 + 文本断言(`eng=0` 时带标记) | | 5 | 首帧前显示 `--` 而非 `0.000` | 模拟器:不 publish 直接开页,断言文本 | -| 6 | 定值整定全链闭环 | 就地改 → 保存 → 重启 → 值保持 + 校验和自检通过 + 主站 C_RD 一致 | -| 7 | 像素回归稳定 | `ui-golden` 连续两次运行零差异 | +| 6 | 定值整定全链闭环 | 就地改 → 保存 → 重启 → 值保持 + 校验和自检通过 + 主站 C_RD 一致。**半收口(2026-10-26)**:模拟器侧「改 → 保存 → OK → 回首屏新值已在帧里」已由像素 + 文本断言覆盖(黄金用例 `sparam_save` / `sparam_landed`,越界侧 `sparam_oob`);**重启保持与主站 C_RD 一致只能上板**——模拟器没有 NAND,`apply()` 到 `prot_set_set_sealed()` 就是终点,这条差距已登记在 `docs/测试说明/模拟器差距清单.md`;另注意 40 项里有 13 项 `comm_addr=0`(纯本地项),主站侧根本读不到,不能拿 C_RD 当它们的判据 | +| 7 | 像素回归稳定 | `ui-golden` 连续两次运行零差异。**已验(2026-10-26)**:27 页连跑两次逐字节一致(含整定 9 态);轮询定时器不影响——无头模式不调 `lv_timer_handler()`(§9.2 注 6) | +| 9 | 「写不进去」与「改了不动作」两条事实互不冒充 | 反向文本断言(`check_content_pages.py` 的 `ABSENT`:查看页与只读态不得出现「未标定」徽标与「保存」行)+ 台架:制造一次 `locked`(改坏校验和档)目检保存行 `LOCK` 且 ENTER 无反应 | | 8 | 主机单测覆盖数据模块 | `test/host/ui_data` 全绿并挂 pre-commit 钩子 | --- diff --git a/docs/UI设计/页面扩充详细方案.md b/docs/UI设计/页面扩充详细方案.md index 84bbe833..103311d4 100644 --- a/docs/UI设计/页面扩充详细方案.md +++ b/docs/UI设计/页面扩充详细方案.md @@ -203,6 +203,12 @@ V0.1 **无直流采样通道**(`ADC_SEM` 只有 8 路电气 + 温度),点 8 个叶子(整定 4 + 查看 4)共用**一份** `ui_w_setlist` 组件 + **4 张视图表**。查看与整定的差别只是 `edit_capable` 与权限。 +> 【2026-10-26 落地】查看 4 页(道E task-15 批 3)与**整定 4 页(道F task-2)都已落**: +八个叶子绑的是**同一批模块**(`view_param` / `view_set` / `view_word` / `view_plate`), +两态共用**同一份页面描述符**——差别不写在描述符里,而在 `pg_init` 按 `ui_content_page_is_editable()` +选 `ui_w_setview_attach()`(只读)还是 `ui_w_setview_attach_edit()`(可写)。 +下方 §3.2.6 那张按两种描述符写的表已照此更正。 + #### 3.2.1 40 项四视图划分 | 视图 | 项数 | 判据 | 页数(8 行/页) | @@ -294,23 +300,48 @@ V0.1 **无直流采样通道**(`ADC_SEM` 只有 8 路电气 + 温度),点 | 项 | 查看(4 页) | 整定(4 页) | |---|---|---| -| 权限 | 无密码 → `UI_PERM_VIEW` | 密码 → 按所输密码授 `VIEW`/`MODIFY` | -| `edit_capable` | 0 | 1 | -| 页脚 | 只显示页码 | 额外一行"保存"(第 8 行固定为保存行) | -| 提交 | — | 整表 `prot_set_set*` + `prot_set_seal()` + 置脏,走请求/状态交接 | +| 权限 | 无密码 → `UI_PERM_VIEW` | 密码 → 按所输密码授 `VIEW`/`MODIFY`(123 = 修改、000 = 查看) | +| `edit_capable` | 描述符置 1,但 `is_editable()` 为假 → 走只读 attach | 同一份描述符,`is_editable()` 为真 → 走 `attach_edit` | +| 组件回调 | `commit = state = NULL` → **不画保存行**、ENTER 不消费(绑了 `key_cb` 也等价于没绑) | 绑 `set/commit/state` → 画保存行、放开编辑 | +| 页脚 | 只显示页码 | 额外一行"保存"(挂在**末行之后**,不是固定第 8 行) | +| 提交 | — | 只提交**改动位图**内的 id(一次封存、一次校验和),经 `ui_data_param_request()` 投递,App 侧 `drain` 落定 → 见 `docs/UI设计/UI_数据供给设计说明.md` §6.2 | +| 越界反显来源 | `par.bad_mask`(装载侧,且 `bad_valid=1` 才参与) | 同上 **+** `ui_data_param_err_mask()`(本次提交被整批预检挡下的项) | +| 闭锁 | 无写通路,不需要表达 | `par.locked=1` 或投递回 `ERR_LOCKED` → 保存行显 `LOCK` **且 ENTER 一律不消费**(禁编辑,不是只换个词) | +| 未标定 | 不画(查看页像素恒零变化) | 标题右侧「未标定」徽标(`par.cal_block`);与 `LOCK` 是两件事,两源两通道两词 | > 保存行占掉一个可见行 → 整定页行数 = 视图项数 + 1,分页随之增加:参数 9 行 2 页、定值 19 行 3 页、控制字 5 行 1 页、软压板 11 行 2 页(合计 8 页)。这是"保存必须整表封签"带来的必然代价,不是设计缺陷。 +> 【2026-10-26 落地实测 + 两条必修缺陷】上述分页与黄金参考逐页对上(`sparam_p2` / +> `sset` / `sword_cycle` / `splate` 四条用例的页脚分别是 +> 「共 2 页 / 共 3 页 / 共 1 页 / 共 2 页」)。**「整表封签」指的是一次保存只封存一次、 +> 只重算一次校验和,不是「把 40 项全写一遍」**——实际只写用户改过的项:Q10 存不下 0.95 这类 +> 返回系数(真存 972/1024),从显示值回提全表会把用户没动的项顺手改掉。 +> +> 这一批暴露了两条「只有第一个消费者才会暴露」的缺陷(都已修;写路径口径见 +> `docs/UI设计/UI_数据供给设计说明.md` §6.2/§6.3): +> ① `ui_w_setlist` 的二态行 ENTER **不回绕**——落在最后一项时写出 opts 之外的 +> 下标,屏上看着「按了没反应」,叠层里却已是越界值,随保存投出去必被预检判 `ERR_RANGE`; +> 控制字 4 行与软压板 10 行的出厂值**全是**最后一项(跳闸 / 投入),所以是必现而不是边角。 +> 本方案落地之前,没有任何页面按过这条键(查看页 `commit=NULL` 走不到)。 +> ② 编辑态的「参数帧已就位」原先只在**绘制路径**里被置位,而按键门读它 → 无头回归里开页后 +> 第一个 ENTER 永远被挡(真机开页 10 ms 内就重绘,症状只在测试侧出现)。修法是让 +> 「数据就位」与「画过没有」解耦:判就位之前先按 seq 做一次 sync(帧未变时只是一次比较)。 +> +> 另记一条生成器侧的事实(不在本道修,已登记为待办「生成器幂等性检查」):菜单树重跑时 +> `ui_menu_tab.h` 有一行注释跟着变了 —— 说明 HEAD 上生成器的模板文字与已提交的 +> 生成物本来就**不同步**(改模板那笔没重跑生成器)。生成物是产物,以生成器输出为准即可, +> 但这条滞后不补,任何人都可能在不知情的情况下把它 revert 掉。 #### 3.2.7 编辑交互(复用网口设置页的模型) | 键 | 正常模式 | 编辑模式 | |---|---|---| | ↑↓ | 移选(框架内置) | 当前位 +1 / −1(按项步进) | | ←→ | ← 返回上级(框架) | 移动数字位光标 | -| ENTER | 进编辑 / 触发保存行 | 确认本项(写 RAM 副本) | +| ENTER | 进编辑 / 触发保存行(**闭锁态 `LOCK` 下一律不消费**) | 确认本项(越界则拒退并打提示,值不进叠层) | | ESC | 关页(框架恒可拦截除外) | 关页(未保存的调整留在 RAM,不落盘) | -- 权限不足时 ENTER 一律忽略(`ui_content_page_is_editable()` 判)。 +- 权限不足时 ENTER 一律忽略(`ui_content_page_is_editable()` 判);**权限够但装置写不进去时同样忽略**(`state()` 回 `LOCK` 或帧里 `locked=1`)——前者是「这人不能写」,后者是「这台装置现在写不进」,两件事都必须落在键路上,只改屏上词等于让用户白改一轮。 +- 编辑中「←」是**移动光标**而不是关页:描述符要置 `key_intercept = 1u`,框架才先把 LEFT 交页面(`simulator/tests/` 的 `sparam_edit` / `sparam_oob` 两条用例走的就是这条路)。 - 越界项在保存失败后由 `bad_mask` 高亮(`draw_cb` 画反显)。 - 规模:`ui_w_setlist` **M**(约 250 行),`ui_w_editor` **M**(约 180 行)。 @@ -411,6 +442,12 @@ int run_par_set_deadband(uint8_t yc_idx, float v); /* 范围校验 */ | 操作记录 | 定值保存成功 / 强制复归 / 时钟设置 / 清除记录(4 处) | 动作成功后写一条 | | 遥控记录 | `iec104_pts_yk_exec()` 返回处 | 每次执行结果(成功/失败/选择态) | +> **本表只是页面侧的需求清单**(记录页要消费哪几类),**不是记录层的登记表**: +> 5 个 ID 的定名、记录格式、条数与字节上限、写入时机、以及是否真的落地,一律以 +> `App/log` 侧为准,权威口径 `docs/子系统设计/运行与自检记录设计说明.md`; +> 实施进度由记录侧任务回报后回填本文(本道不代记,免得同一件事在两份文档里各说一遍—— +> 同一事实只在一处详述,见 `docs/文档规范.md`)。 + **`ui_w_soe` 要点**: ```c @@ -505,6 +542,12 @@ void ui_w_soe_refresh(ui_w_soe_t *inst, uint16_t row, char *name, char *valu **本批不改菜单树**——27 个空叶子全在现有 42 节点里,`#page=` 注记逐个补上即可(改 `ui_menu_tree.txt` 后重跑 `python tools/menu_gen/gen_menu.py`)。 +> 【2026-10-26 已改一笔】整定 4 叶(参数 / 定值 / 控制字 / 软压板)已补 `#page=`, +> 绑的是与查看列**同一批模块**(一个模块被两个叶子绑定是生成器允许的,`validate_pages()` 按 +> 集合去重判孤儿页)。重跑生成器后 `UI_PAGE_MAP_COUNT` 12 → 16, +> `uvprojx` 与 `CMakeLists.txt` 未变(没新增文件)。 +> 生成器幂等性的一条滞后见 §3.2.6 落地注末段。 + **唯一建议改动**(可选,需产品拍板):补参考项目的"厂家设置"分支。参考侧该分支有 18 个叶子(元件配置、恢复默认 ×5、交流显示、装置调试 ×8、版本信息)。若采纳,精简为 3 个叶子 + 2 个分组: | 建议叶子 | 理由 | @@ -579,10 +622,10 @@ SNTP 客户端 ──────→ 批4 SNTP设置 | 2 | 3 个静态页全部接真数据 | 改变输入信号 → 页面值变 | | 3 | 未标定态可见 | `eng=0` 时交流量第 7 行显示"未标定" | | 4 | 无源点显示 `--` | 遥信量无效点、直流量直流通道 | -| 5 | 定值整定闭环 | 改 → 保存 → 重启 → 值保持 + 校验和自检通过 + 主站 C_RD 一致 | -| 6 | 分层门禁无新增 | `check_layer_deps.py` 退出码 0,基线 6 → 3 | -| 7 | 像素回归稳定 | `ui-golden` 连续两次零差异 | -| 8 | 每个新页有 golden 参考 | `simulator/tests/ref/` 新增对应 `.bin` | +| 5 | 定值整定闭环 | 改 → 保存 → 重启 → 值保持 + 校验和自检通过 + 主站 C_RD 一致。**2026-10-26 半收口**:模拟器侧「改 → 保存 → `OK` → 回首屏新值已在帧里」与「越界被拒、值不落盘」已由像素 + 文本断言覆盖(`sparam_save` / `sparam_landed` / `sparam_oob`);**重启保持与 C_RD 一致只能上板**(模拟器无 NAND)。另注意 40 项里 **13 项 `comm_addr = 0`**(投退、返回系数、过频/低频段、全局软压板)主站不可寻址,不能拿 C_RD 当它们的判据 | +| 6 | 分层门禁无新增 | `check_layer_deps.py` 退出码 0,基线 6 → 3。**现状:3**(记录 reader 已削 3 条;写路径走注入式 ops 函数表,未新增 include,见 `docs/UI设计/UI_数据供给设计说明.md` §6.2) | +| 7 | 像素回归稳定 | `ui-golden` 连续两次零差异。**已验(2026-10-26)**:27 页连跑两次逐字节一致 | +| 8 | 每个新页有 golden 参考 | `simulator/tests/ref/` 新增对应 `.bin`。**整定 4 页已有 9 张**(含保存/越界/落定/回绕各态);参考图入库,改它 = 改判据,须逐页过目 | --- diff --git a/docs/任务规划/UI补齐.md b/docs/任务规划/UI补齐.md index b0581b64..4000f7ed 100644 --- a/docs/任务规划/UI补齐.md +++ b/docs/任务规划/UI补齐.md @@ -50,10 +50,10 @@ | 页面 | 现有数据源 | 还缺什么 | |---|---|---| -| 整定 4 页 | `prot_set` 31 项、`ui_w_editor` | 第 1 项写路径 | +| 整定 4 页 | `prot_set` 全部定值项(`App/algo/prot_set.h` 的 `PS_ID_COUNT`,现 40 项)、`ui_w_editor` | 写路径已落,缺可编辑页面 | | 时钟设置 | `sys_clock_set_unix()` | 页面与回读校验 | | 强制复归 | `prot_elm_request_reset()`、`ui_page_action` | 页面与 SOE 留痕 | -| 清除记录 | `sys_log` | 删除 API、确认交互 | +| 清除记录 | `sys_log` | 页面与确认交互(删除 API 已落) | | 遥测死区 / 系数 | `run_par`、`adc_cal` | 可编辑页面 | | 通讯设置 | 网口配置保存范式可复用 | `[comm]` 段及 104 消费 | | 记录 5 页 | SOE、自检、运行记录已有 reader 先例 | 五类写入方与记录类型 | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 48ef367b..335d6cf7 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -47,10 +47,10 @@ dhara 失败类 +84;RO 另 +208,全是该失败类名表)→ `ER_IROM1` | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 249,500 B (243.65 KB) | 1,048,576 B (1024 KB) | 23.8% | -| RW_IRAM1 | 79,008 B (77.16 KB) | 131,072 B (128 KB) | 60.3% | +| ER_IROM1 | 253,220 B (247.29 KB) | 1,048,576 B (1024 KB) | 24.1% | +| RW_IRAM1 | 79,992 B (78.12 KB) | 131,072 B (128 KB) | 61.0% | | RW_CCM | 57,376 B (56.03 KB) | 65,280 B (64 KB) | 87.9% | -| **Flash (ROM 合计)** | **250,832 B (245.0 KB)** | 1,048,576 B (1024 KB) | **23.9%** | +| **Flash (ROM 合计)** | **254,660 B (248.7 KB)** | 1,048,576 B (1024 KB) | **24.3%** | 各区的容量与常量来源: diff --git a/docs/内存管理/RAM预算台账.md b/docs/内存管理/RAM预算台账.md index ba36afed..9ac098a3 100644 --- a/docs/内存管理/RAM预算台账.md +++ b/docs/内存管理/RAM预算台账.md @@ -15,19 +15,19 @@ | 区域 | 用量 | 容量 | 占比 | 门禁(≤92%) | |---|---|---|---|---| -| ER_IROM1 (Flash) | 249,500 B | 1,048,576 B | 23.8% | 参考 | +| ER_IROM1 (Flash) | 253,220 B | 1,048,576 B | 24.1% | 参考 | | RW_CCM | 57,376 B | 65,280 B | 87.9% | PASS | | UNINIT_CCM | 48 B | 256 B | 18.8% | 参考 | -| RW_IRAM1 | 79,008 B | 131,072 B | 60.3% | PASS | +| RW_IRAM1 | 79,992 B | 131,072 B | 61.0% | PASS | | 子系统(静态 RW+ZI;除 RTOS 堆在 CCM 外均在 SRAM1) | 现值 | 配额 | 状态 | |---|---|---|---| | 网络 | 13,702 B | 12 KB | ⚠ 超 1.4 KB | | 存储 | 7,056 B | 12 KB | OK | -| UI | 32,335 B | 16 KB | ⚠ 超 15.6 KB | +| UI | 33,247 B | 16 KB | ⚠ 超 16.5 KB | | RTOS 堆 | 57,376 B | 56 KB | 堆=配额 | -| 应用 | 15,422 B | 8 KB | ⚠ 超 7.1 KB | -| 其它 | 10,499 B | — | 参考 | +| 应用 | 15,481 B | 8 KB | ⚠ 超 7.1 KB | +| 其它 | 10,503 B | — | 参考 | - **门禁**:pre-commit `mem-budget` 钩子(`scripts/ram_baseline.py --gate`)—— diff --git a/docs/子系统设计/运行与自检记录设计说明.md b/docs/子系统设计/运行与自检记录设计说明.md index 910e21e9..ffbb5fb8 100644 --- a/docs/子系统设计/运行与自检记录设计说明.md +++ b/docs/子系统设计/运行与自检记录设计说明.md @@ -1,7 +1,7 @@ # 运行/自检记录与待机显示设计说明 -> **版本**:v1.1(已实施) -> **日期**:2026-09-14 +> **版本**:v1.2(§4.3 已随七类记录更新;v1.1 的原始设计口径不变) +> **日期**:2026-09-14(v1.2 更新 2026-10-26) > **状态**:步骤 1–9 全部完成——双端构建 0/0、`--logtest` PASS、ui-verify 五门禁全绿;真机 @test 待上电回归(§8.2) > **关联文档**:`项目架构总览.md` · `STM32F4-Base存储架构说明.md` · `系统墙钟时间维护说明.md` · `UI设计/内容页架构.md`(如存在)· `docs/UI设计/mockups/`(待机显示页面.jpg / 运行记录页.jpg / 自检记录页.jpg / 密码页面实际机器的示例图片.jpg) @@ -72,7 +72,7 @@ |---|---|------| | `App/log/sys_log.h` | 共享 | 公共 API + 记录格式常量 + 行拆分内联函数(无 OS 依赖) | | `App/log/sys_log_mcu.c` | MCU | FatFS 后端:待发队列(启动前静态数组 + FreeRTOS 消息队列)、defaultTask 落盘、容量截断、上电/重启分类 | -| `sim/sim_syslog.c` | 模拟器 | 宿主文件后端(CWD 下 `sim_run.log` / `sim_selftest.log`),直接追加,`time()` 时间戳 | +| `sim/sim_syslog.c` | 模拟器 | 宿主文件后端(CWD 下与七类一一对应的 `sim_*.log` 七份),直接追加,`time()` 时间戳;**没有队列与丢弃**,`syslog_post_quiet()` 在本端等价于 `syslog_post()`(两端差异登记在 `docs/测试说明/模拟器差距清单.md` §2.2) | | `Drivers/BSP/UI/ui_standby.h/.c` | 共享 | 空闲检测 + 待机屏(屏幕覆盖层:保存/还原前屏与焦点) | | `Drivers/BSP/UI/ui_page_runlog.h/.c` | 共享 | 运行记录内容页描述符(动态行,数据源 = sys_log API) | | `Drivers/BSP/UI/ui_page_selftest.h/.c` | 共享 | 自检记录内容页描述符(同上) | @@ -105,14 +105,39 @@ ### 4.3 文件与容量 -| 文件 | 内容 | 上限 | 满时策略 | -|---|------|------|---------| -| `/log/run.log` | 上电/重启记录 | **1024 条(64KB)** | 删除重建(整卷清空,串口告警);DTU 上电频率低,容量充裕 | -| `/log/selftest.log` | 自检故障记录 | **512 条(32KB)** | 同上 | +**记录类型 = 日志 ID 本身,一类记录 = `/log` 下一个文件**(记录体内没有类型字段位, +理由与逐类判据、写入点清单见 `App/log/README.md`「五类记录与『记录类型 = 日志 ID』」—— +本节只登记"有哪些文件、各多大"这一层,判据表**不在这里抄第二份**)。 -- 目录 `/log/` 首次落盘前 `f_mkdir`(已存在则忽略);文件名均 8.3 合规。 -- FTP 视角:卷根下多一个 `LOG` 目录,两个 `.log` 文本文件可直接下载(R5)。 -- 记录**只增不改不删**(除满时重建与未来"清除记录"功能),保证 FTP 端所见即历史。 +| 文件 | 记录类型(= `syslog_id_t`) | 上限 | 满时策略 | +|---|------|------|---------| +| `/log/run.log` | `SYSLOG_RUN` 上电/重启与运行事件 | **1024 条(64KB)** | 删除重建(整卷清空,串口告警);DTU 上电频率低,容量充裕 | +| `/log/selftest.log` | `SYSLOG_SELFTEST` 自检故障(有问题才记) | **512 条(32KB)** | 同上 | +| `/log/accid.log` | `SYSLOG_ACCIDENT` 事故(跳闸型动作) | **512 条(32KB)** | 同上 | +| `/log/alarm.log` | `SYSLOG_ALARM` 保护告警 | **1024 条(64KB)** | 同上 | +| `/log/start.log` | `SYSLOG_START` 保护启动(越限电平) | **1024 条(64KB)** | 同上 | +| `/log/oper.log` | `SYSLOG_OPER` 操作(改动了装置状态的运维动作) | **512 条(32KB)** | 同上 | +| `/log/yk.log` | `SYSLOG_YK` 遥控(104 命令的选择/执行/拒绝) | **256 条(16KB)** | 同上 | + +- 表内上限是**逻辑上限,不是预分配**:文件按追加写自然增长,一条不写就不占簇,到顶才 + 删除重建;七类全满的名义峰值 = 4864 条 × 64 B = **304 KB**(NAND 2 GB,且这些都只在 + 事件边沿写,不构成容量或磨损瓶颈)。 +- 告警/启动给到 1024 条:一次系统故障会连片触发多个元件,别让最能说明故障过程的两类 + 记录最先被截掉。 +- `id` 只准**尾部追加**:枚举序 = 两端 `s_paths[]`/`s_caps[]` 的下标,中间插值会让 + 某类记录安静地写进别的文件(不报错、屏上照看照翻),机械防线是 + `TC-SYSLOG-130`/`131`/`131b`(逐 id 验"哪类落哪个文件、各用各的上限")。 +- 另有两类**不属于本表**的文件各有各的属主与通道:`/log/wdev.log`(停喂取证, + `sys_log_evidence()`,§6)、`/log/soe.log`(二进制 SOE,64 B 定长但**另一套 + 布局与读者**,见本节末尾与 `App/log/README.md` soe 小节)。 +- 目录 `/log/` 首次落盘前 `f_mkdir`(已存在则忽略);文件名均**严格 8.3** + (`FF_USE_LFN=0`:主名 ≤8、扩展 ≤3、不以点开头),逐条机器核 + `TC-SYSLOG-081`/`130c`。 +- FTP 视角:卷根下 `LOG` 目录内上述记录文件均为**同一格式**的 64 B 定长文本记录, + 可直接下载后按固定偏移解析(R5)。 +- 记录**只增不改不删**;删除只有两条既有途径——满时重建,以及 UI「清除记录」 + (`syslog_request_clear(id)` 请求 / defaultTask `syslog_drain()` 尾部落地, + 语义与判据见 `App/log/README.md`,O3 已由此关闭)。 ### 4.4 写路径(单写者,调研结论落地) @@ -283,7 +308,7 @@ Forbidden:`FAIL`/`ERROR`/`HardFault`。 |---|----|------| | O1 | 断电 <10min 的"上电/重启"歧义 | §5.3 阈值折中,文档声明;如需精确可后续加掉电标志(PVD) | | O2 | 故障"已复归"/持续监测(mockup 语义) | v2:周期自检 + 活动故障状态机 + 恢复记录 | -| O3 | "清除记录"菜单项(node 27 #P,现为空框) | v2:UI 动作 = 删除/重建 log 文件(与满时策略同路径) | +| O3 | ~~"清除记录"菜单项~~ **已实施(2026-10-26 道F task-4)** | 数据面 = `syslog_request_clear(id)` + `syslog_clear_state(id)`,落地在 defaultTask(唯一写者)的 `syslog_drain()` 尾部;UI 侧页面归记录/维护页批次。细口径见 `App/log/README.md` | | O4 | 待机页电流值接实时数据 | 待交流量标定管线(AC 页占位数据替换)落地后接入 `tpafe5160_snapshot()`(原子快照;旧 `tpafe5160_get_buf()` 已于 2026-10-10 删除,见 `AGENTS.md`) | | — | FAT32 小追加簇开销 | §3 调研结论:写频率极低可接受;容量上限约束增长 | | — | UI 读与落盘并发 | 只读既有内容 + EOF 追加 + FatFS 系统锁,无互斥需求(§4.4) | diff --git a/docs/测试说明/模拟器差距清单.md b/docs/测试说明/模拟器差距清单.md index 57a93129..b3cd2f96 100644 --- a/docs/测试说明/模拟器差距清单.md +++ b/docs/测试说明/模拟器差距清单.md @@ -25,7 +25,7 @@ | 渲染链路 | LVGL I1 渲染 → SDL 窗口(MCU 端为帧缓冲 → RemoDispBus) | | 配置文件引擎 | `App/cfg/cfg_file.c` 共享核心 + `cfg_file_store_sim.c`(宿主文件 `sim_config.ini`) | | `net_*` BSD API 之上的协议 | `ui_net_sink.c`(RemoDispBus 服务端,TCP 7003)与 `net_socket_sim.c`(Winsock 后端) | -| 日志记录格式 | `sys_log` 定长记录编解码(`sim_syslog.c` 写宿主文件) | +| 日志记录格式 | `sys_log` 定长记录编解码 + 记录删除 + **七类记录**(run / selftest / accid / alarm / start / oper / yk 各一份宿主文件,道F task-10;口径 = `App/log/README.md`「记录类型 = 日志 ID」)。`sim_syslog.c` 写/删宿主文件;删除与投递的**时序**与真机不同,见 §2.2 | **不覆盖**:CH395F 真实网络栈、GD5F NAND、SD2506 RTC、TPAFE5160 ADC、RS-485、IWDG、中断/时序相关行为。 @@ -54,6 +54,8 @@ | **控制台单写者无对应实现** | `simulator/dbg_log.h` 把 `DBG_*` 映射到 `printf`;**不编译** `console_log.c` | 单写者环形缓冲 + defaultTask 唯一发送 | `console_log` 的环形缓冲溢出/丢弃计数逻辑**模拟器测不到**;共享代码若误调 `console_log.h` 声明,模拟器链接失败(已被此约束捕获) | `@test` + 板端串口观测;`docs/子系统设计/控制台单写者日志设计说明.md` §4.1 | | **mtime 粒度不同** | 宿主文件 mtime(**秒级**) | FAT 打包时间(`fdate<<16\|ftime`,**分钟级**) | `cfg_file_poll` 的 `(size, mtime)` 变更令牌在两端触发时机不同;分钟级粒度下的"同分钟内两次改动"在真机可能被判为无变化 | 板端 STORAGE 套件;`配置文件设计说明.md` §变更检测 | | **无应用回调(空操作)** | 模拟器不注册段回调,`cfg_file_poll` 重载后回调为空 | 各段回调真正执行(如网络重配置) | "配置变更 → 触发回调 → 副作用"链路在模拟器上**只验证到写盘**,回调逻辑不覆盖 | `@test`(配置保存 → UI 生效) | +| **「清除记录」两端时序不同** | `syslog_request_clear()` 在调用内**同步**删除宿主文件(`sim_syslog.c` 单线程,没有卷锁、没有队列),返回后状态立刻是 `OK`/`FAIL`,`SYSLOG_CLEAR_WAIT` 永不出现 | 非写者上下文只置标志,真正的 `f_unlink` 排在 defaultTask 的 `syslog_drain()` 尾部(卷锁 try:拿不到锁就顺延,见 `App/log/sys_log_mcu.c`) | 「按了按钮 → 下一拍才见结果」这条异步链、卷占用顺延、以及删除与写者重建的交错,在模拟器上**结构上不存在**;UI 页面若把 WAIT 当失败,模拟器上看不出来 | 主机单测 `test/host/sys_log_mcu/`(TC-SYSLOG-110..122:请求侧零盘触点 / 落地只在 drain / 卷占用顺延 / 幂等 / 不误删;TC-SYSLOG-133/133b:清除成功投一条操作记录、清 oper 自己时不投)+ 台架装置维护页目检 | +| **两条投递口在宿主端合一** | `syslog_post_quiet()` = `syslog_post()` 的别名(直写宿主文件,没有队列/待发数组),`syslog_post_dropped()` **恒返 0** | 两个口分开且判据不同:quiet 的调用链上不许内联日志行缓冲(protectTask / iec104Task 的静态最坏链硬线),丢弃只累加计数、由 defaultTask 在 `syslog_drain()` 里按差值补留痕 | 五类记录(事故 / 告警 / 启动 / 操作 / 遥控)在模拟器上**永不丢弃**,"队列满丢记录"、"丢了几条"、"投递侧不许打日志"这三件事在模拟器上结构上不可见;UI 若把"投了"当成"落盘了",模拟器看不出来 | 主机单测 `test/host/sys_log_mcu/`(TC-SYSLOG-132 / 134 / 135:队列侧与启动前数组侧各自的计数与零留痕)+ `scripts/stack_report.py` 静态最坏链(真机侧唯一手段) | | **堆后端不同** | `cfg_store_alloc` = 宿主 `malloc`(堆余量以 MB 计) | FreeRTOS 堆(`configTOTAL_HEAP_SIZE` 48 KB,实测最低水位 ~4.8 KB) | **分配失败路径在模拟器上永不触发**——"KB 级分配必然返回 NULL"这类缺陷模拟器照不出来(`AGENTS.md` 已列为铁律) | 内存预算门禁 + 真机 `[HEAP]` 水位告警(S11) | ### 2.3 硬件专属(模拟器完全无对应) diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index 76bd9fa1..295970c3 100644 --- a/scripts/ram_baseline.tsv +++ b/scripts/ram_baseline.tsv @@ -8,4 +8,4 @@ date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi 2026-10-23 213676 18860 1188 131500 232536 22.2 75264 57.4 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4120;sys_log_mcu.o=3588;default_task.o=2736 2026-10-24 213780 18860 1204 131852 232640 22.2 75632 57.7 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4128;sys_log_mcu.o=3944;default_task.o=2736 2026-10-25 224860 24640 1332 135100 249500 23.8 79008 60.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4128;sys_log_mcu.o=3944;default_task.o=2736 -2026-10-26 224860 24640 1332 135100 249500 23.8 79008 60.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4128;sys_log_mcu.o=3944;default_task.o=2736 +2026-10-26 228260 24960 1440 135976 253220 24.1 79992 61.0 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4128;sys_log_mcu.o=4003;default_task.o=2736 diff --git a/scripts/size_attestation.json b/scripts/size_attestation.json index 46fbb33a..d9a16980 100644 --- a/scripts/size_attestation.json +++ b/scripts/size_attestation.json @@ -1,25 +1,25 @@ { - "built_at": "2026-10-26T12:21:03+08:00", - "fingerprint": "27086d0cd632d2443ff06d519183e4b9f748d08582c7e4f61b26b3d81ee0d312", + "built_at": "2026-10-26T15:42:13+08:00", + "fingerprint": "9aa277983cb6290c1c0aa5d3d0e2261e440f96141f88827575449c4bdc82b303", "fingerprint_files": 1422, "fingerprint_source": "git-index", "gated_regions": [ "RW_IRAM1", "RW_CCM" ], - "git_head": "7635480e06f853fd27f1e48e059d71e59dca2165", + "git_head": "0f7b5a87fb701e676fe747b96f0a2d4adeed26f8", "map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map", - "map_mtime": 1792982281, + "map_mtime": 1793000530, "pct": { - "ER_IROM1": 23.79, + "ER_IROM1": 24.15, "RW_CCM": 87.89, - "RW_IRAM1": 60.28, + "RW_IRAM1": 61.03, "UNINIT_CCM": 18.75 }, "regions": { "ER_IROM1": { "max": 1048576, - "size": 249500 + "size": 253220 }, "RW_CCM": { "max": 65280, @@ -27,7 +27,7 @@ }, "RW_IRAM1": { "max": 131072, - "size": 79008 + "size": 79992 }, "UNINIT_CCM": { "max": 256, @@ -37,10 +37,10 @@ "schema": 2, "scope_note": "\u540c\u7801\u51ed\u8bc1\uff1a\u6307\u7eb9\u8986\u76d6\u56fa\u4ef6\u4f53\u79ef\u76f8\u5173\u6e90\u7801\uff0c\u53e3\u5f84\u89c1\u672c\u811a\u672c docstring", "sizes": { - "code": 224860, - "ro_data": 24640, - "rw_data": 1332, - "zi_data": 135100 + "code": 228260, + "ro_data": 24960, + "rw_data": 1440, + "zi_data": 135976 }, "threshold_pct": 92.0, "worktree_dirt_at_stamp": 0, diff --git a/simulator/README.md b/simulator/README.md index 9d8719da..659eadd1 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -26,7 +26,7 @@ simulator/ │ ├── check_content_pages.py # 文本断言 + 内容页黄金参考回归 + 往返池门禁(--update 刷参考) │ ├── check_remo_sink.py # RemoDispBus 远端推流协议回归(起窗口模拟器 + TCP 取帧 + 参考比对) │ ├── regen_ref_png.py # 由 ref/*.bin 批量重生成目检 PNG(--scale 6 --invert) -│ └── ref/ # 黄金参考 shot_*.bin + 目检 *.png(**本地生成物,不入库**) +│ └── ref/ # 黄金参考 shot_*.bin(**入库**,判据本体)+ 目检 *.png(本地派生物,不入库) ├── dbg_log.h # 调试日志 stub:DBG_* 映射到 printf(覆盖 MCU 版头文件) ├── SDL2/ │ └── SDL2-2.32.10/ # SDL2 预编译 SDK(随仓库提供,无需安装) @@ -53,7 +53,7 @@ simulator/ | `build_sim.bat` / `build_sim.ps1` | 一键构建入口:vswhere 探测本机 VS 大版本(16=2019 / 17=2022 / 18=2026,取已装最高)→ 映射 CMake 生成器;`build/` 缓存生成器与本机不符(换机/升级 VS)时自动删除重建(带重试,抗文件锁)。**构建入口一律走它,文档/命令中勿写死生成器版本**;`.bat` 仅负责绕过 PowerShell 执行策略并转交 `.ps1` | | `CMakeLists.txt` | 收集 LVGL 源码(`../Lib/lvgl`)+ 共享 UI 源码(`../Drivers/BSP/UI`)+ 通用配置文件引擎(`../App/cfg_file.c`、`../Drivers/BSP/NETCFG` 的 net_cfg;`net_cfg_legacy` 已于 2026-10-10 删除)+ `sim/` 平台层,链接预编译 SDL2,产出 `lvgl_sim.exe` | | `i1_to_png.py` | 把 `--screenshot` 产出的 I1(1bpp) 原始位图转成 PNG,便于离线比对设计稿。详见 §3.5 | -| `tests/` | 无头回归脚本(菜单 8 状态像素回归、内容页文本断言 + 黄金参考回归);`ref/` 为**本地生成物、不入库**(首次 `--update` 生成)。详见 §3.6 | +| `tests/` | 无头回归脚本(菜单 8 状态像素回归 + 内容页 27 页:文本断言 / 反向断言 / 黄金参考逐字节比对);`ref/shot_*.bin` **随仓库入库**(判据本体,改动等于改判据,评审须逐页过目),`ref/*.png` 是本地派生的目检图(`.gitignore` 挡掉)。详见 §3.6 | > **共享代码不复制**:`ui_menu.c` / `ui_font_cn_12.c` / `ui_net_sink.c` 直接引用 `Drivers/BSP/UI/` 下的原件, > MCU 与 PC 两端永远同步。**不要**在本目录内复制 UI 源码。 @@ -116,6 +116,8 @@ LVGL 场景(同一份 ui_menu.c) | 日志 | `Drivers/BSP/dbg_log.h`(USART1 + RTC 时间戳) | `simulator/dbg_log.h`(printf stub) | | 业务任务 | netTask / ftpTask / timeSyncTask 等 | 无(仅编译纯软件部分的配置文件引擎 net_cfg/cfg_file,可离线验证配置加载/保存) | | 时钟 | `lv_tick_inc` 由 ui_task 手动喂(10ms 周期) | SDL 驱动内部 `lv_tick_set_cb(SDL_GetTicks)`,**不可再手动喂** | +| UI 数据来源 | `App/app/ui_data_feed.c` 每轮从 phasor / prot_set / 104 点表…装配并发布三帧 | `main.c` 一次性发布**固定帧**(定值不手抄数字,取链进来的 `App/algo/prot_set.c` 出厂默认);口径 `docs/UI设计/UI_数据供给设计说明.md` §9.2 | +| 定值写路径(保存) | `ui_data_feed_tick()` 里 `ui_data_param_drain()` 落定 → 置脏 → defaultTask 写 `/prot_set.bin` | `sim_param_pump()` 用**同一份** `g_prot_set_meta` 上下限预检后落值到 RAM,**到此为止**:模拟器无 NAND,"重启值保持 / 主站 C_RD 读到同值"只能上板验(差距登记 `docs/测试说明/模拟器差距清单.md`) | ### 2.4 关键设计决策 @@ -220,13 +222,23 @@ UI 改动的自动化门禁脚本(完整流程见 `docs/UI设计/UI_自动化 | 脚本 | 原理 | 失败输出 | |------|------|----------| | `check_menu_rows.py` | 键序注入(u/d/e/s)跑 8 个菜单状态,断言选中行白带、非选行黑底白字、框体边框、往返状态逐像素一致;MENU-STATE-00 池水位须等于脚本内 `MENU_POOL_BASELINE` | 未过断言清单 + 保留临时截图 | -| `check_content_pages.py` | ① 文本断言(解析字体字形,在截图内精确匹配期望串,不依赖 `ref/`)② 11 内容页无头截图与 `tests/ref/shot_*.bin` **逐字节**比对;另校验开页-关页往返池水位 = `POOL_AFTER_ROUNDTRIP` | 文本未命中项 / 差异像素数 + 双方 ink runs 对照 | +| `check_content_pages.py` | ① 文本断言(解析字体字形,在截图 y 带内精确匹配期望串,不依赖 `ref/`)② **反向断言**(`ABSENT` 表:某串**不该**出现,如查看页/只读态不得出现「未标定」徽标与「保存」行——像素参考会被 `--update` 刷成"自证正确",反向断言不依赖参考图)③ **27 页**无头截图与 `tests/ref/shot_*.bin` **逐字节**比对(含 2026-10-26 新增的整定 9 态:进页 / 保存行 / 只读态 / 编辑态 / 越界 / 保存 OK / 落定后可读 / 二态回绕);另校验开页-关页往返池水位 = `POOL_AFTER_ROUNDTRIP` | 文本未命中项 / 反向断言被命中 / 差异像素数 + 双方 ink runs 对照 | | `check_remo_sink.py` | 启动**窗口**模拟器(非无头,`CREATE_NEW_PROCESS_GROUP` 防管道阻塞),经 TCP 7003 发 LCDMEM 取 1bpp 帧;断言首帧=LCDMEM(无 INIT 污染)/位图 3200 字节/非平凡;复用 `i1_to_png` 出目检 PNG;与无头参考(`lvgl_sim --screenshot`)逐位比对 <2% 判 MATCH。守护"INIT 污染 / 快照卡死"两个历史缺陷 | 各判据失败原因 + `sim_err.txt` 尾部 + 保留 PNG(默认不删,`--clean` 才删) | -- **黄金参考与目检 PNG**(`ref/` 不入库):首次/换机后先生成一次—— +- **黄金参考与目检 PNG**(`ref/*.bin` **入库**、`ref/*.png` 本地派生不入库):换机或想目检时—— `.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update` 出 `ref/*.bin`, `.venv\Scripts\python.exe simulator/tests/regen_ref_png.py` 出目检 `ref/*.png`(默认 `--scale 6 --invert`)。 - 有意改视觉时先肉眼确认新效果正确再 `--update`(会先跑文本断言防误刷)。 + 有意改视觉时先肉眼确认新效果正确再 `--update`(会先跑文本 + 反向断言防误刷), + 并且**改完必须** `git diff --stat simulator/tests/ref` 逐页过目:出现在这份清单里的每一张 + 都是"判据被改了",正常应当只有本次有意改动的那几张;`--update` 一把全刷等于把别的意外 + 一起签进仓库(`ref/*.bin` 是入库的,刷错就是提交了错判据)。 +- **回归为什么是确定的(无头不跑 LVGL 定时器)**:`run_headless()` 只在注入键值时调 + `lv_group_send_data()`,**从不调 `lv_timer_handler()`/`lv_refr_now()`**(I1 + FULL 刷新模式在 + 离屏渲染上会进死循环,见 §四 已知坑与 `main.c` 里 `shot_delay_cb` 的注释)。推论有两条: + ① 界面侧任何**周期性 LVGL 定时器**(如定值视图为"落定/主站写值/标定完成"上屏而建的 100 ms + 轮询)在回归里一次都不会触发 → 像素与它无关,只有窗口模式才用它;② 两次注键之间不发生 + 重绘,所以"数据就位"不能依赖"这一页画过没有"——判据连跑两次逐字节一致即为证据,设计口径见 + `docs/UI设计/UI_数据供给设计说明.md` §9.2 注 6。 - **池水位常量漂移**:菜单/页面结构变更导致门禁失败时,确认新值合理后再更新脚本内常量并登记流程文档变更记录;无法解释的漂移 = 内存回归 bug。 - 退出码均为 0=PASS / 1=断言失败 / 2=环境错误(exe 缺失等)。 diff --git a/simulator/main.c b/simulator/main.c index 04184e34..23404f9b 100644 --- a/simulator/main.c +++ b/simulator/main.c @@ -37,6 +37,7 @@ * lvgl_sim.exe --logtest * 验证链:post N 条 → count/get 回读断言 + 宿主文件字节级校验 * (64B 定长、ISO 时间戳、epoch 字段、NUL 填充) + * → 「清除记录」:计数归零 + 文件真没了 + 只动自己那份 + 幂等 + 删后还能继续写 * * 待机屏截图模式(无窗口,强制进入待机后截图): * lvgl_sim.exe --standby shot.bin @@ -232,9 +233,53 @@ static const ui_soe_reader_t s_soe_reader = { sim_soe_count, sim_soe_get }; * 3. 定值取 prot_set 出厂默认(App/algo/prot_set.c 是纯算法,双端可编), * 不手抄 40 个数字——抄一次就是一份会漂移的第二真相 */ +/* 最近一次发布的参数帧校验和(sim_param_pump 的"值变了才重发"门,与真机同纪律) */ +static uint32_t s_par_pub_sum; + +/* + * 函数功能:按 prot_set 现状填一帧参数快照(模拟器版 feed_fill_par) + * 入口参数:p_par - 目标帧(调用方已清零;本函数只填有真源的几个字段) + * 返回值:无 + * 限定条件:prot_set_init() 已跑过(值区有效) + * 函数说明:40 项值全部现取 prot_set,不手抄数字(抄一次就是一份会漂移的第二真相)。 + * cal_block 固定给 1:**真机现在就是**"UA/UN/IA 未标定 → 判据停走" + * (产品构建下 [adc_cal] 的 valid 位没写,用户裁定保持闭锁),模拟器要 + * 与本机一致才配当像素基准——rt.eng 也按同一条口径钉在引脚档。 + * 标定链在模拟器里没有数据源,所以这里给常量而不是"猜一个会变的值"; + * 要拍"已标定"态的图,改这一处即可(黄金参考随之要一起刷) + */ +static void sim_fill_par_frame(ui_data_par_t *p_par) { + uint8_t i; + + p_par->valid = 1u; + p_par->checksum = prot_set_checksum(); + p_par->locked = prot_set_locked(); + p_par->bad_mask = 0u; /* 装载成功:无越界项 */ + p_par->cal_block = 1u; /* 与真机同口径:未标定(判据停走) */ + for (i = 0u; i < UI_DATA_SET_N; i++) { + p_par->q10[i] = prot_set_get((prot_set_id_t)i); + } +} + +/* + * 函数功能:发布一帧参数快照并记下其校验和(写路径落定后重发用) + * 入口参数:无 + * 返回值:无 + * 限定条件:prot_set_init() 之后 + * 函数说明:与 App/app/ui_data_feed.c 同一条纪律——值变了才重发(这里比校验和, + * 模拟器没有自检闭锁与标定链,那两个源恒不变);页面按发布序号失效缓存 + */ +static void sim_publish_par_frame(void) { + ui_data_par_t par; + + (void)memset(&par, 0, sizeof(par)); + sim_fill_par_frame(&par); + (void)ui_data_publish_par(&par); + s_par_pub_sum = par.checksum; +} + static void sim_publish_data_frames(void) { ui_data_rt_t rt; - ui_data_par_t par; ui_data_info_t info; uint8_t i; @@ -286,16 +331,9 @@ static void sim_publish_data_frames(void) { (void)snprintf(rt.clock, sizeof(rt.clock), "%s", "2026-10-25 08:00:00"); (void)ui_data_publish_rt(&rt); - (void)memset(&par, 0, sizeof(par)); - (void)prot_set_init(); /* 出厂默认 + 封存(幂等) */ - par.valid = 1u; - par.checksum = prot_set_checksum(); - par.locked = prot_set_locked(); - par.bad_mask = 0u; /* 装载成功:无越界项 */ - for (i = 0u; i < UI_DATA_SET_N; i++) { - par.q10[i] = prot_set_get((prot_set_id_t)i); - } - (void)ui_data_publish_par(&par); + /* 定值帧:出厂默认 + 封存(幂等),再按 prot_set 现状发一帧(见上面两个函数) */ + (void)prot_set_init(); + sim_publish_par_frame(); (void)memset(&info, 0, sizeof(info)); info.valid = 1u; @@ -311,6 +349,86 @@ static void sim_publish_data_frames(void) { (void)ui_data_publish_info(&info); } +/* ── 定值写路径镜像(道F task-2:整定 4 页的"保存"落定)──────────────────── */ + +/* + * 函数功能:写路径判定"定值闭锁中吗"(模拟器版,镜像 App/app/ui_data_feed.c) + * 入口参数:无 + * 返回值:prot_set 的闭锁态(模拟器里恒 0:不跑 L5-1 自检) uint8_t + * 限定条件:主循环 / 无头注键链(单线程) + */ +static uint8_t sim_param_locked(void) { + return prot_set_locked(); +} + +/* + * 函数功能:写路径整批预检——单项是否落在 meta 上下限内 + * 入口参数:a_id - 定值项编号;a_q10 - Q10 定点值 + * 返回值:0 - 合法;-1 - 编号越界或值越界 int + * 限定条件:只读 g_prot_set_meta + * 函数说明:与真机走的是**同一份**上下限真源(App/algo/prot_set.c), + * 所以"模拟器里越界被拒"与"真机上越界被拒"是同一个判定,不是两份规矩 + */ +static int sim_param_check(uint8_t a_id, int32_t a_q10) { + if (a_id >= (uint8_t)PS_ID_COUNT) { + return -1; + } + if ((a_q10 < g_prot_set_meta[a_id].min) || (a_q10 > g_prot_set_meta[a_id].max)) { + return -1; + } + return 0; +} + +/* + * 函数功能:写路径落值——单项原子写入 + 重封 + 置脏(模拟器无文件系统,落盘到此为止) + * 入口参数:a_id - 定值项编号;a_q10 - Q10 定点值 + * 返回值:0 - 已写入;-1 - 拒写 int + * 限定条件:BSP 侧已整批预检通过 + * 函数说明:真机在这里之后还有 defaultTask 扫脏写 /prot_set.bin 一段——模拟器没有 + * NAND,跨复位保持这条只能在台架验(差距已登记在案,别把模拟器跑绿 + * 当成"保存能落盘"的证据 → docs/测试说明/模拟器差距清单.md) + */ +static int sim_param_apply(uint8_t a_id, int32_t a_q10) { + if (a_id >= (uint8_t)PS_ID_COUNT) { + return -1; + } + return prot_set_set_sealed((prot_set_id_t)a_id, a_q10); +} + +/* 注入给 ui_data_param_drain() 的执行侧实现(与真机 s_param_ops 同构) */ +static const ui_data_param_ops_t s_param_ops = { + sim_param_locked, + sim_param_check, + sim_param_apply +}; + +/* + * 函数功能:落定页面投递的定值整定请求(模拟器版装配节拍) + * 入口参数:无 + * 返回值:无 + * 限定条件:主循环每轮一次(窗口模式)/ 注键结束后同步一次(无头模式)—— + * 两处都必须排在按键之后、渲染/截图之前,否则保存行停在 WAIT + * 函数说明:真机这段在 App/app/ui_data_feed.c 的 tick 里(模拟器不链接装配器), + * 这里只做同样的三件事:出队落定 → 值变了就重发参数帧 → 让页面看见。 + * drain 每轮最多处理一条,故用 while 把在队的清空(无头模式后面没有 + * 下一个 tick,必须当场排空才能拍到终态)。 + * 16 次上界是纯防御:队列深度只有 4,正常永不触及 + */ +static void sim_param_pump(void) { + uint32_t guard = 0u; + + while (ui_data_param_drain(&s_param_ops) == 1) { + if (prot_set_checksum() != s_par_pub_sum) { + sim_publish_par_frame(); + ui_content_page_invalidate(); /* 让页面重绘:保存行 OK + 新值同帧 */ + } + guard++; + if (guard >= 16u) { + break; + } + } +} + /* * 函数功能:无窗口离屏渲染(可注入键序列)并导出末帧像素 * 入口参数:keys - 键序列(u=上 d=下 l=左 r=右 e=确认 s=返回;空 = 不注键) const char * @@ -382,6 +500,11 @@ static int run_headless(const char * keys, const char * path, int standby) { net_cfg_save_run(); } + /* 同上理由落定"整定保存"请求:真机由 ui_data_feed_tick() 每 10 ms 排一条, + * 无头模式没有那个节拍,不在此当场排空,截图就会停在 WAIT(终态拍不到, + * OK/ERR/LOCK 三条判据的像素证据也就全都不成立) */ + sim_param_pump(); + /* --standby:注键后强制进入待机屏(截图前) */ if (standby != 0) { ui_standby_force_enter(); @@ -588,6 +711,10 @@ static int run_cfgtest(void) { * 返回值:0 - PASS;非 0 - FAIL int * 说明:验证链 清理旧档 → post N 条 → count/get 回读断言 + 宿主文件字节级校验 * (64B 定长、ISO 时间戳格式、epoch 字段 ≈ now、NUL 填充) + * → 「清除记录」(syslog_request_clear / syslog_clear_state)。宿主后端与 MCU 端 + * 判据相同、时序不同:模拟器单线程,请求在调用内就落地,所以本端断言的是 + * 「立刻 OK」——读到 WAIT 就说明两端语义漂了。完整分支矩阵(卷占用、unlink + * 错误码、幂等、读窗口失效)在 test/host/sys_log_mcu/ 那侧用假卷注入去测。 */ static int run_logtest(void) { uint32_t ep; @@ -675,6 +802,81 @@ static int run_logtest(void) { } printf("[logtest] OK count/get/epoch/bytes\n"); + /* ── 「清除记录」:宿主后端必须与 MCU 端共用同一组判据 ── + * 分支矩阵(卷占用 / unlink 错误码 / 幂等 / 读窗口失效)由假卷那边测, + * 这里只测「同一份头契约在宿主上真对得上」——宿主没有卷锁,也就没有顺延。 */ + + /* 从未请求过的 id 必须是 NONE:UI 据此把「没操作过」和「已清除」分开显示 */ + if (syslog_clear_state(SYSLOG_SELFTEST) != SYSLOG_CLEAR_NONE) { + printf("[logtest] FAIL selftest clear state = %d (expect NONE before any ask)\n", + syslog_clear_state(SYSLOG_SELFTEST)); + return 1; + } + /* 越界 id:请求被拒、状态仍 NONE,且不得牵连合法 id */ + if ((syslog_request_clear((syslog_id_t)SYSLOG_COUNT) == 0) + || (syslog_clear_state((syslog_id_t)SYSLOG_COUNT) != SYSLOG_CLEAR_NONE) + || (syslog_clear_state(SYSLOG_SELFTEST) != SYSLOG_CLEAR_NONE)) { + printf("[logtest] FAIL out-of-range clear was accepted\n"); + return 1; + } + if (syslog_request_clear(SYSLOG_RUN) != 0) { + printf("[logtest] FAIL request_clear(run)\n"); + return 1; + } + if (syslog_count(SYSLOG_RUN) != 0u) { + printf("[logtest] FAIL run count after clear = %u (expect 0)\n", + (unsigned)syslog_count(SYSLOG_RUN)); + return 1; + } + /* 同步落地的终态;本端读到 WAIT 就是两端语义漂移 */ + if (syslog_clear_state(SYSLOG_RUN) != SYSLOG_CLEAR_OK) { + printf("[logtest] FAIL run clear state = %d (expect OK on host)\n", + syslog_clear_state(SYSLOG_RUN)); + return 1; + } + /* 文件必须真没了(不是「截成 0 字节」),而且不许牵连邻居 */ + { + FILE *gone = fopen("sim_run.log", "rb"); + FILE *neigh = fopen("sim_selftest.log", "rb"); + + if (gone != NULL) { + (void)fclose(gone); + printf("[logtest] FAIL sim_run.log still there after clear\n"); + return 1; + } + if (neigh == NULL) { + printf("[logtest] FAIL sim_selftest.log lost: clear touched the wrong file\n"); + return 1; + } + (void)fclose(neigh); + } + /* 幂等:文件已不在,再清一次仍算成功(UI 上重复点击不该报故障) */ + if ((syslog_request_clear(SYSLOG_RUN) != 0) + || (syslog_clear_state(SYSLOG_RUN) != SYSLOG_CLEAR_OK)) { + printf("[logtest] FAIL second clear is not idempotent\n"); + return 1; + } + /* 删后重建可继续写:清除不能把日志通道本身弄坏(MCU 端那条 FA_WRITE 陷阱由 + * test/host/sys_log_mcu/ 的 TC-SYSLOG-112 钉,本端同判据同断言形状) */ + syslog_post(SYSLOG_RUN, "after clear"); + if (syslog_count(SYSLOG_RUN) != 1u) { + printf("[logtest] FAIL run count after re-post = %u (expect 1)\n", + (unsigned)syslog_count(SYSLOG_RUN)); + return 1; + } + if ((syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) != 0) + || (strcmp(desc, "after clear") != 0)) { + printf("[logtest] FAIL get_record after clear: desc mismatch\n"); + return 1; + } + /* 两份记录各清各的:清 selftest 不得带走 run 里刚写的那一条 */ + if ((syslog_request_clear(SYSLOG_SELFTEST) != 0) + || (syslog_count(SYSLOG_SELFTEST) != 0u) + || (syslog_count(SYSLOG_RUN) != 1u)) { + printf("[logtest] FAIL selftest clear (run must keep its 1 record)\n"); + return 1; + } + printf("[logtest] OK clear: zeroed + file gone + isolated + idempotent + rebuild\n"); printf("[logtest] PASS\n"); return 0; } @@ -797,6 +999,10 @@ int main(int argc, char **argv) { * 无请求 = 空操作);"立即应用"回调未注册 → 空操作 */ net_cfg_save_run(); + /* 落定定值整定请求(镜像真机 ui_data_feed_tick 里的 ui_data_param_drain: + * 出队 → prot_set 落值 → 重发参数帧);队列空时只是一次计数判断 */ + sim_param_pump(); + /* 配置文件轮询(2s 周期:外部文件变更 4s 沉降后自动重载) */ uint32_t now_ms = (uint32_t)SDL_GetTicks(); if ((uint32_t)(now_ms - cfg_last_poll) >= 2000u) { diff --git a/simulator/sim/sim_syslog.c b/simulator/sim/sim_syslog.c index 6221e2ce..4790b2dd 100644 --- a/simulator/sim/sim_syslog.c +++ b/simulator/sim/sim_syslog.c @@ -1,12 +1,30 @@ /* * 模块名称:System Log (模拟器后端) — 运行/自检记录落盘 - * 模块功能:宿主文件 sim_run.log / sim_selftest.log(工作目录),与 MCU 端同格式 - * (定长 64B 记录);单线程直写,无队列 + * 模块功能:宿主文件七份(sim_run / sim_selftest / sim_accid / sim_alarm / sim_start / + * sim_oper / sim_yk,均 .log,工作目录),与 MCU 端同格式、同 id 口径 + * (定长 64B 记录);单线程直写,无队列;含「清除记录」后端(与 MCU 端 + * 同一套 API 与同一组判据:删后重建可继续写 / 计数归零 / 文件不在时幂等) * 适用平台:PC 模拟器 * 作者:王建锋 * 创建日期:2026-09-14 * 修改记录: * v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4) + * v1.1 2026-10-26 王建锋 新增「记录删除」后端(道F task-4,与 sys_log_mcu.c + * 同一份 sys_log.h 契约):syslog_request_clear() / + * syslog_clear_state()。两端**时序有别、判据相同**: + * 模拟器单线程(本文件里 syslog_post 也是直写不排队, + * 同一条先例),请求在调用内同步落地,因此 + * SYSLOG_CLEAR_WAIT 在本端永不出现;判据(幂等 0 / + * 删后可重建继续写 / 失败不动原文件)逐条与 MCU 端一致 + * v1.2 2026-10-26 王建锋 task-10 扩五类记录:s_paths/s_caps 同步到七项。 + * **顺手修一处既有隐患**(Lead 批准,须在提交信息留痕): + * 原 `s_paths[SYSLOG_COUNT][16]` 里 "sim_selftest.log" + * 正好 16 字符 → 初值**装不满、没有 NUL 位**,而它又是 + * 数组末行,后面没有任何保证为 0 的字节:stat()/remove() + * 拿它当 C 串会读越过数组尾,目前纯粹是"后面那块 .rodata + * 碰巧是 0"才没出事(未定义行为,换编译器布局即可能变成 + * 打开一个错名字的文件)。行宽加到 24 并逐项写出, + * 越界这条随扩容一起消失。 */ /* 头文件包含区 */ @@ -19,8 +37,24 @@ /* 静态变量定义区 */ -static const char s_paths[SYSLOG_COUNT][16] = { "sim_run.log", "sim_selftest.log" }; -static const uint32_t s_caps[SYSLOG_COUNT] = { SYSLOG_RUN_MAX, SYSLOG_SELFTEST_MAX }; +/* 行宽 24 = 最长宿主名 "sim_selftest.log"(16) + NUL,留余量给后续类别。 + * ⚠ 原来这里是 [16]:正好等于最长名的字符数,**没给 NUL 留位**(v1.2 修的隐患, + * 详见文件头修改记录)——加宽数组是这条修复的一部分,不要"看着没用"再改回去。 + * ⚠ 下标 = syslog_id_t 枚举序,与 sys_log_mcu.c 的 s_paths[] 逐项同序(两端一致是 + * "宿主与真机行为等价"这条口径的前提)。 */ +static const char s_paths[SYSLOG_COUNT][24] = { + "sim_run.log", "sim_selftest.log", "sim_accid.log", "sim_alarm.log", + "sim_start.log", "sim_oper.log", "sim_yk.log" +}; +static const uint32_t s_caps[SYSLOG_COUNT] = { + SYSLOG_RUN_MAX, SYSLOG_SELFTEST_MAX, SYSLOG_ACCIDENT_MAX, SYSLOG_ALARM_MAX, + SYSLOG_START_MAX, SYSLOG_OPER_MAX, SYSLOG_YK_MAX +}; + +/* 「清除记录」的结果字节(SYSLOG_CLEAR_NONE/OK/FAIL)。本端同步落地,没有 + * "排队中"这一态,所以只需要结果一枚,MCU 端那枚 s_clear_req[] 在这里是多余的。 + * 宿主进程态,不参与 MCU 的 RAM 预算台账。 */ +static int8_t s_clear_st[SYSLOG_COUNT]; /* 函数定义区 */ @@ -126,6 +160,115 @@ void syslog_post(syslog_id_t id, const char *desc) { (void)s_append(id, desc); } +/* + * 函数功能:投递一条日志(零日志缓冲版本;宿主实现) + * 入口参数:id - 日志 ID syslog_id_t + * desc - 描述串(UTF-8,超长截断) const char * + * 返回值:无 + * 函数说明:MCU 端设这个入口是为了绕开 DBG 的内联行缓冲(protectTask / + * iec104Task 的静态最坏链,理由写在 sys_log.h 同号声明处);宿主进程 + * 没有那条硬线,也**没有队列与丢弃**,所以这里与 syslog_post 完全等价。 + * 刻意不做成"宿主端另起一套语义"——五类记录的写入点在两端跑同一份 + * 调用,语义差异越少,模拟器作为回归面的价值越高。 + */ +void syslog_post_quiet(syslog_id_t id, const char *desc) { + (void)s_append(id, desc); +} + +/* + * 函数功能:取 quiet 通道累计丢弃条数(宿主实现) + * 入口参数:无 + * 返回值:恒 0——本端直写宿主文件、无队列无数组,不存在"满态丢弃"这条路径 uint32_t + * 函数说明:不要为了"看起来对称"在这里造一个假计数;MCU 端那条路径的丢条数由 + * test/host/sys_log_mcu 的 TC 直接注入队列满来验,宿主验不了也不该装。 + */ +uint32_t syslog_post_dropped(void) { + return 0u; +} + +/* + * 函数功能:删掉本端的记录文件(「清除记录」的落地半边,宿主实现) + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:0 - 已删除,或文件本就不存在(幂等成功,对齐 MCU 端的 FR_NO_FILE); + * -1 - 删除失败,**原文件未被改动** int + * 限定条件:调用方即本端写者(模拟器单线程,syslog_post 在这里也是直写不排队); + * 本端没有卷锁,也就没有 MCU 端"卷占用 = 顺延"那一态 + * 函数说明:先 stat 再 remove,是为了把"文件不在"与"删除失败"分开—— + * remove() 对不存在的文件同样返回 -1,直接拿它的返回值判失败会把 + * **幂等成功误报成故障**(与 MCU 端把 FR_NO_FILE 算成功不对称)。 + * 与 MCU 端同一条纪律:只动自己那两个记录文件,不碰同目录里的其它文件。 + */ +static int s_clear_apply(syslog_id_t id) { + struct stat st; + + if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { + return -1; + } + if (stat(s_paths[id], &st) != 0) { + return 0; + } + return (remove(s_paths[id]) == 0) ? 0 : -1; +} + +/* + * 函数功能:登记「清除某日志全部记录」的请求(宿主后端:调用内同步落地) + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:0 - 已执行(结果读 syslog_clear_state);-1 - id 越界(存量文件未动) int + * 限定条件:任意调用点(模拟器单线程,这里没有"非写者上下文"可言) + * 函数说明:与 MCU 端的**唯一**差异是时序:MCU 端只置请求位、等 defaultTask 的 + * syslog_drain() 尾部才碰盘,本端在本调用内就删完了,因此 + * SYSLOG_CLEAR_WAIT 在模拟器上永不出现(同 syslog_post:MCU 有队列与 + * 丢弃、本端直写无队列)。判据一条没少:删后再投日志会重建文件并正常 + * 写入、syslog_count() 归零、文件不在时幂等、失败不动原文件。 + */ +int syslog_request_clear(syslog_id_t id) { + /* 与 MCU 端 s_clear_rec[] 同一组文案(一类一枚 ROM 常量、零拼接;改动必须 + * 两处同改,两端"同格式同口径"是模拟器作为回归面的前提)。SYSLOG_OPER + * 那一格同样永不被使用——清 oper.log 时不投递,否则"我清除了记录"这条会把 + * 刚删空的 oper.log 又写成 1 条,砸掉「删除成功后 count 归零」这条判据。 */ + static const char *const s_clear_rec[SYSLOG_COUNT] = { + "clear run.log", "clear selftest.log", "clear accid.log", "clear alarm.log", + "clear start.log", "clear oper.log", "clear yk.log" + }; + int rc; + + if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { + return -1; + } + rc = s_clear_apply(id); + s_clear_st[id] = (rc == 0) ? (int8_t)SYSLOG_CLEAR_OK : (int8_t)SYSLOG_CLEAR_FAIL; + /* 成功才留痕(失败那次什么都没改),且跳过 oper.log 自己——理由见上面表头。 + * 本端 syslog_post_quiet 等价于直写,所以这条操作记录在函数返回前就已落盘, + * 与 MCU 端"下一轮 drain 才落"时序不同(两端差异已登记在 + * docs/测试说明/模拟器差距清单.md,task-4 那次也写在那里)。 */ + if ((rc == 0) && ((uint32_t)id != (uint32_t)SYSLOG_OPER)) { + syslog_post_quiet(SYSLOG_OPER, s_clear_rec[id]); + } + return 0; +} + +/* + * 函数功能:查「清除记录」的状态(宿主后端) + * 入口参数:id - 日志 ID syslog_id_t + * 返回值:SYSLOG_CLEAR_NONE(0) / OK(1) / FAIL(-2);本端不产出 WAIT(-1), + * id 越界按 NONE 返回 int + * 限定条件:任意调用点(单线程只读一字节) + * 函数说明:与 MCU 端共用同一组常量与同一段消费代码语义,UI 页面两端一份逻辑 + * 就能同时处理"排队中"(真机)与"立即完成"(模拟器)。 + */ +int syslog_clear_state(syslog_id_t id) { + if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { + return (int)SYSLOG_CLEAR_NONE; + } + if (s_clear_st[id] == (int8_t)SYSLOG_CLEAR_OK) { + return (int)SYSLOG_CLEAR_OK; + } + if (s_clear_st[id] == (int8_t)SYSLOG_CLEAR_FAIL) { + return (int)SYSLOG_CLEAR_FAIL; + } + return (int)SYSLOG_CLEAR_NONE; +} + /* * 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1)) * 入口参数:id - 日志 ID syslog_id_t diff --git a/simulator/tests/check_content_pages.py b/simulator/tests/check_content_pages.py index 88999999..53ea409e 100644 --- a/simulator/tests/check_content_pages.py +++ b/simulator/tests/check_content_pages.py @@ -54,9 +54,30 @@ PAGES = [ ("vset", "ddedede"), ("vword", "ddededde"), ("vplate", "ddededdde"), + # ── 道F task-2:整定 4 页(参数定值 → 整定 → *,#P 叶,123=修改密码可编辑)── + # 导航公共前缀:dd 进"参数定值"→ e 进(选中"整定")→ e 进整定子框(选中"参数") + # → e 进密码页;密码 123 = "uruuruuue"(三框各 +1、两次右移、确认,与 netcfg 同串法); + # 000(直接 e)= 查看密码 → 只读。定值/控制字/软压板 在整定子框里多按 d 选到该行再 e。 + # 保存行在第 (行数) 行:参数 9 行 = 2 页 → 8×d 落到保存行;定值 19 行 = 3 页。 + ("sparam_p2", "ddeee uruuruuue dddddddd"), # 参数整定第 2 页:保存行 ENTER + ("sparam_ro", "ddeee e"), # 000 只读:无保存行、无徽标 + ("sparam_edit", "ddeee uruuruuue d e"), # PT二次 进编辑(值列是编辑串,无单位) + ("sparam_oob", "ddeee uruuruuue d e l l d e"), # 编到 0(< min=1)→ ENTER 拒退 + 越界 + ("sparam_save", "ddeee uruuruuue e u e dddddddd e"), # PT一次 +1 → 保存行 ENTER → OK + ("sparam_landed", "ddeee uruuruuue e u e dddddddd e u"), # 保存后回首屏:新值 10001V 已在帧里 + ("sset", "ddeed e uruuruuue"), # 定值整定首屏(18 项 + 保存 = 3 页) + ("sword_cycle", "ddeedde uruuruuue e"), # 控制字 二态行 ENTER:跳闸 → 告警 + ("splate", "ddeeddde uruuruuue"), # 软压板整定首屏(10 项 + 保存 = 2 页) ("pwd", "ddeee"), # 密码页初始态:3 框 000、光标首框 ("pwe", "ddeee uru"), # 密码页调值+移光标:110、光标第 2 框 - ("pwk", "ddeee uruuruuue"), # 密码页输 123:u / r / u,u / r / u,u,u / e → 关页进空白终点框(菜单屏) + # 密码页输 123:u / r / u,u / r / u,u,u / e → 关页并按授权"续做进入"。 + # ⚠ **道F task-2 起这张图的落点变了**(黄金参考已 --update 刷新,属有意改判据): + # 整定 4 叶绑定内容页之前,123 确认后走 menu_password_ok 的"无内容页 → 压空白 + # 终点框"分支(屏上是 4 框菜单,整定叶本身是个死叶子);绑定之后走 + # "有内容页 → ui_content_page_open(page, UI_PERM_MODIFY)"分支,落在**整定-参数页**。 + # 也就是说这张图现在同时钉住两件事:密码页授权链正确 + 123 真的能开可编辑整定页。 + # 旧图并非"错",是那条分支从此不再可达——留档见 2026-10-26 提交正文。 + ("pwk", "ddeee uruuruuue"), ("pwc", "ddeee ue"), # 密码页 100 不一致 → 清空重输(回到 000 初始态像素) # 网口设置(node 30,通讯参数下第 2 项,需密码): # dddddd = 根框下移 5 至通讯参数;e 进框;d 选中网口设置;e 进密码页 @@ -272,6 +293,79 @@ TEXTS = { ("r1名", "子网掩码", (38, 52), False), ("r4名", "保存", (80, 95), False), ], + # ── 道F task-2:整定 4 页文本级断言 ───────────────────────────────────────── + # 值全部来自 simulator/main.c 的固定帧(prot_set 出厂默认 + cal_block 恒 1), + # 每条断言都是"某个具体事实":徽标在不在、保存行在第几页、越界提示、改值后落没落。 + # ⚠ 编辑态的值列**没有单位后缀**(ui_w_setlist_refresh 编辑分支走 + # ui_w_editor_format,非编辑分支才拼 unit)——所以 sparam_edit 断 "100" 而不是 + # "100V":这条差异本身就是"这一行正在编辑"的像素证据,别把它"顺手统一"掉。 + "pwk": [ + ("标题", "参数", (0, 24), False), + ("徽标", "未标定", (0, 24), False), # cal_block=1:整定页必须看得见 + ("r0名", "PT一次", (24, 38), True), ("r0值", "10000V", (24, 38), True), + ("r4名", "过量返回", (80, 94), False), ("r4值", "0.95", (80, 94), False), + ("r7名", "低频返回", (122, 136), False), ("r7值", "1.01", (122, 136), False), + ("页脚", "第 1 页,共 2 页", (140, 160), False), # 8 项 + 保存行 = 9 行 → 2 页 + ], + "sparam_p2": [ + ("徽标", "未标定", (0, 24), False), + ("保存名", "保存", (24, 38), True), + ("保存值", "ENTER", (24, 38), True), # 从未投递 → IDLE + ("页脚", "第 2 页,共 2 页", (140, 160), False), + ], + "sparam_ro": [ + # 000 查看密码:同一份描述符的只读分支——有行、无保存行、无徽标(见 ABSENT) + ("标题", "参数", (0, 24), False), + ("r0名", "PT一次", (24, 38), True), ("r0值", "10000V", (24, 38), True), + ("r7名", "低频返回", (122, 136), False), + ("页脚", "第 1 页,共 1 页", (140, 160), False), # 无保存行 → 8 行正好 1 页 + ], + "sparam_edit": [ + # 编辑态的可见特征:选中带反显(白底黑字)+ 值列**没有单位后缀**(见 ABSENT) + ("r1名", "PT二次", (38, 52), True), + ("r0名", "PT一次", (24, 38), False), ("r0值", "10000V", (24, 38), False), + ("r3名", "CT二次", (66, 80), False), ("r3值", "5A", (66, 80), False), + ], + "sparam_oob": [ + # PT二次 下限 1:把百位从 1 调到 0 → 0 < min → ENTER 被拒、留在编辑态并打"越界" + ("越界提示", "越界", (38, 52), True), + ("r1名", "PT二次", (38, 52), True), + ("r0值未变", "10000V", (24, 38), False), # 别的行不受影响(编辑只作用选中行) + ], + "sparam_save": [ + ("保存名", "保存", (24, 38), True), + ("保存值", "OK", (24, 38), True), # 投递 → drain 落定 → 终态 OK + ("页脚", "第 2 页,共 2 页", (140, 160), False), + ], + "sparam_landed": [ + # 保存后再按一次上移回到首屏:新值必须已在参数帧里(写路径的完整闭环) + ("r0值新", "10001V", (24, 38), False), + ("r1值旧", "100V", (38, 52), False), # 只提交改动行:没动的行还是旧值 + ("页脚", "第 1 页,共 2 页", (140, 160), False), + ], + "sset": [ + ("标题", "定值", (0, 24), False), + ("徽标", "未标定", (0, 24), False), + ("r0名", "Ⅰ段电流", (24, 38), True), ("r0值", "10.00A", (24, 38), True), + ("r7名", "过负荷延时", (122, 136), False), ("r7值", "10000ms", (122, 136), False), + ("页脚", "第 1 页,共 3 页", (140, 160), False), # 18 项 + 保存行 = 19 行 → 3 页 + ], + "sword_cycle": [ + # 二态行 ENTER:跳闸(1) → **回绕到 告警(0)**;未动的行保持 跳闸(只提交改动 id) + ("标题", "控制字", (0, 24), False), + ("r0名", "Ⅰ段跳闸", (24, 38), True), ("r0值", "告警", (24, 38), True), + ("r1名", "Ⅱ段跳闸", (38, 52), False), ("r1值", "跳闸", (38, 52), False), + ("r3名", "过负荷跳闸", (66, 80), False), ("r3值", "告警", (66, 80), False), + ("保存名", "保存", (80, 95), False), ("保存值", "ENTER", (80, 95), False), + ("页脚", "第 1 页,共 1 页", (140, 160), False), + ], + "splate": [ + ("标题", "软压板", (0, 24), False), + ("徽标", "未标定", (0, 24), False), + ("r0名", "Ⅰ段投入", (24, 38), True), ("r0值", "投入", (24, 38), True), + ("r7名", "过频投入", (122, 136), False), ("r7值", "投入", (122, 136), False), + ("页脚", "第 1 页,共 2 页", (140, 160), False), # 10 项 + 保存行 = 11 行 → 2 页 + ], "netcfg_save": [ ("标题", "网口设置", (0, 24), False), ("r0名", "IP地址", (24, 38), False), ("r0值", "192.168.001.100", (24, 38), False), @@ -280,6 +374,36 @@ TEXTS = { ], } +# 反向文本断言表:页名 -> [(名称, 期望**不出现**的串, y 带, 是否反显态)] +# ⚠ inv 必须与"这一行本来会怎么画"一致,否则反向断言恒真、形同没写: +# 选中/编辑行是白底黑字 → inv=True;普通行黑底白字 → inv=False。 +# 为什么需要"不该出现"这一侧:像素参考能钉住整张图,但参考图是按当前实现刷出来的 +# ——如果"未标定"徽标哪天在两处都画了(整定 attach 判据失效 + 顺手 --update 刷参考), +# 像素比对会"一致"而事实已经错了。反向断言不依赖参考图,钉的是"这条规矩本身": +# · 徽标只属于整定态(可改值但判据停走),不属于只读态,也不属于四个查看页; +# · 只读态没有"保存"这件事(无 commit → 组件不画保存行)。 +ABSENT = { + "sparam_ro": [ + ("只读页无未标定徽标", "未标定", (0, 24), False), + ("只读页无保存行", "保存", (0, 140), False), + ], + # 查看 4 页:task-2 给同一批描述符绑了 key_cb / edit_capable,像素必须零变化; + # 徽标与保存行都不该出现在它们身上(出现 = 整定 attach 漏判了权限) + "vparam": [("查看页无未标定徽标", "未标定", (0, 24), False), + ("查看页无保存行", "保存", (0, 140), False)], + "vset": [("查看页无未标定徽标", "未标定", (0, 24), False), + ("查看页无保存行", "保存", (0, 140), False)], + "vword": [("查看页无未标定徽标", "未标定", (0, 24), False), + ("查看页无保存行", "保存", (0, 140), False)], + "vplate": [("查看页无未标定徽标", "未标定", (0, 24), False), + ("查看页无保存行", "保存", (0, 140), False)], + # 编辑态的值列由 ui_w_editor_format 生成(**不拼单位**),非编辑态才拼: + # 所以"100V 不在这一行"就是"这一行确实在编辑中"的证据。 + # 反过来正向断言"100"是没有意义的——它是"100V"甚至"10000V"的子串, + # 字形匹配器只保证 bbox 内干净、不检查词边界(实测在上一行的 10000V 上命中) + "sparam_edit": [("编辑态不打单位后缀", "100V", (38, 52), True)], +} + W = H = 160 STRIDE = W // 8 # I1:20 字节/行 @@ -468,16 +592,26 @@ def main(): run_sim(seq, shot, tmp) shots[name] = open(shot, "rb").read() + n_pos = 0 for name, _seq in PAGES: for tname, text, band, inv in TEXTS.get(name, []): + n_pos += 1 if not _match_text(shots[name], text, band, inv): ok = False print("[%s] FAIL 文本断言未命中:%s = %r(y 带 %s 反显=%s)" % (name, tname, text, band, inv)) + n_abs = 0 + for name, _seq in PAGES: + for tname, text, band, inv in ABSENT.get(name, []): + n_abs += 1 + if _match_text(shots[name], text, band, inv): + ok = False + print("[%s] FAIL 反向断言被命中:%s 不该出现却出现了(y 带 %s)" + % (name, tname, band)) if not ok: print("FAIL: 文本级断言未通过(缺字/错标签);中止像素比对(现场保留在 %s)" % tmp) return 1 - print("文本级断言:%d 条全部命中" % sum(len(TEXTS.get(n, [])) for n, _ in PAGES)) + print("文本级断言:%d 条命中 + %d 条反向(不该出现)全部通过" % (n_pos, n_abs)) # 2) 像素黄金参考比对 / 刷新 for name, seq in PAGES: diff --git a/simulator/tests/ref/shot_pwk.bin b/simulator/tests/ref/shot_pwk.bin index 8e34eaa3..f0434c38 100644 Binary files a/simulator/tests/ref/shot_pwk.bin and b/simulator/tests/ref/shot_pwk.bin differ diff --git a/simulator/tests/ref/shot_sparam_edit.bin b/simulator/tests/ref/shot_sparam_edit.bin new file mode 100644 index 00000000..5c7b7f01 Binary files /dev/null and b/simulator/tests/ref/shot_sparam_edit.bin differ diff --git a/simulator/tests/ref/shot_sparam_landed.bin b/simulator/tests/ref/shot_sparam_landed.bin new file mode 100644 index 00000000..d69531a3 Binary files /dev/null and b/simulator/tests/ref/shot_sparam_landed.bin differ diff --git a/simulator/tests/ref/shot_sparam_oob.bin b/simulator/tests/ref/shot_sparam_oob.bin new file mode 100644 index 00000000..342c4b5b Binary files /dev/null and b/simulator/tests/ref/shot_sparam_oob.bin differ diff --git a/simulator/tests/ref/shot_sparam_p2.bin b/simulator/tests/ref/shot_sparam_p2.bin new file mode 100644 index 00000000..bfb9cf3b Binary files /dev/null and b/simulator/tests/ref/shot_sparam_p2.bin differ diff --git a/simulator/tests/ref/shot_sparam_ro.bin b/simulator/tests/ref/shot_sparam_ro.bin new file mode 100644 index 00000000..81067fa5 Binary files /dev/null and b/simulator/tests/ref/shot_sparam_ro.bin differ diff --git a/simulator/tests/ref/shot_sparam_save.bin b/simulator/tests/ref/shot_sparam_save.bin new file mode 100644 index 00000000..597e96c2 Binary files /dev/null and b/simulator/tests/ref/shot_sparam_save.bin differ diff --git a/simulator/tests/ref/shot_splate.bin b/simulator/tests/ref/shot_splate.bin new file mode 100644 index 00000000..338bd056 Binary files /dev/null and b/simulator/tests/ref/shot_splate.bin differ diff --git a/simulator/tests/ref/shot_sset.bin b/simulator/tests/ref/shot_sset.bin new file mode 100644 index 00000000..5325e2f7 Binary files /dev/null and b/simulator/tests/ref/shot_sset.bin differ diff --git a/simulator/tests/ref/shot_sword_cycle.bin b/simulator/tests/ref/shot_sword_cycle.bin new file mode 100644 index 00000000..bcc37d9c Binary files /dev/null and b/simulator/tests/ref/shot_sword_cycle.bin differ diff --git a/test/host/iec104/iec104_test.c b/test/host/iec104/iec104_test.c index 1ff24131..23c11112 100644 --- a/test/host/iec104/iec104_test.c +++ b/test/host/iec104/iec104_test.c @@ -24,6 +24,11 @@ * TC-IEC104-011 t2 到期发 S * TC-IEC104-012 t3 空闲发 TESTFR act;未答 t1 超时 → IDLE;con 清挂起不误杀 * TC-IEC104-013 遥控两段式:选择/执行成功;失配 PN;拒绝类选择段即 PN + * TC-IEC104-030 遥控 / 操作记录投递(道F task-10 的 104 侧写入点):未知 IOA、 + * YK11 选择与执行、YK12 选择 / 执行 / 非法 DPC 各投哪一类哪一句,外加 + * "拒绝不投操作记录""记录与压板实际状态一致""文案不超 28 字节"三条会 + * 静默变坏但台架看不出的点(YK12 的 set_sealed 失败腿不可达,理由写在 + * 该用例注释里,没有为它伪造定值范围) * TC-IEC104-014 定值两段写(真实 prot_set):越界 PN;执行后 get_f + selfcheck * TC-IEC104-015 C_RD:YC 命中 / 定值命中 / 未知 IOA COT14+EVI * TC-IEC104-016 C_CS:clock_sync 收正确 epoch;IV → PN;clock_sync 失败 → PN @@ -148,6 +153,65 @@ void syslog_post(syslog_id_t id, const char *desc) { rp_s_syslog_count++; } +/* ---- syslog_post_quiet 桩:记下投了什么(task-10 遥控 / 操作记录用) ---- + * 真机上 quiet 与 post 的差别只在"投递侧不打日志",收/丢判据逐条相同; + * 本套件是协议栈单测,不验投递本身(那归 test/host/sys_log_mcu),只验 + * **板端源在每条返回路径上投了哪一类、哪一句**——所以这里存成环形日志。 */ +#define RP_REC_MAX 16u +static uint8_t rp_s_rec_id[RP_REC_MAX]; +static char rp_s_rec_desc[RP_REC_MAX][SYSLOG_DESC_MAX + 1u]; +static uint32_t rp_s_rec_n; /* 累计条数(不回绕,供"一条都没多投"断言) */ +static uint32_t rp_s_drop_quiet; /* 本桩不模拟丢弃,恒 0 */ + +void syslog_post_quiet(syslog_id_t id, const char *desc) { + uint32_t slot; + size_t dl = 0u; + + if (desc == NULL) { + return; + } + slot = rp_s_rec_n % RP_REC_MAX; + rp_s_rec_id[slot] = (uint8_t)id; + while ((dl < SYSLOG_DESC_MAX) && (desc[dl] != '\0')) { + dl++; + } + (void)memcpy(rp_s_rec_desc[slot], desc, dl); + rp_s_rec_desc[slot][dl] = '\0'; + rp_s_rec_n++; +} + +uint32_t syslog_post_dropped(void) { return rp_s_drop_quiet; } + +/* 第 k 条(0 起)记录;越界返回 -1 */ +static int rp_rec_at(uint32_t k, syslog_id_t id, const char *desc) +{ + if (k >= rp_s_rec_n) { + return -1; + } + return (rp_s_rec_id[k % RP_REC_MAX] == (uint8_t)id) + && (strcmp(rp_s_rec_desc[k % RP_REC_MAX], desc) == 0) ? 1 : 0; +} + +/* 某类记录一共投了几条 */ +static uint32_t rp_rec_cnt(syslog_id_t id) +{ + uint32_t i, n = 0u; + + for (i = 0u; (i < rp_s_rec_n) && (i < RP_REC_MAX); i++) { + if (rp_s_rec_id[i] == (uint8_t)id) { + n++; + } + } + return n; +} + +static void rp_rec_clear(void) +{ + rp_s_rec_n = 0u; + (void)memset(rp_s_rec_id, 0, sizeof(rp_s_rec_id)); + (void)memset(rp_s_rec_desc, 0, sizeof(rp_s_rec_desc)); +} + /* ---- adc_input_desc 桩:V0.1 权威映射(docs/硬件平台/ADC通道映射说明.md) ---- */ #include "adc_input.h" @@ -809,6 +873,93 @@ static void tc_dc_yk12(void) "TC-IEC104-024 an invalid DPC returns PN in the select phase"); } +/* TC-IEC104-030(道F task-10):遥控记录——板端默认源 s_src_yk_exec() 的三条返回 + * 路径各投哪一类记录、投几句、是否顺手投了操作记录。 + * 为什么直接调 s_src_yk_exec 而不是发 ASDU:本套件绑定的是**假源** f_yk, + * YK11 / 未知 IOA 在 f_yk 里就被短路(只有 YK12 转发到真板端源,见 f_yk), + * 走 ASDU 反而测不到要验的那两条腿;"ASDU -> link -> pts -> 源"这条转发链 + * 已由 TC-IEC104-013/024 覆盖,不重复。**投递本身**(quiet 通道的收/丢)归 + * test/host/sys_log_mcu,本用例只钉"哪条路径投了哪一条"。 + * 覆盖不到的一条腿(登记,不假装):YK12 的 rc!=0 分支要 prot_set_set_sealed + * 返 -1 才进得去,而 PS_GLOBAL_EN 的 min/max 恰好就是本函数会写的 0/1 两值 + * → 该产品路径上不可达,是防御性分支;不为它伪造定值范围(那是改判据)。 */ +static void tc_yk_records(void) +{ + uint32_t now = 1000u; + + reset_all(now); + rp_rec_clear(); + + /* 1) 未知 IOA:拒绝,只投一条遥控记录;拒绝没改动任何东西 -> 不该有操作记录 */ + CHECK(s_src_yk_exec(0x6000u, (uint8_t)IEC104_DPC_ON, 1u) == -1, + "TC-IEC104-030 an IOA without output hardware is still rejected"); + CHECK((rp_s_rec_n == 1u) && (rp_rec_at(0u, SYSLOG_YK, "YK unknown ioa") == 1), + "TC-IEC104-030 the reject path posts exactly one telecontrol record"); + CHECK(rp_rec_cnt(SYSLOG_OPER) == 0u, + "TC-IEC104-030 a rejected command posts no operation record"); + + /* 2) YK11 选择段:受理但未动作 -> 只有遥控记录 */ + rp_rec_clear(); + CHECK((s_src_yk_exec(0x600Au, (uint8_t)IEC104_DPC_ON, 1u) == 0) + && (rp_s_rec_n == 1u) && (rp_rec_at(0u, SYSLOG_YK, "YK11 select") == 1), + "TC-IEC104-030 the YK11 select phase posts a select record and acts on nothing"); + CHECK(rp_rec_cnt(SYSLOG_OPER) == 0u, + "TC-IEC104-030 the select phase is not an operation yet"); + + /* 3) YK11 执行段:遥控记录 + 操作记录各一条,顺序 = 先命令后动作 */ + rp_rec_clear(); + CHECK((s_src_yk_exec(0x600Au, (uint8_t)IEC104_DPC_ON, 0u) == 0) + && (rp_s_rec_n == 2u) + && (rp_rec_at(0u, SYSLOG_YK, "YK11 exec") == 1) + && (rp_rec_at(1u, SYSLOG_OPER, "force reset") == 1), + "TC-IEC104-030 the YK11 execute phase posts one telecontrol plus one operation record"); + + /* 4) YK12 非法双点值:拒绝 + 一条带原因的遥控记录,压板不动 */ + rp_rec_clear(); + CHECK((s_src_yk_exec(0x600Bu, 0u, 0u) == -1) && (rp_s_rec_n == 1u) + && (rp_rec_at(0u, SYSLOG_YK, "YK12 bad dpc") == 1), + "TC-IEC104-030 an invalid DPC posts the reason and changes nothing"); + CHECK(prot_set_get_ctl(PS_GLOBAL_EN) == 1u, + "TC-IEC104-030 the invalid DPC leaves the soft plate engaged"); + + /* 5) YK12 执行 OFF:遥控 off + 操作 plate out,且压板真的退出了(记录与事实同步) */ + rp_rec_clear(); + CHECK((s_src_yk_exec(0x600Bu, (uint8_t)IEC104_DPC_OFF, 0u) == 0) + && (rp_s_rec_n == 2u) + && (rp_rec_at(0u, SYSLOG_YK, "YK12 exec off") == 1) + && (rp_rec_at(1u, SYSLOG_OPER, "plate out") == 1), + "TC-IEC104-030 releasing the soft plate records both the command and the operation"); + CHECK(prot_set_get_ctl(PS_GLOBAL_EN) == 0u, + "TC-IEC104-030 the record agrees with the plate state the settings layer holds"); + + /* 6) YK12 执行 ON:对称的另一半(只测 OFF 的话,两条 desc 写反也发现不了) */ + rp_rec_clear(); + CHECK((s_src_yk_exec(0x600Bu, (uint8_t)IEC104_DPC_ON, 0u) == 0) + && (rp_rec_at(0u, SYSLOG_YK, "YK12 exec on") == 1) + && (rp_rec_at(1u, SYSLOG_OPER, "plate in") == 1), + "TC-IEC104-030 engaging the soft plate records the matching pair"); + + /* 7) 选择段不落地:YK12 select 只投一条,压板状态不变 */ + rp_rec_clear(); + CHECK((s_src_yk_exec(0x600Bu, (uint8_t)IEC104_DPC_OFF, 1u) == 0) + && (rp_s_rec_n == 1u) && (rp_rec_at(0u, SYSLOG_YK, "YK12 select") == 1) + && (prot_set_get_ctl(PS_GLOBAL_EN) == 1u), + "TC-IEC104-030 the YK12 select phase records the intent without changing the plate"); + + /* 8) 文案长度契约:desc 全是 ROM 常量,任何一条超过 SYSLOG_DESC_MAX 都会在 + * 投递侧被静默截断(屏上少几个字,没人会去数)-> 在单测里钉住。 */ + CHECK((strlen(YK_REC_UNKNOWN) <= SYSLOG_DESC_MAX) + && (strlen(YK_REC_YK11_EXEC) <= SYSLOG_DESC_MAX) + && (strlen(YK_REC_YK12_OFF) <= SYSLOG_DESC_MAX) + && (strlen(YK_REC_YK12_FAIL) <= SYSLOG_DESC_MAX) + && (strlen(YK_REC_DPC_BAD) <= SYSLOG_DESC_MAX) + && (strlen(YK_REC_YK12_SEL) <= SYSLOG_DESC_MAX) + && (strlen(YK_REC_YK11_SEL) <= SYSLOG_DESC_MAX) + && (strlen(YK_REC_YK12_ON) <= SYSLOG_DESC_MAX), + "TC-IEC104-030 every telecontrol description fits the 28-byte record field"); + rp_rec_clear(); +} + static void tc_se(void) { uint32_t now = 1000u; @@ -1283,6 +1434,7 @@ int main(void) tc_t3_t1(); tc_dc(); tc_dc_yk12(); + tc_yk_records(); tc_se(); tc_rd(); tc_cs(); diff --git a/test/host/iec104_serv/iec104_serv_test.c b/test/host/iec104_serv/iec104_serv_test.c index 235be207..32a7032b 100644 --- a/test/host/iec104_serv/iec104_serv_test.c +++ b/test/host/iec104_serv/iec104_serv_test.c @@ -28,10 +28,17 @@ * 否则对端会以为设备时间是 1970-01-01。 * 4. 活性分频(每 32 轮才探一次)是 CPU 预算的取舍:写错成每轮都探,就把 100ms * 轮询变成了每轮两次系统调用;写错成分频相位,TESTFR 会迟迟不发。 + * 5. 操作记录是**审计面**(道F task-19):只要"投了"而不管"改成功了没有",屏上的 + * 操作记录页就会出现从没发生过的对时;越界拒绝与 RTC 写失败这两条腿必须 + * 一条都不投,而这只有能注入 sys_clock_set_unix 的返回值才测得到。 + * 同时钉住"用 quiet 口不用 post 口"——post 的两个满态留痕分支各带约 216 B + * 内联行缓冲,iec104Task 的静态最坏链放不下它(台架与单测都看不出这个差别, + * 只有 stack_report 会响,所以先在源码侧钉住)。 * * 覆盖点:TC-I104SRV-001..004 建监听;010..013 发送与连续短写判死;020..023 时钟 * 桥接;030..033 代次重建与 accept 门控;034..036 活性分频与对端 FIN;037..041 - * 错误丢弃与协议掉线重生。 + * 错误丢弃与协议掉线重生;050..053 对时的操作记录(道F task-19:成功才记、 + * 越界拒绝与写钟失败两条腿都不记、每次对时各记一条、且只走 quiet 投递口)。 * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 @@ -194,6 +201,61 @@ static int g_wd_beats; static int g_wd_last_id; static uint32_t g_now; +/* ===================== sys_log 桩(task-19 操作记录) ===================== + * 两个投递口都抓下来:quiet 是 iec104Task 该用的那个(本任务栈深,不许在调用链上 + * 内联日志行缓冲),post 一旦在这里被调到就是"绕回旧口",053 会红。 + * 投递本身(收/丢/计数)归 test/host/sys_log_mcu,本套只钉"哪条腿投了哪一句"。 */ +#include "sys_log.h" + +#define RP_I4_REC_MAX 8u +static uint8_t rp_i4_id[RP_I4_REC_MAX]; +static char rp_i4_desc[RP_I4_REC_MAX][SYSLOG_DESC_MAX + 1u]; +static uint32_t rp_i4_n; /* quiet 通道累计(不回绕,供"恰好几条"断言) */ +static uint32_t rp_i4_post_n; /* 误用 syslog_post 的次数,期望恒 0 */ + +static void rp_i4_rec_clear(void) +{ + rp_i4_n = 0u; + rp_i4_post_n = 0u; + (void)memset(rp_i4_id, 0, sizeof(rp_i4_id)); + (void)memset(rp_i4_desc, 0, sizeof(rp_i4_desc)); +} + +void syslog_post_quiet(syslog_id_t id, const char *desc) +{ + uint32_t slot = rp_i4_n % RP_I4_REC_MAX; + size_t dl = 0u; + + if (desc == NULL) { + return; + } + while ((dl < SYSLOG_DESC_MAX) && (desc[dl] != '\0')) { + dl++; + } + (void)memcpy(rp_i4_desc[slot], desc, dl); + rp_i4_desc[slot][dl] = '\0'; + rp_i4_id[slot] = (uint8_t)id; + rp_i4_n++; +} + +void syslog_post(syslog_id_t id, const char *desc) +{ + (void)id; + (void)desc; + rp_i4_post_n++; +} + +uint32_t syslog_post_dropped(void) { return 0u; } + +static int rp_i4_at(uint32_t k, syslog_id_t id, const char *desc) +{ + if (k >= rp_i4_n) { + return 0; + } + return (rp_i4_id[k % RP_I4_REC_MAX] == (uint8_t)id) + && (strcmp(rp_i4_desc[k % RP_I4_REC_MAX], desc) == 0) ? 1 : 0; +} + uint32_t sys_clock_get(void) { return g_clock_val; } int sys_clock_set_unix(uint32_t unix_secs) { @@ -250,6 +312,7 @@ static void reset_all(void) g_lk_state = IEC104_ST_RUN; g_pts_init = 0; g_clock_val = 1700000000u; + rp_i4_rec_clear(); g_clock_set_rc = 0; g_clock_last_secs = 0u; g_clock_set_calls = 0; @@ -367,6 +430,38 @@ static void tc_clock(void) reset_all(); g_clock_val = 1700000123u; CHECK(s_epoch_ms(NULL) == 1700000123000ULL, "TC-I104SRV-023 with a synced clock the answer is in milliseconds"); + + /* ---- task-19:对时 = 操作记录(审计面只记真发生过的动作) ---- */ + reset_all(); + (void)s_clock_sync(NULL, 1700000123000ULL); + CHECK((rp_i4_n == 1u) && (rp_i4_at(0u, SYSLOG_OPER, "clock set by 104") == 1), + "TC-I104SRV-050 a successful sync books exactly one operation record naming the source"); + + reset_all(); + (void)s_clock_sync(NULL, 0x100000000ULL * 1000ULL); + CHECK((rp_i4_n == 0u) && (g_clock_set_calls == 0), + "TC-I104SRV-050b the out-of-range rejection books nothing (an audit record for something that never happened is worse than none)"); + + reset_all(); + g_clock_set_rc = -1; /* 写钟失败(RTC 回写失败那一类) */ + CHECK(s_clock_sync(NULL, 1700000123000ULL) == -1, + "TC-I104SRV-051 a failed clock write still answers the link layer with -1"); + CHECK(rp_i4_n == 0u, + "TC-I104SRV-051b a failed clock write books no operation record"); + + reset_all(); + (void)s_clock_sync(NULL, 1700000123000ULL); + (void)s_clock_sync(NULL, 1700000456000ULL); + CHECK((rp_i4_n == 2u) && (rp_i4_at(1u, SYSLOG_OPER, "clock set by 104") == 1), + "TC-I104SRV-052 every re-sync is its own operation record (no dedup, no latching)"); + + /* 053:用错投递口的形状——iec104Task 是深链任务,只许用 quiet。 + * 顺带量文案长度:超过 SYSLOG_DESC_MAX 会在投递侧被静默截断。 */ + reset_all(); + (void)s_clock_sync(NULL, 1700000123000ULL); + CHECK((rp_i4_post_n == 0u) && (rp_i4_n == 1u) + && (strlen("clock set by 104") <= SYSLOG_DESC_MAX), + "TC-I104SRV-053 the record goes out over the stack-safe channel only, and its text fits the record field"); } /* ===================== 代次重建与 accept 门控 ===================== */ diff --git a/test/host/prot_elm/prot_elm_test.c b/test/host/prot_elm/prot_elm_test.c index ab1cdba5..84132c4f 100644 --- a/test/host/prot_elm/prot_elm_test.c +++ b/test/host/prot_elm/prot_elm_test.c @@ -24,6 +24,11 @@ * TC-PROTELEM-012 低频 UF(B8/L3-5):欠量方向 + RET_UF 滞回 + 方向隔离 * TC-PROTELEM-013 run() 接线(B8):测频驱动 OF 动作;幅值门失效 OF 复位且 * 相量类元件仍被评估(不整拍跳过) + * TC-PROTELEM-014 启动位图与动作位图分离(道F task-10「保护启动」记录的前置): + * 延时段已启动未到点时两枚位图必须不同 + 不变式 asserted ⊆ picked + * TC-PROTELEM-015 动作边沿的记录分类(道F task-10「事故 / 保护告警」): + * 逐项独立读 PS_*_TRIP、改定值即改类别、无该投退项的五项恒告警 + * (同时挡住 trip_ctl=0xFF 的越界读)、越界编号不落记录 * * 通过:全部 TC PASS(main 返回 0)。 * 创建日期:2026-10-11(B5);2026-10-12 扩 OF/UF(B8) @@ -442,6 +447,101 @@ static void tc_run_freq_wiring(void) { "TC-PROTELEM-013 run(): UV still evaluated with zero-amplitude (no whole-beat skip)"); } +/* TC-PROTELEM-014(道F task-10):启动位图与动作位图**分离**。 + * 「保护启动」记录的边沿检测靠 pick_mask,若它等于 active_mask(或干脆没实现, + * 让 protect_task 去轮询 state),"启动了但延时段还没到点"这一整段在记录里就是 + * 空的——而那恰恰是这一页唯一比"动作记录"多出来的信息。所以这里把两者的 + * **不等**关系钉死,并钉住不变式 asserted ⊆ picked。 */ +static void tc_pick_mask(void) { + float m[PE_COUNT]; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_OC1_DLY, PROT_SET_Q10(100.0)); /* 延时段 */ + (void)prot_set_set(PS_OC3_DLY, PROT_SET_Q10(0.0)); /* 瞬时段 */ + mk_base(m); + m[PE_OC1] = 10.1f; /* 越限,但 100 ms 未到 */ + m[PE_OC3] = 2.1f; /* 越限,dly=0 同拍动作 */ + + prot_elm_eval(6000u, m); + CHECK(prot_elm_pick_mask() == ((1uL << PE_OC1) | (1uL << PE_OC3)), + "TC-PROTELEM-014 pick mask carries the delayed element that has not tripped yet"); + CHECK(prot_elm_active_mask() == (1uL << PE_OC3), + "TC-PROTELEM-014 active mask still excludes it (the two masks are genuinely different)"); + CHECK((prot_elm_active_mask() & ~prot_elm_pick_mask()) == 0u, + "TC-PROTELEM-014 invariant: asserted is a subset of picked"); + + prot_elm_eval(6100u, m); /* OC1 定时限到点 */ + CHECK(prot_elm_active_mask() == ((1uL << PE_OC1) | (1uL << PE_OC3)), + "TC-PROTELEM-014 after the delay the active mask catches up"); + CHECK(prot_elm_pick_mask() == ((1uL << PE_OC1) | (1uL << PE_OC3)), + "TC-PROTELEM-014 pick mask unchanged while the level holds"); + + m[PE_OC1] = 9.4f; /* 跌破返回门槛 */ + m[PE_OC3] = 1.0f; + prot_elm_eval(6200u, m); + CHECK((prot_elm_pick_mask() == 0u) && (prot_elm_active_mask() == 0u), + "TC-PROTELEM-014 both masks clear together below the return threshold"); +} + +/* TC-PROTELEM-015(道F task-10):动作上升沿该归"事故"还是"保护告警"。 + * 判据的唯一来源是定值表里的跳闸投退项 PS_*_TRIP(枚举注释即"0 告警 / 1 跳闸"), + * 元件->投退项的对应关系在 k_pe[] 的 trip_ctl 列。这里要钉的是三件容易做错的事: + * a) **逐项独立**:OC1 投入、OC2 退出必须给出不同类别(做成一个全局开关、 + * 或在本函数里另写一份"哪些元件算跳闸"的名单,都会在这里翻红——而那份 + * 名单一旦与 g_prot_set_meta / k_pe[] 分叉,就是"整定视图与记录分类打架", + * setview-check 也只认既有表); + * b) **改定值即改类别**:同一元件同一状态,写 1 变事故、写 0 变告警, + * 证明它是查表而不是编译期常量; + * c) **没有该投退项的五项恒告警**,且**不会串到别人的字**:把全部 TRIP 项 + * 写成 1,OV/UV/3U0/OF/UF 仍然是告警——这条同时是 trip_ctl=0xFF 那格的 + * 越界读防线(若实现把 0xFF 直接喂给 prot_set_get_ctl(),读到的是定值区 + * 之外,类别会随内存内容漂,这条必红)。 + * 越界编号返回"不落任何记录"。 */ +static void tc_record_kind(void) { + prot_set_init(); + prot_elm_init(); + + /* a) 逐项独立 */ + (void)prot_set_set(PS_OC1_TRIP, PROT_SET_Q10(1)); + (void)prot_set_set(PS_OC2_TRIP, PROT_SET_Q10(0)); + CHECK((prot_elm_record_kind(PE_OC1) == PROT_REC_TRIP) + && (prot_elm_record_kind(PE_OC2) == PROT_REC_ALARM), + "TC-PROTELEM-015 the kind is read per element, not from one global switch"); + + /* b) 改定值即改类别(同元件、同状态) */ + (void)prot_set_set(PS_OC1_TRIP, PROT_SET_Q10(0)); + CHECK(prot_elm_record_kind(PE_OC1) == PROT_REC_ALARM, + "TC-PROTELEM-015 releasing the trip word turns the same element into an alarm"); + (void)prot_set_set(PS_OC1_TRIP, PROT_SET_Q10(1)); + CHECK(prot_elm_record_kind(PE_OC1) == PROT_REC_TRIP, + "TC-PROTELEM-015 re-engaging it turns it back into an accident (no latching)"); + (void)prot_set_set(PS_OC3_TRIP, PROT_SET_Q10(1)); + (void)prot_set_set(PS_OL_TRIP, PROT_SET_Q10(1)); + CHECK((prot_elm_record_kind(PE_OC3) == PROT_REC_TRIP) + && (prot_elm_record_kind(PE_OL) == PROT_REC_TRIP), + "TC-PROTELEM-015 OC3/OL follow their own trip words too"); + + /* c) 无跳闸投退项的五项:全表写 1 之后仍恒告警(也挡住 0xFF 越界读) */ + (void)prot_set_set(PS_OC1_TRIP, PROT_SET_Q10(1)); + (void)prot_set_set(PS_OC2_TRIP, PROT_SET_Q10(1)); + (void)prot_set_set(PS_OC3_TRIP, PROT_SET_Q10(1)); + (void)prot_set_set(PS_OL_TRIP, PROT_SET_Q10(1)); + CHECK((prot_elm_record_kind(PE_OV) == PROT_REC_ALARM) + && (prot_elm_record_kind(PE_UV) == PROT_REC_ALARM) + && (prot_elm_record_kind(PE_3U0) == PROT_REC_ALARM) + && (prot_elm_record_kind(PE_OF) == PROT_REC_ALARM) + && (prot_elm_record_kind(PE_UF) == PROT_REC_ALARM), + "TC-PROTELEM-015 alarm-only elements stay alarms with every trip word engaged"); + + /* 守卫:越界编号 = 一条都不许落 */ + CHECK(prot_elm_record_kind(PE_COUNT) == PROT_REC_NONE, + "TC-PROTELEM-015 out-of-range id -> no record at all"); + /* 只读性:判定不改任何状态(protectTask 每拍都会问,问完还要跑判据) */ + CHECK(prot_elm_pick_mask() == 0u, + "TC-PROTELEM-015 the classifier leaves the element states untouched"); +} + int main(void) { printf("=== Prot Elm Tests ===\n"); tc_init_clear(); @@ -457,6 +557,8 @@ int main(void) { tc_of_hysteresis(); tc_uf_direction(); tc_run_freq_wiring(); + tc_pick_mask(); + tc_record_kind(); printf("\n=== Prot Elm Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); diff --git a/test/host/sys_log_mcu/run_unit_test.py b/test/host/sys_log_mcu/run_unit_test.py index 78443819..4bf3481f 100644 --- a/test/host/sys_log_mcu/run_unit_test.py +++ b/test/host/sys_log_mcu/run_unit_test.py @@ -7,7 +7,12 @@ r"""运行/自检记录落盘后端(sys_log_mcu)的主机侧单元测试运 App/log/sys_log_mcu.c,并纳入共享假卷 test/host/common/fatfs_fake.c),在 PC 上离线 跑 64B 定长记录格式、单条/批量两条写路径、syslog_drain 的 preboot+队列排空与 "失败回投不丢条目"、日志读写放大收口的 8 条滑动读窗口 + 写者代数失效、挂载幂等与 - 上电/重启分类、sys_log_evidence 的 wdev.log 取证通道,失败即非 0 退出。 + 上电/重启分类、sys_log_evidence 的 wdev.log 取证通道、「清除记录」的请求/落地分离 + 与四条判据(请求侧零盘触点、drain 内恰好一次 unlink、删后重建可继续写、 + FR_NO_FILE 幂等返 0、卷占用/真失败返 -1 且不误删原文件)、以及七类记录(道F + task-10 新增 accid/alarm/start/oper/yk 五类)的存储面——逐 id 验"哪类落哪个 + 文件、各用各的容量上限、名字过严格 8.3",以及 quiet 投递通道的丢弃计数与 + "投递侧零留痕",失败即非 0 退出。 工具链与判定委托 test/host/common/unit_test_harness.py。 为什么要主机侧单测(而不是只用台架): @@ -20,7 +25,11 @@ r"""运行/自检记录落盘后端(sys_log_mcu)的主机侧单元测试运 3. **挂载幂等**:syslog_mcu_mounted 曾在建 /log 失败后永久 no-op(运行期日志与自检 全部滞留),改成幂等 + 重试后又必须防"每 2s 刷一条同样错误" → 要注入 mkdir 失败才测得到,且必须能数出"到底留痕几次"。 - 4. **读窗口 try 语义 / 日志读写放大收口的读侧**:窗口"命中即零 I/O"与"写者一落地就失效"是一枚硬币两面, + 4. **「清除记录」的请求 / 落地分离(2026-10-26 加)**:在 UI 按键回调里直接 f_unlink + 也能删掉文件、台架当场看不出毛病,代价是把 FAT 目录项回写挂进 uiTask 的 + 100 ms 喂狗节拍、并与唯一写者自己的"容量满 unlink 重建"交错。这条边界只能靠 + 数调用钉住:请求侧零 FatFs / 零卷锁、删除只在 syslog_drain 内且恰好一次 unlink。 + 5. **读窗口 try 语义 / 日志读写放大收口的读侧**:窗口"命中即零 I/O"与"写者一落地就失效"是一枚硬币两面, 只做前者会把陈旧读当正确、只做后者会退回逐条 open/seek/close 的旧实现。 桩的边界:ff.h / sys_log.h / soe_store.h / sys_clock.h / vol_lock.h 用**真实头**; diff --git a/test/host/sys_log_mcu/sys_log_mcu_test.c b/test/host/sys_log_mcu/sys_log_mcu_test.c index 3f3cb20d..f0906fc1 100644 --- a/test/host/sys_log_mcu/sys_log_mcu_test.c +++ b/test/host/sys_log_mcu/sys_log_mcu_test.c @@ -8,7 +8,8 @@ * 失败回投不丢条目、-3 退回慢路径) * D 读侧(日志读写放大收口的 8 条滑动窗口 + 写者代数失效 + 越界/忙/短读处置) * 外加 syslog_mcu_mounted 的 挂载幂等与上电/重启分类、sys_log_evidence - * 的 wdev.log 取证通道(8KB 护栏 + 第二条写必须在释放卷锁之后)。 + * 的 wdev.log 取证通道(8KB 护栏 + 第二条写必须在释放卷锁之后), + * 以及「清除记录」的请求 / 落地分离与四条判据(K 族,2026-10-26)。 * * 被测对象:真实源文件 App/log/sys_log_mcu.c(#include 原样纳入,不改生产代码)。 * 假件与桩: @@ -28,6 +29,20 @@ * 4. **读窗口 try 语义 / 日志读写放大收口的读侧**:读窗口"命中即零 I/O"与"写者一落地就必须失效重填"是同一 * 枚硬币的两面——只做前者会把陈旧读当正确,只做后者会退化成逐条 open/seek/close * 的旧实现(UI 翻 20 条 = 20 次 FAT 往返)。两者都靠假卷的调用计数才断言得了。 + * 5. **「清除记录」的请求 / 落地分离**(2026-10-26 加,K 族):最顺手的写法是在 UI + * 按键回调里直接 f_unlink —— 文件确实删了,台架当场也看不出毛病,代价是把 FAT + * 目录项回写挂进 uiTask 每 100 ms 一次的喂狗节拍,并与唯一写者自己的"容量满 + * unlink 重建"交错。这条"谁在什么上下文碰盘"的边界,只有离线数调用才钉得住: + * 请求侧必须零 FatFs / 零卷锁(TC-SYSLOG-110),删除必须恰好一次 unlink 且 + * 发生在排空之后(TC-SYSLOG-111/117)。真机上这条写错的表现为偶发丢一段日志 + * 或一次误复位,串口里没有任何一行会指着这处代码。 + * 6. **一张表的下标序 = 枚举序**(2026-10-26 加,L 族):记录类型就是"哪个文件", + * 于是 s_paths[]/s_caps[] 与 syslog_id_t 的**逐项同序**成了正确性本身。少写一项 + * 编译期就报,写错序却什么都不报错——事故记录会安静地写进告警文件,屏上一切 + * 正常,只有查记录的人被误导。这类"顺序即语义"的耦合只能靠逐 id 断言"我投的 + * 这句出现在哪个路径、别的文件里没有"来钉(TC-SYSLOG-130),容量同理(131)。 + * quiet 投递通道也在这一族:它存在的唯一理由是"投递侧不许带日志行缓冲", + * 所以必须断言**丢弃时零留痕**(132/135),否则退化回 syslog_post 也没人发现。 * * 覆盖点(TC 号族 SYSLOG,形如 TC-SYSLOG-001,族内按功能分档;缺口在小节末尾如实登记): * —— A 记录格式 —— @@ -129,11 +144,69 @@ * TC-SYSLOG-081 生产路径全过严格 8.3:/log /run.log /selftest.log /wdev.log /soe.log * TC-SYSLOG-082 FR_INVALID_NAME 反复失败 20 轮:unlink 次数有界、/log 目录与 * 邻居文件一根毛没动(自愈只针对自己的文件) + * —— K 记录删除(「清除记录」前置,2026-10-26)—— + * TC-SYSLOG-110 请求侧只登记:零卷锁、零 FatFs、记录还在盘上(uiTask 唯一被 + * 允许做的事就是发这个请求) + * TC-SYSLOG-111 落地在 syslog_drain(defaultTask)内:恰好一次 unlink、零 open、 + * count 归零、卷锁配对、邻居文件不动、零留痕 + * TC-SYSLOG-112 删后重建可继续写:清除后下一条记录把文件建回来、打开模式带写位、 + * 64/64 写全、读侧从 idx 0 读到新内容 + * TC-SYSLOG-113 FR_NO_FILE 幂等返 0:无留痕、卷锁配对、读侧代数照常自增 + * TC-SYSLOG-114 卷占用返 -1:零盘触点、不 release(没拿到锁)、不计故障、原文件一字节不少 + * TC-SYSLOG-115 请求路径遇卷忙 = 静默顺延(WAIT 不是 FAIL、请求挂回、零留痕), + * 下一轮卷空闲自己落地(用户不必重按) + * TC-SYSLOG-116 unlink 真失败 -> -1 + 留痕一次 + 文件完好;请求侧判 FAIL 且 + * **绝不自动重试**(反复删自己的日志 = 写侧刚修掉的坑,别再造一遍) + * TC-SYSLOG-117 drain 内顺序本身也是判据:本轮排空出来的旧记录一起被清掉, + * 删除之后新投的条目仍落盘(清除不等于把日志关掉) + * TC-SYSLOG-118 同 id 重复请求合并为一次 unlink(请求是标志,不是计数器) + * TC-SYSLOG-119 只删自己:清 RUN 不碰 SELFTEST,也不碰取证文件 wdev.log + * TC-SYSLOG-120 三处入口的入参守卫都在取锁之前;坏 id 不污染合法 id 的状态 + * TC-SYSLOG-121 删除成功 -> 读侧代数自增(屏上"已清除"而窗口还装着旧记录 = 错数据); + * 删除失败 -> 代数不变、缓存记录仍可读(盘上什么都没动) + * TC-SYSLOG-122 /log 目录不在 -> FR_NO_PATH **不算**幂等成功:-1 + 一次留痕 + + * 零 f_mkdir(名字/参数类码不得升级成卷级动作);目录回来后 FAIL 不粘 * - * 覆盖率台账(--module sys_log_mcu 自量):App/log/sys_log_mcu.c 310/313 行 = - * 99.0%、函数 12/12。未覆盖 3 行:L628(下面第三条死分支)、L727~728 - * (sys_log_evidence 里 snprintf 返回 <=0 的分支——宿主 snprintf 不可能失败, - * 假件也不伪造返回值;为刷数字去伪造等于把断言写在假件规则上,宁可留空)。 + * —— L 五类记录与 quiet 投递通道(道F task-10,2026-10-26)—— + * TC-SYSLOG-130 七类记录各进各的文件、各带各的描述:**id -> 文件的表序就是语义** + * TC-SYSLOG-130b 卷里恰好七个记录文件,没有第八个拼错名字被顺手建出来的 + * TC-SYSLOG-130c 七条生产路径逐条过严格 8.3(主名<=8 / 扩展=3 / 不以点开头) + * TC-SYSLOG-131 accid 到自己那格(512)轮转,邻居 alarm 一字节没动 + * TC-SYSLOG-131b yk 用的是自己那格(256)——三档上限互不相同,能照出 s_caps[] 串格 + * TC-SYSLOG-132 quiet 通道队列满:计数前进、投递侧**零留痕**、drain 侧按差值报一次 + * TC-SYSLOG-133 清除记录 = 一次运维动作 -> 落一条操作记录点名被清的那份;被清那份仍归零 + * TC-SYSLOG-133b 清 oper.log 自己时**不投**操作记录(否则刚删空又变 1 条,砸掉归零判据) + * TC-SYSLOG-134 quiet 的入参守卫:坏 id / NULL 既不投也不计丢弃(参数错 != 容量故障) + * TC-SYSLOG-135 启动前数组满同样计数、同样静默(另一条腿,与 F 项的两个通道分区一致) + * TC-SYSLOG-136 同内容在七类之间**逐字节相同**:记录体不编码类型 = "文件即类型"的机器表达 + * + * 覆盖率台账(`scripts/check_test_coverage.py --module sys_log_mcu` 本机实跑): + * App/log/sys_log_mcu.c **403/406 行 = 99.3%、函数 22/22**。 + * 上一轮(K 族)是 377/380 行、函数 18/18 —— L 族新增 4 枚函数 + * (s_entry_fill / s_put / syslog_post_quiet / syslog_post_dropped)与 26 行 + * 可执行代码**全部**有断言跑到:覆盖行 +26、函数 +4,未覆盖仍是同一批 3 行老账 + * (一条死分支 + sys_log_evidence 里 snprintf<=0),**一条没涨**。 + * ⚠ 全量门禁(40 套并集 + 基线只增不减)由提交前那一次统一跑,本行只是自量记录。 + * L 族变异实测(2026-10-26,三处同时注入后逐字节还原): + * ① s_paths[] 里 accid 与 alarm 两格**对调**(就是"表序错"那个静默错法); + * ② 给 quiet 的丢弃分支补一条 `DBG_ERROR`(= 悄悄退化成 syslog_post); + * ③ 去掉"清 oper.log 自己不投操作记录"那半枚守卫。 + * → 137 掉到 132:**TC-SYSLOG-130 / 131**(①)、**132 / 135**(②)、 + * **133b**(③)各自转红且互不牵连——每条新纪律都被单独钉住,不是靠一锅大断言。 + * App/log/sys_log_mcu.c **377/380 行 = 99.2%、函数 18/18**。 + * 本轮(K 族)之前是 328/330 行、函数 13/13 —— 新增 5 个函数 + * (s_clear_try / s_clear_service / syslog_clear_apply / syslog_request_clear / + * syslog_clear_state)与 50 行新增可执行代码(已覆盖 +49):新增行**全部**有断言 + * 跑到,未覆盖仍是 3 行,一条没涨。 + * 仍未覆盖的 3 行是同一批老账:一条死分支 + sys_log_evidence 里 snprintf 返回 + * <=0 的分支——宿主 snprintf 不可能失败,假件也不伪造返回值;为刷数字去伪造 + * 等于把断言写在假件规则上,宁可留空。 + * 变异实测("断言有牙齿"的证据,2026-10-26,改完已逐字节还原): + * ① 在排空循环**之前**多调一次 `s_clear_service()`(净效果 = 删除抢在条目落盘之前 + * 执行,尾部那次因请求已销账而空转);② 把 `FR_NO_PATH` 也算成幂等成功。 + * 两处一起改 → TC-SYSLOG-115 / 115b / 117 / 117b / 122 **五条转红**(126 → 121), + * 还原后复绿 126/126。也就是说"删除必须排在条目排空之后落地"与"名字/参数类码 + * 不算幂等成功"这两条本来只写在注释里的纪律,现在真的会被测试拦住。 * * 本套照出的四条现状 —— 已在 task-4(2026-10-24)全部处置,相应断言已翻成正向: * @@ -172,6 +245,18 @@ * (TC-SOESTORE-022 / 022b),两侧的差异是刻意的,注释也互指。 * * 故意没测(写在这里,不留"看起来全覆盖"的错觉): + * - 真机上 uiTask 发请求与 defaultTask 落地的并发:主机单线程编不出真竞态,本套只 + * 钉"请求侧零盘触点 + 落地只在 drain 里"这两条形状;两个 volatile 单字节交接的 + * 最坏竞态(读到上一次的结果 / 失败被新请求清掉)写在 sys_log.h 的函数头里。 + * - quiet 通道与 post 通道在**同一轮**里交错时的抑制位归属(quiet 成功不复位 F 项 + * 那两个位):这是刻意的设计(那两个位描述 post 自己的历史),TC-SYSLOG-107c 已 + * 钉住两位互不干扰;"quiet 投成功把 post 的故障静默位清了"这种错向,本套没有 + * 单独一条——要钉它得先给两个通道各造一套满态时序,收益不抵这套的复杂度。 + * - 五类记录各自的**业务触发时序**(哪个元件边沿算事故、哪条 104 命令算操作)不在 + * 本套:那是判据与协议侧的事,分别由 test/host/prot_elm 的 TC-PROTELEM-015 与 + * test/host/iec104 的 TC-IEC104-030 钉;本套只管"投进来以后存储对不对"。 + * - 删除之后"下一次上电会不会多写一条 power on":那是 syslog_mcu_mounted() 的 + * 上电分类逻辑(E 族已测),与删除路径无关,两边各测各的。 * - 真跨任务并发:读窗口用"对齐 32 位读写在 ARM 上原子"免锁,主机 x64 + 单线程 * 编不出真竞态;本套只断言单写者语义与代数失效逻辑本身。 * - f_close 内部 sync_fs -> disk_ioctl(CTRL_SYNC) -> ftl_flush_cache() 的掉电落盘 @@ -218,6 +303,12 @@ static int s_failed; #define P_RUN "/log/run.log" #define P_SELFTEST "/log/selftest.log" #define P_WDEV "/log/wdev.log" +/* 五类新记录(task-10,L 族):生产用的就是这七条路径,改名必须同步这里 */ +#define P_ACCID "/log/accid.log" +#define P_ALARM "/log/alarm.log" +#define P_START "/log/start.log" +#define P_OPER "/log/oper.log" +#define P_YK "/log/yk.log" /* ===================== 假件与生产源码纳入 ===================== */ @@ -494,18 +585,28 @@ static void reset_all(void) s_queue = NULL; s_fs_ready = 0u; s_mount_fail_reported = 0u; - /* B / F 项整改新增的留痕节流位:跨场景必须显式清(否则第二个故障场景看不到留痕) */ - s_requeue_reported[SYSLOG_RUN] = 0u; - s_requeue_reported[SYSLOG_SELFTEST] = 0u; + /* B / F 项整改新增的留痕节流位:跨场景必须显式清(否则第二个故障场景看不到留痕)。 + * ⚠ 按 SYSLOG_COUNT 循环清,不要逐 id 手写——task-10 把 id 从 2 扩到 7 之后, + * 任何"逐枚点名"的清理都会静默漏掉新增的那几格,症状是"单独跑绿、连跑偶红"。 */ + (void)memset(s_requeue_reported, 0, sizeof(s_requeue_reported)); s_post_qfull_reported = 0u; s_post_pboot_full_reported = 0u; + /* quiet 投递通道(L 族):丢弃计数与 drain 侧已上报水位都要归零, + * 否则上一个场景的丢弃会让本场景第一条留痕被当成"已经报过" */ + s_quiet_drop = 0u; + s_quiet_reported = 0u; (void)memset(s_rd_win, 0, sizeof(s_rd_win)); s_rd_win_id = -1; s_rd_win_base = 0u; s_rd_win_cnt = 0u; s_rd_win_gen = 0u; - s_rd_gen[SYSLOG_RUN] = 0u; - s_rd_gen[SYSLOG_SELFTEST] = 0u; + (void)memset(s_rd_gen, 0, sizeof(s_rd_gen)); /* 同上:按数组清,扩 id 不会漏 */ + /* 「清除记录」的请求 / 结果交接(本套新增,K 族):两枚都必须显式清, + * 否则上一个场景留下的 WAIT/FAIL 会让下一个场景第一眼就读到错状态 */ + /* 「清除记录」的请求 / 结果交接(K 族):两枚按数组清,理由同 s_rd_gen—— + * 五类新记录每一类都可被清除,漏清一格就是上一场景的 WAIT/FAIL 咬下一场景。 */ + (void)memset(s_clear_req, 0, sizeof(s_clear_req)); + (void)memset(s_clear_st, 0, sizeof(s_clear_st)); /* 0 == SYSLOG_CLEAR_NONE */ /* 卷状态:/log 由 defaultTask 挂载时建好;本套多数场景直接假定已建, * "没建 -> 补建" 那条路径单独用 TC-SYSLOG-060/061 测 */ (void)ff_fake_mkdir_direct("/log"); @@ -1838,6 +1939,469 @@ static void tc_post_throttle(void) "TC-SYSLOG-107c the two channels keep independent throttle bits"); } +/* ===================== K 记录删除(「清除记录」前置) ===================== */ + +/* + * 这一族钉的是"谁在什么上下文把文件删掉",加上删除本身的四条判据: + * 计数归零(111)、文件不在时幂等返 0(113)、卷占用/真失败返 -1 且不误删原文件 + * (114/116)、删后重建可继续写(112)。 + * 为什么值得离线测:最顺手的写法是在 UI 按键回调里直接 f_unlink —— 台架上当场看不出 + * 毛病,代价是把 FAT 目录项回写挂进 uiTask 每 100 ms 一次的喂狗节拍,并与唯一写者 + * 自己的"容量满 unlink 重建"交错。请求/落地分开之后,这条边界只能靠"数请求侧的盘 + * 触点"和"数删除发生在哪一轮"钉住(110/111/117 干的就是这件事)。 + * 一处限度说清楚:本目录的卷锁假件不记录超时入参,所以"绝不阻塞"只能断言成 + * "try 失败 = 零盘触点 + 不 release + 不计故障"(114),测不到传进去的超时值到底 + * 是不是 0;要测那一层得给共用的卷锁假件加一列观测,不属本套的范围。 + */ +static void tc_clear(void) +{ + char ts[SYSLOG_TS_LEN + 1u]; + char desc[SYSLOG_DESC_MAX + 1u]; + uint8_t rec[SYSLOG_REC_SIZE]; + uint32_t i; + int ok; + + printf("-- TC-SYSLOG-110..121 record clear (request / land split) --\n"); + + /* 110 请求侧:登记就是登记 —— 一把锁都不取、一个 FatFs 调用都没有、记录还在盘上。 + * 这是"UI 只能发请求"的全部技术含义:按键回调在 uiTask,那侧不许碰存储。 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "old1"); + (void)s_append(SYSLOG_RUN, "old2"); + { + int open_base = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + int unlink_base = g_ff_obs.calls[FF_FAKE_OP_UNLINK]; + int lock_base = s_lock_waits; + int rel_base = s_lock_releases; + ok = (syslog_request_clear(SYSLOG_RUN) == 0) + && (syslog_count(SYSLOG_RUN) == 2u) + && (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_WAIT) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == open_base) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == unlink_base) + && (s_lock_waits == lock_base) + && (s_lock_releases == rel_base); + CHECK(ok, "TC-SYSLOG-110 request only books the ask: zero volume lock, zero FatFs, records still on the volume"); + } + + /* 111 落地发生在 drain(defaultTask)里:恰好一次 unlink(不 open)、计数归零、 + * 锁成对、零留痕,且邻居文件 selftest.log 一字节没动 */ + (void)s_append(SYSLOG_SELFTEST, "neighbour"); + { + int open_base = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + syslog_drain(); + ok = (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_OK) + && (syslog_count(SYSLOG_RUN) == 0u) + && (ff_fake_exists(P_RUN) == 0) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (strcmp(g_ff_obs.last_unlink_path, "/LOG/RUN.LOG") == 0) /* 观测位存的是归一化后的 8.3 大写名 */ + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == open_base) + && (s_lock_seq_err == 0) + && (g_sys_err_count == 0) + && (ff_fake_exists(P_SELFTEST) == 1) + && (ff_fake_size(P_SELFTEST) == SYSLOG_REC_SIZE); + } + CHECK(ok, "TC-SYSLOG-111 drain lands it: one unlink and zero open, count 0, lock paired, no trace, neighbour intact"); + + /* 112 判据"删后重建可继续写":文件被删之后,下一条记录必须把文件建回来并写全。 + * 陷阱一在这里才有牙齿 —— 只有"文件不存在"这一条路才走 f_open 的创建分支, + * 少了 FA_WRITE 位,平时写得好好的,清除之后从此每条静默写不进去。 */ + ok = (s_append(SYSLOG_RUN, "after-clear") == 0) + && (ff_fake_exists(P_RUN) == 1) + && (syslog_count(SYSLOG_RUN) == 1u) + && (g_ff_obs.last_open_mode == (BYTE)(FA_WRITE | FA_OPEN_APPEND)) + && (g_ff_obs.last_write_bw == SYSLOG_REC_SIZE); + CHECK(ok, "TC-SYSLOG-112 after a clear the next record recreates the file with the write bit set and lands 64/64 bytes"); + ok = ok && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) == 0) + && (strcmp(desc, "after-clear") == 0); + CHECK(ok, "TC-SYSLOG-112b the recreated file is readable from index 0 (cleared history never comes back)"); + + /* 113 幂等:文件本来就不存在 -> 返 0、只留一次 unlink、零留痕、卷锁照样配对 */ + reset_all(); + ready_only(); + { + int rel_base = s_lock_releases; + uint32_t gen = s_rd_gen[SYSLOG_RUN]; + ok = (ff_fake_exists(P_RUN) == 0) + && (syslog_clear_apply(SYSLOG_RUN) == 0) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (g_sys_err_count == 0) + && (s_lock_releases == rel_base + 1) + && (s_lock_seq_err == 0) + && (s_rd_gen[SYSLOG_RUN] == gen + 1u); + } + CHECK(ok, "TC-SYSLOG-113 FR_NO_FILE is an idempotent success: 0 back, no trace, lock released, window bumped"); + + /* 114 卷占用:公开 API 返 -1,一个盘操作都没有、不 release(没拿到锁)、不计故障、 + * 原文件一字节不少。"try 失败不许 release"是漏锁 = FTP 永久等不到卷的老账。 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "keepme"); + { + int open_base = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + int unlink_base = g_ff_obs.calls[FF_FAKE_OP_UNLINK]; + int rel_base = s_lock_releases; + lock_busy_next(1); + ok = (syslog_clear_apply(SYSLOG_RUN) == -1) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == open_base) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == unlink_base) + && (s_lock_releases == rel_base) + && (s_lock_seq_err == 0) + && (g_sys_err_count == 0) + && (ff_fake_size(P_RUN) == SYSLOG_REC_SIZE) + && (syslog_count(SYSLOG_RUN) == 1u); + } + CHECK(ok, "TC-SYSLOG-114 busy volume -> -1 with zero disk touch, no release, no fault counted, original file intact"); + + /* 115 走请求路径时卷占用 = 静默顺延,不是失败:状态仍是 WAIT、请求挂回、零留痕; + * 下一轮卷空闲后同一个请求自己落地(用户不必重按一次按钮)。 */ + reset_all(); + ready_only(); + syslog_drain(); /* 建队列,本轮空转 */ + (void)s_append(SYSLOG_RUN, "keepme"); + (void)syslog_request_clear(SYSLOG_RUN); + lock_busy_next(1); + g_sys_err_count = 0; + syslog_drain(); /* 这一轮的清除拿锁失败 */ + ok = (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_WAIT) + && (ff_fake_exists(P_RUN) == 1) + && (g_sys_err_count == 0) + && (s_lock_seq_err == 0); + CHECK(ok, "TC-SYSLOG-115 busy while landing defers: ask kept, state WAIT (not FAIL), file untouched, silent"); + syslog_drain(); /* 卷空闲 -> 落地 */ + ok = ok && (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_OK) + && (syslog_count(SYSLOG_RUN) == 0u); + CHECK(ok, "TC-SYSLOG-115b the deferred ask lands on the next round without a second press"); + + /* 116 真失败(非 FR_NO_FILE):apply 返 -1 + 留痕一次;请求侧判 FAIL 且绝不自动 + * 重试 —— 反复 unlink 自己的日志文件是本项目在写侧刚修掉的坑(名字/参数类 + * 错误码同名同因,重试只会每轮删一次历史),删除路径不许再造一遍。 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "precious"); + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_UNLINK, FR_DISK_ERR, 4); + ok = (syslog_clear_apply(SYSLOG_RUN) == -1) + && (g_sys_err_count == 1) + && (ff_fake_exists(P_RUN) == 1) + && (ff_fake_size(P_RUN) == SYSLOG_REC_SIZE); + CHECK(ok, "TC-SYSLOG-116 a real unlink error -> -1, one trace, the log file itself is still there"); + reset_all(); + ready_only(); + syslog_drain(); + (void)s_append(SYSLOG_RUN, "precious"); + (void)syslog_request_clear(SYSLOG_RUN); + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_UNLINK, FR_DISK_ERR, 4); + syslog_drain(); + { + int unlink_base = g_ff_obs.calls[FF_FAKE_OP_UNLINK]; + ok = (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_FAIL) + && (ff_fake_exists(P_RUN) == 1); + syslog_drain(); /* 不许自己再删一次 */ + ok = ok && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == unlink_base) + && (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_FAIL); + } + CHECK(ok, "TC-SYSLOG-116b a failed clear latches FAIL and is never retried on its own (re-press is the retry)"); + + /* 117 drain 内的顺序是判据的一部分:本轮排空出来的旧记录必须一起被清掉 + * (先删后写 = 白清,count 不归零);而删除**之后**投的条目要留下 + * (否则"清除记录"变成了"关掉日志")。 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); /* 建队列 */ + for (i = 0u; i < 3u; i++) { + syslog_post(SYSLOG_RUN, "old"); + } + (void)syslog_request_clear(SYSLOG_RUN); + syslog_drain(); /* 排空 3 条 -> 然后删除 */ + ok = (syslog_count(SYSLOG_RUN) == 0u) && (ff_fake_exists(P_RUN) == 0) + && (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_OK); + CHECK(ok, "TC-SYSLOG-117 the clear runs after the entry drain: the queued backlog does not survive it"); + syslog_post(SYSLOG_RUN, "new"); + syslog_drain(); + ok = ok && (syslog_count(SYSLOG_RUN) == 1u) + && (raw_at(P_RUN, 0u, rec) != 0) + && (memcmp(rec + SYSLOG_DESC_OFFS, "new", 3u) == 0); + CHECK(ok, "TC-SYSLOG-117b a record posted after the clear still lands: clearing is not disabling logging"); + + /* 118 同一 id 连点两次只登记一次(合并成一次删除,不是计数器) */ + reset_all(); + ready_only(); + ok = (syslog_request_clear(SYSLOG_RUN) == 0) && (syslog_request_clear(SYSLOG_RUN) == 0) + && (s_clear_req[SYSLOG_RUN] == 1u); + syslog_drain(); + ok = ok && (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_OK) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (s_clear_req[SYSLOG_RUN] == 0u); + CHECK(ok, "TC-SYSLOG-118 duplicate asks merge into exactly one unlink (the ask is a flag, not a counter)"); + + /* 119 只删自己:清 RUN 不许碰 SELFTEST,也不许碰取证文件 wdev.log —— + * 三个名字都是严格 8.3 短名,逐个对应,不存在"通配删除"。 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "r"); + (void)s_append(SYSLOG_SELFTEST, "s"); + (void)ff_fake_mkfile_size(P_WDEV, 128u); + ok = (syslog_clear_apply(SYSLOG_RUN) == 0) + && (ff_fake_exists(P_RUN) == 0) + && (ff_fake_exists(P_SELFTEST) == 1) + && (ff_fake_exists(P_WDEV) == 1) + && (strcmp(g_ff_obs.last_unlink_path, "/LOG/RUN.LOG") == 0); + CHECK(ok, "TC-SYSLOG-119 clearing one id never touches the other log or the watchdog evidence file"); + + /* 120 入参守卫:三处公开入口都必须在取锁之前挡住,并且不污染合法 id 的状态 */ + reset_all(); + ready_only(); + ok = (syslog_request_clear((syslog_id_t)SYSLOG_COUNT) == -1) + && (syslog_clear_apply((syslog_id_t)SYSLOG_COUNT) == -1) + && (syslog_clear_state((syslog_id_t)99u) == SYSLOG_CLEAR_NONE) + && (s_lock_waits == 0) + && (s_lock_releases == 0) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0); + CHECK(ok, "TC-SYSLOG-120 out-of-range id is refused before the lock and the disk (a guard behind the lock occupies the volume for nothing)"); + reset_all(); + ready_only(); + ok = (syslog_request_clear(SYSLOG_RUN) == 0) + && (syslog_request_clear((syslog_id_t)SYSLOG_COUNT) == -1) + && (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_WAIT) + && (syslog_clear_state((syslog_id_t)SYSLOG_COUNT) == SYSLOG_CLEAR_NONE); + CHECK(ok, "TC-SYSLOG-120b a rejected request leaves a valid id's pending ask and state untouched"); + + /* 121 读侧:删除成功必须让读者的 8 条窗口作废(否则屏上显示"已清除"而记录还在); + * 失败路径不许自增 —— 盘上什么都没动,窗口里仍是真数据。 */ + reset_all(); + ready_only(); + for (i = 0u; i < 3u; i++) { + (void)s_append(SYSLOG_RUN, "w"); + } + { + uint32_t gen = 0u; + ok = (syslog_get_record(SYSLOG_RUN, 2u, ts, sizeof(ts), desc, sizeof(desc)) == 0); + gen = s_rd_gen[SYSLOG_RUN]; + ok = ok && (syslog_clear_apply(SYSLOG_RUN) == 0) + && (s_rd_gen[SYSLOG_RUN] == gen + 1u) + && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) == -1); + } + CHECK(ok, "TC-SYSLOG-121 a successful clear invalidates the reader window (no records left to show)"); + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "keep"); + ok = (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) == 0); + { + uint32_t gen = s_rd_gen[SYSLOG_RUN]; + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_UNLINK, FR_DISK_ERR, 1); + ok = ok && (syslog_clear_apply(SYSLOG_RUN) == -1) + && (s_rd_gen[SYSLOG_RUN] == gen) + && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) == 0) + && (strcmp(desc, "keep") == 0); + } + CHECK(ok, "TC-SYSLOG-121b a failed clear neither bumps the generation nor loses the cached valid record"); + + /* 122 /log 目录本身不在:假件与真实 ff.c 一样落 FR_NO_PATH。这条刻意**不算**幂等 + * 成功(记录读不到是真的,但成因是目录没了而不是"被清空",把卷异常洗成 + * "清除成功"是另一种撒谎),而且本函数一个卷级动作都不做:不补目录、 + * 不重开、不碰别的对象——补目录归 syslog_mcu_mounted() 每 2s 的重试。 */ + reset_all(); + ready_only(); + ok = (f_unlink("/log") == FR_OK) && (ff_fake_exists("/log") == 0); + { + int mkdir_base = g_ff_obs.calls[FF_FAKE_OP_MKDIR]; + ok = ok && (syslog_clear_apply(SYSLOG_RUN) == -1) + && (g_ff_obs.calls[FF_FAKE_OP_MKDIR] == mkdir_base) + && (g_ff_obs.calls[FF_FAKE_OP_MKDIR] == 0) + && (ff_fake_exists("/log") == 0) + && (g_sys_err_count == 1); + } + CHECK(ok, "TC-SYSLOG-122 FR_NO_PATH is not counted as an idempotent success: -1, one trace, and zero f_mkdir (no volume-level action on a name/param code)"); + (void)ff_fake_mkdir_direct("/log"); /* 目录回来了(模拟挂载门补建成功) */ + ok = (syslog_request_clear(SYSLOG_RUN) == 0) + && (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_WAIT); + syslog_drain(); + ok = ok && (syslog_clear_state(SYSLOG_RUN) == SYSLOG_CLEAR_OK) + && (syslog_count(SYSLOG_RUN) == 0u); + CHECK(ok, "TC-SYSLOG-122b FAIL is not sticky: once the directory is back a new request clears normally"); +} + +/* ===================== L 五类记录与 quiet 投递通道(task-10) ===================== */ + +/* 七类记录一枚描述、一枚路径:描述自带类别号,用来证明"id -> 文件"的对应关系 + * 与两端表序一致(表序错 = 事故安静地写进告警文件,屏上看不出任何毛病)。 */ +static const char *const s_kind_desc[SYSLOG_COUNT] = { + "k0run", "k1selftest", "k2accid", "k3alarm", "k4start", "k5oper", "k6yk" +}; +static const char *const s_kind_path[SYSLOG_COUNT] = { + P_RUN, P_SELFTEST, P_ACCID, P_ALARM, P_START, P_OPER, P_YK +}; + +static void tc_kinds(void) +{ + uint8_t raw[SYSLOG_REC_SIZE]; + int ok; + uint32_t i; + int base_entries; + + printf("-- TC-SYSLOG-130..136 five record kinds + quiet posting channel --\n"); + + /* 130 一类一个文件:每类只进自己那份,且没有第八个文件被顺手建出来 */ + reset_all(); + ready_only(); + base_entries = ff_fake_count_entries(); + for (i = 0u; i < (uint32_t)SYSLOG_COUNT; i++) { + (void)s_append((syslog_id_t)i, s_kind_desc[i]); + } + ok = 1; + for (i = 0u; i < (uint32_t)SYSLOG_COUNT; i++) { + if (ff_fake_size(s_kind_path[i]) != SYSLOG_REC_SIZE) { ok = 0; break; } + if (!raw_at(s_kind_path[i], 0u, raw)) { ok = 0; break; } + if (memcmp(raw + SYSLOG_DESC_OFFS, s_kind_desc[i], strlen(s_kind_desc[i])) != 0) { ok = 0; break; } + if (raw[SYSLOG_DESC_OFFS + strlen(s_kind_desc[i])] != 0u) { ok = 0; break; } + } + CHECK(ok, "TC-SYSLOG-130 each kind lands in its own file and carries its own description (id-to-path table order is semantics)"); + CHECK(ff_fake_count_entries() == (base_entries + (int)SYSLOG_COUNT), + "TC-SYSLOG-130b exactly seven record files exist, none created under a mistyped 8.3 name"); + + /* 130c 五类新路径都过严格 8.3(081 只覆盖老三类 + wdev;扩 id 必须一起扩这条) */ + ok = 1; + for (i = 0u; i < (uint32_t)SYSLOG_COUNT; i++) { + /* 上面已经真打开写成功过;这里再核一次名字本身:主名 <=8、扩展 = log、 + * 不以点开头、不含非法字符 */ + const char *p = s_kind_path[i]; + const char *slash = strrchr(p, '/'); + size_t body = 0u, ext = 0u; + const char *q; + + if ((slash == NULL) || (slash[1] == '.')) { ok = 0; break; } + for (q = slash + 1; (*q != '\0') && (*q != '.'); q++) { body++; } + if (*q == '.') { for (q++; *q != '\0'; q++) { ext++; } } + if ((body == 0u) || (body > 8u) || (ext != 3u)) { ok = 0; break; } + } + CHECK(ok, "TC-SYSLOG-130c all seven production paths are strict 8.3 short names (FF_USE_LFN=0)"); + + /* 131 各用各的容量上限:accid 到自己那格(512)轮转,且不动邻居 */ + reset_all(); + ready_only(); + (void)ff_fake_mkfile_size(P_ACCID, (SYSLOG_ACCIDENT_MAX - 1u) * SYSLOG_REC_SIZE); + (void)ff_fake_mkfile_size(P_ALARM, (SYSLOG_ALARM_MAX - 1u) * SYSLOG_REC_SIZE); + (void)put_rec(SYSLOG_ACCIDENT, "cap-a1"); + ok = (syslog_count(SYSLOG_ACCIDENT) == SYSLOG_ACCIDENT_MAX) + && (ff_fake_size(P_ACCID) == (SYSLOG_ACCIDENT_MAX * SYSLOG_REC_SIZE)); + (void)put_rec(SYSLOG_ACCIDENT, "cap-a2"); /* 到顶 -> 删除重建,只剩本次一条 */ + ok = ok && (syslog_count(SYSLOG_ACCIDENT) == 1u) + && (ff_fake_size(P_ACCID) == SYSLOG_REC_SIZE); + CHECK(ok && (ff_fake_size(P_ALARM) == ((SYSLOG_ALARM_MAX - 1u) * SYSLOG_REC_SIZE)), + "TC-SYSLOG-131 the accident log rotates on its own cap and leaves the alarm log alone"); + + /* 131b yk 用的是自己那格(256),不是抄来的 512/1024 —— 三档上限互不相同, + * 所以这一条能区分出"s_caps[] 串格"这种安静错误 */ + reset_all(); + ready_only(); + (void)ff_fake_mkfile_size(P_YK, (SYSLOG_YK_MAX - 1u) * SYSLOG_REC_SIZE); + (void)put_rec(SYSLOG_YK, "cap-y1"); + ok = (syslog_count(SYSLOG_YK) == SYSLOG_YK_MAX); + (void)put_rec(SYSLOG_YK, "cap-y2"); + ok = ok && (syslog_count(SYSLOG_YK) == 1u) && (ff_fake_size(P_YK) == SYSLOG_REC_SIZE); + CHECK(ok, "TC-SYSLOG-131b the telecontrol log rotates on its own 256-record cap (per-id caps are not shared)"); + + /* 132 quiet 通道(队列侧):丢弃要计数,但投递侧一条日志都不许打 + * ——这正是它存在的唯一理由,退化成 syslog_post 的别名必须被照出来 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); /* 建队列 */ + g_sys_err_count = 0; + for (i = 0u; i < (SYSLOG_QUEUE_MAX + 4u); i++) { + syslog_post_quiet(SYSLOG_ACCIDENT, "q"); + } + ok = (s_q_cnt == SYSLOG_QUEUE_MAX) && (syslog_post_dropped() == 4u) + && (g_sys_err_count == 0); + syslog_drain(); /* 落盘 + drain 侧按差值补一条 */ + ok = ok && (syslog_count(SYSLOG_ACCIDENT) == SYSLOG_QUEUE_MAX) + && (g_sys_err_count == 1) && (syslog_post_dropped() == 4u); + syslog_drain(); /* 计数没前进 -> 不得重复报 */ + CHECK(ok && (g_sys_err_count == 1), + "TC-SYSLOG-132 quiet drops are counted and reported once from the writer side, never at the posting site"); + + /* 133 清除记录 = 一次真实运维动作 -> 落操作记录;且不影响被清那份的归零判据 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + syslog_post(SYSLOG_RUN, "before clear"); + syslog_drain(); + ok = (syslog_count(SYSLOG_RUN) == 1u); + ok = ok && (syslog_request_clear(SYSLOG_RUN) == 0); + syslog_drain(); /* 删除落地 + 投递那条操作记录 */ + ok = ok && (syslog_count(SYSLOG_RUN) == 0u) + && (syslog_clear_state(SYSLOG_RUN) == (int)SYSLOG_CLEAR_OK); + syslog_drain(); /* 操作记录这才落盘 */ + ok = ok && (syslog_count(SYSLOG_OPER) == 1u) && raw_at(P_OPER, 0u, raw) + && (memcmp(raw + SYSLOG_DESC_OFFS, "clear run.log", 13u) == 0); + CHECK(ok, "TC-SYSLOG-133 clearing a log writes one operation record naming the file, while the cleared log stays at zero"); + + /* 133b 反过来:清 oper.log 自己时不得投递操作记录,否则"清除成功"这一条 + * 会把刚删空的文件又写成 1 条,直接砸掉 task-4 的「count 归零」判据 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + syslog_post(SYSLOG_OPER, "an operation"); + syslog_drain(); + ok = (syslog_count(SYSLOG_OPER) == 1u); + ok = ok && (syslog_request_clear(SYSLOG_OPER) == 0); + syslog_drain(); + ok = ok && (syslog_clear_state(SYSLOG_OPER) == (int)SYSLOG_CLEAR_OK) + && (syslog_count(SYSLOG_OPER) == 0u); + syslog_drain(); /* 再排一轮:真投了自我记录,这轮就写回去 */ + CHECK(ok && (syslog_count(SYSLOG_OPER) == 0u) && (syslog_post_dropped() == 0u), + "TC-SYSLOG-133b clearing the operation log itself posts nothing, so the zeroed-count criterion survives"); + + /* 134 守卫:越界 id / NULL 描述在 quiet 通道上既不投递也不计丢弃 + * ("被守卫挡下"与"满了被丢掉"是两件事,混起来就等于把参数错报成容量故障) */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + g_sys_err_count = 0; + syslog_post_quiet((syslog_id_t)SYSLOG_COUNT, "bad-id"); + syslog_post_quiet(SYSLOG_RUN, NULL); + ok = (syslog_post_dropped() == 0u) && (s_q_cnt == 0u) && (s_preboot_cnt == 0u) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) && (g_sys_err_count == 0); + CHECK(ok, "TC-SYSLOG-134 quiet channel guards bad id and NULL desc without counting them as drops and without touching the volume"); + + /* 135 启动前数组侧同样计数、同样静默(两个通道各自独立,与 F 项那两个抑制位 + * 的分区方式一致;漏掉这一侧就会"运行期有账、启动期没账") */ + reset_all(); + ready_only(); /* 内核未跑 -> 走 s_preboot */ + g_sys_err_count = 0; + for (i = 0u; i < (SYSLOG_QUEUE_MAX + 3u); i++) { + syslog_post_quiet(SYSLOG_START, "p"); + } + ok = (s_preboot_cnt == SYSLOG_QUEUE_MAX) && (syslog_post_dropped() == 3u) + && (g_sys_err_count == 0); + s_kernel_state = osKernelRunning; + syslog_drain(); + CHECK(ok && (syslog_count(SYSLOG_START) == SYSLOG_QUEUE_MAX) && (g_sys_err_count == 1), + "TC-SYSLOG-135 pre-boot array overflow is counted and traced from the writer side too (8 slots kept, 3 lost)"); + + /* 136 五类记录与老两类**逐字节同构**:同一枚 s_format_rec,记录体里没有类型字段 + * ——这就是"文件即类型"这条决策的机器表达(task-11 页面因此只需一套解析) */ + reset_all(); + { + uint8_t a[SYSLOG_REC_SIZE]; + uint8_t b[SYSLOG_REC_SIZE]; + s_now = 1760234567u; + s_clock_ok = 1; + s_format_rec(SYSLOG_RUN, "same text", a); + s_format_rec(SYSLOG_YK, "same text", b); + ok = (memcmp(a, b, SYSLOG_REC_SIZE) == 0); + s_format_rec(SYSLOG_ACCIDENT, "same text", b); + ok = ok && (memcmp(a, b, SYSLOG_REC_SIZE) == 0); + s_format_rec(SYSLOG_OPER, "same text", b); + ok = ok && (memcmp(a, b, SYSLOG_REC_SIZE) == 0); + } + CHECK(ok, "TC-SYSLOG-136 a record body does not encode its kind: same content is byte-identical across all seven kinds"); +} + /* ===================== 入口 ===================== */ int main(void) @@ -1855,6 +2419,8 @@ int main(void) tc_extra_branches(); tc_selfheal_codes(); tc_post_throttle(); + tc_clear(); + tc_kinds(); printf("\n=== Sys Log Mcu Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); diff --git a/test/host/ui_data/ui_data_test.c b/test/host/ui_data/ui_data_test.c index a1d74d70..98108e94 100644 --- a/test/host/ui_data/ui_data_test.c +++ b/test/host/ui_data/ui_data_test.c @@ -41,6 +41,25 @@ * PE_COUNT == 表长);元件名不超名列宽、非元件/越界回占位 * TC-UIDATA-014 注入 reader 后的两行渲染:时标行/内容行分工、单条读盘缓存 * (同记录两行只读一次)、IV 时标抑制、读失败退化与不绑空态 + * ── 以下 6 条 = 定值写路径(道F task-1,docs/任务规划/UI补齐.md §2 第 1 行)── + * TC-UIDATA-015 队列深度 4:四格都进得去、第五格满返 -1 且状态转 ERR_BUSY; + * FIFO 顺序(最后投的一条最后落定)、空队列 drain 返 0、 + * drain(NULL) 返 -1 且不动队列、投递成功即转 WAIT + * TC-UIDATA-016 请求表示法:NULL/带非法位 mask 返 -2 且状态与队列都不动; + * mask=0 空请求返 0 且直接落 OK 不占槽;请求尺寸与队列 .bss + * 预算钉死(168 B/格 × 4 格)——加深或加宽必须先过这条 + * TC-UIDATA-017 正常往返:请求 → WAIT → drain → OK → 新值可读;**只提交 mask + * 内的 id**(未提交的槽一位都不动,这是"从显示值回提全表会 + * 顺手改掉返回系数"那条坑的机器防线) + * TC-UIDATA-018 越界不改值:整批预检任一项越界 → ERR_RANGE 且**一字不写** + * (含同批里合法的那几项)+ err_mask 指认越界项 + 下一次成功 + * 落定后 err_mask 自动清零 + * TC-UIDATA-019 闭锁拒写:locked 置位 → ERR_LOCKED、丢弃本条(不排队等解锁)、 + * 值区一字不动;解闭锁后重新提交可成功 + * TC-UIDATA-020 apply 中途失败(预检与执行不一致的防御分支):如实 ERR_RANGE, + * 已生效的前几项不回滚(prot_set 无回滚接口,谎称整批未写更坏) + * TC-UIDATA-021 v2 帧扩展:UI_DATA_VER == 2、既有字段偏移逐字节见证(只允许尾 + * 追加)、cal_block 两种态(未标定/已标定)整帧往返 * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 @@ -49,6 +68,7 @@ */ #include +#include /* offsetof:v2 帧布局见证(只许尾追加) */ #include /* 生产源文件原样纳入(纯内存叶子,零平台依赖,直接编) */ @@ -521,6 +541,351 @@ static void tc_soe_render(void) ui_w_soe_bind(NULL); } +/* ====== TC-UIDATA-015~020:定值写路径(队列 + 状态机 + 注入执行侧) ====== */ + +/* + * 假执行侧(替掉 App/algo/prot_set):一张 40 项值区 + 一张上下限表。 + * ⚠ 本单测不链 App 侧任何 .c(prot_set 依赖 Flash/校验和编排,且写路径的**契约** + * 本来就只是"三个函数的返回值"),所以测的是 ui_data.c 里那份真实的队列 + + * 状态机代码:越界不改值、闭锁拒写、只提交改动 id 三条判据都落在生产代码上, + * 不是测试里的副本。真实 App 实现的等价性由 ui_data_feed.c 的三个包函数 + * (feed_param_locked/check/apply)在代码审查与台架侧保证。 + */ +#define FAKE_LO_ALL (-100000) /* 默认宽松下限:单条 TC 里再逐行收紧 */ +#define FAKE_HI_ALL (100000) + +static int32_t s_fake_val[UI_DATA_SET_N]; +static int32_t s_fake_lo[UI_DATA_SET_N]; +static int32_t s_fake_hi[UI_DATA_SET_N]; +static uint8_t s_fake_locked; +static uint8_t s_fake_poison; /* 0xFF = 不投毒;否则该 id 的 apply 必失败 */ +static uint32_t s_fake_apply_calls; + +static uint8_t fake_locked(void) +{ + return s_fake_locked; +} + +static int fake_check(uint8_t a_id, int32_t a_q10) +{ + if (a_id >= (uint8_t)UI_DATA_SET_N) { + return -1; + } + if ((a_q10 < s_fake_lo[a_id]) || (a_q10 > s_fake_hi[a_id])) { + return -1; + } + return 0; +} + +static int fake_apply(uint8_t a_id, int32_t a_q10) +{ + if (fake_check(a_id, a_q10) != 0) { + return -1; + } + if ((s_fake_poison != 0xFFu) && (a_id == s_fake_poison)) { + return -1; /* 投毒:模拟"预检过了但执行侧仍拒"(理论不可达的防御分支) */ + } + s_fake_val[a_id] = a_q10; + s_fake_apply_calls++; + return 0; +} + +static const ui_data_param_ops_t s_fake_ops = { fake_locked, fake_check, fake_apply }; + +/* 每个 TC 开头调一次:值区填可辨识的旧值 + 快照与队列复位 */ +static void fake_reset(void) +{ + uint8_t i; + + for (i = 0u; i < (uint8_t)UI_DATA_SET_N; i++) { + s_fake_val[i] = (int32_t)i * 1000; + s_fake_lo[i] = FAKE_LO_ALL; + s_fake_hi[i] = FAKE_HI_ALL; + } + s_fake_locked = 0u; + s_fake_poison = 0xFFu; + s_fake_apply_calls = 0u; + ui_data_init(); +} + +/* 投一条只改若干项的请求;未列出的 id 不进 mask(= 本次不提交) */ +static int fake_submit(const uint8_t *a_ids, const int32_t *a_vals, uint8_t a_n) +{ + ui_data_param_req_t req; + uint8_t i; + + (void)memset(&req, 0, sizeof(req)); + for (i = 0u; i < a_n; i++) { + req.mask |= ((uint64_t)1u << a_ids[i]); + req.q10[a_ids[i]] = a_vals[i]; + } + return ui_data_param_request(&req); +} + +static int32_t fake_val(uint8_t a_id) +{ + return s_fake_val[a_id]; +} + +static void tc_param_queue(void) +{ + ui_data_param_req_t req; + uint8_t k; + int accepted = 1; + int was_wait = 1; + + fake_reset(); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_IDLE, + "TC-UIDATA-015a state IDLE after init (never submitted)"); + CHECK(ui_data_param_drain(&s_fake_ops) == 0, "TC-UIDATA-015b drain on empty queue == 0"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_IDLE, + "TC-UIDATA-015c empty drain leaves state alone (not WAIT)"); + CHECK(ui_data_param_err_mask() == 0u, "TC-UIDATA-015d err_mask clean at idle"); + + /* 四格全进得去;每投一条状态就该是 WAIT */ + (void)memset(&req, 0, sizeof(req)); + req.mask = 1u; /* 只改 id0 */ + for (k = 0u; k < UI_DATA_PARAM_QD; k++) { + req.q10[0] = (int32_t)(1000 + (int32_t)k); + if (ui_data_param_request(&req) != 0) { + accepted = 0; + } + if (ui_data_param_state() != UI_DATA_PARAM_ST_WAIT) { + was_wait = 0; + } + } + CHECK(UI_DATA_PARAM_QD == 4u, "TC-UIDATA-015e queue depth is 4 (contract witness)"); + CHECK(accepted == 1, "TC-UIDATA-015f four requests all accepted"); + CHECK(was_wait == 1, "TC-UIDATA-015g state WAIT while queued"); + + /* 第五格:满 → 返 -1 + 状态 ERR_BUSY(在队四条一条不掉) */ + req.q10[0] = 9999; + CHECK(ui_data_param_request(&req) == -1, "TC-UIDATA-015h fifth request rejected (-1)"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_ERR_BUSY, + "TC-UIDATA-015i rejected request surfaces ERR_BUSY"); + + /* FIFO:前三条落定后仍是 WAIT,第四条才落终态;值 = 最后投的那条(1003) */ + CHECK(ui_data_param_drain(&s_fake_ops) == 1, "TC-UIDATA-015j drain processes one"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_WAIT, + "TC-UIDATA-015k still WAIT with 3 requests left in queue"); + (void)ui_data_param_drain(&s_fake_ops); + (void)ui_data_param_drain(&s_fake_ops); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_WAIT, + "TC-UIDATA-015l WAIT until the last queued request settles"); + CHECK(ui_data_param_drain(&s_fake_ops) == 1, "TC-UIDATA-015m fourth request drained"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_OK, "TC-UIDATA-015n terminal OK when drained"); + CHECK((fake_val(0) == 1003) && (s_fake_apply_calls == 4u), + "TC-UIDATA-015o FIFO: last queued value lands, all four applied"); + CHECK(ui_data_param_drain(&s_fake_ops) == 0, "TC-UIDATA-015p queue empty again"); + + /* ops 无效:不动队列也不动状态 */ + fake_reset(); + CHECK(fake_submit(NULL, NULL, 0u) == 0, "TC-UIDATA-015q empty request accepted (see 016)"); + CHECK(ui_data_param_drain(NULL) == -1, "TC-UIDATA-015r drain(NULL) == -1"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_OK, + "TC-UIDATA-015s failed drain does not rewrite state"); + CHECK(ui_data_param_drain(&s_fake_ops) == 0, "TC-UIDATA-015t failed drain does not consume"); +} + +static void tc_param_request_shape(void) +{ + ui_data_param_req_t req; + + fake_reset(); + CHECK(ui_data_param_request(NULL) == -2, "TC-UIDATA-016a request(NULL) == -2"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_IDLE, + "TC-UIDATA-016b invalid request leaves state IDLE (page must not show ERR for a bug)"); + + (void)memset(&req, 0, sizeof(req)); + req.mask = ((uint64_t)1u << 63u); /* 第 63 项不存在 */ + CHECK(ui_data_param_request(&req) == -2, "TC-UIDATA-016c mask with bit63 == -2"); + req.mask = ((uint64_t)1u << (UI_DATA_SET_N - 1u)); /* 第 39 项 = 最后一项,合法 */ + CHECK(ui_data_param_request(&req) == 0, "TC-UIDATA-016d last legal bit accepted"); + CHECK(ui_data_param_drain(&s_fake_ops) == 1, "TC-UIDATA-016e drained last-bit request"); + CHECK(s_fake_apply_calls == 1u, "TC-UIDATA-016f item 39 really reached the executor"); + + /* 空请求:不占槽、直接 OK("没改东西却按了保存"是正常操作) */ + fake_reset(); + req.mask = 0u; + CHECK(ui_data_param_request(&req) == 0, "TC-UIDATA-016g empty request returns 0"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_OK, "TC-UIDATA-016h empty request settles OK"); + CHECK(ui_data_param_drain(&s_fake_ops) == 0, "TC-UIDATA-016i empty request consumed no slot"); + + /* 内存预算护栏:请求是 .bss 里的定长槽,168 B/格 × 4 格 = 672 B + * (台账 docs/内存管理/RAM使用分析与优化方案.md;扩深度/加宽必须先过这条) */ + CHECK(sizeof(ui_data_param_req_t) == 168u, "TC-UIDATA-016j request is 168 bytes"); + CHECK(sizeof(s_pq) == (672u), "TC-UIDATA-016k queue .bss is 4 x 168 = 672 bytes"); + CHECK(sizeof(s_pq) <= 768u, "TC-UIDATA-016l queue .bss within budget (<=768B)"); + CHECK((uint32_t)UI_DATA_SET_N <= 64u, "TC-UIDATA-016m item count fits the 64-bit mask"); +} + +static void tc_param_roundtrip(void) +{ + const uint8_t ids[3] = { 2u, 3u, 7u }; + const int32_t vals[3] = { 12345, 60000, 7 }; + + fake_reset(); + CHECK(fake_submit(ids, vals, 3u) == 0, "TC-UIDATA-017a batch of three submitted"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_WAIT, "TC-UIDATA-017b WAIT before drain"); + CHECK(fake_val(2) == 2000, "TC-UIDATA-017c old value still in effect before drain"); + CHECK(ui_data_param_drain(&s_fake_ops) == 1, "TC-UIDATA-017d drained one request"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_OK, "TC-UIDATA-017e OK after drain"); + CHECK((fake_val(2) == 12345) && (fake_val(3) == 60000) && (fake_val(7) == 7), + "TC-UIDATA-017f new values readable after roundtrip"); + /* 只提交改动项:mask 外的槽一位都不动(Q10 往返精度坑的机器防线) */ + CHECK((fake_val(0) == 0) && (fake_val(1) == 1000) && (fake_val(4) == 4000) + && (fake_val(6) == 6000) && (fake_val(8) == 8000) + && (fake_val(UI_DATA_SET_N - 1u) == (int32_t)(UI_DATA_SET_N - 1u) * 1000), + "TC-UIDATA-017g untouched items are byte-identical (mask-only commit)"); + CHECK(s_fake_apply_calls == 3u, "TC-UIDATA-017h exactly the masked items were applied"); + + /* 回环的后半段:App 落值后重发参数帧 → 页面按新 seq 读到新值 */ + { + ui_data_par_t frame; + ui_data_par_t got; + uint8_t i; + + (void)memset(&frame, 0, sizeof(frame)); + frame.valid = 1u; + frame.checksum = 0x1234u; + frame.cal_block = 0u; + for (i = 0u; i < (uint8_t)UI_DATA_SET_N; i++) { + frame.q10[i] = s_fake_val[i]; + } + CHECK(ui_data_publish_par(&frame) == 0, "TC-UIDATA-017i par frame republished"); + (void)memset(&got, 0, sizeof(got)); + CHECK((ui_data_par(&got) == 0) && (got.q10[2] == 12345) && (got.q10[7] == 7), + "TC-UIDATA-017j page reads back the committed values"); + } +} + +static void tc_param_range_atomic(void) +{ + /* 非 const:本 TC 复用同一组数组跑三种越界组合(改了再投) */ + uint8_t ids[2] = { 2u, 5u }; + int32_t vals[2] = { 12345, 99999 }; + + fake_reset(); + s_fake_hi[5] = 100; /* 第 5 项上限 100 → 99999 越界;第 2 项本身合法 */ + CHECK(fake_submit(ids, vals, 2u) == 0, "TC-UIDATA-018a out-of-range batch accepted for queue"); + CHECK(ui_data_param_drain(&s_fake_ops) == 1, "TC-UIDATA-018b drained"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_ERR_RANGE, "TC-UIDATA-018c state ERR_RANGE"); + CHECK(ui_data_param_err_mask() == ((uint64_t)1u << 5u), + "TC-UIDATA-018d err_mask points at the offending item"); + CHECK((fake_val(2) == 2000) && (fake_val(5) == 5000), + "TC-UIDATA-018e all-or-nothing: the legal item in the same batch NOT written"); + CHECK(s_fake_apply_calls == 0u, "TC-UIDATA-018f precheck blocks every apply call"); + + /* 多项同时越界:err_mask 全给(页面逐行反显,不是只报第一行) */ + fake_reset(); + s_fake_lo[6] = 500; + s_fake_hi[5] = 100; + ids[0] = 5u; + ids[1] = 6u; + vals[0] = 99999; /* 超上限 */ + vals[1] = 60; /* 低于下限 */ + CHECK(fake_submit(ids, vals, 2u) == 0, "TC-UIDATA-018g two-item bad batch queued"); + (void)ui_data_param_drain(&s_fake_ops); + CHECK(ui_data_param_err_mask() == (((uint64_t)1u << 5u) | ((uint64_t)1u << 6u)), + "TC-UIDATA-018h err_mask lists every offending item (both bounds)"); + + /* 下一次成功落定 → err_mask 自动清零(否则页面把上次越界显示成这次) */ + ids[0] = 2u; + vals[0] = 12345; + CHECK(fake_submit(ids, vals, 1u) == 0, "TC-UIDATA-018i good request queued"); + (void)ui_data_param_drain(&s_fake_ops); + CHECK((ui_data_param_state() == UI_DATA_PARAM_ST_OK) && (ui_data_param_err_mask() == 0u), + "TC-UIDATA-018j err_mask cleared on next successful commit"); +} + +static void tc_param_locked(void) +{ + const uint8_t ids[1] = { 2u }; + const int32_t vals[1] = { 12345 }; + + fake_reset(); + s_fake_locked = 1u; + CHECK(fake_submit(ids, vals, 1u) == 0, "TC-UIDATA-019a request queued while locked"); + CHECK(ui_data_param_drain(&s_fake_ops) == 1, "TC-UIDATA-019b drained under lock"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_ERR_LOCKED, "TC-UIDATA-019c state ERR_LOCKED"); + CHECK((fake_val(2) == 2000) && (s_fake_apply_calls == 0u), + "TC-UIDATA-019d locked commit writes nothing (no apply call)"); + CHECK(ui_data_param_err_mask() == 0u, "TC-UIDATA-019e lock failure is not a range failure"); + CHECK(ui_data_param_drain(&s_fake_ops) == 0, + "TC-UIDATA-019f locked request is dropped, not queued waiting for unlock"); + + /* 解闭锁后重新提交 → OK(闭锁丢弃的是那一条,不是永久拉黑写路径) */ + s_fake_locked = 0u; + CHECK(fake_submit(ids, vals, 1u) == 0, "TC-UIDATA-019g resubmit after unlock accepted"); + (void)ui_data_param_drain(&s_fake_ops); + CHECK((ui_data_param_state() == UI_DATA_PARAM_ST_OK) && (fake_val(2) == 12345), + "TC-UIDATA-019h write path healthy again after unlock"); +} + +static void tc_param_apply_fail(void) +{ + const uint8_t ids[2] = { 3u, 7u }; + const int32_t vals[2] = { 33333, 77777 }; + + fake_reset(); + s_fake_poison = 7u; /* 预检全过、执行侧仍拒第 7 项(两真源不一致的防御分支) */ + CHECK(fake_submit(ids, vals, 2u) == 0, "TC-UIDATA-020a request with poisoned item queued"); + CHECK(ui_data_param_drain(&s_fake_ops) == 1, "TC-UIDATA-020b drained"); + CHECK(ui_data_param_state() == UI_DATA_PARAM_ST_ERR_RANGE, + "TC-UIDATA-020c apply failure reported as ERR_RANGE (not silent OK)"); + CHECK(ui_data_param_err_mask() == ((uint64_t)1u << 7u), "TC-UIDATA-020d failing item named"); + CHECK(fake_val(3) == 33333, "TC-UIDATA-020e earlier item already applied is NOT rolled back"); + CHECK(fake_val(7) == 7000, "TC-UIDATA-020f failing item itself unchanged"); +} + +static void tc_param_frame_v2(void) +{ + ui_data_par_t src; + ui_data_par_t got; + + /* 版本号是页面与写侧唯一对齐凭据;v2 = 尾追加 cal_block */ + CHECK(UI_DATA_VER == 2u, "TC-UIDATA-021a contract version is 2"); + /* v1 布局逐字节见证:字段只允许尾追加,任何插入/重排都会让下面四条红 + * (页面按名字取字段,错位是静默的——只有把偏移钉死才照得出来) */ + CHECK(offsetof(ui_data_par_t, seq) == 0u, "TC-UIDATA-021b seq offset unchanged"); + CHECK(offsetof(ui_data_par_t, bad_mask) == 16u, "TC-UIDATA-021c bad_mask offset unchanged"); + CHECK(offsetof(ui_data_par_t, q10) == 24u, "TC-UIDATA-021d q10 offset unchanged"); + CHECK(offsetof(ui_data_par_t, cal_block) == 184u, + "TC-UIDATA-021e cal_block is the appended tail field (offset 184)"); + CHECK(sizeof(ui_data_par_t) == 192u, "TC-UIDATA-021f par frame is 192 B (+8 for v2)"); + /* 写侧组装缓冲是 rt/par/info 三帧共用的 union(App/app/ui_data_feed.c 的 s_scr): + * rt 恒大于 par,所以给 par 追加字段**不会**连带抬升那块 .bss——加字段之前 + * 先确认这条,否则每次尾追加都悄悄多啃一份 RAM(台账只记总量,看不出是谁长的) */ + CHECK(sizeof(ui_data_rt_t) == 208u, "TC-UIDATA-021g rt frame is 208 B (union stays put)"); + CHECK(sizeof(ui_data_par_t) < sizeof(ui_data_rt_t), + "TC-UIDATA-021h par still smaller than rt (shared scratch unchanged)"); + + /* 闭锁态:1 = 未标定(写侧按 UA/UN/IA 三路 VALID 现算,本层只存真值) */ + ui_data_init(); + (void)memset(&src, 0, sizeof(src)); + src.valid = 1u; + src.locked = 0u; + src.cal_block = 1u; + CHECK(ui_data_publish_par(&src) == 0, "TC-UIDATA-021g publish blocked frame"); + (void)memset(&got, 0, sizeof(got)); + CHECK((ui_data_par(&got) == 0) && (got.cal_block == 1u) && (got.locked == 0u), + "TC-UIDATA-021h cal_block=1 roundtrip, distinct from locked"); + + /* 解卡态:写侧填 0 后覆盖发布(同一份存储不会粘住旧值) */ + src.cal_block = 0u; + (void)ui_data_publish_par(&src); + (void)memset(&got, 0xAA, sizeof(got)); + CHECK((ui_data_par(&got) == 0) && (got.cal_block == 0u), + "TC-UIDATA-021i cal_block=0 roundtrip (no stale block flag)"); + + /* 写侧默认值纪律:调用方清零后不填本字段 = 0(未闭锁),首帧不会假报徽标 */ + (void)memset(&src, 0, sizeof(src)); + src.valid = 1u; + (void)ui_data_publish_par(&src); + (void)ui_data_par(&got); + CHECK(got.cal_block == 0u, "TC-UIDATA-021j zero-initialized frame means not blocked"); +} + /* ===================== main ===================== */ int main(void) @@ -551,6 +916,20 @@ int main(void) tc_soe_elms(); printf("-- soe render --\n"); tc_soe_render(); + printf("-- param write path: queue --\n"); + tc_param_queue(); + printf("-- param write path: request shape --\n"); + tc_param_request_shape(); + printf("-- param write path: roundtrip --\n"); + tc_param_roundtrip(); + printf("-- param write path: range atomic --\n"); + tc_param_range_atomic(); + printf("-- param write path: locked --\n"); + tc_param_locked(); + printf("-- param write path: apply failure --\n"); + tc_param_apply_fail(); + printf("-- param frame v2 (cal_block) --\n"); + tc_param_frame_v2(); 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 203230db..7f5dd3df 100644 --- a/ui_menu_tree.txt +++ b/ui_menu_tree.txt @@ -24,10 +24,10 @@ 遥信量 #page=yx 参数定值 整定 - 参数 #P - 定值 #P - 控制字 #P - 软压板 #P + 参数 #P #page=view_param + 定值 #P #page=view_set + 控制字 #P #page=view_word + 软压板 #P #page=view_plate 查看 参数 #page=view_param 定值 #page=view_set