diff --git a/AGENTS.md b/AGENTS.md index e7247bf4..8412e918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,7 @@ cmake --build build --config Debug - 详见 `docs/UI 设计/LVGL模拟器部署与使用说明.md` - 中文字体:`ui_cn_chars.txt`(字符集)+ `tools/font_gen/` 生成 `Drivers/BSP/UI/ui_font_cn_12.c`,见 `docs/UI 设计/UI_中文字体生成与配置.md` +- 配置自检:`simulator\build\Debug\lvgl_sim.exe --cfgtest`(无头、进程内闭环:加载/旧档迁移 → 2s 轮询沉降 → 重载断言 + 外科保存无损回环;0=PASS,详见 `simulator/README.md` §3.7) ## 图形绘制(文档配图) diff --git a/App/cfg_file.c b/App/cfg_file.c index 2de9a449..4feae13e 100644 --- a/App/cfg_file.c +++ b/App/cfg_file.c @@ -105,8 +105,8 @@ static int s_str_ci_eq(const char *p_a, const char *p_b) { * 入口参数:p_p - 当前位置指针(入/出) char ** * 返回值:行首指针 char * * 限定条件:*p_p 指向可写缓冲内 - * 函数说明:行界定 = '\n'(缺失时到 NUL);会修改缓冲(行末字节), - * 保存路径 Pass 1 后须经 s_restore_newlines 复原 + * 函数说明:行界定 = '\n'(缺失时到 NUL);会修改缓冲(行末字节, + * 含 CRLF 的 '\r'),保存路径 Pass 1 须用一次性副本 */ static char *s_split_line(char **p_p) { char *p_line = *p_p; @@ -136,22 +136,6 @@ static char *s_split_line(char **p_p) { return p_line; } -/* - * 函数功能:复原行末换行符(s_split_line 的逆操作,供保存 Pass 2 复用缓冲) - * 入口参数:p_buf - 行末已 NUL 化的缓冲 char * - * 返回值:无 - * 限定条件:p_buf 非空 - */ -static void s_restore_newlines(char *p_buf) { - char *p = p_buf; - - while (*p != '\0') { - size_t l = strlen(p); - p[l] = '\n'; - p = p + l + 1u; - } -} - /* * 函数功能:识别段名行(非修改式) * 入口参数:p_line - 行文本 const char * @@ -802,7 +786,7 @@ static int s_surgical(char *p_in, size_t in_len, char *p_out, size_t out_cap, si * 函数功能:保存 Pass 1——定位段头行号与各受管键行号 * 入口参数:p_in - 原文本(可修改) char * * 返回值:无 - * 限定条件:完成后须调 s_restore_newlines 复原行末 + * 限定条件:行末被 NUL 化且不复原——调用方须传一次性副本 */ static void s_surgical_pass1(char *p_in) { char *p = p_in; @@ -861,8 +845,6 @@ static void s_surgical_pass1(char *p_in) { } line_no++; } - - s_restore_newlines(p_in); } /* @@ -1039,23 +1021,39 @@ int cfg_file_save(void) { /* 外科手术式保存:保留注释/未知段/未知键 */ { - uint8_t *p_in = NULL; - char *p_out = (char *)malloc(CFG_OUT_MAX); + uint8_t *p_in = NULL; /* 加载原文(pristine,供 Pass 2 重切分) */ + uint8_t *p_in1 = NULL; /* Pass 1 专用副本(s_split_line 破坏性 NUL 化后丢弃) */ + char *p_out = NULL; size_t in_len = 0u; size_t out_len = 0u; int r; - if (p_out == NULL) { - DBG_ERROR("save: malloc %uB FAIL", (unsigned)CFG_OUT_MAX); - return -1; - } if (s_load_file(&p_in, &in_len) != 0) { - free(p_out); DBG_ERROR("save: file read FAIL"); return -1; } - s_surgical_pass1((char *)p_in); + /* Pass 1 在独立副本上切分(避免破坏原文的行结束符); + * 副本在 p_out 分配前释放,堆峰值维持 p_in + p_out(约 9KB) */ + p_in1 = (uint8_t *)malloc(in_len + 1u); + if (p_in1 == NULL) { + free(p_in); + DBG_ERROR("save: malloc %uB FAIL", (unsigned)(in_len + 1u)); + return -1; + } + (void)memcpy(p_in1, p_in, in_len + 1u); + + s_surgical_pass1((char *)p_in1); + + free(p_in1); + + p_out = (char *)malloc(CFG_OUT_MAX); + if (p_out == NULL) { + free(p_in); + DBG_ERROR("save: malloc %uB FAIL", (unsigned)CFG_OUT_MAX); + return -1; + } + r = s_surgical((char *)p_in, in_len, p_out, CFG_OUT_MAX, &out_len); free(p_in); diff --git a/docs/配置文件设计说明.md b/docs/配置文件设计说明.md index 6e1fdd9c..4d218618 100644 --- a/docs/配置文件设计说明.md +++ b/docs/配置文件设计说明.md @@ -486,7 +486,7 @@ void adc_cfg_init(void) | **P2** | FS 线程安全 + 时间戳 | `ffconf.h`、`Lib/FatFs/ffsystem.c`(新)、`App/util/sys_clock.c`(sys_clock_get_time;移入中间件层)、`uvprojx`、架构总览 §1.1(sys_clock 改归 ④) | `@build` 0/0;`@test` 启动 PASS(无死锁) | | **P3** ✅ 2026-09-14 | cfg_file 引擎(双端)+ net_cfg 集成 + 旧档迁移 | `App/cfg_file.{h,c}`、`App/cfg_file_store.h`、`App/cfg_file_store_mcu.c`(新)、`simulator/sim/cfg_file_store_sim.c`(新)、`net_cfg.{h,c}`(段注册 + 引擎化 load/save)、`net_cfg_legacy.c`(新,NCFG 迁移)、删 `net_cfg_store*`、`uvprojx`、`CMakeLists.txt` | Keil `@build` 0/0;模拟器生成 `sim_config.ini` 且编辑回读一致;旧档 NCFG 自动迁移三态正确 —— **已实测通过**(工厂模板 / 编辑回读 / 非法键回退 / 迁移三态 / 双端 0 警告;`cfg_file.c` 含中文注释块 → UTF-8 **带 BOM**) | | **P4** | 轮询 + D15 协议 + 测试标记 | `net_task.c`(沉降去抖轮询 + 落盘收敛 + 串口标记)、`lftpd.c`(STOR tmp+rename) | `@build`;`@test` 启动新标记(`cfg file: loaded OK` 等);FTP 上传无半截可见(rename 原子) | -| **P5** | 模拟器迁移 | `simulator/sim/cfg_file_store_sim.c`(新)、删 `net_cfg_store_sim.c`、`main.c`(轮询)、`CMakeLists.txt` | sim 构建 + 运行;`sim_netcfg.bin` 自动迁移 | +| **P5** ✅ 2026-09-14 | 模拟器迁移 | `simulator/sim/cfg_file_store_sim.c`(新)、删 `net_cfg_store_sim.c`、`simulator/main.c`(2s 轮询 + `--cfgtest` 自检)、`CMakeLists.txt` | sim 构建 0 警告 + 4 场景无头 PASS(工厂 / 有效旧档迁移 / 无效旧档保留 / UI 无头);`sim_netcfg.bin` 自动迁移 + 轮询沉降重载 + 外科保存无损回环 —— **已实测通过** | | **P6** | 文档同步 | `网络配置说明.md`(§0/3/6/7 重写)、`AGENTS.md`、`README.md`、`项目架构总览.md` + `gen_arch_svg.py`、`内容页架构.md`、BSD 指南、`simulator/README.md`、`ui_page_net_cfg.c` 头注释、`docs/测试说明/` | pre-commit 全绿;全库无"Flash"旧表述残留 | | **P7** | FTP 闭环脚本 | `test/ftp_config_test.py`(新)+ 测试说明 | 真机闭环 PASS(§11.2) | | **P8** | 总回归 + 提交 | `@build` + `@test` + 模拟器 ui-verify + `pre-commit --all-files` | 0 错误 0 警告;全 PASS | @@ -569,3 +569,4 @@ void adc_cfg_init(void) | 2026-09-13 | v1.6:`sys_clock` 由 ③基础服务层改归 **④中间件层**(通用平台时间服务),文件移入 `App/util/`(与 crc/ringbuf 同目录):`ffsystem` → `sys_clock` 成为**层内引用**,工程反向依赖消除;"反向依赖例外" 措辞同步撤销 | | 2026-09-13 | v1.7:FS 并发定案 **A+**(REENTRANT 互斥 + 三条一致性协议:写 tmp+rename 扩至 FTP STOR / 沉降去抖重载 / 配置文件单写者收敛 netTask);**actor 方案(单所有者队列 + 全代理层)经完整评估否决**(代理 ~400–600 行、RAM +4KB、符号改名/影子头、自死锁纪律——结构优势可由三协议 ~40 行等价中性化);新增 D15,D9 改为"一切写入 tmp+rename",P4 增列 lftpd STOR 改造 | | 2026-09-14 | v1.8:**P3 完成**(双端 0/0):`cfg_file` 段键表引擎 + `cfg_file_store` 双端后端 + `net_cfg` [network] 段集成(对外 API 不变,D14 兑现)+ `net_cfg_legacy` 旧档迁移;无头实测通过(工厂模板生成 / 编辑回读 / 非法键工厂回退 / 未知段容留 / 迁移三态 / 双端 0 警告);§6 接口修订为实现版(store 带路径参数 + 三路径 getter;变更回调随段注册,撤销独立 `cfg_file_register_cb`);§8 静态增量按实测 706 B 修订 | +| 2026-09-14 | v1.9:**P5 完成**(sim 0 警告):模拟器主循环接入 2s `cfg_file_poll`(与 MCU netTask 同周期);新增无头自检 `lvgl_sim.exe --cfgtest`(加载/迁移 → 建基线 → 伪造外部编辑 → 两次轮询沉降 → 断言 RAM 更新 + 外科保存回环校验注释/值无损);修复其暴露的两处缺陷:① MSVC `rename()` 不覆盖已存在目标(C1117)——模拟器后端先删目标再 rename(目标丢失时引擎自动重建模板兜底);② **外科保存截断 bug**——`s_split_line` 将 CRLF 行的 `\r`/`\n` 双双 NUL 化,旧 `s_restore_newlines` 只复原一个 `\n` 即遇 NUL 终止,保存产物只剩首行(P3 未触发"对已存在文件保存"故未暴露)——改为 Pass 1 用一次性副本(堆峰值维持约 9KB)、删除复原函数 | diff --git a/simulator/README.md b/simulator/README.md index e54ab16b..aafdaa96 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -228,6 +228,18 @@ UI 改动的自动化门禁脚本(完整流程见 `docs/UI 设计/UI_自动化 - **池水位常量漂移**:菜单/页面结构变更导致门禁失败时,确认新值合理后再更新脚本内常量并登记流程文档变更记录;无法解释的漂移 = 内存回归 bug。 - 退出码均为 0=PASS / 1=断言失败 / 2=环境错误(exe 缺失等)。 +### 3.7 配置文件自检(--cfgtest) + +配置引擎"加载 → 轮询 → 重载 → 保存"的进程内闭环验证(无需窗口 / LVGL,退出码 0=PASS / 非 0=FAIL),在当前工作目录产生 `sim_config.ini`(`sim_netcfg.bin` 参与迁移时): + +```powershell +.\build\Debug\lvgl_sim.exe --cfgtest +``` + +验证链:`net_cfg_load`(含旧档自动迁移)→ 第 1 次 poll 建基线 → 伪造"外部编辑"(原子写,等价 FTP 上传/文本编辑器)→ 2 次 poll 沉降(对应真机 4s 沉降)→ 断言 RAM 副本已更新为新值 → 对已存在文件做外科式保存、回读校验注释/值无损。 + +空目录运行 = 验证出厂模板生成;预置有效 `sim_netcfg.bin` = 验证旧档迁移(迁移后删除);无效旧档(CRC 不符)= 保留旧文件 + 生成出厂模板。窗口模式主循环以与 MCU netTask 相同的 2s 周期调用 `cfg_file_poll()`:外部文件变更沉降后自动重载 `[network]` 段(模拟器无网络栈,变更回调为空操作,仅日志可见)。 + --- ## 四、已知坑(构建/运行) diff --git a/simulator/main.c b/simulator/main.c index 378b8131..0d543336 100644 --- a/simulator/main.c +++ b/simulator/main.c @@ -17,6 +17,12 @@ * lvgl_sim.exe --keys shot.bin * seq 字符:u=↑ d=↓ l=← r=→ e=Enter s=Esc(其余字符忽略) * 每键经 lv_group_send_data 注入(与 keypad 输入设备同路径),驱动 5 帧后取快照 + * + * 配置轮询自检模式(无窗口、进程内闭环、退出码 0=PASS / 非0=FAIL): + * lvgl_sim.exe --cfgtest + * 验证链:net_cfg_load(含旧档迁移)→ poll 建基线 → 伪造外部编辑 + * (原子写新文档,等价 FTP 上传/文本编辑器)→ poll 两次(4s 沉降) + * → 断言 RAM 副本已按新文件重载 */ #include @@ -38,6 +44,8 @@ #include "ui_menu.h" #include "ui_net_sink.h" #include "net_cfg.h" +#include "cfg_file.h" +#include "cfg_file_store.h" /* 屏幕尺寸(与 MCU 端一致) */ #define SIM_SCREEN_W 160 @@ -168,6 +176,89 @@ static int run_headless(const char * keys, const char * path) return 0; } +/* 伪造"外部编辑"文档(绕过引擎原子写 = 等价 FTP 上传/文本编辑器); + * 刻意短于出厂模板,保证 (size, mtime) 变更令牌必变 */ +static const char s_cfgtest_doc[] = + "; cfgtest forged external edit\r\n" + "\r\n" + "[network]\r\n" + "ip=10.0.0.1\r\n" + "mask=255.255.255.0\r\n" + "gw=10.0.0.254\r\n" + "mac=00:00:00:00:00:00\r\n"; + +/* 伪造编辑后 RAM 副本应呈现的 IP */ +static const uint8_t s_cfgtest_ip[4] = { 10u, 0u, 0u, 1u }; + +/* + * @brief 配置轮询自检(无窗口、无需 LVGL/SDL,进程内闭环) + * @note 验证链:net_cfg_load(含旧档迁移)→ poll 建基线 → 伪造外部编辑 + * → poll 两次(沉降去抖)→ 重载并更新 RAM 副本 + * @return 0 PASS,非 0 FAIL + */ +static int run_cfgtest(void) +{ + int i; + const net_cfg_t *p_cfg; + + setvbuf(stdout, NULL, _IONBF, 0); + + (void)net_cfg_load(); /* 加载 / 旧档迁移(含 [network] 段注册) */ + + cfg_file_poll(); /* 第 1 次 poll:建立变更基线 */ + + /* 伪造外部编辑(原子写,与 P4 后 FTP STOR 同路径) */ + if (cfg_store_write_atomic(cfg_store_config_path(), + (const uint8_t *)s_cfgtest_doc, + strlen(s_cfgtest_doc)) != 0) { + fprintf(stderr, "[cfgtest] forge write failed\n"); + return 1; + } + + cfg_file_poll(); /* 第 2 次 poll:检出变更,沉降计数 1 */ + cfg_file_poll(); /* 第 3 次 poll:沉降完成 → 重载 + 变更回调 */ + + p_cfg = net_cfg_get(); + for (i = 0; i < 4; i++) { + if (p_cfg->ip[i] != s_cfgtest_ip[i]) { + printf("[cfgtest] FAIL ip after reload = %u.%u.%u.%u (expect 10.0.0.1)\n", + (unsigned)p_cfg->ip[0], (unsigned)p_cfg->ip[1], + (unsigned)p_cfg->ip[2], (unsigned)p_cfg->ip[3]); + return 1; + } + } + printf("[cfgtest] OK ip after reload = 10.0.0.1\n"); + + /* 保存回环:外科手术式保存(目标文件已存在)→ 回读校验注释保留与值一致 */ + if (net_cfg_save() != 0) { + printf("[cfgtest] FAIL net_cfg_save (existing file)\n"); + return 1; + } + { + uint8_t buf[CFG_FILE_MAX_LEN + 1u]; + char *p_doc; + size_t n = 0u; + + if (cfg_store_read(cfg_store_config_path(), buf, sizeof(buf), &n) != 0) { + printf("[cfgtest] FAIL readback open\n"); + return 1; + } + buf[n] = '\0'; + p_doc = (char *)buf; + if (strstr(p_doc, "; cfgtest forged external edit") == NULL) { + printf("[cfgtest] FAIL comment lost after surgical save\n"); + return 1; + } + if (strstr(p_doc, "ip=10.0.0.1") == NULL) { + printf("[cfgtest] FAIL value lost after surgical save\n"); + return 1; + } + } + printf("[cfgtest] OK surgical save (comment kept, value intact)\n"); + printf("[cfgtest] PASS\n"); + return 0; +} + /** * @brief 程序入口 */ @@ -181,6 +272,9 @@ int main(int argc, char **argv) if (strcmp(argv[i], "--keys") == 0 && (i + 2) < argc) { return run_headless(argv[i + 1], argv[i + 2]); } + if (strcmp(argv[i], "--cfgtest") == 0) { + return run_cfgtest(); + } } /* 初始化 Winsock(供 ui_net_sink 使用) */ @@ -233,6 +327,9 @@ int main(int argc, char **argv) printf("Keyboard: ↑↓←→ navigate, Enter confirm, Escape back\n"); printf("Press Ctrl+C or close window to exit\n\n"); + /* 配置文件轮询起点(2s 周期,与 MCU netTask 一致) */ + uint32_t cfg_last_poll = (uint32_t)SDL_GetTicks(); + /* 主循环:驱动 LVGL 定时器与刷新 * 注意:SDL 事件(键盘/窗口/退出)由 LVGL SDL 驱动内部 5ms 定时器 * (lv_sdl_window.c 的 sdl_event_handler)统一轮询分发, @@ -242,6 +339,13 @@ int main(int argc, char **argv) /* 轮询 RemoDispBus 协议(非阻塞收发) */ ui_net_sink_poll(); + /* 配置文件轮询(2s 周期:外部文件变更 4s 沉降后自动重载) */ + uint32_t now_ms = (uint32_t)SDL_GetTicks(); + if ((uint32_t)(now_ms - cfg_last_poll) >= 2000u) { + cfg_last_poll = now_ms; + cfg_file_poll(); + } + /* 驱动 LVGL 定时器与刷新 */ uint32_t sleep_ms = lv_timer_handler(); if (sleep_ms == LV_NO_TIMER_READY) { diff --git a/simulator/sim/cfg_file_store_sim.c b/simulator/sim/cfg_file_store_sim.c index 523fc510..7b2d314c 100644 --- a/simulator/sim/cfg_file_store_sim.c +++ b/simulator/sim/cfg_file_store_sim.c @@ -56,8 +56,10 @@ int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nre * 入口参数:p_path - 目标文件路径 const char * * p_buf - 待写数据(不可为 NULL) const uint8_t * * len - 数据长度 size_t - * 返回值:0 - 成功;-1 - 写/改名失败(目标文件保持不动,临时文件清理) int - * 限定条件:无 + * 返回值:0 - 成功;-1 - 写/改名失败(临时文件清理) int + * 限定条件:MSVC rename() 不覆盖已存在目标(C1117),须先 remove 再 rename + * (tmp 已完整写盘关闭后操作,中断窗口仅毫秒级;目标丢失时引擎 + * 下次加载自动重建出厂模板,与 MCU 端 f_rename 直接替换等效) */ int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len) { FILE *fp; @@ -76,6 +78,7 @@ int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len) (void)remove(s_path_tmp); return -1; } + (void)remove(p_path); /* 目标可能不存在(首写):remove 失败忽略 */ if (rename(s_path_tmp, p_path) != 0) { (void)remove(s_path_tmp); return -1;