feat(ui+记录): 整定写路径与四类页落地,五类记录写入方补齐

UI 面:ui_data 增加深度 4 参数写队列(请求 168 B、队列 .bss 672 B)与状态机
OK/ERR_RANGE/ERR_LOCKED/ERR_BUSY,ui_data_feed 出队后按注入 ops 走「闭锁 → 整批
预检 → 逐项 prot_set_set_sealed」,只提交改动位图;par 帧尾追加 cal_block
(口径 = UA/UN/IA 三路 ADC_CAL_F_VALID,与 protect_task 判据同构,不用 rt.eng
反推,V0.1 下 eng 恒 0)、UI_DATA_VER 升到 2。参数/定值/控制字/软压板四叶绑定
内容页(UI_PAGE_MAP_COUNT 12→16,复用四个查看模块,零新增 .c),编辑态按
is_editable 分派,LOCK(写不进去)与「未标定」(仍可改值)两源两通道两词。

记录面:syslog_request_clear/state/apply 三段式删除 API(UI 只发请求,落地在
defaultTask 的 drain 尾部且必须后置,卷锁一律 try,幂等只认 FR_NO_FILE);
事故/告警/启动/操作/遥控五类各有一个真实写入点,记录类型 = 日志 ID 本身
(一类一个 /log 严格 8.3 文件;64B 定长体没有类型字段位,塞字段会砸掉既有读者)。
为守 protectTask 872 B 静态最坏链另拆 syslog_post_quiet()(无 DBG 的投递核,
丢弃留痕挪到 drain 侧差值补发),syslog_post 对外语义逐字不变。

为什么两道合成一笔而不是各一笔(实测,不是省事):simulator/main.c 同时消费
两道新增 API(整定 pump 与 logtest 清除用例),按道切文件会让中间提交编不过;
RAM 受管快照与体积同码凭证描述的是合并后的 .map,拆开必落在「台账与 .map 不同
码」这条红线上。pre-commit 跑钩子前会 stash 未 staged 部分,这类耦合没法绕。

实测:@build 0 错误 0 警告;Code 228,260 / RO 24,960 / RW 1,440 / ZI 135,976 B;
RW_IRAM1 79,992 B = 61.0%、RW_CCM 87.9%(阈值 92%);九个任务静态最坏链全 PASS,
protectTask 872 B / 57% 改前后逐字节不变(记录投递没垫栈)。主机单测 ui_data 82→
164、sys_log_mcu 106→137、prot_elm 47→60、iec104 141→154、iec104_serv 28→34,
既有断言零删改;48 个钩子 --all-files 全绿;编号门禁可判定 86.34%(基线 86.0%)、
不可判定 1,153 处低于上限 1,166;doc-links 无法判定 0;UI 黄金 18 页扩到 27 页
连跑两次逐字节一致;字库 diff 空。

顺手修掉三处既有缺陷(都是本轮第一个消费者才暴露):sim_syslog.c 的 s_paths 行宽
16 而 "sim_selftest.log" 正好 16 字符没有 NUL 位(stat/remove 读越过数组尾,此前
靠后面 .rodata 碰巧为 0);ui_w_setlist 二态行 ENTER 不回绕(控制字与软压板出厂值
全在最后一项,按了像没反应而叠层里已是越界值);生成物 ui_menu_tab.h 与 gen_menu.py
模板漂移(上一笔改了模板没重跑生成器),现随生成器输出对齐。

未收口(不算完成):保存后重启值保持与主站 C_RD 读回两条只能上板,台架窗口执行;
LOCK 屏显与越界反显的页面侧在模拟器造不出来(模拟器不跑定值自检、无 NAND),
已由生成侧单测与代码注释点名差距;同类页数与钩子数陈述残留另立待办。
This commit is contained in:
2026-10-26 15:51:12 +08:00
parent 0f7b5a87fb
commit 77779d49d6
55 changed files with 4236 additions and 289 deletions
+17 -5
View File
@@ -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 缺失等)。
+217 -11
View File
@@ -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) {
+147 -4
View File
@@ -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
+136 -2
View File
@@ -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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.