#!/usr/bin/env python3 # -*- coding: utf-8 -*- """生成菜单节点表与页面注册表(输出到 Drivers/BSP/UI/)。 把"菜单长什么样"声明在缩进文本源 ui_menu_tree.txt(缩进即层级,每级 2 空格, `#` 开头为注释;行尾可选注记,可叠加: ` #P` 该叶子需密码(密码页按所输密码授权:查看/修改) ` #page=<名>` 该叶子绑定内容页实例 ui_page_<名>.c(描述符 ui_page_<名>_desc) 文件级指令(写在注释行里): `# @password=<名>` 需密码叶子统一进的密码页模块名(省略 = 无密码页) 密码值在 Drivers/BSP/UI/ui_pwd_def.h 编译期配置。 本脚本读源、推导每个节点的 parent 下标,产出: ① ui_menu_tab.c / .h 菜单节点扁平父子索引表(供 ui_menu.c 消费) ② ui_page_registry.c/.h 叶子节点 → 页面描述符注册表(取代手写 case 分发) ③ 双端注册:把 ui_page_registry.c 与各绑定页 .c 自动写进 MDK-ARM/STM32F407-Demo.uvprojx 与 simulator/CMakeLists.txt 的显式列表 (只做定点插入、不重排文件;已存在则不动 = 幂等) 并校验:声明了 #page 但实例文件/符号缺失 → 报错;定义了 desc 却无叶子绑定 → 报错。 改菜单/加页面只需编辑 ui_menu_tree.txt + 写页面实例文件,然后重跑本脚本。 用法(仓库根目录或任意目录均可,脚本自动定位仓库根): python tools/menu_gen/gen_menu.py """ import os import re import sys ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) SRC = os.path.join(ROOT, 'ui_menu_tree.txt') OUT_H = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_menu_tab.h') OUT_C = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_menu_tab.c') OUT_REG_H = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_page_registry.h') OUT_REG_C = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_page_registry.c') UI_DIR = os.path.join(ROOT, 'Drivers', 'BSP', 'UI') UVPROJX = os.path.join(ROOT, 'MDK-ARM', 'STM32F407-Demo.uvprojx') CMAKELISTS = os.path.join(ROOT, 'simulator', 'CMakeLists.txt') UI_GROUP = 'Drivers/BSP/UI' # uvprojx 里的组名 ROOT_ID = 0xFF # 内部根标记(= MENU_ROOT=0xFF);与 8 位索引一致,映射到 C 的 MENU_ROOT 宏 MAX_DEPTH = 8 MAX_NODES = 255 # uint8_t 索引 + 哨兵 0xFF → 最多 255 个节点(下标 0..254) STEM_RE = re.compile(r'^[a-z][a-z0-9_]*$') # 行尾注记串:一个或多个 ` #KEY` / ` #KEY=VAL` ANN_TAIL_RE = re.compile(r'((?:[ \t]+#[A-Za-z0-9]+(?:=[^\s#]+)?)+)$') ANN_ONE_RE = re.compile(r'#([A-Za-z0-9]+)(?:=([^\s#]+))?') # 文件级指令:`# @key=value`(整行注释,不与节点冲突) DIRECTIVE_RE = re.compile(r'^[ \t]*#[ \t]*@([A-Za-z0-9_]+)[ \t]*=[ \t]*(\S+)') def parse_annotations(stripped, raw): """剥出行尾注记,返回 (节点名, {'P': bool, 'page': str|None})。 节点名原样保留(含内部空格),只去掉尾部 ` #KEY[=VAL]` 串。 未知注记 / #P 带值 / #page 缺值 一律报错——注记写错必须当场失败, 不能静默降级成"没密码/没页面"(那类错误两端编译全过、只在菜单里打不开页)。 """ m = ANN_TAIL_RE.search(stripped) if not m: return stripped, {'P': False, 'page': None} name = stripped[:m.start()] pwd, page = False, None for tok in ANN_ONE_RE.findall(m.group(1)): key, val = tok[0].upper(), tok[1] or None if key == 'P': if val is not None: sys.exit('ERROR: #P 不接受取值: %r' % raw) pwd = True elif key == 'PAGE': if val is None: sys.exit('ERROR: #page 需要取值(页面模块名): %r' % raw) if page is not None: sys.exit('ERROR: #page 重复: %r' % raw) if not STEM_RE.match(val): sys.exit('ERROR: #page 取值须为小写字母/数字/下划线且以字母开头: %r(出现 %r)' % (raw, val)) page = val else: sys.exit('ERROR: 行尾注记仅支持 #P(需密码) 与 #page=<模块名>(绑定内容页): ' '%r(出现 #%s;旧 #V/#M 类别注记已废弃,密码值改在 ui_pwd_def.h 配置)' % (raw, tok[0])) return name, {'P': pwd, 'page': page} def parse_tree(text): """把缩进文本解析为 (nodes, directives)。 nodes = [(parent, name, depth, pwd, page), ...]; parent 为父节点下标,根节点为 ROOT_ID;子项 = parent 等于本下标的所有行(按表序)。 缩进单元取"最小非零缩进"(每级一个单元),据此把缩进换算成层级。 directives = {小写指令名: 取值},来自 `# @key=value` 注释行。 """ lines = [] directives = {} for raw in text.splitlines(): line = raw.rstrip() stripped = line.strip() if not stripped: continue if stripped.startswith('#'): dm = DIRECTIVE_RE.match(line) if dm: key = dm.group(1).lower() if key in directives: sys.exit('ERROR: 指令重复定义: @%s' % key) directives[key] = dm.group(2) continue if '\t' in line: sys.exit('ERROR: 请用空格缩进(勿用 Tab): ' + repr(raw)) indent = len(line) - len(line.lstrip(' ')) name, ann = parse_annotations(stripped, raw) lines.append((indent, name, ann)) if not lines: sys.exit('ERROR: ui_menu_tree.txt 无有效节点') indents = sorted(set(i for i, _, _ in lines if i > 0)) unit = indents[0] if indents else 1 for i, name, _ in lines: if i % unit != 0: sys.exit('ERROR: 缩进不是 %d 的整数倍: %r (indent=%d)' % (unit, name, i)) nodes = [] stack = [] # [(depth, index), ...] 当前各层的父节点下标 for indent, name, ann in lines: depth = indent // unit if depth > MAX_DEPTH: sys.exit('ERROR: 层级过深(>%d): %r' % (MAX_DEPTH, name)) while stack and stack[-1][0] >= depth: stack.pop() parent = stack[-1][1] if stack else ROOT_ID idx = len(nodes) nodes.append((parent, name, depth, ann['P'], ann['page'])) stack.append((depth, idx)) roots = [i for i, (_, _, d, _, _) in enumerate(nodes) if d == 0] if len(roots) != 1: sys.exit('ERROR: 必须有且仅有一个根节点(depth 0),当前 %d 个: %s' % (len(roots), [nodes[i][1] for i in roots])) return nodes, directives def emit(nodes): n = len(nodes) menus = sum(1 for i in range(n) if any(nodes[j][0] == i for j in range(n))) max_depth = max(d for _, _, d, _, _ in nodes) h = [ '/*', ' * 模块名称:UI 菜单节点表头文件(自动生成)', ' * 模块功能:菜单节点结构、表规模护栏与表查询 API 声明', ' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)', ' * 作者:王建锋', ' * 创建日期:2026-09-07', ' * 修改记录:', ' * 2026-09-13 王建锋 工程约定规范化(文件头),内容由生成器自动更新', ' * 自动生成:本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 自动生成,请勿手改。', ' * 修改菜单:编辑 ui_menu_tree.txt 后重跑 `python tools/menu_gen/gen_menu.py`。', ' */', '#ifndef UI_MENU_TAB_H', '#define UI_MENU_TAB_H', '', '#include ', '#include ', '', '#define MENU_ROOT 0xFFu /* 根哨兵:仅根节点(主界面)的 parent 取此值;8 位,上限 255 项 */', '', '/* 菜单节点:子项 = parent 等于本下标的所有行(按表序);有子项=菜单,无=叶子 */', '/* 字段序 parent/pwd 在前:32 位下两项 8 位合并 + 4 位指针 = 8 B/项 */', 'typedef struct {', ' uint8_t parent; /* 父节点下标(0..254);根为 MENU_ROOT(0xFF) */', ' uint8_t pwd; /* 1 = 需密码(密码页按所输密码授权);0 = 直接进(只读) */', ' const char *name; /* 显示文本(字面量,存 Flash) */', '} ui_menu_node_t;', '', '#define UI_MENU_TAB_COUNT %d' % n, '', '/* 护栏:8 位索引上限 255 项(哨兵 0xFF 占用 255);超界则编译失败(C99 写法,兼容 ARMCC v5) */', 'typedef char menu_tab_count_check[(UI_MENU_TAB_COUNT <= 255) ? 1 : -1];', '', 'extern const ui_menu_node_t g_menu_tab[];', '', '/*', ' * 表查询 API(导航逻辑层 ui_menu.c 与显示层 ui_menu_view.c 共用):', ' * 实现随表生成,改菜单(ui_menu_tree.txt)调用方零代码改动。', ' */', "", "/*", " * 函数功能:取某节点的直接子项(按表序)", " * 入口参数:node - 节点下标 uint8_t 0 - 表长-1", " * out - 子项下标输出 uint8_t[] 长度 >= max,可为 NULL(只数个数时)", " * max - 最多写出个数 uint8_t >= 1", " * 返回值:实际写出的子项个数 uint8_t 0 - max(0 = 叶子节点)", " * 限定条件:node 越界时返回 0,不写 out(与\"叶子\"同形,调用方需要区分就先用 has_children)", " * 函数说明:不分配内存、不递归,只查一层。", " */", "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 合法),不是成败码——bsp-err 的 err-type 检查里白名单放行的形态。", " */", "bool ui_menu_tab_has_children(uint8_t node);", "", "/*", " * 函数功能:取节点的显示名", " * 入口参数:node - 节点下标 uint8_t 0 - 表长-1", " * 返回值:显示名字符串指针 const char* 指向表内静态存储,**不为 NULL**(越界返回空串)", " * 限定条件:返回的是静态存储地址,调用方不得写、不得 free", " * 函数说明:编码随表来源(ui_menu_tree.txt),显示层直接喂 LVGL。", " */", "const char *ui_menu_tab_name(uint8_t node);", "", "/*", " * 函数功能:判断该叶子是否需要密码", " * 入口参数:node - 节点下标 uint8_t 0 - 表长-1", " * 返回值:true - 需密码(先进密码页,按所输密码授权);false - 直接进(只读)", " * 限定条件:纯查询;node 越界返回 false(保守取\"只读\",不放开写权限)", " * 函数说明:授权强度由密码页所输密码决定,本函数只回答\"要不要过这一关\"。", " */", "bool ui_menu_tab_has_pwd(uint8_t node);", "", "/*", " * 函数功能:菜单表启动自检(表与生成器是否自洽)", " * 入口参数:无", " * 返回值:true - 表自洽(非根节点的 parent 下标均有效、pwd 只取 0/1);", " * false - 表被手工改坏,调用方应拒绝进入菜单而不是继续用", " * 限定条件:ui_menu_init() 阶段调用一次", " * 函数说明:宁可拒绝显示,也不要按一张错表导航——错表可能把只读叶子指到写页面上。", " */", "bool ui_menu_tab_valid(void);", '', '#endif', '', ] c = [ '/*', ' * 模块名称:UI 菜单节点表(自动生成)', ' * 模块功能:菜单节点扁平父子索引表 + 5 个表查询 API(导航逻辑层与显示层共用)', ' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)', ' * 作者:王建锋', ' * 创建日期:2026-09-07', ' * 修改记录:', ' * 2026-09-13 王建锋 工程约定规范化(文件头 / K&R 大括号),内容由生成器自动更新', ' * 自动生成:本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 自动生成,请勿手改。', ' * 修改菜单:编辑 ui_menu_tree.txt 后重跑 `python tools/menu_gen/gen_menu.py`。', ' */', '#include "ui_menu_tab.h"', '', 'const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {', ] for i, (parent, name, depth, pwd, _page) in enumerate(nodes): pstr = 'MENU_ROOT' if parent == ROOT_ID else '%d' % parent cstr = '1u' if pwd else '0u' c.append(' { %8s, %2s, "%s" }, /* %2d L%d */' % (pstr, cstr, name, i, depth)) c.append('};') c.append('') c += [ '/*', ' * 函数功能:取 node 的直接子项(按表序),最多 max 个', ' * 入口参数:node - 节点下标 uint8_t', ' * out - 子节点下标输出缓冲 uint8_t *', ' * max - 缓冲容量 uint8_t', ' * 返回值:实际子项个数 uint8_t', ' * 限定条件:node < UI_MENU_TAB_COUNT', ' * 函数说明:子项 = 表中所有 parent == 本下标的行(表序即显示序);建页核心查询', ' */', 'uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max) {', ' uint8_t n = 0u;', ' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT && n < max; i++) {', ' if (g_menu_tab[i].parent == node) {', ' out[n++] = i;', ' }', ' }', ' return n;', '}', '', '/*', ' * 函数功能:判断 node 有无子项', ' * 入口参数:node - 节点下标 uint8_t', ' * 返回值:有子项返回 true(=菜单、行带▶);无子项返回 false(=叶子)', ' * 限定条件:node < UI_MENU_TAB_COUNT', ' * 函数说明:菜单/叶子由"是否有子项"自动推导,无单独类型字段', ' */', 'bool ui_menu_tab_has_children(uint8_t node) {', ' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT; i++) {', ' if (g_menu_tab[i].parent == node) {', ' return true;', ' }', ' }', ' return false;', '}', '', '/*', ' * 函数功能:取节点显示名', ' * 入口参数:node - 节点下标 uint8_t', ' * 返回值:显示文本(Flash 字面量) const char *', ' * 限定条件:node < UI_MENU_TAB_COUNT', ' * 函数说明:行文本一律经此取静态字符串,不 strdup', ' */', 'const char *ui_menu_tab_name(uint8_t node) {', ' return g_menu_tab[node].name;', '}', '', '/*', ' * 函数功能:判断节点是否需密码(叶子密码保护)', ' * 入口参数:node - 节点下标 uint8_t', ' * 返回值:true - 需密码页(按所输密码授权查看/修改权限);false - 直接进(只读)', ' * 限定条件:无', ' * 说明:表 pwd 列取自源 ui_menu_tree.txt 行尾 #P 注记;导航逻辑层据此决定是否先经密码页', ' */', 'bool ui_menu_tab_has_pwd(uint8_t node) {', ' return (g_menu_tab[node].pwd != 0u);', '}', '', '/*', ' * 函数功能:菜单表启动自检', ' * 入口参数:无', ' * 返回值:true - 表有效;false - 存在越界 parent(表内笔误)', ' * 限定条件:无', ' * 函数说明:每个非根节点的 parent 必须为 MENU_ROOT 或有效下标;在 ui_menu_init 调用', ' */', 'bool ui_menu_tab_valid(void) {', ' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT; i++) {', ' if (i == 0u) {', ' continue; /* 根 */', ' }', ' uint8_t p = g_menu_tab[i].parent;', ' if (p != MENU_ROOT && p >= UI_MENU_TAB_COUNT) {', ' return false;', ' }', ' if (g_menu_tab[i].pwd > 1u) {', ' return false; /* pwd 只取 0/1(行尾注记笔误捕获) */', ' }', ' }', ' return true;', '}', '', ] return '\n'.join(h), '\n'.join(c), menus, max_depth def has_children(nodes, idx): """节点是否有子项(有子项 = 菜单,进子框而非内容页)。""" return any(n[0] == idx for n in nodes) def page_files_on_disk(): """扫描 Drivers/BSP/UI/ui_page_*.c,返回 {模块名: 文件名}。 判定与 scripts/check_registration.py 规则④一致:只有**定义了 ui_page_<名>_desc** 的 ui_page_*.c 才算页面实例;生成物 ui_page_registry.c 不定义该符号,天然排除。 """ out = {} for fn in sorted(os.listdir(UI_DIR)): if not (fn.startswith('ui_page_') and fn.endswith('.c')): continue stem = fn[:-2][len('ui_page_'):] if stem == 'registry': continue try: text = open(os.path.join(UI_DIR, fn), encoding='utf-8-sig', errors='replace').read() except OSError: continue if re.search(r'\bui_page_%s_desc\b\s*=' % re.escape(stem), text): out[stem] = fn return out def validate_pages(nodes, directives): """校验 #page 绑定与磁盘实例双向对齐,返回 ([(node, 节点名, 模块名)], 密码页模块名|None)。 五类错误一律当场失败(不产出半成品表): ① #page 标在有子项的节点上(那种节点进子框,永不进内容页) ② 声明了 #page / @password 但实例文件或描述符符号不存在 ③ 磁盘上有页面实例却无任何叶子绑定(两端编译链接全过、菜单打不开 = 死代码) ④ 有 #P 叶子却没有 @password 指令(运行时取到 NULL 描述符,开页静默失败) ⑤ 有 @password 指令却没有任何 #P 叶子(密码页永不出现的死配置) """ disk = page_files_on_disk() password_stem = directives.get('password') bound = [] for idx, (parent, name, depth, pwd, page) in enumerate(nodes): if page is None: continue if has_children(nodes, idx): sys.exit('ERROR: #page 只能标在叶子上: %r (node %d) 有子项,该节点进子框而非内容页' % (name, idx)) if page not in disk: sys.exit('ERROR: 节点 %r (node %d) 声明 #page=%s,但 Drivers/BSP/UI/ui_page_%s.c ' '不存在或未定义 ui_page_%s_desc' % (name, idx, page, page, page)) bound.append((idx, name, page)) if password_stem is not None and password_stem not in disk: sys.exit('ERROR: 指令 @password=%s 指向的 Drivers/BSP/UI/ui_page_%s.c 不存在' '或未定义 ui_page_%s_desc' % (password_stem, password_stem, password_stem)) # 密码一致性:#P 叶子与 @password 指令必须成对出现。 # 只标 #P 不声明指令 → 运行时 ui_page_registry_password() 返回 NULL,按 ENTER 开页 # 失败停在菜单屏(两端编译全过、只在真按钥匙才暴露);只声明指令没有 #P 叶子 → # 密码页永不出现,属死配置。两种都在生成期就拦。 protected_nodes = [idx for idx, (_p, _n, _d, pwd, _pg) in enumerate(nodes) if pwd] if protected_nodes and password_stem is None: sys.exit('ERROR: %d 个叶子标了 #P(需密码)但菜单树缺 `# @password=<模块名>` 指令:' '这些叶子按 ENTER 会取到 NULL 描述符、开页失败停在菜单屏(node %s 等)' % (len(protected_nodes), ', '.join(str(i) for i in protected_nodes[:5]))) if password_stem is not None and not protected_nodes: sys.exit('ERROR: 指令 @password=%s 已声明,但没有任何叶子标 #P —— 密码页永不出现,' '属死配置(要么给相应叶子加 #P,要么删掉该指令)' % password_stem) used = {p for _, _, p in bound} if password_stem: used.add(password_stem) orphans = sorted(s for s in disk if s not in used) if orphans: sys.exit('ERROR: 以下页面实例未被任何叶子 #page 引用(写了却从不生效的死代码): %s' % ', '.join('ui_page_%s.c' % s for s in orphans)) return bound, password_stem def emit_registry(bound, password_stem): """产出 ui_page_registry.h / .c 文本。""" stems = sorted({p for _, _, p in bound} | ({password_stem} if password_stem else set())) h = [ '/*', ' * 模块名称:UI 页面注册表头文件(自动生成)', ' * 模块功能:叶子节点 → 内容页描述符的映射表与查询 API;密码页非节点绑定,单独出口', ' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)', ' * 作者:王建锋', ' * 创建日期:2026-10-15', ' * 自动生成:本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 的 #page= 注记生成,请勿手改。', ' * 新增一页:菜单树给叶子加 " #page=<模块名>" + 写 ui_page_<模块名>.c,重跑生成器', ' * (双端注册 uvprojx/CMakeLists 亦由生成器自动写入)', ' */', '#ifndef UI_PAGE_REGISTRY_H', '#define UI_PAGE_REGISTRY_H', '', '#include ', '', '#include "ui_content_page.h"', '', '#ifdef __cplusplus', 'extern "C" {', '#endif', '', '/* 注册表条目:一个绑定了内容页的菜单叶子(表序 = 节点下标升序) */', 'typedef struct {', ' uint8_t node; /* 菜单叶子节点下标(g_menu_tab 下标) */', ' const content_page_def_t *desc; /* 页面描述符(Flash const) */', '} ui_page_entry_t;', '', '#define UI_PAGE_MAP_COUNT %du /* 绑定了内容页的叶子数 */' % len(bound), '', 'extern const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT];', '', '/*', ' * 函数功能:查叶子节点绑定的内容页描述符', ' * 入口参数:node - 菜单叶子节点下标 uint8_t', ' * 返回值:页面描述符;该叶子未绑定内容页返回 NULL const content_page_def_t *', ' * 限定条件:node < UI_MENU_TAB_COUNT', ' * 函数说明:取代旧 ui_menu.c 手写 case 分发——映射的唯一真源是 ui_menu_tree.txt,', ' * 表序变化不会造成"下标硬编码错位"(线性扫描:绑定页是个位数量级)', ' */', 'const content_page_def_t *ui_page_registry_for(uint8_t node);', '', '/*', ' * 函数功能:取密码页描述符(需密码叶子统一进这一页)', ' * 入口参数:无', ' * 返回值:密码页描述符;菜单树未声明 @password 指令时返回 NULL const content_page_def_t *', ' * 限定条件:无', ' * 函数说明:密码页**不是**节点绑定的(任何 #P 叶子都进同一页),故不进 node→desc 映射,', ' * 由本接口单独出口;取值来自 ui_menu_tree.txt 的 `# @password=<模块名>` 指令', ' */', 'const content_page_def_t *ui_page_registry_password(void);', '', '#ifdef __cplusplus', '}', '#endif', '', '#endif /* UI_PAGE_REGISTRY_H */', '', ] c = [ '/*', ' * 模块名称:UI 页面注册表(自动生成)', ' * 模块功能:叶子节点 → 内容页描述符映射表 + 2 个查询 API(导航逻辑层消费)', ' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)', ' * 作者:王建锋', ' * 创建日期:2026-10-15', ' * 自动生成:本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 的 #page= 注记生成,请勿手改。', ' * 新增一页:菜单树给叶子加 " #page=<模块名>" + 写 ui_page_<模块名>.c,重跑生成器', ' */', '#include "ui_page_registry.h"', '', ] for s in stems: c.append('#include "ui_page_%s.h"' % s) c.append('') c.append('const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT] = {') for idx, name, page in bound: c.append(' { %3du, &ui_page_%s_desc }, /* node %d %s */' % (idx, page, idx, name)) c.append('};') c.append('') c += [ '/*', ' * 函数功能:查叶子节点绑定的内容页描述符', ' * 入口参数:node - 菜单叶子节点下标 uint8_t', ' * 返回值:页面描述符;未绑定返回 NULL const content_page_def_t *', ' * 限定条件:无(越界 node 不会命中任何条目,直接 NULL)', ' * 函数说明:线性扫描绑定表(条目 = 有内容页的叶子,个位数量级)', ' */', 'const content_page_def_t *ui_page_registry_for(uint8_t node) {', ' for (uint8_t i = 0u; i < UI_PAGE_MAP_COUNT; i++) {', ' if (g_ui_page_map[i].node == node) {', ' return g_ui_page_map[i].desc;', ' }', ' }', ' return NULL;', '}', '', '/*', ' * 函数功能:取密码页描述符(需密码叶子统一进这一页,非节点绑定)', ' * 入口参数:无', ' * 返回值:密码页描述符%s const content_page_def_t *' % (';未声明 @password 时返回 NULL' if not password_stem else ''), ' * 限定条件:无', ' * 函数说明:取值来自 ui_menu_tree.txt 的 @password 指令(生成期解析,运行期零开销)', ' */', 'const content_page_def_t *ui_page_registry_password(void) {', ] if password_stem: c.append(' return &ui_page_%s_desc;' % password_stem) else: c.append(' return NULL;') c.append('}') c.append('') return '\n'.join(h), '\n'.join(c) def _read_text(path): """按原字节读文本(newline='' 防 Windows 把 LF 写成 CRLF)。""" with open(path, 'r', encoding='utf-8', newline='') as fp: return fp.read() def _write_text(path, text): with open(path, 'w', encoding='utf-8', newline='') as fp: fp.write(text) def prune_registration(): """删掉两份注册表里指向**已不存在的 ui_page_*.c** 的死条目,返回被删项。 与 sync_registration 对称:删页/改名后残留的条目会让 Keil 直接报 "cannot open source input file"(模拟器同理报源文件缺失),属必须清掉的死引用; 只匹配 ui_page_*.c 这一类(生成器自己负责的范围),不碰其它组/其它模块的条目。 """ pruned = [] uv = _read_text(UVPROJX) blk_re = re.compile(r'[ \t]*[ \t]*\r?\n' r'[ \t]*(ui_page_[a-z0-9_]*\.c)' r'.*?[ \t]*\r?\n', re.S) def _drop(m): fn = m.group(1) if os.path.isfile(os.path.join(UI_DIR, fn)): return m.group(0) pruned.append(('uvprojx', fn)) return '' new_uv = blk_re.sub(_drop, uv) if new_uv != uv: _write_text(UVPROJX, new_uv) cm = _read_text(CMAKELISTS) line_re = re.compile(r'^[ \t]*\$\{UI_DIR\}/(ui_page_[a-z0-9_]*\.c)[ \t]*\r?\n', re.M) def _drop_cm(m): fn = m.group(1) if os.path.isfile(os.path.join(UI_DIR, fn)): return m.group(0) pruned.append(('CMakeLists', fn)) return '' new_cm = line_re.sub(_drop_cm, cm) if new_cm != cm: _write_text(CMAKELISTS, new_cm) return pruned def sync_registration(filenames): """把 UI 目录下的 .c 文件名补进 uvprojx 与 CMakeLists 的显式列表,返回实际插入项。 只做定点插入、不重排不改写其余内容:uvprojx 由 UV4 维护,任何无关改动都会污染 diff(AGENTS 铁律"UV4 抖动不入库");已存在则不动(幂等,重复跑不产生 diff)。 """ inserted = [] # ① uvprojx:Drivers/BSP/UI 组的 末尾补 块 uv = _read_text(UVPROJX) gi = uv.find('%s' % UI_GROUP) if gi < 0: sys.exit('ERROR: uvprojx 找不到组 %r,无法自动注册(请检查工程结构)' % UI_GROUP) fe = uv.find('', gi) if fe < 0: sys.exit('ERROR: uvprojx 的 %s 组没有 ,无法自动注册' % UI_GROUP) line_start = uv.rfind('\n', 0, fe) + 1 add = '' for fn in filenames: if '..\\Drivers\\BSP\\UI\\%s' % fn in uv: continue add += (' \n' ' %s\n' ' 1\n' ' ..\\Drivers\\BSP\\UI\\%s\n' ' \n' % (fn, fn)) inserted.append(('uvprojx', fn)) if add: uv = uv[:line_start] + add + uv[line_start:] _write_text(UVPROJX, uv) # ② CMakeLists:set(UI_SOURCES ...) 列表末尾补 ${UI_DIR}/xxx.c cm = _read_text(CMAKELISTS) si = cm.find('set(UI_SOURCES') if si < 0: sys.exit('ERROR: CMakeLists.txt 找不到 set(UI_SOURCES ...),无法自动注册') ce = cm.find('\n)', si) if ce < 0: sys.exit('ERROR: CMakeLists.txt 的 UI_SOURCES 没有行首 ) 收尾,无法自动注册') line_start = ce + 1 add = '' for fn in filenames: if '${UI_DIR}/%s' % fn in cm[si:ce]: continue add += ' ${UI_DIR}/%s\n' % fn inserted.append(('CMakeLists', fn)) if add: cm = cm[:line_start] + add + cm[line_start:] _write_text(CMAKELISTS, cm) return inserted def main(): if not os.path.exists(SRC): sys.exit('ERROR: 未找到 ui_menu_tree.txt(应在仓库根目录)') text = open(SRC, encoding='utf-8-sig').read() nodes, directives = parse_tree(text) if len(nodes) > MAX_NODES: sys.exit('ERROR: 节点数 %d 超过 uint8_t 索引上限 %d(哨兵 MENU_ROOT=0xFF 占用 255);请精简菜单或改回 16 位索引' % (len(nodes), MAX_NODES)) unknown = sorted(set(directives) - {'password'}) if unknown: sys.exit('ERROR: 未知指令 @%s(当前仅支持 @password=<页面模块名>)' % ', @'.join(unknown)) bound, password_stem = validate_pages(nodes, directives) header, body, menus, max_depth = emit(nodes) reg_h, reg_c = emit_registry(bound, password_stem) # Drivers/BSP/UI/* 约定为 UTF-8 带 BOM(同 ui_menu.c);字符串字面量含中文, # 无 BOM 时 cl.exe(代码页 936) 会按 GBK 误读 UTF-8 字节导致字符串解析错乱。 for path, content in ((OUT_H, header), (OUT_C, body), (OUT_REG_H, reg_h), (OUT_REG_C, reg_c)): with open(path, 'w', encoding='utf-8-sig', newline='') as fp: fp.write(content) # 双端注册:注册表本身 + 每个绑定页实例(密码页也必须是已绑定或 @password 声明的) needed = ['ui_page_registry.c'] + sorted({'ui_page_%s.c' % p for _, _, p in bound}) pruned = prune_registration() # 先清死引用,再补缺目(改名场景两头都覆盖) inserted = sync_registration(needed) protected = sum(1 for _, _, _, p, _ in nodes if p) print('nodes=%d menu=%d leaf=%d protected=%d max_depth=%d' % (len(nodes), menus, len(nodes) - menus, protected, max_depth)) print('pages bound=%d password=%s' % (len(bound), password_stem or '(none)')) print('->', os.path.relpath(OUT_H, ROOT)) print('->', os.path.relpath(OUT_C, ROOT)) print('->', os.path.relpath(OUT_REG_H, ROOT)) print('->', os.path.relpath(OUT_REG_C, ROOT)) for target, fn in pruned: print(' pruned %-12s %s' % (target, fn)) for target, fn in inserted: print(' registered %-12s %s' % (target, fn)) if not pruned and not inserted: print(' registration up-to-date (uvprojx + CMakeLists unchanged)') if __name__ == '__main__': main()