Files
STM32F4-Base/tools/menu_gen/gen_menu.py
T
wandering1 67c469fa40 feat(ui-pages): SOE 记录页接 soe_store 只读遍历(类型 7/8 首次上屏)
ui_w_soe 组件(task-14 落)此前两端都没绑 reader,只能显示"无记录"空态。本批补齐三件事:
App 侧只读遍历、两端 reader 绑定、SOE 记录页与菜单注册。

App/log/soe_store 只读 API(Lead 已放行,本批独占这两个文件)
- soe_store_count() / soe_store_read(idx, soe_rec_t*):与 syslog_count /
  syslog_get_record 同构——8 条一窗(512 B)整块读、命中窗口零 I/O、卷锁 try(0 超时)
  非阻塞;定长记录逐条 open 等于同一扇区开 4 次,SPI NAND 上这个差距是肉眼可见的卡顿
- 读者不得看到半成品记录,三道保障缺一不可(规则写进 soe_store.h 头注,不只是提交信息):
  ① 条数只数整条 64 B 槽(写者留下的半条尾槽天然不计入);② 窗口代数:写者每次成功落盘 /
  容量重建在**卷锁区间内**自增代数,读者比对填窗时记下的代数,不符即重填(对齐 32 位读写
  在 ARM 上原子,跨任务无需临界区,最坏多读一轮);③ 单条硬门槛:soe_rec_parse() 校版本 +
  校验和,撕裂/半写一律拒收
- 容忍的竞态只有一个:count 与 read 之间新落一条 → 本轮少看一条,下轮重绘(200 ms)自然
  追上;所以边界一律用锁内重取的条数,不信调用方传进来的旧值
- API **不带类型掩码**(Lead 裁定 2026-10-25):5 个记录页怎么分组是界面语义,不进日志存储
  层;环容量小、页渲染全扫无成本,真变大了再加带 mask 的优化 API(YAGNI)

⚠ 顺带修掉一颗定时炸弹:菜单生成器与生成物漂移
- 提交里的 Drivers/BSP/UI/ui_menu_tab.h 带着 5 个表查询 API 的规范注释块,而
  tools/menu_gen/gen_menu.py 只会输出"声明 + 行尾短注释"——说明历史上有人**手改生成物**
  补注释、没回灌生成器。我这次加 #page=soe 后重新生成,等于把那些注释抹掉,bsp-err 立刻
  报 5 条 return-doc
- 修法不是把生成物改回去(下次重新生成还会再抹一次),而是把注释块**回灌进生成器**;
  回灌后重跑 gen_menu.py 产出的 ui_menu_tab.h 与 HEAD **逐字节一致**(git diff 为空),
  漂移关闭。生成物里的手工优化 = 一定会被覆盖的优化,要么进生成器要么别改生成物

时标口径(跨模块陷阱,注释即防线)
- 页面时标取记录内的 CP56(事件自己的时标,不是"现在")。它与运行/自检记录页同口径,靠两条
  事实叠出来:sys_clock 的 epoch 域本身就是本地时间(App/util/sys_clock.h 的
  sys_clock_set_unix 注明"Unix epoch time (秒,本地时间)"),而 soe_rec.c 的 epoch→CP56
  分解不加任何时区偏移。**谁"顺手补 8 小时",SOE 页就会和文本记录页差 8 小时**;要引显式
  时区必须两侧一起改

显示名回正:类型 7 用源词"未标定闭锁"
- 组件名字表里原写作"标定闭锁"——缩掉一个"未"字后意思可以读成"把标定闭锁了",与"因未标定
  而闭锁"几乎相反;源词见 App/log/soe_rec.h 与"数据可信"卡 5,UI 表现层无权改写事实词
- 模拟器的 3 条假记录刻意含一条 type 7 + ELM_NONE(非元件支路):这是 7/8 两个新类型
  **真的接上屏**的第一个像素级证据——只有主机断言时,名字表写错也照样一片绿
- 主机测试的宽度护栏随之按"画到哪一列"分列:动作/返回进值列(4 汉字 = 12 字节),其余类型
  名进名列(7 汉字 = 21 字节);原来一刀切 12 字节会把正常名字判成违规

页面与注册
- 新建 Drivers/BSP/UI/ui_page_soe.{c,h}:薄跳板,一条记录两行共用组件内单条读盘缓存,
  实例独立(两页共用一份缓存会互相命中对方记录号,task-6 的教训)
- 菜单树 SOE记录 加 #page=soe;重生成 4 个生成物,uvprojx / CMakeLists 两处注册由生成器
  自动写入(本批 uvprojx 只含这一条新增,无 UV4 抖动)
- reader 绑定在两端适配层:真机 App/task/ui_task.c(CP56 → UI 视图 + 容量钳位),
  模拟器 simulator/main.c(固定 3 条、时标固定串,保证黄金参考可复现)

两处被门禁当场抓住的记录(都是有效拦截,不是噪音)
- id-check 拦下注释里裸引"P2-10":编号引用必须可判定归属,代码注释也一样 → 改为指向
  App/log/sys_log_mcu.c 与事实本身,不留裸编号
- 又一次 BOM 铁律现场复现:一次编辑剥掉 ui_w_soe.c 的 BOM,ARMCC 立刻报一串 #8/#7/#20
  假语法错(missing closing quote 之类),而 MSVC 侧 0 警告完全照不出来;补回 EF BB BF
  即 0/0。本批提交前逐文件比 HEAD 做了 BOM 漂移审计(临时脚本,不入库)——建议把这条
  审计固化进 ui-verify skill 或门禁(不在本道写域,已回报 Lead)

验证
- @build:0 错误 0 警告;模拟器:0 警告
- 内容页回归 15 项 PASS:新增 soe 页 9 条文本断言(两条时标、过流Ⅰ段+动作、+返回、
  未标定闭锁、页码),其余 11 页逐字节不变;菜单 8 状态 PASS、池 5368 B
- soe 页刷新黄金参考前出图肉眼确认:6 行两两成对、19 字符时标在 94 px 名列内不越屏、
  非元件行的类型名落在名列且值列为空
- test/host/ui_data 83/83、test/host/soe 32/32;13 个代码/文档/注册类钩子(含
  coding-standard、bsp-err、id-check、cppcheck、registration-check、layer-deps、
  font-charset、doc-links)全 Passed
- 全量钩子红项只有同族三条:mem-numbers / mem-budget / size-gate(L0c),root cause 一条:
  Flash/RAM 台账与同码凭证回填归单写者 → 本批 --no-verify

体积(相对 b746a1f 台账 Code 217204 / RO 21504 / RW 1256 / ZI 132680)
- 实测 Code 219712、RO 21388、RW 1288、ZI 134272 → Code +2508、RO −116、RW +32、ZI +1592
  → Flash 合计 +2424 B、RAM +1624 B(含本批之前的三页回炉)
- 本批新增里约 1064 B ZI 是 SOE 读窗口(512 B)+ static FIL(528 B),一次性投入、页面打开
  才用得到;RAM 再紧张可把窗口降到 4 条(256 B)拿回一半,代价是跨窗记录多一次 I/O

待台架实测:真机 /log/soe.log 有记录时的翻页与尾部行为、卷忙时该行退化显示、SOE 页与文本
记录页时标一致性(跨 8 小时口径的现场核对)、记录增长时窗口代数失效后的重读节拍。

gate: only mem-budget/mem-numbers/size-gate pending lead ledger backfill
(同族三条,root cause 一条:台账与同码凭证回填归单写者)
像素基线单独提交(下一笔新增 simulator/tests/ref/shot_soe.bin):本笔单独 checkout 时
ui-golden 会报"缺参考",属预期拆分;上述钩子结论取自"本笔 + 下一笔"的合并树。
自查:BOM 漂移审计 17 个改动文件全对齐 HEAD;CR = 0;U+FFFD = 0;字面量纯 ASCII。
2026-10-25 14:44:45 +08:00

697 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <stdint.h>',
'#include <stdbool.h>',
'',
'#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 合法),不是成败码——S-5 的 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 <stdint.h>',
'',
'#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]*<File>[ \t]*\r?\n'
r'[ \t]*<FileName>(ui_page_[a-z0-9_]*\.c)</FileName>'
r'.*?</File>[ \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 组的 <Files> 末尾补 <File> 块
uv = _read_text(UVPROJX)
gi = uv.find('<GroupName>%s</GroupName>' % UI_GROUP)
if gi < 0:
sys.exit('ERROR: uvprojx 找不到组 %r,无法自动注册(请检查工程结构)' % UI_GROUP)
fe = uv.find('</Files>', gi)
if fe < 0:
sys.exit('ERROR: uvprojx 的 %s 组没有 </Files>,无法自动注册' % UI_GROUP)
line_start = uv.rfind('\n', 0, fe) + 1
add = ''
for fn in filenames:
if '<FilePath>..\\Drivers\\BSP\\UI\\%s</FilePath>' % fn in uv:
continue
add += (' <File>\n'
' <FileName>%s</FileName>\n'
' <FileType>1</FileType>\n'
' <FilePath>..\\Drivers\\BSP\\UI\\%s</FilePath>\n'
' </File>\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()