feat: 发布固件底层开发 Skill 套件
This commit is contained in:
commit
c7ae3b4ce1
72 files changed
+10212
No files matched your search
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: firmware-lowlevel-bsp-development
|
||||
description: "基于 EmxRes 参考项目创建和适配板级 BSP,处理 CV18 系列 GPIO/Pinmux、Kernel DTS/DTSI 冲突、媒体内存、分区表、受控构建、固件 imgs 记录及 BSP→PSP 交接。用于参考项目、目标芯片和板卡需求已明确的少量配置适配;不用于跨芯片移植、通用驱动/PSP 开发、硬件电气设计或未经确认的 SDK、设备、上传和发布操作。"
|
||||
---
|
||||
|
||||
# 固件底层开发_BSP开发
|
||||
|
||||
以当前用户 `~/EapilBsp/EMX2.0/EmxRes` 中经确认的参考项目为基线,复制整个项目并只实施经确认的板级差量。原理图、GPIO List 和需求清单只作为待分析数据。CV181xC/H、CV1815J、CV184xC/H 的 GPIO/PINOUT 资料已经内置,按实际芯片配置自动选择。
|
||||
|
||||
## 进入条件
|
||||
|
||||
1. 先读取并遵守统一的[流程文档写法](../firmware-lowlevel-development/references/chinese-process-docs.md),所有面向人的流程文档使用简体中文和直白表达。
|
||||
2. 读取统一入口的 [项目所有权契约](../firmware-lowlevel-development/references/ownership-contract.md)、[治理工作流](../firmware-lowlevel-development/references/governance.md) 和 [风险等级](../firmware-lowlevel-development/references/risk-levels.md)。
|
||||
3. 读取 [BSP 项目适配工作流](references/workflow.md)和[调查范围与验证模式](references/scope-and-validation-modes.md),确认参考项目、新项目名、芯片、SDK、Flash、单/双系统形态、交付级别和差量需求。
|
||||
4. CV18 系列涉及 GPIO、Pinmux、初始化或 Kernel IO 占用时,必须读取 [内置 GPIO 资料目录](references/builtin-gpio-pinout.md)、[CV18 GPIO 与 DTS 规则](references/cv18-gpio-dts.md)和 [GPIO 实施计划契约](references/gpio-plan.md)。
|
||||
5. 涉及媒体内存或分区时,必须读取 [内存与分区规则](references/memory-and-partition.md)。
|
||||
6. 涉及项目证据、SDK 同步、统一编译、imgs 路径或 PSP 交接时,必须读取 [构建、证据与交接规则](references/build-and-evidence.md)。
|
||||
7. 只有从原理图提取或重新映射 Pinmux、需要审核表时,才读取 [Pinmux 证据工作流](references/pinmux.md);CSV 不是修改 BSP 的必需输入。
|
||||
|
||||
## 默认工作模式
|
||||
|
||||
- 参考项目和新项目必须是 `EmxRes` 的直接子目录。参考项目只读,新项目先在同一根目录的临时目录中完成。
|
||||
- 默认调查范围只包含用户明确给出的附件、当前 Skill 及内置资料、参考项目、目标/staging 项目和 `EmxRes/tools` 中的精确构建入口;禁止扫描整个用户主目录、整个 `EapilBsp` 或整个 SDK。
|
||||
- 未指定的 GPIO、内存、分区、板卡版本和其他配置继承参考项目;禁止以“清理”“顺手升级”扩大差异。
|
||||
- 默认要求参考与目标的精确芯片、SDK、Flash 和单/双系统形态兼容。目录名只能提供线索,必须读取实际配置。
|
||||
- 新项目以 `<芯片型号>-<项目型号>` 命名;双系统追加 `_dualos`。额外版本、日期、存储或优化后缀只在用户明确要求时使用。
|
||||
- 开始持久化复制前,展示一次预检摘要、交付级别和完整 GPIO 实施计划并等待确认。确认覆盖本地复制、已列明差量、项目内文档、所选静态验证模式,以及任务需要构建产物时的标准全量编译;不覆盖设备、上传或发布。
|
||||
- 使用 `scripts/bsp/project_guard.py` 执行确定性的复制、清单、差异白名单和最终原子写入;项目特定配置由 Agent 根据真实结构修改,不使用万能文本替换。
|
||||
|
||||
## 允许的 BSP 差量
|
||||
|
||||
正常范围包括项目命名及必要引用、CV18 `cvi_board_init.c` 与有效 DTS/DTSI 的 GPIO 释放或重分配、项目级媒体 ION 内存、现有分区源定义、`imgs` 软链接,以及 `doc/bsp_adaptation/` 证据。
|
||||
|
||||
项目级媒体保留内存和已有分区配置属于本 Skill;DDR 初始化、MMU、通用分配器、存储驱动、Boot 逻辑、跨芯片适配和公共平台能力属于 PSP 或新的已确认工作包。不得修改应用业务。
|
||||
|
||||
## 不可绕过的门禁
|
||||
|
||||
- 目标目录已存在、参考项目本身有未确认改动、路径逃逸、非约定外部软链接、兼容性不成立或生效配置不唯一时停止。
|
||||
- 只对精确相对文件路径放行差异。项目名、GPIO、内存、分区和文档以外的变化不能进入正式目录。
|
||||
- GPIO 任务必须从项目 `buildEnvConfig.sh` 中唯一、静态的 `EP_CHIP` 自动选择内置设计表,并用 `scripts/bsp/query_builtin_pinout.py` 查询目标 Pad/GPIO/FMUX;不得根据项目目录名猜芯片。芯片没有唯一匹配的内置表时停止并向用户索取匹配资料,不得借用相近型号。
|
||||
- GPIO 的 Pad、Bank/Bit、方向、初值或 Kernel 消费者不明确时禁止猜测。功能明确无 GPIO 时移除参考项目旧控制并写清功能化注释。
|
||||
- 原理图派生 GPIO 必须先写入并通过 `scripts/bsp/validate_gpio_plan.py`,再由用户确认整个实施表;`provisional` 或 `blocked` 行不能进入代码。
|
||||
- 其他厂商芯片的媒体内存不自动修改;报告人工修改警告,但可继续独立的已确认工作。
|
||||
- 任务需要构建产物时,`bsp_preflight.py build` 通过后自动执行标准全量编译:在固定 `EmxRes` 根目录加载 `tools/script_completion.sh`,再运行 `EMX2_BuildSDKTool <项目名> <构建版本> all`,不再单独请求确认。标准脚本内部的配置同步和 `git fetch origin` 视为该构建的一部分。直接运行 `ep_kernel.sh`、`ep_uboot.sh`、`EPSYNCSDKConfig.sh`,改用组件构建、覆盖已有输出或修改统一构建脚本不属于自动范围,出现这些需求时停止并请用户确认。
|
||||
- 编译成功后必须核对唯一 `kernel.bin`、`rootfs.bin` 与实际生效分区表;每个镜像相对其所有对应分区中的最小容量必须至少保留 512 KiB(524288 字节),否则不能记录 `build_passed`。通过后持久化检查证据、精确 imgs 版本目录、产物 hash 和 PSP 交接 manifest,并用 `project_guard.py audit-final` 复核正式项目。
|
||||
- 本地配置检查通过只能声明项目已创建或 `local validation passed`;未执行同步、编译和板上验证时必须如实标注。
|
||||
- 完成时按 [记录规范](../firmware-lowlevel-development/references/records.md) 和 [交付契约](../firmware-lowlevel-development/references/deliverable-contract.md) 报告;不执行 `git add`、提交或远端推送。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "固件底层开发_BSP开发"
|
||||
short_description: "克隆参考 BSP 并适配 GPIO、内存和分区"
|
||||
default_prompt: "使用 $firmware-lowlevel-bsp-development 基于 EmxRes 参考项目创建并验证这个 BSP 配置项目。"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -0,0 +1,133 @@
|
||||
# 构建、Git、imgs 与 PSP 交接证据
|
||||
|
||||
## 项目内证据目录
|
||||
|
||||
新项目必须包含:
|
||||
|
||||
```text
|
||||
doc/bsp_adaptation/
|
||||
├── adaptation_report.md
|
||||
├── inputs/
|
||||
├── validation/
|
||||
├── handoff/
|
||||
│ └── psp_handoff.json
|
||||
└── status.json
|
||||
```
|
||||
|
||||
`adaptation_report.md` 至少记录参考与目标、芯片/SDK/Flash/系统形态、交付级别、调查范围、验证模式、用户确认、输入依据、修改文件、旧值与新值、继承项、GPIO/DTS 冲突处理、内存/分区检查、未执行验证、风险和真实状态。
|
||||
|
||||
小型 GPIO List、内存或分区需求原样复制到 `inputs/`。大型或敏感原理图、芯片手册和工程资料默认不重复复制,只记录受控路径、版本、适用型号和 SHA-256。禁止保存密钥、token、证书内容或许可证凭据。
|
||||
|
||||
CSV 只有从原理图提取或重新映射 Pinmux 时才作为派生审核证据写入 `inputs/`;它不是唯一事实源。
|
||||
|
||||
## 本地 Git
|
||||
|
||||
`EmxRes` 已有 Git worktree 只用于只读状态、参考项目脏文件发现和差异证据。不得执行 `git init`、`add`、`commit`、`stash`、清理、分支切换、历史改写或 `push`。
|
||||
|
||||
项目创建本身不需要远端 Git。根仓库其他项目的改动不阻塞;与参考或目标重叠的已有改动必须先确认。
|
||||
|
||||
## 项目创建阶段
|
||||
|
||||
仅创建 `config-project` 或只读审查时不执行:
|
||||
|
||||
- `ep_kernel.sh`;
|
||||
- `ep_uboot.sh`;
|
||||
- `EPSYNCSDKConfig.sh`;
|
||||
- `EMX2_BuildSDKTool`。
|
||||
|
||||
这些脚本会将配置复制或 `rsync` 到项目外 SDK,有的还会改变脚本权限或访问远端。仅创建配置项目时只完成目录、配置回读、DTS/GPIO 静态一致性、精确差异白名单和文档检查。任务要求 `bsp-build-artifacts` 或更高交付级别时,静态检查完成后按下一节自动执行标准全量编译。
|
||||
|
||||
## 统一编译
|
||||
|
||||
需求和实施方案已经确认、任务需要构建产物时,标准流程自动执行:
|
||||
|
||||
```bash
|
||||
cd ~/EapilBsp/EMX2.0/EmxRes
|
||||
source tools/script_completion.sh
|
||||
EMX2_BuildSDKTool <项目名> <YYYYMMDD_N> all
|
||||
```
|
||||
|
||||
`YYYYMMDD` 使用 Asia/Shanghai 当前日期;未指定时选择尚不存在的最小正整数序号。默认全量 `all`,只有用户明确要求时使用组件构建。
|
||||
|
||||
先运行:
|
||||
|
||||
```bash
|
||||
python3 <BSP Skill>/scripts/bsp/bsp_preflight.py build \
|
||||
--project <项目名> \
|
||||
--version <YYYYMMDD_N>
|
||||
```
|
||||
|
||||
只有 `passed=true` 才能自动执行。执行前把工具返回的精确工作目录、完整命令、项目与 SDK 路径、构建范围、最终 imgs 版本路径、工作区状态、配置同步副作用,以及统一脚本中的远端 `git fetch origin` 写入记录,不再请求第二次确认。不得从 `EmxRes` 的父目录启动,也不得依赖登录 Shell 的偶然当前目录。
|
||||
|
||||
下列情况不自动处理:预检失败、统一脚本或路径与固定规则不符、输出版本已存在、参考或目标出现新的重叠改动、需要直接运行同步脚本、需要组件构建、需要改变构建参数、需要修改统一脚本,或者构建失败后需要清理和重试。此时停止并向用户说明异常、影响和可选处理方式。
|
||||
|
||||
不得修改、注入或静默绕过统一构建脚本的远端检查。本地免远端模式属于独立工具优化任务。
|
||||
|
||||
## 输出冲突与权限
|
||||
|
||||
输出目录通常为:
|
||||
|
||||
```text
|
||||
/epbuild/EMX2.0/EmxRes/<项目名>/imgs/<构建版本>
|
||||
```
|
||||
|
||||
已存在时默认停止并建议下一序号。绝不自动向“强制删除并继续”回答 `yes`;覆盖需要针对精确绝对路径的独立确认。
|
||||
|
||||
构建前记录新项目内同步脚本权限;统一脚本临时 `chmod 777` 后,构建结束恢复已记录的原权限并写入报告。只恢复新项目内的已知文件,不改 SDK 或其他项目权限。
|
||||
|
||||
## 构建证据与状态
|
||||
|
||||
成功必须同时满足:
|
||||
|
||||
- 命令退出码为零;
|
||||
- 日志到达正常完成阶段;
|
||||
- 输出目录存在且含非空固件文件;
|
||||
- 当前项目实际生效的分区表可唯一确定,或已显式指定 `configs/` 内精确文件;
|
||||
- 最终 imgs 中唯一 `kernel.bin` 和 `rootfs.bin` 相对各自最小对应分区均保留至少 512 KiB(524288 字节);
|
||||
- 文件名、大小和 SHA-256 清单写入 `validation/`;
|
||||
- 构建后项目源码差异仍通过白名单。
|
||||
|
||||
满足以上条件后运行:
|
||||
|
||||
```bash
|
||||
python3 <BSP Skill>/scripts/bsp/record_build_handoff.py \
|
||||
--project <项目名> \
|
||||
--build-version <YYYYMMDD_N> \
|
||||
--build-exit-code 0 \
|
||||
--completion-marker-seen \
|
||||
--risk <仍存在的硬件风险>
|
||||
|
||||
python3 <BSP Skill>/scripts/bsp/project_guard.py audit-final \
|
||||
--target-project <项目名>
|
||||
```
|
||||
|
||||
自动发现多个 `partition*.xml` 时,上述记录命令必须追加:
|
||||
|
||||
```bash
|
||||
--partition-table configs/<实际生效路径>/partition_spinand.xml
|
||||
```
|
||||
|
||||
该参数只接受当前 BSP 项目 `configs/` 内的文件。检查失败时脚本不会生成 `build_passed` handoff。
|
||||
|
||||
记录脚本生成或更新:
|
||||
|
||||
- `validation/artifact_manifest_<构建版本>.sha256`;
|
||||
- `validation/image_partition_margin_<构建版本>.json`;
|
||||
- `handoff/psp_handoff.json`;
|
||||
- `status.json`。
|
||||
|
||||
`psp_handoff.json` 遵循统一入口的 [BSP→PSP 交接契约](../../firmware-lowlevel-development/references/bsp-psp-handoff.md)。`audit-final` 必须在这些文档写入后执行;它使用 prepare 时冻结的参考清单,而不是假定参考项目从未变化。
|
||||
|
||||
成功时保存简明摘要、kernel/rootfs 分区余量证据、固件哈希清单和精确 imgs 版本路径;失败时保存完整日志、阶段和关键错误。后续同步或编译失败不删除已经通过静态门禁的正式项目,只更新状态。不得只记录 `imgs` 软链接、父目录或“latest”;交付和 PSP 依赖必须绑定具体构建版本目录。
|
||||
|
||||
没有同步和编译时写:
|
||||
|
||||
```text
|
||||
本地 BSP 配置项目已创建;尚未同步 SDK,尚未完成编译验证
|
||||
```
|
||||
|
||||
本地编译成功也不能替代板上 GPIO、电平、时序、外设或负责人验收。
|
||||
|
||||
## 最终回复
|
||||
|
||||
完成构建时必须显式输出:项目路径、精确 imgs 版本路径、kernel/rootfs 的镜像大小、最小对应分区大小和实际余量、余量证据路径、交接 manifest 路径、产物清单/hash 路径、`audit-final` 结果,以及硬件/负责人/发布状态。没有生成交接 manifest 时不能声称已完成 PSP 交接。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 内置 CV18 GPIO/PINOUT 资料目录
|
||||
|
||||
本 Skill 内置五份芯片 GPIO/PINOUT 设计表。对下表覆盖的芯片,用户只需提供项目原理图或 GPIO List,不再重复提供芯片 GPIO 设计文档。内置表说明芯片 Pad、封装脚号和 FMUX 能力,不能替代项目原理图上的真实连接、有效电平和板级用途。
|
||||
|
||||
## 自动选表
|
||||
|
||||
优先读取目标项目;目标尚未创建时读取已确认的同芯片参考项目:
|
||||
|
||||
```bash
|
||||
python3 scripts/bsp/query_builtin_pinout.py \
|
||||
--project <EmxRes项目绝对路径> \
|
||||
--query <Pad、GPIO或FMUX功能> \
|
||||
--output <证据JSON>
|
||||
```
|
||||
|
||||
脚本从 `<项目>/buildEnvConfig.sh` 唯一、静态的 `EP_CHIP` 选表,校验内置文件 SHA-256,自动找到唯一的“管脚信息表”,并返回封装脚号、Pad、IO 属性、FMUX 寄存器、默认值、复用列表和 Excel 行号。`--query` 可重复;`GPIOA_7` 与表中的 `XGPIOA[7]` 按同一 GPIO 查询。只有在还没有可读取项目时,才允许用已确认的精确芯片执行 `--chip <芯片>`;目录名永远不能代替 `EP_CHIP`。
|
||||
|
||||
| `EP_CHIP` 范围 | 内置资料 | 目录 ID |
|
||||
|---|---|---|
|
||||
| `CV1810`、`CV1810C` 至 `CV1814C` 的 C/CA/CP/CPA 变体 | `CV181xC_PINOUT_V0.1_CN.xlsx` | `cv181xc` |
|
||||
| `CV1810H` 至 `CV1814H` 的 H/HA/HP/HPA 变体 | `CV181xH__PINOUT_V0.1_CN.xlsx` | `cv181xh` |
|
||||
| `CV1815J`、`CV1815JA` | `CV1815J__PINOUT_V0.1_CN.xlsx` | `cv1815j` |
|
||||
| `CV184xC` 的 C/CA/CP/CPA 变体,如 `CV1841CP`、`CV1842CP` | `CV184xC__PINOUT_V13_CN.xlsx` | `cv184xc` |
|
||||
| `CV184xH` 的 H/HA/HP/HPA 变体,如 `CV1842HP`、`CV1843HP` | `CV184xH__PINOUT_V13_CN.xlsx` | `cv184xh` |
|
||||
|
||||
精确规则、版本和文件 hash 由 `references/gpio-pinout/manifest.json` 管理。`CV1801B` 等未列型号必须失败并报告“没有对应的内置 GPIO 设计表”;禁止用名称相近的表降级处理。
|
||||
|
||||
## 证据与门禁
|
||||
|
||||
- 把查询输出保存到目标项目 `doc/bsp_adaptation/inputs/chip_pinout_lookup.json`,并在 `gpio_plan.json.source_evidence` 中记录查询结果的内置文件路径、SHA-256、工作表名和 Excel 行号。
|
||||
- 一个查询无匹配会使命令返回失败;先核对原理图标注和规范化名称,不能靠模糊匹配直接定案。`--match contains` 只用于探索候选,正式证据应回到精确 Pad、脚号或 FMUX 功能。
|
||||
- `EP_CHIP` 缺失、动态展开、重复冲突、无法唯一选表、hash 不一致或工作表结构改变时停止 GPIO 实施。
|
||||
- 芯片型号匹配不等于封装匹配。仍须用表中的脚号/球位与原理图封装交叉核对;不一致时阻塞,不生成最终 GPIO 结论。
|
||||
|
||||
## 维护内置资料
|
||||
|
||||
更新或新增表格时,将原始 XLSX 放入 `references/gpio-pinout/`,同步修改 `manifest.json` 的匹配规则、资料版本和 SHA-256,并运行:
|
||||
|
||||
```bash
|
||||
python3 scripts/bsp/test_query_builtin_pinout.py
|
||||
```
|
||||
|
||||
不得在未更新 hash 和行为测试的情况下替换内置表格。
|
||||
@@ -0,0 +1,86 @@
|
||||
# CV18 GPIO、Pinmux 与 Kernel DTS
|
||||
|
||||
## 适用范围
|
||||
|
||||
CV180*、CV181*、CV182*、CV183*、CV184* 项目先以实际 `buildEnvConfig.sh` 和配置布局确认芯片与有效文件。目录名和相近项目只能作为线索。
|
||||
|
||||
CV18 项目的 GPIO 复用、方向和启动安全初值通常由实际生效的 `cvi_board_init.c` 实现;Kernel DTS/DTSI 仍可能在 Linux 启动后声明同一 IO,必须同时检查。
|
||||
|
||||
## 内置 GPIO 资料查询
|
||||
|
||||
按 [内置 GPIO 资料目录](builtin-gpio-pinout.md)从项目 `buildEnvConfig.sh` 的 `EP_CHIP` 自动选择设计表,不要求用户重复提供已覆盖芯片的 PINOUT 文档。对 GPIO List 或原理图中的每个目标 Pad/GPIO/FMUX 执行 `scripts/bsp/query_builtin_pinout.py`,用返回的 Excel 行、Pad、Bank/Bit 复用项、FMUX 寄存器和默认值建立芯片侧证据。内置资料没有覆盖、查询无匹配或封装脚号冲突时停止;不得根据项目目录名或相近芯片猜测。
|
||||
|
||||
## `cvi_board_init.c` 语义
|
||||
|
||||
修改前读取整个相关函数,保留原有签名、调用阶段和代码风格:
|
||||
|
||||
- `PINMUX_CONFIG(pad, function)`:选择 Pad 复用功能;
|
||||
- `Gpio_Set_Dir(base, bit, 1)`:按现有 CV18 实现配置输出;
|
||||
- `Gpio_Set_Dir(base, bit, 0)`:按现有 CV18 实现配置输入;
|
||||
- `Gpio_Set_Value(base, bit, value)`:设置输出状态或安全初值;
|
||||
- `Gpio_Get_Value(base, bit)`:读取输入状态;
|
||||
- `cvi_board_init()`:启动期复用、方向和初值;
|
||||
- `cvi_update_led_control*()`、`cvi_get_key_status()` 等:运行期业务钩子,只修改与其功能相符的 GPIO;
|
||||
- `ep_flash_bootidx()` 等启动钩子:只有需求确实涉及其 GPIO 时修改。
|
||||
|
||||
不得只按函数名或文本位置替换。对每个目标信号建立:功能名、Pad、复用功能、Bank/Bit、方向、有效电平、启动初值、运行期钩子和来源证据。
|
||||
|
||||
## 新增、移除与“无 GPIO”
|
||||
|
||||
- 参考项目没有、目标项目需要:只有 Pad、复用功能、Bank/Bit、方向和初值完整时才新增真实代码。
|
||||
- 目标项目明确“无 GPIO”“未装配”或“不需要控制”:移除参考项目的旧复用、方向、初值及运行期控制,并在对应位置留下包含功能名的说明,例如:
|
||||
|
||||
```c
|
||||
// IR_LED: no GPIO on this board; no initialization required.
|
||||
```
|
||||
|
||||
- 信息不完整时禁止猜测寄存器、Bank/Bit 或初始电平;在报告中列为阻塞。
|
||||
|
||||
## Kernel DTS/DTSI 占用检查
|
||||
|
||||
先按 [调查范围与验证模式](scope-and-validation-modes.md)确定验证模式。两种模式都从实际构建使用的板级 `.dts` 开始,递归读取允许范围内的有效 `.dts`/`.dtsi`,不能扫描无关项目后把非生效引用当成冲突。
|
||||
|
||||
完整 DTS 审核模式下,SDK include 文件或条件编译上下文不可读时,不能得出“Kernel 未占用该 GPIO”的结论,并阻塞完整审核通过。EmxRes 配置模式下,只核对项目 Overlay 并明确降低验证等级;它不阻塞配置项目最终化,但在统一编译及最终 DTB 核对前不能声明 Kernel GPIO 占用已验证。
|
||||
|
||||
按 GPIO Bank/Bit 检查:
|
||||
|
||||
- `*-gpio`、`*-gpios` 和厂商自定义 GPIO 属性;
|
||||
- `scl-pinmux`、`sda-pinmux` 等厂商 Pinmux 属性;
|
||||
- 节点覆盖、子设备和 `status`;
|
||||
- 被条件编译或注释掉的定义,区分生效与非生效;
|
||||
- 新旧消费者和 `cvi_board_init.c` 之间的用途与有效电平。
|
||||
|
||||
## 释放旧消费者
|
||||
|
||||
优先最小化释放,沿用项目现有 Device Tree 语法:
|
||||
|
||||
1. 只冲突单个 GPIO 属性时,在最接近板级的有效节点覆盖中使用 `/delete-property/`,保留模块其他功能。
|
||||
2. 软件 I2C 等成组配置中,相关 `scl/sda-pinmux` 与 `scl/sda-gpios` 必须成对处理,不能留下半套声明。
|
||||
3. 只有用户明确不使用整个模块,或释放 IO 后模块已经无法成立时,才设置 `status = "disabled"`。
|
||||
4. 只停用总线下某个设备时,禁用对应子节点,不牺牲整个控制器。
|
||||
5. 若模块仍有必要功能且无法安全释放单个 IO,停止并报告冲突。
|
||||
|
||||
示例:
|
||||
|
||||
```dts
|
||||
/* Release GPIOC_13 for LCD_DC; old board used it as display reset. */
|
||||
&old_display {
|
||||
/delete-property/ reset-gpios;
|
||||
};
|
||||
```
|
||||
|
||||
若新 GPIO 只用于 U-Boot 启动期初始化,DTS 只解除旧消费者,不虚构新设备节点。若它属于 Kernel 驱动管理的新设备,则在释放旧消费者后声明给正确新节点。
|
||||
|
||||
## 验证
|
||||
|
||||
逐个目标信号确认:
|
||||
|
||||
- Pad 复用与 GPIO Bank/Bit 一致;
|
||||
- 输入没有无意义的输出初值,输出有明确安全初值;
|
||||
- 同一 Pad 或 GPIO 没有重复用途;
|
||||
- 运行期函数与启动期初始化引用一致;
|
||||
- 明确无 GPIO 的功能没有残留旧控制代码;
|
||||
- 项目 Overlay 与 SDK 的完整有效 DTS/DTSI include 链中,旧消费者、Pinmux 和 GPIO 属性已解除;
|
||||
- 新 Kernel 消费者与 U-Boot 的用途和极性不冲突。
|
||||
|
||||
完整 DTS 审核模式检查整个有效 include 链;EmxRes 配置模式检查项目 Overlay,并在统一构建后尽量从最终 DTB 核对。不能执行对应验证时记录准确的未验证项,不得声称 Kernel 已验证。
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"documents": [
|
||||
{
|
||||
"id": "cv181xc",
|
||||
"revision": "V0.1_CN",
|
||||
"file": "CV181xC_PINOUT_V0.1_CN.xlsx",
|
||||
"sha256": "422b0114b24d20917c0b67e30622012d67082637cb7700ddfa0bffe23949c2a7",
|
||||
"chip_patterns": ["^CV181[0-4]C(?:P)?A?$"],
|
||||
"chip_aliases": ["CV1810"]
|
||||
},
|
||||
{
|
||||
"id": "cv181xh",
|
||||
"revision": "V0.1_CN",
|
||||
"file": "CV181xH__PINOUT_V0.1_CN.xlsx",
|
||||
"sha256": "3f066800b8233e485f0f6422a1b752cf00e6fb15ca37692ebba17141cd23de30",
|
||||
"chip_patterns": ["^CV181[0-4]H(?:P)?A?$"],
|
||||
"chip_aliases": []
|
||||
},
|
||||
{
|
||||
"id": "cv1815j",
|
||||
"revision": "V0.1_CN",
|
||||
"file": "CV1815J__PINOUT_V0.1_CN.xlsx",
|
||||
"sha256": "d65c73ff850b0c32d6d8a4e0846c9cf8cde1b6ba849112b14789a8e1813922d4",
|
||||
"chip_patterns": ["^CV1815JA?$"],
|
||||
"chip_aliases": []
|
||||
},
|
||||
{
|
||||
"id": "cv184xc",
|
||||
"revision": "V13_CN",
|
||||
"file": "CV184xC__PINOUT_V13_CN.xlsx",
|
||||
"sha256": "6a796107dc87d2d59f375fac68a1887b5813e3ab5a2104596dffc1bf241bd331",
|
||||
"chip_patterns": ["^CV184[0-9]C(?:P)?A?$"],
|
||||
"chip_aliases": []
|
||||
},
|
||||
{
|
||||
"id": "cv184xh",
|
||||
"revision": "V13_CN",
|
||||
"file": "CV184xH__PINOUT_V13_CN.xlsx",
|
||||
"sha256": "260d2d7ea9169f970129932762f1a3ae27a887de1363553c805ede17ce9de109",
|
||||
"chip_patterns": ["^CV184[0-9]H(?:P)?A?$"],
|
||||
"chip_aliases": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# GPIO 实施计划契约
|
||||
|
||||
原理图或 GPIO List 导致 GPIO/Pinmux/DTS 变化时,在写代码前创建:
|
||||
|
||||
```text
|
||||
doc/bsp_adaptation/inputs/gpio_plan.json
|
||||
```
|
||||
|
||||
CSV 可作为附加审核视图,但不能替代该计划。计划顶层包含 `schema_version=1`、项目名、验证模式、来源、确认信息和 `signals`。每个信号至少包含:
|
||||
|
||||
- `function`:稳定的板级功能名;
|
||||
- `pad`、`mux`;
|
||||
- `gpio`:GPIO 时写 `A14`、`C17`、`PWR22` 等,非 GPIO 写 `null`;
|
||||
- `direction`:`input`、`output`、`peripheral` 或 `none`;
|
||||
- `active_level`:`high`、`low`、`paired`、`unknown` 或 `null`;
|
||||
- `boot_value`:输出为 `0`/`1`,其他方向为 `null`;
|
||||
- `runtime_owner`;
|
||||
- `dts_consumers`:已知的旧/新消费者数组;
|
||||
- `source_evidence`:带页码、表格行或文件位置的来源数组;芯片侧证据必须包含自动选中的内置表路径、SHA-256、工作表名和 Excel 行号;
|
||||
- `decision`:`confirmed`、`inherited`、`no-gpio`、`provisional` 或 `blocked`;
|
||||
- `note`。
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
python3 scripts/bsp/validate_gpio_plan.py <gpio_plan.json>
|
||||
```
|
||||
|
||||
校验器检查字段、Pad/GPIO 重复、方向与启动值、GPIO/非 GPIO 语义和决策状态。写代码前向用户展示同一份实施表;用户确认后写入 `confirmation.confirmed=true`、确认时间和摘要,再次运行校验器。
|
||||
|
||||
只有 `confirmed`、`inherited` 和 `no-gpio` 行可以进入已确认实现。`provisional` 或 `blocked` 行必须先补证据或取得明确硬件决定。`no-gpio` 行不得生成虚构 GPIO 代码,应移除参考项目旧控制并留下包含功能名的注释。
|
||||
|
||||
对内置目录覆盖的 CV18 芯片,先按 [内置 GPIO 资料目录](builtin-gpio-pinout.md)运行 `query_builtin_pinout.py`,再建立计划。项目原理图或 GPIO List 负责说明板级连接与目标用途;内置表负责证明 Pad、脚号和 FMUX 能力,二者不能互相替代。
|
||||
@@ -0,0 +1,75 @@
|
||||
# 媒体内存与分区适配
|
||||
|
||||
## 通用规则
|
||||
|
||||
媒体内存由用户提供明确数值和单位。Skill 可以转换为目标文件已有格式,但不根据算法、媒体规格或经验自行计算容量。
|
||||
|
||||
写入前读取实际构建引用和解析格式;写入后重新解析,验证字节值、唯一性和已知容量约束。未知单位、目标键重复、文件不唯一或配置链不明确时停止对应修改。
|
||||
|
||||
## CV181X 媒体内存
|
||||
|
||||
规范化芯片型号后,`CV181*` 路由为 CV181X。典型型号包括 CV1810、CV1811C、CV1815JA。
|
||||
|
||||
从参考项目实际构建配置定位唯一生效的 `memmap.py`,只修改:
|
||||
|
||||
```python
|
||||
ION_SIZE
|
||||
```
|
||||
|
||||
保持该文件现有的数值表达、对齐和注释风格。不存在、存在多个生效候选或 `ION_SIZE` 语义不明时停止,不创建推测性文件。
|
||||
|
||||
## CV184X 媒体内存
|
||||
|
||||
规范化芯片型号后,`CV184*` 路由为 CV184X。典型型号包括 CV1841CP、CV1842CP、CV1842HP、CV1843HP。
|
||||
|
||||
根据实际板卡 build type 定位唯一的:
|
||||
|
||||
```text
|
||||
cv18xxcp_wevb_xxxx_spinand_defconfig
|
||||
```
|
||||
|
||||
用户必须同时明确:
|
||||
|
||||
```text
|
||||
CONFIG_ION_SIZE
|
||||
CONFIG_RTOS_ION_SIZE
|
||||
```
|
||||
|
||||
小核不使用 ION 时也要明确给出零值或禁用值。只修改板卡 defconfig,不直接修改 `configs/kernel/.config`。若 `.config` 中存在不同值,记录差异并通过后续配置生成验证;项目流程把 `.config` 当作不可再生输入时停止并报告。
|
||||
|
||||
`xxxx` 是板卡版本;每套配置下应定位一个板卡 defconfig。默认继承参考版本。用户改变版本时按主工作流一致更新整个引用链。
|
||||
|
||||
## 其他芯片
|
||||
|
||||
SigmaStar 等其他前缀不自动修改媒体内存。报告目标文件和人工修改警告;GPIO、分区或项目命名等独立工作可以继续。
|
||||
|
||||
前缀匹配 CV181/CV184 但项目中不存在对应结构时也不得硬套规则。
|
||||
|
||||
## 分区表差量
|
||||
|
||||
用户主要提供发生变化的分区名称和目标大小。未提及的分区名称、顺序、属性、偏移和大小继承参考项目。
|
||||
|
||||
1. 从板卡版本、Flash 类型和同步脚本定位唯一生效的分区源定义;不修改 SDK 生成副本。
|
||||
2. 对项目原有自动顺序布局,按同一规则重新计算受影响的后续偏移。
|
||||
3. 固定绝对偏移、Boot、保留区域和安全相关分区不自动移动。
|
||||
4. 检查擦除块对齐、偏移重叠、总容量、保留区以及可用镜像能否装入。
|
||||
5. 多个分区变体并存且无法从实际 build type 唯一定位时停止。
|
||||
6. 不修改存储驱动、分区解析器、Boot 选择逻辑或升级策略;这些是新的工作包。
|
||||
|
||||
## 编译后镜像余量门禁
|
||||
|
||||
全量 BSP 编译完成后必须从当前项目 `configs/` 唯一定位实际生效的 `partition*.xml`;存在多个候选时,不按文件名或时间猜测,必须通过 `record_build_handoff.py --partition-table <项目内精确路径>` 指定构建实际使用的文件。
|
||||
|
||||
对最终版本 imgs 目录执行以下强制检查:
|
||||
|
||||
- `kernel.bin` 和 `rootfs.bin` 都必须是唯一、非空普通文件;`kerne.bin` 不作为别名,除非后续实际构建和分区契约正式改变文件名;
|
||||
- 按分区 XML 的 `file` 属性查找对应项并将十进制 `size_in_kb` 按 1024 字节换算;
|
||||
- 同一镜像对应 A/B 等多个分区时,以其中最小分区作为有效容量;
|
||||
- `有效分区字节数 - 实际镜像字节数 >= 512 * 1024` 才通过,恰好 512 KiB 允许通过;
|
||||
- 镜像缺失/重复、分区项缺失、大小非法、XML 无法解析或余量不足都阻止 `build_passed` 和 BSP→PSP 交接。
|
||||
|
||||
通过结果记录分区表绝对路径和 SHA-256、镜像绝对路径/大小、全部对应分区、最小容量、实际余量和固定阈值。该门禁不允许通过修改阈值、四舍五入为 MB 或选择较大的 A/B 分区绕过。
|
||||
|
||||
## 证据
|
||||
|
||||
在 `adaptation_report.md` 记录每个配置键或分区的原值、新值、单位转换、源文件、有效构建类型和检查结果。未知总容量或未执行编译后 kernel/rootfs 余量检查时明确列为未验证,不合成通过。
|
||||
@@ -0,0 +1,46 @@
|
||||
# BSP 原理图 Pinmux 审核证据
|
||||
|
||||
根据用户提供的项目原理图与本 Skill 自动选择的内置芯片引脚表,生成可审核的项目 Pinmux CSV。CSV 是原理图到 BSP 配置的派生视图,不是修改 `cvi_board_init.c`、DTS/DTSI 或其他 BSP 源码的必需输入,也不能独立表达完整的方向、初值、Pad 电气和初始化顺序。
|
||||
|
||||
## 输入和输出
|
||||
|
||||
输入包括项目名称、项目原理图和可读取的 EmxRes 项目。按 [内置 GPIO 资料目录](builtin-gpio-pinout.md)读取 `buildEnvConfig.sh` 的 `EP_CHIP` 并选取对应 PINOUT;对已覆盖芯片不再要求用户提供 GPIO/FMUX 手册。项目名称没有明确给出时,从原理图标题栏、图号或文件名推导,并在交付时说明。内置目录未覆盖该芯片时才列为缺失输入并请求匹配资料。
|
||||
|
||||
需要 CSV 时,输出文件名为 `<项目名称>_pinmux.csv`。新 BSP 项目中的默认位置为 `doc/bsp_adaptation/inputs/`。清理文件名中的 `/\\:*?\"<>|`,将连续空白替换为下划线,保留可安全使用的中文。CSV 使用 UTF-8 BOM。
|
||||
|
||||
CSV 仅保留原理图中实际使用且拥有至少两种编号 FMUX 选择的信号引脚。表头及顺序严格为:
|
||||
|
||||
1. `引脚号`
|
||||
2. `功能名称`
|
||||
3. `引脚名称`
|
||||
4. `有效电平`
|
||||
5. `FMUX寄存器`
|
||||
6. `FMUX当前值`
|
||||
7. `FMUX默认值`
|
||||
8. `FMUX_list`
|
||||
|
||||
生成表格前必须阅读 [workflow-and-schema.md](workflow-and-schema.md),按其中的数据结构、有效电平规则和验收要求执行。
|
||||
|
||||
## 执行流程
|
||||
|
||||
1. 识别原理图中的主芯片型号、器件号、封装和所有分单元页面;同时读取项目 `buildEnvConfig.sh` 的唯一 `EP_CHIP`。两者冲突时停止。
|
||||
2. 提取主芯片所有实际连接的信号脚。排除电源、地、EPAD、明确 NC 和未连接脚,但不能因当前功能不是 GPIO 而排除。
|
||||
3. 执行 `query_builtin_pinout.py --project <项目> --query <目标Pin或功能>`,从自动选择的内置表提取球位、Pad 名、FMUX 寄存器、默认值,以及每个编号对应的全部复用能力;查询 JSON 保存在 `doc/bsp_adaptation/inputs/chip_pinout_lookup.json`。
|
||||
4. 以规范化 Pad 名为主键对齐原理图和手册,并用球位、封装和功能交叉检查。型号或封装不匹配时停止 CSV 生成,只交付资料差异和冲突清单;型号与封装已匹配但个别字段仍缺少证据时,才将该字段写为 `UNKNOWN` 或 `待确认` 并报告风险。
|
||||
5. 根据 Pad 支持能力和原理图实际用途确定 `FMUX当前值`。网络名只是线索;网络名声称的功能不在该 Pad 复用表中时,保留手册事实并报告矛盾。
|
||||
6. 只有当前目标功能为通用 GPIO 时才分析 `有效电平`。沿实际信号路径检查上下拉、反相器、三极管、MOS、驱动阵列、低有效命名和上电状态。明确时填 `高有效` 或 `低有效`,证据不足填 `待确认`;非 GPIO 一律填字符串 `null`。
|
||||
7. 建立规范化 JSON,并执行:
|
||||
|
||||
`python3 scripts/bsp/build_pinmux_csv.py --input <normalized.json> --output-dir <交付目录>`
|
||||
|
||||
8. 检查脚本结果,回查寄存器、默认值、当前值、有效电平和所有资料冲突。只有型号与封装匹配且脚本验收通过时才把 CSV 作为审核证据;硬阻塞存在时停在 `investigating` 或 `blocked` 并交付冲突清单。
|
||||
|
||||
## 不可破坏的规则
|
||||
|
||||
- “实际使用”以真实电气连接为准,而不是仅凭网名或相邻文字。
|
||||
- “多种 FMUX”要求 `FMUX_list` 中至少有两个不同编号项,例如 `0:GPIOA_1; 3:UART1_TX`。无编号的 MIPI、Audio、EPHY 等特殊模式可以记录,但不能单独满足筛选条件。
|
||||
- `功能名称`写原理图网络名或可验证的板级用途;`引脚名称`写芯片 Pad 名;`引脚号`优先写 BGA 球位。
|
||||
- `FMUX当前值`是实现原理图当前用途所需的编号,不是寄存器复位值;`FMUX默认值`必须来自匹配的芯片手册。
|
||||
- GPIO 的 `待确认` 与非 GPIO 的 `null` 含义不同,不能混用;禁止猜测缺失事实。
|
||||
- 型号或封装不匹配是生成门禁,不是可用 `UNKNOWN` 降级放行的普通字段缺失。
|
||||
- 内置表路径、SHA-256、工作表名和 Excel 行号必须进入来源证据;目录名不能作为芯片或 Pinmux 证据。
|
||||
@@ -0,0 +1,38 @@
|
||||
# BSP 调查范围与验证模式
|
||||
|
||||
## 默认范围
|
||||
|
||||
默认只读取:
|
||||
|
||||
- 用户明确给出的原理图、GPIO List、规格书和需求清单;
|
||||
- 当前 BSP Skill 的指令、脚本和 `references/gpio-pinout/` 内置资料;
|
||||
- `~/EapilBsp/EMX2.0/EmxRes/<参考项目>`;
|
||||
- 已确认后的目标 staging/正式项目;
|
||||
- `EmxRes/tools/script_completion.sh` 及其为解释实际构建副作用所必需的精确相邻脚本;
|
||||
- 构建后的 `/epbuild/EMX2.0/EmxRes/<目标项目>/imgs/<构建版本>`。
|
||||
|
||||
不得用 `find`、`rg` 或同类工具扫描整个用户主目录、整个 `EapilBsp`、整个 SDK 或无关项目。找不到资料时列出缺失项和建议的精确路径,不扩大搜索范围。用户主动收紧范围后立即采用更窄边界;报告曾经执行但被废弃的越界只读调查,不能写成“从未搜索”。
|
||||
|
||||
## EmxRes 配置模式(默认)
|
||||
|
||||
适用于用户只允许查看项目 Overlay 和统一编译的情况:
|
||||
|
||||
1. 人工分析只覆盖参考/目标项目中的 `buildEnvConfig.sh`、配置目录、`cvi_board_init.c`、板级 DTS/DTSI、defconfig、分区和同步脚本。
|
||||
2. 不人工搜索 SDK 源码,也不据此声称完整 include 链已审计。
|
||||
3. 项目最终化前允许声明“项目级静态检查通过”;不能声明最终 Kernel GPIO 占用已验证。
|
||||
4. 任务需要构建产物时,预检通过后自动执行统一全量编译,并以实际编译成功和生成的最终 DTB/FIT 为生效证据。能从最终 DTB 可靠反编译时,核对目标 GPIO 消费者并保存结果;不能反编译时明确标记未验证。
|
||||
|
||||
该模式不会因为禁止人工读取 SDK 而阻塞项目创建,但会降低 DTS 结论的验证等级。
|
||||
|
||||
## 完整 DTS 审核模式
|
||||
|
||||
仅在用户授权读取精确 SDK 基线路径时使用:
|
||||
|
||||
1. 从实际板级 DTS 开始递归解析有效 DTS/DTSI include 链和条件编译上下文。
|
||||
2. 读取范围限于为该 build type 解析出的精确文件和必要的 Pinmux 定义;仍禁止扫描整个 SDK。
|
||||
3. include 无法解析或上下文不唯一时,目标 GPIO 占用检查不得判为通过。
|
||||
4. 本模式只授予只读分析;只有任务明确进入构建产物交付级别时,才按 BSP 构建规则自动执行标准全量编译。其他 SDK 写入仍需用户确认。
|
||||
|
||||
## 模式记录
|
||||
|
||||
预检摘要和 `adaptation_report.md` 必须记录所选模式、允许路径、排除路径、已执行检查和结论上限。模式变化会使此前依赖更宽范围得出的未确认结论失效。
|
||||
@@ -0,0 +1,75 @@
|
||||
# Pinmux 派生 CSV 数据结构与验收规范
|
||||
|
||||
本规范只约束按需生成的审核 CSV。BSP 代码修改仍以经确认的 GPIO/Pinmux 需求、自动选择并校验的内置目标芯片资料、参考项目源码和实际构建引用为依据。
|
||||
|
||||
## 规范化 JSON
|
||||
|
||||
导出脚本接收 UTF-8 JSON 对象:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_name": "项目名称",
|
||||
"rows": [
|
||||
{
|
||||
"pin_number": "A12",
|
||||
"function_name": "CAM_RST0",
|
||||
"pin_name": "PAD_MIPI_TXM4",
|
||||
"is_gpio": true,
|
||||
"active_level": "低有效",
|
||||
"fmux_register": "0x030010A4",
|
||||
"fmux_current": "3",
|
||||
"fmux_default": "0",
|
||||
"fmux_list": "0:XGPIOA_20; 1:CAM_MCLK0; 3:GPIOA_20; MIPI:TXM4"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| JSON 字段 | 最终列 | 规则 |
|
||||
|---|---|---|
|
||||
| `pin_number` | 引脚号 | BGA 球位优先;非 BGA 填封装脚号 |
|
||||
| `function_name` | 功能名称 | 原理图网络名或经核实的板级用途 |
|
||||
| `pin_name` | 引脚名称 | 芯片手册中的 Pad 名 |
|
||||
| `is_gpio` | 不输出 | 当前用途是否为通用 GPIO,不是 Pad 是否支持 GPIO |
|
||||
| `active_level` | 有效电平 | GPIO 为 `高有效`、`低有效`或`待确认`;非 GPIO 为 `null` |
|
||||
| `fmux_register` | FMUX寄存器 | 完整地址或手册定义的寄存器名 |
|
||||
| `fmux_current` | FMUX当前值 | 当前用途对应编号;不确定填 `待确认` |
|
||||
| `fmux_default` | FMUX默认值 | 手册复位默认值;不确定填 `待确认` |
|
||||
| `fmux_list` | FMUX_list | `编号:功能`,以 `; ` 分隔;可保留特殊模式说明 |
|
||||
|
||||
## 资料提取
|
||||
|
||||
- 先定位主芯片全部分单元页面和封装表,再沿导线、网络标签、页间端口及器件连接确认每个信号脚用途。
|
||||
- 芯片 XLSX 由 [内置 GPIO 资料目录](builtin-gpio-pinout.md)自动选择并使用 `query_builtin_pinout.py` 解析;禁止绕过 manifest 的型号映射和 SHA-256 校验直接借用相近表格。
|
||||
- PDF 优先使用保布局文本提取;文本层错位或原理图密集时,将关键页面转为图像核查。
|
||||
- 为候选 Pad 保留来源页码或工作表行号,至少覆盖型号、封装、球位、Pad、FMUX 地址、默认值、编号映射和特殊模式。
|
||||
|
||||
## 有效电平判定
|
||||
|
||||
仅在 `is_gpio=true` 时判定:
|
||||
|
||||
- GPIO 直接连接使能、复位、LED、按键或驱动器时,依据器件逻辑及真实电流路径判断。
|
||||
- 奇数级 NPN/NMOS 共射或共源级通常造成逻辑反相;P 沟道高边电路还要结合栅极驱动方向。
|
||||
- 上拉且按下接地的按键通常低有效;`RSTN`、`RESET#`、`CS_N` 是低有效线索,但仍需核对中间反相级。
|
||||
- 上下拉可能只定义上电安全态,不能单独作为有效电平结论。
|
||||
- 证据冲突或信号链不完整时填 `待确认`,并说明缺少的证据。
|
||||
|
||||
非 GPIO 当前用途必须填 `null`,即使该 Pad 可以复用为 GPIO。
|
||||
|
||||
## 多 FMUX 筛选
|
||||
|
||||
`FMUX_list` 中,以字符串开头或 `; ` 后出现的 `0:` 到 `7:` 为编号选项。至少有两个不同编号才输出。
|
||||
|
||||
- 保留:`0:GPIOA_1; 2:PWM_1; 3:UART1_TX`
|
||||
- 排除:`0:GPIOA_1; MIPI:RX0`
|
||||
|
||||
## 验收清单
|
||||
|
||||
- 文件名为安全化的 `<项目名称>_pinmux.csv`,编码为 UTF-8 BOM。
|
||||
- 表头严格为八列,顺序不变。
|
||||
- 每个球位最多一行,且每行至少有两个不同编号 FMUX 选项。
|
||||
- GPIO 有效电平不是 `null`;非 GPIO 有效电平恰为 `null`。
|
||||
- `FMUX当前值`为编号时,该编号存在于 `FMUX_list`。
|
||||
- 球位、Pad、寄存器和默认值来自匹配型号及封装的资料。
|
||||
- `待确认`、`UNKNOWN` 和功能冲突均在交付信息中逐项列出;型号或封装不匹配时禁止生成最终 CSV,只交付差异与冲突清单。
|
||||
- 不包含电源、地、EPAD、NC 或未连接脚。
|
||||
@@ -0,0 +1,142 @@
|
||||
# BSP 项目适配工作流
|
||||
|
||||
## 1. 识别模式
|
||||
|
||||
本工作流用于在当前用户的 `~/EapilBsp/EMX2.0/EmxRes` 中,从一个已确认参考项目创建新 BSP 配置项目。`EmxRes` 是配置 Overlay;不能假定它是完整 SDK 源码树。
|
||||
|
||||
若用户只要求审查已有 BSP,不创建项目,则执行对应的只读检查,不运行复制和最终写入步骤。
|
||||
|
||||
## 2. 最低输入与继承
|
||||
|
||||
始终需要参考项目名、新项目型号、精确芯片型号、单/双系统形态、验证模式和交付级别。交付级别必须明确为以下之一:
|
||||
|
||||
- `config-project`:仅创建 BSP 配置项目;
|
||||
- `bsp-build-artifacts`:再运行统一构建并交付 imgs 版本目录;
|
||||
- `flash-package`:要求项目原生流程产生明确的可烧录/升级包;
|
||||
- `production-package`:要求量产整片包、签名或发布时另行确认对应权限。
|
||||
|
||||
用户要求修改某一领域时再要求对应输入:
|
||||
|
||||
- GPIO:GPIO List 或原理图;已覆盖 CV18 芯片的 GPIO/PINOUT 设计表由 Skill 内置并自动选择;
|
||||
- 媒体内存:明确数值和单位;
|
||||
- 分区:变化的分区名称和目标大小。
|
||||
|
||||
未提及的领域完整继承参考项目。用户直接提供的 GPIO List 是目标需求;只提供原理图时,先按 [GPIO 实施计划契约](gpio-plan.md)提取候选差量并让用户确认。
|
||||
|
||||
## 3. 只读预检
|
||||
|
||||
1. 将 `~` 解析为执行任务的当前用户主目录,确认参考项目是 `EmxRes` 的直接子目录,目标也是新的直接子目录。
|
||||
2. 检查目标正式目录和同名残留目录;正式目录已存在时停止,不覆盖。
|
||||
3. 先运行 `scripts/bsp/bsp_preflight.py project`,再只读取报告定位的 `buildEnvConfig.sh`、配置同步脚本和实际配置目录,发现精确芯片、SDK、Flash、架构、单/双系统形态和板卡 build type。
|
||||
4. GPIO 任务按 [内置 GPIO 资料目录](builtin-gpio-pinout.md)从 `buildEnvConfig.sh` 的 `EP_CHIP` 自动选表并查询全部目标信号;将结果保存为输入证据。目录名与 `EP_CHIP` 冲突时以后者为芯片配置事实并报告命名风险,原理图芯片与 `EP_CHIP` 冲突时停止。
|
||||
5. 按 [调查范围与验证模式](scope-and-validation-modes.md)执行 GPIO/DTS 检查。默认 EmxRes 配置模式不人工搜索 SDK;完整 DTS 审核模式只读取精确解析出的 include 链。
|
||||
6. 默认要求参考与目标使用相同精确芯片、SDK、Flash 和系统形态。不同芯片或布局转换不是少量适配,要求更接近的参考项目或新的工作包。
|
||||
7. 只读检查 `EmxRes` Git 状态。参考项目自身包含已修改或未跟踪文件时,列出并要求确认复制当前磁盘状态;其他项目的改动只记录、不阻塞。
|
||||
8. 扫描软链接。允许保留项目内相对链接;约定的 `imgs` 链接按第 6 节处理;其他指向 `EmxRes` 外部的链接要求确认。
|
||||
9. 发现凭据、私钥、token 文件或未知大型生成物时停止复制该项并报告,不能静默带入新项目。
|
||||
|
||||
## 4. 命名与板卡版本
|
||||
|
||||
默认目录名:
|
||||
|
||||
```text
|
||||
<大写芯片型号>-<项目型号>
|
||||
<大写芯片型号>-<项目型号>_dualos
|
||||
```
|
||||
|
||||
项目型号内部原有连字符可保留。`_dualos` 由明确的双系统形态决定,不能从 `CONFIG_RTOS_ION_SIZE` 单独推断。`_v6.3.3`、`_optimize`、日期、Flash 等额外后缀只有用户明确要求时添加。
|
||||
|
||||
板卡 build type 中的 `xxxx` 默认继承参考项目。用户明确改变时,必须一致修改配置目录、板卡 defconfig、DTS 名称和所有实际引用;禁止只重命名单个文件或全局盲替换项目名。
|
||||
|
||||
## 5. 一次性实施确认
|
||||
|
||||
持久化复制前展示:
|
||||
|
||||
- 参考和目标绝对路径;
|
||||
- 芯片、SDK、Flash、系统形态、布局和板卡版本;
|
||||
- 最终项目名;
|
||||
- GPIO、内存、分区差量和继承项;
|
||||
- 候选修改文件;
|
||||
- `imgs` 链接变化;
|
||||
- 输入证据和 `doc/` 计划;
|
||||
- 交付级别、调查范围、验证模式及结论上限;
|
||||
- 完整 GPIO 实施表和所有 `provisional`/`blocked` 项;
|
||||
- 将执行及不会执行的验证、同步、编译和外部动作。
|
||||
|
||||
用户确认后运行本地复制。交付级别需要构建产物时,该确认同时覆盖预检通过后的标准全量编译,不再在编译前重复询问;设备动作、上传和发布仍不在授权范围内。
|
||||
|
||||
## 6. 安全复制与 `imgs`
|
||||
|
||||
使用:
|
||||
|
||||
```bash
|
||||
python3 scripts/bsp/project_guard.py prepare \
|
||||
--reference-project <参考项目> \
|
||||
--target-project <新项目>
|
||||
```
|
||||
|
||||
脚本在 `EmxRes` 下创建唯一的 `.<新项目>.bsp-staging-*` 临时目录,保留普通文件权限和软链接,排除 Git 元数据、缓存、明确构建输出和日志,并写出克隆清单。
|
||||
|
||||
约定的:
|
||||
|
||||
```text
|
||||
/epbuild/EMX2.0/EmxRes/<参考项目>/imgs
|
||||
```
|
||||
|
||||
只重定向为:
|
||||
|
||||
```text
|
||||
/epbuild/EMX2.0/EmxRes/<新项目>/imgs
|
||||
```
|
||||
|
||||
不跟随或复制镜像内容。新目标不存在时允许悬空,并记录警告。
|
||||
|
||||
## 7. 差量实施
|
||||
|
||||
1. 先读取目标文件的真实结构、调用语义和构建引用。
|
||||
2. 只修改经确认字段;未给出的方向、电平、初值、分区属性等继承参考项目。
|
||||
3. CV18 GPIO 使用 [CV18 GPIO 与 DTS 规则](cv18-gpio-dts.md)。
|
||||
4. 媒体内存和分区使用 [内存与分区规则](memory-and-partition.md)。
|
||||
5. 在 `doc/bsp_adaptation/` 建立适配报告、输入证据和验证记录。
|
||||
6. GPIO 任务保存已确认的 `inputs/gpio_plan.json`,并在写代码前后运行校验器。
|
||||
7. 不执行与目标差量无关的格式化、重构、warning 清理或版本升级。
|
||||
|
||||
## 8. 差异白名单与正式写入
|
||||
|
||||
对每个允许修改的文件传入精确相对路径;不要用 `configs/**` 一类宽泛规则:
|
||||
|
||||
```bash
|
||||
python3 scripts/bsp/project_guard.py verify \
|
||||
--reference-project <参考项目> \
|
||||
--staging-dir <临时目录名> \
|
||||
--target-project <新项目> \
|
||||
--allow-path <精确相对文件> [--allow-path <另一文件> ...]
|
||||
```
|
||||
|
||||
`doc/bsp_adaptation/` 和约定 `imgs` 链接由工具内建允许。出现白名单外新增、删除、内容或权限变化时停止。
|
||||
|
||||
全部必需静态检查通过且 `adaptation_report.md` 已完成后:
|
||||
|
||||
```bash
|
||||
python3 scripts/bsp/project_guard.py finalize \
|
||||
--reference-project <参考项目> \
|
||||
--staging-dir <临时目录名> \
|
||||
--target-project <新项目> \
|
||||
--allow-path <精确相对文件> [--allow-path <另一文件> ...]
|
||||
```
|
||||
|
||||
最终化不信任旧的 `project_delta.json`,会用同一参考项目和精确白名单重新执行完整验证,再在同一文件系统原子重命名。失败时保留临时目录供检查;清理临时目录必须再次确认。
|
||||
|
||||
## 9. 完成状态
|
||||
|
||||
没有执行 SDK 同步和完整编译时,正式状态写为:
|
||||
|
||||
```text
|
||||
本地 BSP 配置项目已创建;尚未同步 SDK,尚未完成编译验证
|
||||
```
|
||||
|
||||
后续同步或编译失败不删除已创建项目,只更新项目内验证证据和真实状态。
|
||||
|
||||
任务已要求构建产物时,正式项目静态检查通过后自动进入标准全量编译。预检失败、输出版本冲突、统一脚本缺失、工作区出现重叠改动、命令返回失败或产物检查失败时停止,不自动覆盖、清理或重试;记录证据后请用户决定如何处理。
|
||||
|
||||
完整编译成功后还要按分区表验证唯一 `kernel.bin`、`rootfs.bin` 均至少保留 512 KiB 分区余量,再记录精确 imgs 版本目录和 BSP→PSP 交接 manifest;未执行或未通过该检查,以及仅有 `imgs` 软链接或“最新目录”,都不能作为下游基线。
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Produce bounded BSP project and build preflight reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import project_guard
|
||||
|
||||
|
||||
VERSION_RE = re.compile(r"^\d{8}_[1-9]\d*$")
|
||||
ASSIGNMENT_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
||||
|
||||
|
||||
def parse_assignments(path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = ASSIGNMENT_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
value = match.group(2).strip()
|
||||
if value and value[0:1] in {'"', "'"} and value[-1:] == value[0]:
|
||||
value = value[1:-1]
|
||||
values[match.group(1)] = value
|
||||
return values
|
||||
|
||||
|
||||
def config_directories(project: Path) -> list[Path]:
|
||||
configs = project / "configs"
|
||||
if not configs.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
path
|
||||
for path in configs.iterdir()
|
||||
if path.is_dir() and not path.is_symlink() and not path.name.startswith(".")
|
||||
)
|
||||
|
||||
|
||||
def detects_dual_os(config_dir: Path) -> bool:
|
||||
for path in sorted(config_dir.glob("*_defconfig")):
|
||||
if re.search(r"^CONFIG_DUAL_OS=y\s*$", path.read_text(encoding="utf-8"), re.MULTILINE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def project_preflight(reference_name: str, target_name: str, root: Path) -> dict[str, object]:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
root = project_guard.normalize_root(root)
|
||||
reference = project_guard.direct_child(root, reference_name, "reference-project", True)
|
||||
target = project_guard.direct_child(root, target_name, "target-project", False)
|
||||
if target.exists() or target.is_symlink():
|
||||
errors.append(f"目标项目已存在: {target}")
|
||||
|
||||
build_env = reference / "buildEnvConfig.sh"
|
||||
assignments: dict[str, str] = {}
|
||||
if not build_env.is_file():
|
||||
errors.append(f"缺少 buildEnvConfig.sh: {build_env}")
|
||||
else:
|
||||
assignments = parse_assignments(build_env)
|
||||
config_dirs = config_directories(reference)
|
||||
if len(config_dirs) != 1:
|
||||
errors.append(f"参考项目必须唯一定位一个配置目录,实际为 {len(config_dirs)} 个")
|
||||
config_dir = config_dirs[0] if len(config_dirs) == 1 else None
|
||||
sync_script = reference / "configs" / "EPSYNCSDKConfig.sh"
|
||||
if not sync_script.is_file():
|
||||
warnings.append(f"未找到项目同步脚本: {sync_script}")
|
||||
|
||||
imgs = reference / "imgs"
|
||||
imgs_target = os.readlink(imgs) if imgs.is_symlink() else None
|
||||
if imgs_target != project_guard.expected_imgs_target(reference_name):
|
||||
warnings.append("参考项目 imgs 不是约定的项目专属软链接")
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"kind": "project-preflight",
|
||||
"passed": not errors,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"emxres_root": str(root),
|
||||
"reference_project": reference_name,
|
||||
"reference_path": str(reference),
|
||||
"target_project": target_name,
|
||||
"target_path": str(target),
|
||||
"facts": {
|
||||
"chip": assignments.get("EP_CHIP"),
|
||||
"sdk": assignments.get("EP_SDK_VER"),
|
||||
"architecture": assignments.get("EP_ARCH"),
|
||||
"flash": assignments.get("EP_FLASH_TYPE"),
|
||||
"nand_flash_id": assignments.get("NAND_FLASH_ID"),
|
||||
"board_build_type": config_dir.name if config_dir else None,
|
||||
"system_form": "dual-os" if config_dir and detects_dual_os(config_dir) else "single-os",
|
||||
},
|
||||
"bounded_paths": {
|
||||
"build_env": str(build_env),
|
||||
"config_directory": str(config_dir) if config_dir else None,
|
||||
"sync_script": str(sync_script),
|
||||
"reference_imgs_target": imgs_target,
|
||||
"target_imgs_target": project_guard.expected_imgs_target(target_name),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_preflight(project_name: str, version: str, root: Path, output_root: Path) -> dict[str, object]:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
root = project_guard.normalize_root(root)
|
||||
project = project_guard.direct_child(root, project_name, "project", True)
|
||||
if not VERSION_RE.fullmatch(version):
|
||||
errors.append("构建版本必须为 YYYYMMDD_N,且 N 为正整数")
|
||||
|
||||
completion_script = root / "tools" / "script_completion.sh"
|
||||
if not completion_script.is_file():
|
||||
errors.append(f"统一构建入口不存在: {completion_script}")
|
||||
elif not os.access(completion_script, os.R_OK):
|
||||
errors.append(f"统一构建入口不可读: {completion_script}")
|
||||
|
||||
imgs = project / "imgs"
|
||||
expected_imgs = project_guard.expected_imgs_target(project_name)
|
||||
actual_imgs = os.readlink(imgs) if imgs.is_symlink() else None
|
||||
if actual_imgs != expected_imgs:
|
||||
errors.append(f"项目 imgs 必须指向 {expected_imgs},实际为 {actual_imgs!r}")
|
||||
|
||||
output_root = output_root.expanduser().resolve(strict=False)
|
||||
output_directory = output_root / project_name / "imgs" / version
|
||||
if output_directory.exists() or output_directory.is_symlink():
|
||||
errors.append(f"构建输出目录已存在,禁止覆盖: {output_directory}")
|
||||
|
||||
command = (
|
||||
f"cd {shlex.quote(str(root))} && source tools/script_completion.sh && "
|
||||
f"EMX2_BuildSDKTool {project_name} {version} all"
|
||||
)
|
||||
warnings.append("统一构建可能同步配置到项目外 SDK,并按工具实现执行 git fetch origin")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"kind": "build-preflight",
|
||||
"passed": not errors,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"project": project_name,
|
||||
"project_path": str(project),
|
||||
"build_version": version,
|
||||
"working_directory": str(root),
|
||||
"completion_script": str(completion_script),
|
||||
"command": command,
|
||||
"output_directory": str(output_directory),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--emxres-root", type=Path, default=project_guard.production_root())
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
project_parser = subparsers.add_parser("project")
|
||||
project_parser.add_argument("--reference-project", required=True)
|
||||
project_parser.add_argument("--target-project", required=True)
|
||||
build_parser = subparsers.add_parser("build")
|
||||
build_parser.add_argument("--project", required=True)
|
||||
build_parser.add_argument("--version", required=True)
|
||||
build_parser.add_argument(
|
||||
"--output-root", type=Path, default=Path("/epbuild/EMX2.0/EmxRes")
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "project":
|
||||
result = project_preflight(
|
||||
args.reference_project, args.target_project, args.emxres_root
|
||||
)
|
||||
else:
|
||||
result = build_preflight(
|
||||
args.project, args.version, args.emxres_root, args.output_root
|
||||
)
|
||||
except (project_guard.GuardError, OSError, UnicodeDecodeError) as exc:
|
||||
result = {"passed": False, "errors": [str(exc)]}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("passed") is True else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate normalized pinmux data and write the final project CSV."""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HEADERS = ["引脚号", "功能名称", "引脚名称", "有效电平", "FMUX寄存器", "FMUX当前值", "FMUX默认值", "FMUX_list"]
|
||||
INPUT_FIELDS = ["pin_number", "function_name", "pin_name", "is_gpio", "active_level", "fmux_register", "fmux_current", "fmux_default", "fmux_list"]
|
||||
NUMBERED_MUX_RE = re.compile(r"(?:^|;\s*)([0-7])\s*:")
|
||||
UNSAFE_FILENAME_RE = re.compile(r"[/\\:*?\"<>|]+")
|
||||
|
||||
|
||||
def text(value):
|
||||
return "null" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def safe_name(value):
|
||||
value = UNSAFE_FILENAME_RE.sub("_", value.strip())
|
||||
value = re.sub(r"\s+", "_", value)
|
||||
value = re.sub(r"_+", "_", value).strip(" ._")
|
||||
if not value:
|
||||
raise ValueError("project_name 清理后为空")
|
||||
return value
|
||||
|
||||
|
||||
def normalize(raw, index, seen):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"rows[{index}] 必须是对象")
|
||||
missing = [key for key in INPUT_FIELDS if key not in raw]
|
||||
if missing:
|
||||
raise ValueError(f"rows[{index}] 缺少字段: {', '.join(missing)}")
|
||||
pin = text(raw["pin_number"])
|
||||
if not pin or pin == "null":
|
||||
raise ValueError(f"rows[{index}] pin_number 为空")
|
||||
if pin in seen:
|
||||
raise ValueError(f"rows[{index}] 引脚号重复: {pin}")
|
||||
seen.add(pin)
|
||||
if not isinstance(raw["is_gpio"], bool):
|
||||
raise ValueError(f"rows[{index}] is_gpio 必须是布尔值")
|
||||
active = text(raw["active_level"])
|
||||
if raw["is_gpio"] and active not in {"高有效", "低有效", "待确认"}:
|
||||
raise ValueError(f"rows[{index}] GPIO 有效电平无效")
|
||||
if not raw["is_gpio"] and active != "null":
|
||||
raise ValueError(f"rows[{index}] 非 GPIO 有效电平必须为 null")
|
||||
mux_list = text(raw["fmux_list"])
|
||||
mux_numbers = set(NUMBERED_MUX_RE.findall(mux_list))
|
||||
current = text(raw["fmux_current"])
|
||||
if current in set("01234567") and current not in mux_numbers:
|
||||
raise ValueError(f"rows[{index}] 引脚 {pin}: FMUX当前值 {current} 未出现在 FMUX_list")
|
||||
row = {
|
||||
"引脚号": pin,
|
||||
"功能名称": text(raw["function_name"]),
|
||||
"引脚名称": text(raw["pin_name"]),
|
||||
"有效电平": active,
|
||||
"FMUX寄存器": text(raw["fmux_register"]),
|
||||
"FMUX当前值": current,
|
||||
"FMUX默认值": text(raw["fmux_default"]),
|
||||
"FMUX_list": mux_list,
|
||||
}
|
||||
return row, len(mux_numbers)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser.add_argument("--project-name")
|
||||
parser.add_argument("--output-dir", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
with args.input.open(encoding="utf-8-sig") as handle:
|
||||
payload = json.load(handle)
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("rows"), list):
|
||||
raise ValueError("JSON 顶层必须是对象并包含 rows 数组")
|
||||
project = args.project_name or text(payload.get("project_name", ""))
|
||||
rows, seen = [], set()
|
||||
for index, raw in enumerate(payload["rows"]):
|
||||
row, mux_count = normalize(raw, index, seen)
|
||||
if mux_count >= 2:
|
||||
rows.append(row)
|
||||
output = args.output or args.output_dir / f"{safe_name(project)}_pinmux.csv"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8-sig", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=HEADERS)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
print(f"已输出 {len(rows)} 行(输入 {len(payload['rows'])} 行): {output}")
|
||||
return 0
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,601 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Safely stage, verify, finalize, and audit an EmxRes BSP project clone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
ROOT_SUFFIX = Path("EapilBsp") / "EMX2.0" / "EmxRes"
|
||||
PROJECT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
EXCLUDED_DIR_NAMES = {
|
||||
".git",
|
||||
".cache",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"logs",
|
||||
"out",
|
||||
"output",
|
||||
}
|
||||
EXCLUDED_FILE_SUFFIXES = {".log", ".pyc", ".tmp"}
|
||||
CREDENTIAL_FILE_NAMES = {
|
||||
".env",
|
||||
"credentials.json",
|
||||
"credentials.yaml",
|
||||
"credentials.yml",
|
||||
"id_ed25519",
|
||||
"id_rsa",
|
||||
}
|
||||
CREDENTIAL_FILE_SUFFIXES = {".key", ".p12", ".pem", ".pfx"}
|
||||
DOC_PREFIX = "doc/bsp_adaptation/"
|
||||
DELTA_REPORT_PATH = "doc/bsp_adaptation/validation/project_delta.json"
|
||||
FINAL_AUDIT_REPORT_PATH = "doc/bsp_adaptation/validation/final_audit.json"
|
||||
|
||||
|
||||
class GuardError(RuntimeError):
|
||||
"""A safety or validation gate failed."""
|
||||
|
||||
|
||||
def now_utc() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def production_root() -> Path:
|
||||
return Path.home() / ROOT_SUFFIX
|
||||
|
||||
|
||||
def normalize_root(root: Path) -> Path:
|
||||
root = root.expanduser()
|
||||
if not root.is_dir():
|
||||
raise GuardError(f"EmxRes 根目录不存在: {root}")
|
||||
if root.is_symlink():
|
||||
raise GuardError(f"EmxRes 根目录不能是软链接: {root}")
|
||||
return root.resolve()
|
||||
|
||||
|
||||
def validate_project_name(name: str, field: str) -> str:
|
||||
if not PROJECT_NAME_RE.fullmatch(name or ""):
|
||||
raise GuardError(
|
||||
f"{field} 必须是 EmxRes 直接子目录名,只能包含字母、数字、点、下划线和连字符: {name!r}"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def direct_child(
|
||||
root: Path, name: str, field: str, must_exist: bool, allow_hidden: bool = False
|
||||
) -> Path:
|
||||
if allow_hidden and name.startswith("."):
|
||||
validate_project_name(name[1:], field)
|
||||
else:
|
||||
name = validate_project_name(name, field)
|
||||
path = root / name
|
||||
if path.parent != root:
|
||||
raise GuardError(f"{field} 发生路径逃逸: {name}")
|
||||
if must_exist:
|
||||
if not path.is_dir() or path.is_symlink():
|
||||
raise GuardError(f"{field} 不是普通项目目录: {path}")
|
||||
if path.resolve().parent != root:
|
||||
raise GuardError(f"{field} 解析后不在 EmxRes 内: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def is_excluded_name(path: Path, is_dir: bool) -> bool:
|
||||
if is_dir and path.name in EXCLUDED_DIR_NAMES:
|
||||
return True
|
||||
return not is_dir and path.suffix.lower() in EXCLUDED_FILE_SUFFIXES
|
||||
|
||||
|
||||
def copy_ignore(_directory: str, names: list[str]) -> set[str]:
|
||||
ignored = set()
|
||||
base = Path(_directory)
|
||||
for name in names:
|
||||
path = base / name
|
||||
is_dir = path.is_dir()
|
||||
if is_excluded_name(path, is_dir):
|
||||
ignored.add(name)
|
||||
return ignored
|
||||
|
||||
|
||||
def scan_credentials(reference: Path) -> list[str]:
|
||||
hits = []
|
||||
for current, dirs, files in os.walk(reference, followlinks=False):
|
||||
base = Path(current)
|
||||
dirs[:] = [
|
||||
name
|
||||
for name in dirs
|
||||
if not is_excluded_name(base / name, True) and not (base / name).is_symlink()
|
||||
]
|
||||
for name in files:
|
||||
path = base / name
|
||||
if path.is_symlink() or is_excluded_name(path, False):
|
||||
continue
|
||||
if name in CREDENTIAL_FILE_NAMES or path.suffix.lower() in CREDENTIAL_FILE_SUFFIXES:
|
||||
hits.append(path.relative_to(reference).as_posix())
|
||||
return sorted(hits)
|
||||
|
||||
|
||||
def excluded_paths(reference: Path) -> list[str]:
|
||||
hits = []
|
||||
for current, dirs, files in os.walk(reference, followlinks=False):
|
||||
base = Path(current)
|
||||
kept_dirs = []
|
||||
for name in dirs:
|
||||
path = base / name
|
||||
if is_excluded_name(path, True):
|
||||
hits.append(path.relative_to(reference).as_posix() + "/")
|
||||
elif path.is_symlink():
|
||||
continue
|
||||
else:
|
||||
kept_dirs.append(name)
|
||||
dirs[:] = kept_dirs
|
||||
for name in files:
|
||||
path = base / name
|
||||
if not path.is_symlink() and is_excluded_name(path, False):
|
||||
hits.append(path.relative_to(reference).as_posix())
|
||||
return sorted(hits)
|
||||
|
||||
|
||||
def within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def expected_imgs_target(project_name: str) -> str:
|
||||
return f"/epbuild/EMX2.0/EmxRes/{project_name}/imgs"
|
||||
|
||||
|
||||
def scan_symlinks(reference: Path, root: Path, reference_name: str) -> list[dict[str, str]]:
|
||||
links = []
|
||||
link_paths = []
|
||||
for current, dirs, files in os.walk(reference, followlinks=False):
|
||||
base = Path(current)
|
||||
kept_dirs = []
|
||||
for name in dirs:
|
||||
path = base / name
|
||||
if is_excluded_name(path, True):
|
||||
continue
|
||||
if path.is_symlink():
|
||||
link_paths.append(path)
|
||||
else:
|
||||
kept_dirs.append(name)
|
||||
dirs[:] = kept_dirs
|
||||
for name in files:
|
||||
path = base / name
|
||||
if path.is_symlink() and not is_excluded_name(path, False):
|
||||
link_paths.append(path)
|
||||
|
||||
for path in sorted(link_paths):
|
||||
relative = path.relative_to(reference).as_posix()
|
||||
raw_target = os.readlink(path)
|
||||
if relative == "imgs" and raw_target == expected_imgs_target(reference_name):
|
||||
kind = "imgs"
|
||||
elif os.path.isabs(raw_target):
|
||||
resolved = Path(raw_target).resolve(strict=False)
|
||||
if not within(resolved, root):
|
||||
raise GuardError(f"存在未经确认的外部绝对软链接: {relative} -> {raw_target}")
|
||||
kind = "absolute-in-root"
|
||||
else:
|
||||
resolved = (path.parent / raw_target).resolve(strict=False)
|
||||
if not within(resolved, root):
|
||||
raise GuardError(f"存在未经确认的外部相对软链接: {relative} -> {raw_target}")
|
||||
kind = "relative-in-root"
|
||||
links.append({"path": relative, "target": raw_target, "kind": kind})
|
||||
return links
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def inventory(
|
||||
project: Path, omit_paths: set[str] | None = None
|
||||
) -> dict[str, dict[str, object]]:
|
||||
omit_paths = omit_paths or set()
|
||||
entries: dict[str, dict[str, object]] = {}
|
||||
for current, dirs, files in os.walk(project, followlinks=False):
|
||||
base = Path(current)
|
||||
kept_dirs = []
|
||||
for name in sorted(dirs):
|
||||
path = base / name
|
||||
if is_excluded_name(path, True):
|
||||
continue
|
||||
if path.is_symlink():
|
||||
rel = path.relative_to(project).as_posix()
|
||||
entries[rel] = {
|
||||
"kind": "symlink",
|
||||
"mode": stat.S_IMODE(path.lstat().st_mode),
|
||||
"target": os.readlink(path),
|
||||
}
|
||||
else:
|
||||
kept_dirs.append(name)
|
||||
dirs[:] = kept_dirs
|
||||
for name in sorted(files):
|
||||
path = base / name
|
||||
if is_excluded_name(path, False):
|
||||
continue
|
||||
rel = path.relative_to(project).as_posix()
|
||||
if rel in omit_paths:
|
||||
continue
|
||||
if path.is_symlink():
|
||||
entries[rel] = {
|
||||
"kind": "symlink",
|
||||
"mode": stat.S_IMODE(path.lstat().st_mode),
|
||||
"target": os.readlink(path),
|
||||
}
|
||||
elif path.is_file():
|
||||
file_stat = path.stat()
|
||||
entries[rel] = {
|
||||
"kind": "file",
|
||||
"mode": stat.S_IMODE(file_stat.st_mode),
|
||||
"size": file_stat.st_size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
return dict(sorted(entries.items()))
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def prepare_project(reference_name: str, target_name: str, root: Path | None = None) -> dict[str, object]:
|
||||
root = normalize_root(root or production_root())
|
||||
reference = direct_child(root, reference_name, "reference-project", True)
|
||||
target = direct_child(root, target_name, "target-project", False)
|
||||
if target.exists() or target.is_symlink():
|
||||
raise GuardError(f"目标项目已存在,禁止覆盖: {target}")
|
||||
|
||||
credentials = scan_credentials(reference)
|
||||
if credentials:
|
||||
raise GuardError("参考项目包含疑似凭据文件,未复制: " + ", ".join(credentials))
|
||||
links = scan_symlinks(reference, root, reference_name)
|
||||
source_before = inventory(reference)
|
||||
staging_name = f".{target_name}.bsp-staging-{uuid.uuid4().hex[:12]}"
|
||||
staging = root / staging_name
|
||||
|
||||
shutil.copytree(
|
||||
reference,
|
||||
staging,
|
||||
symlinks=True,
|
||||
copy_function=shutil.copy2,
|
||||
ignore=copy_ignore,
|
||||
)
|
||||
|
||||
imgs = staging / "imgs"
|
||||
imgs_retargeted = False
|
||||
if imgs.is_symlink() and os.readlink(imgs) == expected_imgs_target(reference_name):
|
||||
imgs.unlink()
|
||||
imgs.symlink_to(expected_imgs_target(target_name))
|
||||
imgs_retargeted = True
|
||||
|
||||
doc_root = staging / "doc" / "bsp_adaptation"
|
||||
(doc_root / "inputs").mkdir(parents=True, exist_ok=True)
|
||||
validation = doc_root / "validation"
|
||||
validation.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_after = inventory(reference)
|
||||
if source_before != source_after:
|
||||
raise GuardError(f"复制期间参考项目发生变化,临时目录保留供检查: {staging}")
|
||||
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"created_at": now_utc(),
|
||||
"emxres_root": str(root),
|
||||
"reference_project": reference_name,
|
||||
"target_project": target_name,
|
||||
"staging_directory": staging_name,
|
||||
"excluded_paths": excluded_paths(reference),
|
||||
"symlinks": links,
|
||||
"imgs_retargeted": imgs_retargeted,
|
||||
"imgs_target": os.readlink(imgs) if imgs.is_symlink() else None,
|
||||
"source_inventory": source_before,
|
||||
"staging_inventory_before_edits": inventory(staging),
|
||||
}
|
||||
write_json(validation / "clone_manifest.json", manifest)
|
||||
return {
|
||||
"staging_directory": staging_name,
|
||||
"staging_path": str(staging),
|
||||
"manifest": str(validation / "clone_manifest.json"),
|
||||
"imgs_retargeted": imgs_retargeted,
|
||||
}
|
||||
|
||||
|
||||
def normalize_allow_paths(values: list[str]) -> set[str]:
|
||||
allowed = set()
|
||||
for value in values:
|
||||
pure = PurePosixPath(value)
|
||||
if pure.is_absolute() or ".." in pure.parts or value in {"", "."}:
|
||||
raise GuardError(f"allow-path 必须是项目内精确相对路径: {value!r}")
|
||||
if any(char in value for char in "*?[]"):
|
||||
raise GuardError(f"allow-path 不允许通配符,必须逐文件列出: {value!r}")
|
||||
allowed.add(pure.as_posix())
|
||||
return allowed
|
||||
|
||||
|
||||
def compare_inventories(
|
||||
source: dict[str, dict[str, object]], target: dict[str, dict[str, object]]
|
||||
) -> dict[str, list[str]]:
|
||||
source_paths = set(source)
|
||||
target_paths = set(target)
|
||||
return {
|
||||
"added": sorted(target_paths - source_paths),
|
||||
"removed": sorted(source_paths - target_paths),
|
||||
"modified": sorted(path for path in source_paths & target_paths if source[path] != target[path]),
|
||||
}
|
||||
|
||||
|
||||
def load_clone_manifest(
|
||||
staging: Path, root: Path, reference_name: str, target_name: str, staging_name: str
|
||||
) -> dict[str, object]:
|
||||
path = staging / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
||||
if not path.is_file():
|
||||
raise GuardError("缺少 clone_manifest.json;临时目录不是受控 prepare 结果")
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
expected = {
|
||||
"emxres_root": str(root),
|
||||
"reference_project": reference_name,
|
||||
"target_project": target_name,
|
||||
"staging_directory": staging_name,
|
||||
}
|
||||
mismatched = [key for key, value in expected.items() if manifest.get(key) != value]
|
||||
if mismatched:
|
||||
raise GuardError("clone_manifest 与当前操作不匹配: " + ", ".join(mismatched))
|
||||
return manifest
|
||||
|
||||
|
||||
def verify_project(
|
||||
reference_name: str,
|
||||
staging_name: str,
|
||||
target_name: str,
|
||||
allow_paths: list[str],
|
||||
root: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
root = normalize_root(root or production_root())
|
||||
reference = direct_child(root, reference_name, "reference-project", True)
|
||||
validate_project_name(target_name, "target-project")
|
||||
staging = direct_child(root, staging_name, "staging-dir", True, allow_hidden=True)
|
||||
expected_prefix = f".{target_name}.bsp-staging-"
|
||||
if not staging_name.startswith(expected_prefix):
|
||||
raise GuardError(f"临时目录与目标项目不匹配: {staging_name}")
|
||||
clone_manifest = load_clone_manifest(staging, root, reference_name, target_name, staging_name)
|
||||
scan_symlinks(staging, root, target_name)
|
||||
credentials = scan_credentials(staging)
|
||||
if credentials:
|
||||
raise GuardError("临时目录包含疑似凭据文件,禁止最终化: " + ", ".join(credentials))
|
||||
unexpected_excluded = excluded_paths(staging)
|
||||
if unexpected_excluded:
|
||||
raise GuardError(
|
||||
"临时目录新增了被排除的缓存、构建输出或日志,必须先处理: "
|
||||
+ ", ".join(unexpected_excluded)
|
||||
)
|
||||
allowed = normalize_allow_paths(allow_paths)
|
||||
source_inventory = clone_manifest.get("source_inventory")
|
||||
if not isinstance(source_inventory, dict):
|
||||
raise GuardError("clone_manifest 缺少 prepare 时的参考项目清单")
|
||||
if inventory(reference) != source_inventory:
|
||||
raise GuardError("prepare 后参考项目发生变化;必须重新确认并重新 prepare")
|
||||
staging_inventory = inventory(staging, {DELTA_REPORT_PATH})
|
||||
changes = compare_inventories(source_inventory, staging_inventory)
|
||||
|
||||
def permitted(path: str) -> bool:
|
||||
return path == "imgs" or path.startswith(DOC_PREFIX) or path in allowed
|
||||
|
||||
violations = sorted(
|
||||
{path for group in changes.values() for path in group if not permitted(path)}
|
||||
)
|
||||
report: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"checked_at": now_utc(),
|
||||
"reference_project": reference_name,
|
||||
"target_project": target_name,
|
||||
"staging_directory": staging_name,
|
||||
"allowed_paths": sorted(allowed),
|
||||
"changes": changes,
|
||||
"violations": violations,
|
||||
"passed": not violations,
|
||||
"verified_staging_inventory": staging_inventory,
|
||||
}
|
||||
write_json(staging / "doc" / "bsp_adaptation" / "validation" / "project_delta.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def finalize_project(
|
||||
reference_name: str,
|
||||
staging_name: str,
|
||||
target_name: str,
|
||||
allow_paths: list[str],
|
||||
root: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
root = normalize_root(root or production_root())
|
||||
target = direct_child(root, target_name, "target-project", False)
|
||||
if target.exists() or target.is_symlink():
|
||||
raise GuardError(f"目标项目已存在,禁止覆盖: {target}")
|
||||
staging = direct_child(root, staging_name, "staging-dir", True, allow_hidden=True)
|
||||
if not staging_name.startswith(f".{target_name}.bsp-staging-"):
|
||||
raise GuardError(f"临时目录与目标项目不匹配: {staging_name}")
|
||||
|
||||
report_path = staging / "doc" / "bsp_adaptation" / "adaptation_report.md"
|
||||
delta = verify_project(reference_name, staging_name, target_name, allow_paths, root=root)
|
||||
if delta.get("passed") is not True:
|
||||
raise GuardError("差异白名单未通过,禁止最终化")
|
||||
verified_inventory = delta.get("verified_staging_inventory")
|
||||
if not isinstance(verified_inventory, dict):
|
||||
raise GuardError("差异报告缺少已验证目录清单;必须重新运行 verify")
|
||||
current_inventory = inventory(staging, {DELTA_REPORT_PATH})
|
||||
if current_inventory != verified_inventory:
|
||||
raise GuardError("verify 后临时目录又发生变化;必须重新运行 verify")
|
||||
if not report_path.is_file() or not report_path.read_text(encoding="utf-8").strip():
|
||||
raise GuardError("缺少非空的 doc/bsp_adaptation/adaptation_report.md")
|
||||
|
||||
os.rename(staging, target)
|
||||
finalized_at = now_utc()
|
||||
clone_path = target / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
||||
if clone_path.is_file():
|
||||
clone = json.loads(clone_path.read_text(encoding="utf-8"))
|
||||
clone["finalized_at"] = finalized_at
|
||||
clone["final_path"] = str(target)
|
||||
write_json(clone_path, clone)
|
||||
delta_path = target / "doc" / "bsp_adaptation" / "validation" / "project_delta.json"
|
||||
delta["finalized_at"] = finalized_at
|
||||
delta["final_path"] = str(target)
|
||||
write_json(delta_path, delta)
|
||||
return {"target_project": target_name, "target_path": str(target), "finalized_at": finalized_at}
|
||||
|
||||
|
||||
def audit_final_project(
|
||||
target_name: str,
|
||||
allow_paths: list[str] | None = None,
|
||||
root: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Audit a finalized project against its immutable prepare-time baseline."""
|
||||
root = normalize_root(root or production_root())
|
||||
target = direct_child(root, target_name, "target-project", True)
|
||||
clone_path = target / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
||||
if not clone_path.is_file():
|
||||
raise GuardError("正式项目缺少 clone_manifest.json,无法恢复 prepare 时基线")
|
||||
clone = json.loads(clone_path.read_text(encoding="utf-8"))
|
||||
expected = {"emxres_root": str(root), "target_project": target_name}
|
||||
mismatched = [key for key, value in expected.items() if clone.get(key) != value]
|
||||
if mismatched:
|
||||
raise GuardError("clone_manifest 与正式项目不匹配: " + ", ".join(mismatched))
|
||||
source_inventory = clone.get("source_inventory")
|
||||
if not isinstance(source_inventory, dict):
|
||||
raise GuardError("clone_manifest 缺少 prepare 时的参考项目清单")
|
||||
|
||||
delta_path = target / DELTA_REPORT_PATH
|
||||
delta: dict[str, object] = {}
|
||||
if delta_path.is_file():
|
||||
delta = json.loads(delta_path.read_text(encoding="utf-8"))
|
||||
effective_allow_paths = allow_paths
|
||||
if effective_allow_paths is None or not effective_allow_paths:
|
||||
stored = delta.get("allowed_paths")
|
||||
if not isinstance(stored, list) or not all(isinstance(item, str) for item in stored):
|
||||
raise GuardError("未提供 allow-path,且 project_delta.json 中没有可复用白名单")
|
||||
effective_allow_paths = stored
|
||||
allowed = normalize_allow_paths(effective_allow_paths)
|
||||
|
||||
scan_symlinks(target, root, target_name)
|
||||
credentials = scan_credentials(target)
|
||||
if credentials:
|
||||
raise GuardError("正式项目包含疑似凭据文件: " + ", ".join(credentials))
|
||||
unexpected_excluded = excluded_paths(target)
|
||||
if unexpected_excluded:
|
||||
raise GuardError(
|
||||
"正式项目包含被排除的缓存、构建输出或日志: " + ", ".join(unexpected_excluded)
|
||||
)
|
||||
|
||||
target_inventory = inventory(target, {DELTA_REPORT_PATH, FINAL_AUDIT_REPORT_PATH})
|
||||
changes = compare_inventories(source_inventory, target_inventory)
|
||||
|
||||
def permitted(path: str) -> bool:
|
||||
return path == "imgs" or path.startswith(DOC_PREFIX) or path in allowed
|
||||
|
||||
violations = sorted(
|
||||
{path for group in changes.values() for path in group if not permitted(path)}
|
||||
)
|
||||
reference_name = clone.get("reference_project")
|
||||
reference_drifted: bool | None = None
|
||||
if isinstance(reference_name, str):
|
||||
reference = root / reference_name
|
||||
if reference.is_dir() and not reference.is_symlink():
|
||||
reference_drifted = inventory(reference) != source_inventory
|
||||
report: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"checked_at": now_utc(),
|
||||
"reference_project": reference_name,
|
||||
"reference_drifted_since_prepare": reference_drifted,
|
||||
"target_project": target_name,
|
||||
"target_path": str(target),
|
||||
"baseline": "clone_manifest.source_inventory",
|
||||
"allowed_paths": sorted(allowed),
|
||||
"changes": changes,
|
||||
"violations": violations,
|
||||
"passed": not violations,
|
||||
"audited_target_inventory": target_inventory,
|
||||
}
|
||||
write_json(target / FINAL_AUDIT_REPORT_PATH, report)
|
||||
return report
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
prepare = subparsers.add_parser("prepare", help="复制参考项目到唯一临时目录")
|
||||
prepare.add_argument("--reference-project", required=True)
|
||||
prepare.add_argument("--target-project", required=True)
|
||||
|
||||
verify = subparsers.add_parser("verify", help="按精确文件白名单检查差异")
|
||||
verify.add_argument("--reference-project", required=True)
|
||||
verify.add_argument("--staging-dir", required=True)
|
||||
verify.add_argument("--target-project", required=True)
|
||||
verify.add_argument("--allow-path", action="append", default=[])
|
||||
|
||||
finalize = subparsers.add_parser("finalize", help="将已验证临时目录原子落为正式项目")
|
||||
finalize.add_argument("--reference-project", required=True)
|
||||
finalize.add_argument("--staging-dir", required=True)
|
||||
finalize.add_argument("--target-project", required=True)
|
||||
finalize.add_argument("--allow-path", action="append", default=[])
|
||||
|
||||
audit = subparsers.add_parser("audit-final", help="复核已落盘正式项目的差异白名单")
|
||||
audit.add_argument("--target-project", required=True)
|
||||
audit.add_argument("--allow-path", action="append", default=[])
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
if args.command == "prepare":
|
||||
result = prepare_project(args.reference_project, args.target_project)
|
||||
elif args.command == "verify":
|
||||
result = verify_project(
|
||||
args.reference_project,
|
||||
args.staging_dir,
|
||||
args.target_project,
|
||||
args.allow_path,
|
||||
)
|
||||
if not result["passed"]:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 2
|
||||
elif args.command == "finalize":
|
||||
result = finalize_project(
|
||||
args.reference_project,
|
||||
args.staging_dir,
|
||||
args.target_project,
|
||||
args.allow_path,
|
||||
)
|
||||
else:
|
||||
result = audit_final_project(args.target_project, args.allow_path)
|
||||
if not result["passed"]:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (GuardError, OSError, json.JSONDecodeError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Select a bundled CV18 pinout workbook and query its pin/FMUX information."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterator
|
||||
from xml.etree import ElementTree as ET
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
|
||||
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
NS = {"m": MAIN_NS}
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CATALOG_ROOT = SKILL_ROOT / "references" / "gpio-pinout"
|
||||
MANIFEST_PATH = CATALOG_ROOT / "manifest.json"
|
||||
EP_CHIP_RE = re.compile(r"^\s*(?:export\s+)?EP_CHIP\s*=\s*(.*?)\s*$")
|
||||
CELL_RE = re.compile(r"^([A-Z]+)([0-9]+)$")
|
||||
MUX_RE = re.compile(r"^\s*([0-7])\s*:\s*(.+?)\s*$")
|
||||
MAX_XLSX_SIZE = 16 * 1024 * 1024
|
||||
MAX_UNCOMPRESSED_SIZE = 64 * 1024 * 1024
|
||||
|
||||
|
||||
class PinoutError(RuntimeError):
|
||||
"""A bundled pinout cannot be selected or queried safely."""
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_json_atomic(path: Path, payload: dict[str, object]) -> None:
|
||||
path = path.expanduser().resolve(strict=False)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||
) as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
temporary = Path(handle.name)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def normalize_chip(value: str) -> str:
|
||||
normalized = re.sub(r"[^A-Za-z0-9]", "", value).upper()
|
||||
if not normalized:
|
||||
raise PinoutError("芯片名称规范化后为空")
|
||||
return normalized
|
||||
|
||||
|
||||
def project_chip(project: Path) -> tuple[str, dict[str, object]]:
|
||||
project = project.expanduser().resolve(strict=True)
|
||||
if not project.is_dir() or project.is_symlink():
|
||||
raise PinoutError(f"项目必须是普通目录: {project}")
|
||||
config = project / "buildEnvConfig.sh"
|
||||
if not config.is_file() or config.is_symlink():
|
||||
raise PinoutError(f"项目缺少普通文件 buildEnvConfig.sh: {config}")
|
||||
values: list[str] = []
|
||||
for line_number, line in enumerate(
|
||||
config.read_text(encoding="utf-8", errors="replace").splitlines(), 1
|
||||
):
|
||||
match = EP_CHIP_RE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
try:
|
||||
tokens = shlex.split(match.group(1), comments=True, posix=True)
|
||||
except ValueError as exc:
|
||||
raise PinoutError(f"buildEnvConfig.sh:{line_number} EP_CHIP 无法解析: {exc}") from exc
|
||||
if len(tokens) != 1 or not tokens[0]:
|
||||
raise PinoutError(
|
||||
f"buildEnvConfig.sh:{line_number} EP_CHIP 必须是一个静态芯片名称"
|
||||
)
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]+", tokens[0]) is None:
|
||||
raise PinoutError(
|
||||
f"buildEnvConfig.sh:{line_number} EP_CHIP 包含展开或非法字符,必须是静态芯片名称"
|
||||
)
|
||||
values.append(normalize_chip(tokens[0]))
|
||||
unique = sorted(set(values))
|
||||
if len(unique) != 1:
|
||||
raise PinoutError(f"buildEnvConfig.sh 必须唯一确定 EP_CHIP,实际 {unique}")
|
||||
return unique[0], {
|
||||
"source": "project-buildEnvConfig",
|
||||
"project": str(project),
|
||||
"build_env_config": str(config),
|
||||
"build_env_config_sha256": sha256_file(config),
|
||||
}
|
||||
|
||||
|
||||
def safe_relative_file(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise PinoutError(f"manifest {field} 必须是非空字符串")
|
||||
pure = PurePosixPath(value)
|
||||
if pure.is_absolute() or ".." in pure.parts or len(pure.parts) != 1:
|
||||
raise PinoutError(f"manifest {field} 必须是当前目录内文件名: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def load_manifest() -> list[dict[str, Any]]:
|
||||
if not MANIFEST_PATH.is_file() or MANIFEST_PATH.is_symlink():
|
||||
raise PinoutError(f"内置 GPIO manifest 缺失或不是普通文件: {MANIFEST_PATH}")
|
||||
path = MANIFEST_PATH.resolve(strict=True)
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
|
||||
raise PinoutError("gpio-pinout/manifest.json schema_version 必须为 1")
|
||||
documents = payload.get("documents")
|
||||
if not isinstance(documents, list) or not documents:
|
||||
raise PinoutError("gpio-pinout/manifest.json.documents 必须是非空数组")
|
||||
checked: list[dict[str, Any]] = []
|
||||
ids: set[str] = set()
|
||||
for index, item in enumerate(documents):
|
||||
if not isinstance(item, dict):
|
||||
raise PinoutError(f"manifest documents[{index}] 必须是对象")
|
||||
identifier = item.get("id")
|
||||
if not isinstance(identifier, str) or not identifier or identifier in ids:
|
||||
raise PinoutError(f"manifest documents[{index}].id 非法或重复")
|
||||
ids.add(identifier)
|
||||
filename = safe_relative_file(item.get("file"), f"documents[{index}].file")
|
||||
digest = item.get("sha256")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
raise PinoutError(f"manifest documents[{index}].sha256 非法")
|
||||
patterns = item.get("chip_patterns")
|
||||
aliases = item.get("chip_aliases")
|
||||
if not isinstance(patterns, list) or not patterns or not all(
|
||||
isinstance(pattern, str) and pattern for pattern in patterns
|
||||
):
|
||||
raise PinoutError(f"manifest documents[{index}].chip_patterns 非法")
|
||||
if not isinstance(aliases, list) or not all(
|
||||
isinstance(alias, str) and alias for alias in aliases
|
||||
):
|
||||
raise PinoutError(f"manifest documents[{index}].chip_aliases 非法")
|
||||
try:
|
||||
compiled = [re.compile(pattern) for pattern in patterns]
|
||||
except re.error as exc:
|
||||
raise PinoutError(f"manifest documents[{index}] 正则非法: {exc}") from exc
|
||||
workbook_candidate = CATALOG_ROOT / filename
|
||||
if not workbook_candidate.is_file() or workbook_candidate.is_symlink():
|
||||
raise PinoutError(f"内置 GPIO 表格缺失或不是普通文件: {workbook_candidate}")
|
||||
workbook = workbook_candidate.resolve(strict=True)
|
||||
if workbook.parent != CATALOG_ROOT.resolve():
|
||||
raise PinoutError(f"内置 GPIO 表格越出目录: {workbook}")
|
||||
actual_digest = sha256_file(workbook)
|
||||
if actual_digest != digest:
|
||||
raise PinoutError(
|
||||
f"内置 GPIO 表格 hash 与 manifest 不一致: {filename}: {actual_digest}"
|
||||
)
|
||||
checked.append(
|
||||
{
|
||||
**item,
|
||||
"path": workbook,
|
||||
"compiled_patterns": compiled,
|
||||
"normalized_aliases": {normalize_chip(alias) for alias in aliases},
|
||||
}
|
||||
)
|
||||
return checked
|
||||
|
||||
|
||||
def select_document(chip: str) -> dict[str, Any]:
|
||||
chip = normalize_chip(chip)
|
||||
matched = [
|
||||
item
|
||||
for item in load_manifest()
|
||||
if chip in item["normalized_aliases"]
|
||||
or any(pattern.fullmatch(chip) for pattern in item["compiled_patterns"])
|
||||
]
|
||||
if len(matched) != 1:
|
||||
identifiers = [item["id"] for item in matched]
|
||||
if not matched:
|
||||
raise PinoutError(f"芯片 {chip} 没有对应的内置 GPIO 设计表")
|
||||
raise PinoutError(f"芯片 {chip} 同时匹配多个内置 GPIO 设计表: {identifiers}")
|
||||
return matched[0]
|
||||
|
||||
|
||||
def xml_text(node: ET.Element) -> str:
|
||||
return "".join(text.text or "" for text in node.iter(f"{{{MAIN_NS}}}t"))
|
||||
|
||||
|
||||
def workbook_parts(archive: ZipFile) -> tuple[list[str], list[tuple[str, str]]]:
|
||||
names = archive.namelist()
|
||||
total_size = sum(item.file_size for item in archive.infolist())
|
||||
if total_size > MAX_UNCOMPRESSED_SIZE:
|
||||
raise PinoutError(f"XLSX 解压后超过 {MAX_UNCOMPRESSED_SIZE} 字节限制")
|
||||
shared: list[str] = []
|
||||
if "xl/sharedStrings.xml" in names:
|
||||
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
|
||||
shared = [xml_text(item) for item in root.findall(f"{{{MAIN_NS}}}si")]
|
||||
workbook = ET.fromstring(archive.read("xl/workbook.xml"))
|
||||
relationships = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
|
||||
targets = {
|
||||
item.attrib["Id"]: item.attrib["Target"]
|
||||
for item in relationships.findall(f"{{{PKG_REL_NS}}}Relationship")
|
||||
}
|
||||
sheets: list[tuple[str, str]] = []
|
||||
for sheet in workbook.findall(f".//{{{MAIN_NS}}}sheet"):
|
||||
rel_id = sheet.attrib.get(f"{{{DOC_REL_NS}}}id")
|
||||
if rel_id not in targets:
|
||||
raise PinoutError(f"工作表 {sheet.attrib.get('name')} 缺少 relationship")
|
||||
target = targets[rel_id].lstrip("/")
|
||||
if not target.startswith("xl/"):
|
||||
target = posixpath.normpath(posixpath.join("xl", target))
|
||||
if target.startswith("../") or target not in names:
|
||||
raise PinoutError(f"工作表目标路径非法或缺失: {target}")
|
||||
sheets.append((sheet.attrib.get("name", ""), target))
|
||||
return shared, sheets
|
||||
|
||||
|
||||
def column_number(cell_reference: str) -> int:
|
||||
match = CELL_RE.fullmatch(cell_reference)
|
||||
if match is None:
|
||||
raise PinoutError(f"XLSX cell reference 非法: {cell_reference!r}")
|
||||
value = 0
|
||||
for character in match.group(1):
|
||||
value = value * 26 + ord(character) - ord("A") + 1
|
||||
return value
|
||||
|
||||
|
||||
def cell_value(cell: ET.Element, shared: list[str]) -> str:
|
||||
cell_type = cell.attrib.get("t")
|
||||
if cell_type == "inlineStr":
|
||||
return xml_text(cell)
|
||||
value = cell.find(f"{{{MAIN_NS}}}v")
|
||||
raw = "" if value is None or value.text is None else value.text
|
||||
if cell_type == "s" and raw:
|
||||
try:
|
||||
return shared[int(raw)]
|
||||
except (IndexError, ValueError) as exc:
|
||||
raise PinoutError(f"XLSX shared string index 非法: {raw!r}") from exc
|
||||
if cell_type == "b":
|
||||
return "TRUE" if raw == "1" else "FALSE"
|
||||
return raw
|
||||
|
||||
|
||||
def sheet_rows(archive: ZipFile, target: str, shared: list[str]) -> Iterator[tuple[int, dict[int, str]]]:
|
||||
root = ET.fromstring(archive.read(target))
|
||||
for row in root.findall(f".//{{{MAIN_NS}}}sheetData/{{{MAIN_NS}}}row"):
|
||||
values: dict[int, str] = {}
|
||||
for cell in row.findall(f"{{{MAIN_NS}}}c"):
|
||||
reference = cell.attrib.get("r")
|
||||
if reference is None:
|
||||
continue
|
||||
value = cell_value(cell, shared).strip()
|
||||
if value:
|
||||
values[column_number(reference)] = value
|
||||
if values:
|
||||
yield int(row.attrib.get("r", "0")), values
|
||||
|
||||
|
||||
def normalized_header(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", value.casefold())
|
||||
|
||||
|
||||
def header_columns(row: dict[int, str]) -> dict[str, int]:
|
||||
aliases = {
|
||||
"pin_number": {"pinnum", "pinnumber"},
|
||||
"pin_name": {"pinname"},
|
||||
"io_type": {"iotype"},
|
||||
"io_group": {"iogroup"},
|
||||
"power_domain": {"powerdomain"},
|
||||
"io_cfg_register": {"iocfgregister"},
|
||||
"fmux_register": {"functionselectregister"},
|
||||
"fmux_default": {"fmuxdefault"},
|
||||
"description": {"description"},
|
||||
"note": {"note"},
|
||||
}
|
||||
result: dict[str, int] = {}
|
||||
for column, value in row.items():
|
||||
normalized = normalized_header(value)
|
||||
for field, candidates in aliases.items():
|
||||
if normalized in candidates and field not in result:
|
||||
result[field] = column
|
||||
return result
|
||||
|
||||
|
||||
def parse_muxes(description: str) -> list[dict[str, object]]:
|
||||
muxes: list[dict[str, object]] = []
|
||||
for line in description.replace("\r", "\n").splitlines():
|
||||
match = MUX_RE.fullmatch(line)
|
||||
if match is None:
|
||||
continue
|
||||
raw_function = match.group(2).strip()
|
||||
default = bool(re.search(r"\(\s*default\s*\)\s*$", raw_function, re.IGNORECASE))
|
||||
function = re.sub(
|
||||
r"\s*\(\s*default\s*\)\s*$", "", raw_function, flags=re.IGNORECASE
|
||||
).strip()
|
||||
muxes.append({"number": int(match.group(1)), "function": function, "default": default})
|
||||
return muxes
|
||||
|
||||
|
||||
def load_pin_rows(path: Path) -> tuple[str, list[dict[str, object]]]:
|
||||
path = path.resolve(strict=True)
|
||||
if path.stat().st_size > MAX_XLSX_SIZE:
|
||||
raise PinoutError(f"内置 XLSX 超过 {MAX_XLSX_SIZE} 字节限制: {path}")
|
||||
try:
|
||||
with ZipFile(path) as archive:
|
||||
shared, sheets = workbook_parts(archive)
|
||||
candidates = [item for item in sheets if "管腳信息表" in item[0] or "管脚信息表" in item[0]]
|
||||
if len(candidates) != 1:
|
||||
raise PinoutError(f"必须唯一定位管脚信息表,实际 {[item[0] for item in candidates]}")
|
||||
sheet_name, target = candidates[0]
|
||||
rows = iter(sheet_rows(archive, target, shared))
|
||||
try:
|
||||
_, header = next(rows)
|
||||
except StopIteration as exc:
|
||||
raise PinoutError(f"管脚信息表为空: {sheet_name}") from exc
|
||||
columns = header_columns(header)
|
||||
required = {"pin_number", "pin_name", "fmux_register", "fmux_default", "description"}
|
||||
missing = sorted(required - set(columns))
|
||||
if missing:
|
||||
raise PinoutError(f"管脚信息表缺少列: {missing}")
|
||||
parsed: list[dict[str, object]] = []
|
||||
for excel_row, values in rows:
|
||||
pin_number = values.get(columns["pin_number"], "").strip()
|
||||
pin_name = values.get(columns["pin_name"], "").strip()
|
||||
if not pin_number and not pin_name:
|
||||
continue
|
||||
item: dict[str, object] = {"excel_row": excel_row}
|
||||
for field, column in columns.items():
|
||||
item[field] = values.get(column, "").strip()
|
||||
item["muxes"] = parse_muxes(str(item.get("description", "")))
|
||||
parsed.append(item)
|
||||
if not parsed:
|
||||
raise PinoutError(f"管脚信息表未解析出数据行: {sheet_name}")
|
||||
return sheet_name, parsed
|
||||
except (BadZipFile, KeyError, ET.ParseError) as exc:
|
||||
raise PinoutError(f"内置 XLSX 结构无效: {path}: {exc}") from exc
|
||||
|
||||
|
||||
def lookup_aliases(value: object) -> set[str]:
|
||||
normalized = re.sub(r"[^A-Za-z0-9]", "", str(value)).upper()
|
||||
if not normalized:
|
||||
return set()
|
||||
aliases = {normalized}
|
||||
if normalized.startswith("XGPIO"):
|
||||
aliases.add(normalized[1:])
|
||||
elif normalized.startswith("GPIO"):
|
||||
aliases.add("X" + normalized)
|
||||
return aliases
|
||||
|
||||
|
||||
def query_rows(
|
||||
rows: list[dict[str, object]], queries: list[str], match_mode: str
|
||||
) -> tuple[list[dict[str, object]], list[str]]:
|
||||
results: list[dict[str, object]] = []
|
||||
unmatched: list[str] = []
|
||||
for query in queries:
|
||||
needles = lookup_aliases(query)
|
||||
query_matches: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
fields: list[tuple[str, object]] = [
|
||||
("pin_number", row.get("pin_number", "")),
|
||||
("pin_name", row.get("pin_name", "")),
|
||||
]
|
||||
fields.extend(
|
||||
("mux_function", mux.get("function", ""))
|
||||
for mux in row.get("muxes", [])
|
||||
if isinstance(mux, dict)
|
||||
)
|
||||
matched_by: list[dict[str, str]] = []
|
||||
for field, raw in fields:
|
||||
haystacks = lookup_aliases(raw)
|
||||
matched = bool(needles & haystacks)
|
||||
if match_mode == "contains":
|
||||
matched = any(needle in haystack for needle in needles for haystack in haystacks)
|
||||
if matched:
|
||||
matched_by.append({"field": field, "value": str(raw)})
|
||||
if matched_by:
|
||||
query_matches.append({**row, "matched_by": matched_by})
|
||||
if query_matches:
|
||||
results.append({"query": query, "matches": query_matches})
|
||||
else:
|
||||
unmatched.append(query)
|
||||
return results, unmatched
|
||||
|
||||
|
||||
def run(project: Path | None, chip_arg: str | None, queries: list[str], match_mode: str) -> dict[str, object]:
|
||||
if project is not None:
|
||||
chip, chip_evidence = project_chip(project)
|
||||
elif chip_arg is not None:
|
||||
chip = normalize_chip(chip_arg)
|
||||
chip_evidence = {"source": "explicit-chip"}
|
||||
else:
|
||||
raise PinoutError("必须提供 --project 或 --chip")
|
||||
document = select_document(chip)
|
||||
sheet, rows = load_pin_rows(document["path"])
|
||||
query_results, unmatched = query_rows(rows, queries, match_mode)
|
||||
errors = [f"内置 GPIO 表中未找到查询项: {item}" for item in unmatched]
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"passed": not errors,
|
||||
"chip": chip,
|
||||
"chip_evidence": chip_evidence,
|
||||
"pinout": {
|
||||
"id": document["id"],
|
||||
"revision": document.get("revision"),
|
||||
"path": str(document["path"]),
|
||||
"sha256": document["sha256"],
|
||||
"worksheet": sheet,
|
||||
"row_count": len(rows),
|
||||
},
|
||||
"match_mode": match_mode,
|
||||
"queries": query_results,
|
||||
"unmatched": unmatched,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--project", type=Path, help="从项目 buildEnvConfig.sh 的 EP_CHIP 选表")
|
||||
source.add_argument("--chip", help="显式芯片名称;不从项目目录名推断")
|
||||
parser.add_argument(
|
||||
"--query",
|
||||
action="append",
|
||||
default=[],
|
||||
help="查询 Pin Num、Pin Name、FMUX function;可重复,不提供时只解析并报告选表",
|
||||
)
|
||||
parser.add_argument("--match", choices=("exact", "contains"), default="exact")
|
||||
parser.add_argument("--output", type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
result = run(args.project, args.chip, args.query, args.match)
|
||||
if args.output is not None:
|
||||
write_json_atomic(args.output, result)
|
||||
except (PinoutError, OSError, json.JSONDecodeError) as exc:
|
||||
result = {
|
||||
"schema_version": 1,
|
||||
"passed": False,
|
||||
"errors": [str(exc)],
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("passed") is True else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record a verified BSP imgs path and produce the BSP-to-PSP handoff manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import bsp_preflight
|
||||
import project_guard
|
||||
|
||||
|
||||
REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024
|
||||
REQUIRED_PARTITION_IMAGES = ("kernel.bin", "rootfs.bin")
|
||||
|
||||
|
||||
def now_utc() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_text_atomic(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||
) as handle:
|
||||
handle.write(content)
|
||||
temporary = Path(handle.name)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def read_reference_project(project: Path) -> str | None:
|
||||
path = project / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
value = payload.get("reference_project")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def git_state(project: Path) -> dict[str, object]:
|
||||
top = subprocess.run(
|
||||
["git", "-C", str(project), "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if top.returncode != 0:
|
||||
return {"tracked": False, "repository_root": None, "commit": None, "dirty": None}
|
||||
commit = subprocess.run(
|
||||
["git", "-C", str(project), "rev-parse", "HEAD"],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
status = subprocess.run(
|
||||
["git", "-C", str(project), "status", "--porcelain", "--untracked-files=all", "--", "."],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if commit.returncode != 0 or status.returncode != 0:
|
||||
return {"tracked": True, "repository_root": top.stdout.strip(), "commit": None, "dirty": None}
|
||||
lines = [line for line in status.stdout.splitlines() if line.strip()]
|
||||
return {
|
||||
"tracked": True,
|
||||
"repository_root": str(Path(top.stdout.strip()).resolve()),
|
||||
"commit": commit.stdout.strip(),
|
||||
"dirty": bool(lines),
|
||||
"status": lines,
|
||||
}
|
||||
|
||||
|
||||
def artifact_inventory(output_directory: Path) -> tuple[list[dict[str, object]], list[str]]:
|
||||
artifacts: list[dict[str, object]] = []
|
||||
warnings: list[str] = []
|
||||
for path in sorted(output_directory.rglob("*")):
|
||||
relative = path.relative_to(output_directory).as_posix()
|
||||
if path.is_symlink():
|
||||
warnings.append(f"未将软链接作为固件产物计入 hash: {relative}")
|
||||
elif path.is_file():
|
||||
size = path.stat().st_size
|
||||
if size <= 0:
|
||||
warnings.append(f"零长度文件未计入固件产物: {relative}")
|
||||
continue
|
||||
artifacts.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"relative_path": relative,
|
||||
"absolute_path": str(path),
|
||||
"size": size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
)
|
||||
return artifacts, warnings
|
||||
|
||||
|
||||
def resolve_partition_table(project: Path, requested: Path | None) -> Path:
|
||||
configs = project / "configs"
|
||||
if not configs.is_dir() or configs.is_symlink():
|
||||
raise project_guard.GuardError(f"项目配置目录不存在或不是普通目录: {configs}")
|
||||
if requested is not None:
|
||||
candidate = requested.expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = project / candidate
|
||||
if not candidate.is_file() or candidate.is_symlink():
|
||||
raise project_guard.GuardError(f"指定分区表不存在或不是普通文件: {candidate}")
|
||||
candidate = candidate.resolve()
|
||||
try:
|
||||
candidate.relative_to(configs.resolve())
|
||||
except ValueError as exc:
|
||||
raise project_guard.GuardError("指定分区表必须位于当前项目 configs/ 内") from exc
|
||||
return candidate
|
||||
|
||||
candidates = sorted(
|
||||
path.resolve()
|
||||
for path in configs.rglob("partition*.xml")
|
||||
if path.is_file() and not path.is_symlink()
|
||||
)
|
||||
if len(candidates) != 1:
|
||||
rendered = ", ".join(str(path) for path in candidates) or "无"
|
||||
raise project_guard.GuardError(
|
||||
f"无法唯一定位生效分区表:找到 {len(candidates)} 个候选({rendered});"
|
||||
"必须用 --partition-table 指定当前构建实际使用的 configs/ 内文件"
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def parse_size_in_kb(value: str, partition_table: Path, image_name: str) -> int:
|
||||
try:
|
||||
size_in_kb = int(value, 10)
|
||||
except ValueError as exc:
|
||||
raise project_guard.GuardError(
|
||||
f"分区表 {partition_table} 中 {image_name} 的 size_in_kb 不是十进制整数: {value!r}"
|
||||
) from exc
|
||||
if size_in_kb <= 0:
|
||||
raise project_guard.GuardError(
|
||||
f"分区表 {partition_table} 中 {image_name} 的 size_in_kb 必须大于零"
|
||||
)
|
||||
return size_in_kb
|
||||
|
||||
|
||||
def validate_image_partition_margin(
|
||||
output_directory: Path,
|
||||
artifacts: list[dict[str, object]],
|
||||
partition_table: Path,
|
||||
) -> dict[str, object]:
|
||||
required_margin_bytes = REQUIRED_IMAGE_MARGIN_BYTES
|
||||
try:
|
||||
root = ET.parse(partition_table).getroot()
|
||||
except ET.ParseError as exc:
|
||||
raise project_guard.GuardError(f"分区表 XML 解析失败 {partition_table}: {exc}") from exc
|
||||
|
||||
checks: list[dict[str, object]] = []
|
||||
failures: list[str] = []
|
||||
for image_name in REQUIRED_PARTITION_IMAGES:
|
||||
image_artifacts = [item for item in artifacts if item.get("name") == image_name]
|
||||
if len(image_artifacts) != 1:
|
||||
failures.append(
|
||||
f"最终 imgs 中 {image_name} 必须唯一存在,实际找到 {len(image_artifacts)} 个"
|
||||
)
|
||||
continue
|
||||
|
||||
partitions: list[dict[str, object]] = []
|
||||
for element in root.iter("partition"):
|
||||
if Path(element.attrib.get("file", "")).name != image_name:
|
||||
continue
|
||||
raw_size = element.attrib.get("size_in_kb")
|
||||
if raw_size is None:
|
||||
raise project_guard.GuardError(
|
||||
f"分区表 {partition_table} 中 {image_name} 对应项缺少 size_in_kb"
|
||||
)
|
||||
size_in_kb = parse_size_in_kb(raw_size.strip(), partition_table, image_name)
|
||||
partitions.append(
|
||||
{
|
||||
"label": element.attrib.get("label"),
|
||||
"alias": element.attrib.get("alias"),
|
||||
"file": element.attrib.get("file"),
|
||||
"size_in_kb": size_in_kb,
|
||||
"size_bytes": size_in_kb * 1024,
|
||||
}
|
||||
)
|
||||
if not partitions:
|
||||
failures.append(f"分区表未找到 file={image_name!r} 的对应分区")
|
||||
continue
|
||||
|
||||
image = image_artifacts[0]
|
||||
image_size = int(image["size"])
|
||||
effective_partition_size = min(int(item["size_bytes"]) for item in partitions)
|
||||
margin = effective_partition_size - image_size
|
||||
passed = margin >= required_margin_bytes
|
||||
check = {
|
||||
"image": image_name,
|
||||
"image_path": str(output_directory / str(image["relative_path"])),
|
||||
"image_size_bytes": image_size,
|
||||
"partitions": partitions,
|
||||
"effective_partition_size_bytes": effective_partition_size,
|
||||
"required_margin_bytes": required_margin_bytes,
|
||||
"actual_margin_bytes": margin,
|
||||
"passed": passed,
|
||||
}
|
||||
checks.append(check)
|
||||
if not passed:
|
||||
failures.append(
|
||||
f"{image_name} 分区余量不足:镜像 {image_size} 字节,最小对应分区 "
|
||||
f"{effective_partition_size} 字节,实际余量 {margin} 字节,"
|
||||
f"要求至少 {required_margin_bytes} 字节"
|
||||
)
|
||||
|
||||
if failures:
|
||||
raise project_guard.GuardError(";".join(failures))
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"passed": True,
|
||||
"checked_at": now_utc(),
|
||||
"partition_table": str(partition_table),
|
||||
"partition_table_sha256": sha256_file(partition_table),
|
||||
"required_margin_bytes": required_margin_bytes,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def record_handoff(
|
||||
project_name: str,
|
||||
build_version: str,
|
||||
exit_code: int,
|
||||
completion_marker_seen: bool,
|
||||
root: Path,
|
||||
output_root: Path,
|
||||
build_command: str | None,
|
||||
risks: list[str],
|
||||
partition_table: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
if exit_code != 0:
|
||||
raise project_guard.GuardError("构建退出码非零,禁止生成 build_passed 交接")
|
||||
if not completion_marker_seen:
|
||||
raise project_guard.GuardError("未确认统一构建完成标记,禁止生成交接")
|
||||
if not bsp_preflight.VERSION_RE.fullmatch(build_version):
|
||||
raise project_guard.GuardError("构建版本必须为 YYYYMMDD_N")
|
||||
|
||||
root = project_guard.normalize_root(root)
|
||||
project = project_guard.direct_child(root, project_name, "project", True)
|
||||
output_root = output_root.expanduser().resolve(strict=False)
|
||||
output_directory = output_root / project_name / "imgs" / build_version
|
||||
if not output_directory.is_dir() or output_directory.is_symlink():
|
||||
raise project_guard.GuardError(f"最终 imgs 版本目录不存在或不是普通目录: {output_directory}")
|
||||
artifacts, warnings = artifact_inventory(output_directory)
|
||||
if not artifacts:
|
||||
raise project_guard.GuardError("最终 imgs 版本目录没有非空普通文件")
|
||||
partition_table = resolve_partition_table(project, partition_table)
|
||||
image_partition_check = validate_image_partition_margin(
|
||||
output_directory, artifacts, partition_table
|
||||
)
|
||||
|
||||
build_env = project / "buildEnvConfig.sh"
|
||||
assignments = bsp_preflight.parse_assignments(build_env) if build_env.is_file() else {}
|
||||
config_dirs = bsp_preflight.config_directories(project)
|
||||
config_dir = config_dirs[0] if len(config_dirs) == 1 else None
|
||||
if len(config_dirs) != 1:
|
||||
warnings.append(f"无法唯一确定 board build type:找到 {len(config_dirs)} 个配置目录")
|
||||
system_form = (
|
||||
"dual-os" if config_dir is not None and bsp_preflight.detects_dual_os(config_dir) else "single-os"
|
||||
)
|
||||
command = build_command or (
|
||||
f"cd {shlex.quote(str(root))} && source tools/script_completion.sh && "
|
||||
f"EMX2_BuildSDKTool {project_name} {build_version} all"
|
||||
)
|
||||
|
||||
validation_dir = project / "doc" / "bsp_adaptation" / "validation"
|
||||
handoff_dir = project / "doc" / "bsp_adaptation" / "handoff"
|
||||
hash_path = validation_dir / f"artifact_manifest_{build_version}.sha256"
|
||||
margin_path = validation_dir / f"image_partition_margin_{build_version}.json"
|
||||
hash_lines = "".join(
|
||||
f"{item['sha256']} {item['relative_path']}\n" for item in artifacts
|
||||
)
|
||||
write_text_atomic(hash_path, hash_lines)
|
||||
write_text_atomic(
|
||||
margin_path, json.dumps(image_partition_check, ensure_ascii=False, indent=2) + "\n"
|
||||
)
|
||||
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"handoff_type": "bsp-to-psp",
|
||||
"generated_at": now_utc(),
|
||||
"producer": {
|
||||
"module": "BSP",
|
||||
"project": project_name,
|
||||
"project_path": str(project),
|
||||
"reference_project": read_reference_project(project),
|
||||
"git": git_state(project),
|
||||
},
|
||||
"baseline": {
|
||||
"chip": assignments.get("EP_CHIP"),
|
||||
"sdk": assignments.get("EP_SDK_VER"),
|
||||
"architecture": assignments.get("EP_ARCH"),
|
||||
"flash": assignments.get("EP_FLASH_TYPE"),
|
||||
"board_build_type": config_dir.name if config_dir else None,
|
||||
"system_form": system_form,
|
||||
},
|
||||
"build": {
|
||||
"version": build_version,
|
||||
"status": "build_passed",
|
||||
"exit_code": exit_code,
|
||||
"completion_marker_seen": True,
|
||||
"working_directory": str(root),
|
||||
"command": command,
|
||||
"imgs_root": project_guard.expected_imgs_target(project_name),
|
||||
"imgs_version_path": str(output_directory),
|
||||
"artifact_manifest_path": str(hash_path),
|
||||
"image_partition_margin_path": str(margin_path),
|
||||
},
|
||||
"artifacts": artifacts,
|
||||
"validation": {
|
||||
"local_build": "passed",
|
||||
"image_partition_margin": image_partition_check,
|
||||
"hardware": "pending",
|
||||
"owner_acceptance": "pending",
|
||||
"release": "not-authorized",
|
||||
},
|
||||
"risks": risks,
|
||||
"warnings": warnings,
|
||||
"consumer_contract": {
|
||||
"module": "PSP",
|
||||
"immutable_artifacts": True,
|
||||
"verify_sha256_before_use": True,
|
||||
"select_latest_directory": False,
|
||||
"baseline_change_rule": "Any project, build version, artifact path/size/hash, partition table/hash, or image-margin change invalidates downstream PSP validation.",
|
||||
},
|
||||
}
|
||||
handoff_path = handoff_dir / "psp_handoff.json"
|
||||
write_text_atomic(
|
||||
handoff_path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
|
||||
)
|
||||
status = {
|
||||
"schema_version": 1,
|
||||
"updated_at": now_utc(),
|
||||
"project": project_name,
|
||||
"state": "build_passed",
|
||||
"imgs_version_path": str(output_directory),
|
||||
"psp_handoff_path": str(handoff_path),
|
||||
"image_partition_margin_path": str(margin_path),
|
||||
"image_partition_margin": image_partition_check,
|
||||
"hardware_validation": "pending",
|
||||
"owner_acceptance": "pending",
|
||||
"release": "not-authorized",
|
||||
}
|
||||
status_path = project / "doc" / "bsp_adaptation" / "status.json"
|
||||
write_text_atomic(status_path, json.dumps(status, ensure_ascii=False, indent=2) + "\n")
|
||||
return {
|
||||
"passed": True,
|
||||
"imgs_version_path": str(output_directory),
|
||||
"artifact_count": len(artifacts),
|
||||
"artifact_manifest_path": str(hash_path),
|
||||
"image_partition_margin_path": str(margin_path),
|
||||
"image_partition_margin": image_partition_check,
|
||||
"psp_handoff_path": str(handoff_path),
|
||||
"status_path": str(status_path),
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project", required=True)
|
||||
parser.add_argument("--build-version", required=True)
|
||||
parser.add_argument("--build-exit-code", type=int, required=True)
|
||||
parser.add_argument("--completion-marker-seen", action="store_true")
|
||||
parser.add_argument("--build-command")
|
||||
parser.add_argument("--risk", action="append", default=[])
|
||||
parser.add_argument(
|
||||
"--partition-table",
|
||||
type=Path,
|
||||
help="自动定位不唯一时,指定当前项目 configs/ 内实际生效的分区表",
|
||||
)
|
||||
parser.add_argument("--emxres-root", type=Path, default=project_guard.production_root())
|
||||
parser.add_argument(
|
||||
"--output-root", type=Path, default=Path("/epbuild/EMX2.0/EmxRes")
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = record_handoff(
|
||||
args.project,
|
||||
args.build_version,
|
||||
args.build_exit_code,
|
||||
args.completion_marker_seen,
|
||||
args.emxres_root,
|
||||
args.output_root,
|
||||
args.build_command,
|
||||
args.risk,
|
||||
args.partition_table,
|
||||
)
|
||||
except (project_guard.GuardError, OSError, json.JSONDecodeError) as exc:
|
||||
result = {"passed": False, "error": str(exc)}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("passed") is True else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavioral tests for bounded preflight and BSP-to-PSP handoff recording."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import bsp_preflight
|
||||
import project_guard
|
||||
import record_build_handoff
|
||||
|
||||
|
||||
def make_project(root: Path, name: str) -> Path:
|
||||
project = root / name
|
||||
config = project / "configs" / "cv1841cp_wevb_0015a_spinand"
|
||||
config.mkdir(parents=True)
|
||||
(project / "buildEnvConfig.sh").write_text(
|
||||
"EP_CHIP=CV1841CP\nEP_SDK_VER=cv184x_v6.3.3\n"
|
||||
"EP_ARCH=MUSL\nEP_FLASH_TYPE=nand\nNAND_FLASH_ID=0xa1d4\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project / "configs" / "EPSYNCSDKConfig.sh").write_text("#!/bin/bash\n", encoding="utf-8")
|
||||
(config / "cv1841cp_wevb_0015a_spinand_defconfig").write_text(
|
||||
"CONFIG_DUAL_OS=y\n", encoding="utf-8"
|
||||
)
|
||||
partition = config / "partition" / "partition_spinand.xml"
|
||||
partition.parent.mkdir()
|
||||
partition.write_text(
|
||||
"""<physical_partition type="spinand">
|
||||
<partition label="BOOT" alias="kernel.A" size_in_kb="1024" file="kernel.bin"/>
|
||||
<partition label="BOOT2" alias="kernel.B" size_in_kb="1536" file="kernel.bin"/>
|
||||
<partition label="ROOTFS" alias="rootfs.A" size_in_kb="1024" file="rootfs.bin"/>
|
||||
</physical_partition>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project / "imgs").symlink_to(project_guard.expected_imgs_target(name))
|
||||
return project
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
root = base / "EmxRes"
|
||||
root.mkdir()
|
||||
tools = root / "tools"
|
||||
tools.mkdir()
|
||||
(tools / "script_completion.sh").write_text("# test\n", encoding="utf-8")
|
||||
reference = make_project(root, "CV1841CP-REF_dualos")
|
||||
|
||||
project_report = bsp_preflight.project_preflight(
|
||||
reference.name, "CV1841CP-NEW_dualos", root
|
||||
)
|
||||
assert project_report["passed"] is True, project_report
|
||||
assert project_report["facts"]["board_build_type"] == "cv1841cp_wevb_0015a_spinand"
|
||||
assert project_report["facts"]["system_form"] == "dual-os"
|
||||
|
||||
output_root = base / "epbuild"
|
||||
build_report = bsp_preflight.build_preflight(
|
||||
reference.name, "20260902_1", root, output_root
|
||||
)
|
||||
assert build_report["passed"] is True, build_report
|
||||
assert build_report["working_directory"] == str(root.resolve())
|
||||
assert build_report["command"].startswith(f"cd {root.resolve()} &&")
|
||||
|
||||
output = output_root / reference.name / "imgs" / "20260902_1"
|
||||
output.mkdir(parents=True)
|
||||
boot = output / "boot.bin"
|
||||
boot.write_bytes(b"verified BSP artifact")
|
||||
kernel = output / "kernel.bin"
|
||||
kernel.write_bytes(b"k" * (512 * 1024))
|
||||
rootfs = output / "rootfs.bin"
|
||||
rootfs.write_bytes(b"r" * (256 * 1024))
|
||||
conflict = bsp_preflight.build_preflight(
|
||||
reference.name, "20260902_1", root, output_root
|
||||
)
|
||||
assert conflict["passed"] is False
|
||||
|
||||
clone = reference / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
||||
clone.parent.mkdir(parents=True)
|
||||
clone.write_text(
|
||||
json.dumps({"reference_project": "CV1841CP-BASE_dualos"}), encoding="utf-8"
|
||||
)
|
||||
recorded = record_build_handoff.record_handoff(
|
||||
reference.name,
|
||||
"20260902_1",
|
||||
0,
|
||||
True,
|
||||
root,
|
||||
output_root,
|
||||
None,
|
||||
["NAND part number pending"],
|
||||
)
|
||||
assert recorded["passed"] is True, recorded
|
||||
assert recorded["imgs_version_path"] == str(output.resolve())
|
||||
assert recorded["image_partition_margin"]["passed"] is True
|
||||
handoff_path = Path(recorded["psp_handoff_path"])
|
||||
handoff = json.loads(handoff_path.read_text(encoding="utf-8"))
|
||||
assert handoff["handoff_type"] == "bsp-to-psp"
|
||||
assert handoff["producer"]["git"]["tracked"] is False
|
||||
assert handoff["build"]["imgs_version_path"] == str(output.resolve())
|
||||
assert handoff["build"]["status"] == "build_passed"
|
||||
margin = handoff["validation"]["image_partition_margin"]
|
||||
assert margin["passed"] is True
|
||||
assert margin["required_margin_bytes"] == 512 * 1024
|
||||
assert margin["partition_table_sha256"]
|
||||
kernel_check = next(item for item in margin["checks"] if item["image"] == "kernel.bin")
|
||||
assert kernel_check["actual_margin_bytes"] == 512 * 1024
|
||||
assert kernel_check["effective_partition_size_bytes"] == 1024 * 1024
|
||||
assert Path(recorded["image_partition_margin_path"]).is_file()
|
||||
assert handoff["consumer_contract"]["select_latest_directory"] is False
|
||||
assert handoff["artifacts"][0]["sha256"] == hashlib.sha256(boot.read_bytes()).hexdigest()
|
||||
assert handoff["validation"]["hardware"] == "pending"
|
||||
|
||||
overflow_output = output_root / reference.name / "imgs" / "20260902_2"
|
||||
overflow_output.mkdir(parents=True)
|
||||
(overflow_output / "boot.bin").write_bytes(b"boot")
|
||||
(overflow_output / "kernel.bin").write_bytes(b"k" * (512 * 1024))
|
||||
(overflow_output / "rootfs.bin").write_bytes(b"r" * (512 * 1024 + 1))
|
||||
try:
|
||||
record_build_handoff.record_handoff(
|
||||
reference.name,
|
||||
"20260902_2",
|
||||
0,
|
||||
True,
|
||||
root,
|
||||
output_root,
|
||||
None,
|
||||
[],
|
||||
)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "rootfs.bin 分区余量不足" in str(exc)
|
||||
else:
|
||||
raise AssertionError("image with less than 512 KiB margin must fail")
|
||||
assert not (
|
||||
reference
|
||||
/ "doc"
|
||||
/ "bsp_adaptation"
|
||||
/ "validation"
|
||||
/ "image_partition_margin_20260902_2.json"
|
||||
).exists()
|
||||
|
||||
original_partition = (
|
||||
reference
|
||||
/ "configs"
|
||||
/ "cv1841cp_wevb_0015a_spinand"
|
||||
/ "partition"
|
||||
/ "partition_spinand.xml"
|
||||
)
|
||||
duplicate_partition = original_partition.with_name("partition_backup.xml")
|
||||
duplicate_partition.write_text(original_partition.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
try:
|
||||
record_build_handoff.resolve_partition_table(reference, None)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "无法唯一定位" in str(exc)
|
||||
else:
|
||||
raise AssertionError("ambiguous partition tables must fail")
|
||||
assert (
|
||||
record_build_handoff.resolve_partition_table(
|
||||
reference,
|
||||
original_partition.relative_to(reference),
|
||||
)
|
||||
== original_partition.resolve()
|
||||
)
|
||||
|
||||
try:
|
||||
record_build_handoff.record_handoff(
|
||||
reference.name,
|
||||
"20260902_1",
|
||||
1,
|
||||
True,
|
||||
root,
|
||||
output_root,
|
||||
None,
|
||||
[],
|
||||
)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "退出码" in str(exc)
|
||||
else:
|
||||
raise AssertionError("failed build must not produce a PSP handoff")
|
||||
print("BSP preflight and handoff behavioral tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavioral smoke tests for build_pinmux_csv.py."""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("build_pinmux_csv.py")
|
||||
HEADERS = ["引脚号", "功能名称", "引脚名称", "有效电平", "FMUX寄存器", "FMUX当前值", "FMUX默认值", "FMUX_list"]
|
||||
|
||||
|
||||
def run(payload):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
source = root / "input.json"
|
||||
output = root / "out.csv"
|
||||
source.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--input", str(source), "--output", str(output)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
data = output.read_bytes() if output.exists() else b""
|
||||
return result, data
|
||||
|
||||
|
||||
def valid_row():
|
||||
return {
|
||||
"pin_number": "A12",
|
||||
"function_name": "CAM_RST0",
|
||||
"pin_name": "PAD_MIPI_TXM4",
|
||||
"is_gpio": True,
|
||||
"active_level": "低有效",
|
||||
"fmux_register": "0x030010A4",
|
||||
"fmux_current": "3",
|
||||
"fmux_default": "0",
|
||||
"fmux_list": "0:XGPIOA_20; 1:CAM_MCLK0; 3:GPIOA_20",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
result, data = run({"project_name": "测试 项目", "rows": [valid_row()]})
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert data.startswith(b"\xef\xbb\xbf")
|
||||
rows = list(csv.reader(data.decode("utf-8-sig").splitlines()))
|
||||
assert rows[0] == HEADERS
|
||||
assert len(rows) == 2 and rows[1][0] == "A12"
|
||||
|
||||
bad = valid_row()
|
||||
bad["active_level"] = None
|
||||
result, data = run({"project_name": "bad", "rows": [bad]})
|
||||
assert result.returncode == 2 and not data
|
||||
assert "GPIO 有效电平无效" in result.stderr
|
||||
|
||||
duplicate = valid_row()
|
||||
result, data = run({"project_name": "dup", "rows": [valid_row(), duplicate]})
|
||||
assert result.returncode == 2 and not data
|
||||
assert "引脚号重复" in result.stderr
|
||||
|
||||
mismatched_mux = valid_row()
|
||||
mismatched_mux["fmux_current"] = "7"
|
||||
result, data = run({"project_name": "mux", "rows": [mismatched_mux]})
|
||||
assert result.returncode == 2 and not data
|
||||
assert "FMUX当前值 7 未出现在 FMUX_list" in result.stderr
|
||||
|
||||
print("Pinmux CSV behavioral tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavioral tests for project_guard.py using isolated temporary projects."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import project_guard # noqa: E402
|
||||
|
||||
|
||||
def make_reference(root: Path, name: str = "CV1841CP-REF_dualos") -> Path:
|
||||
reference = root / name
|
||||
uboot = reference / "configs" / "uboot"
|
||||
uboot.mkdir(parents=True)
|
||||
(reference / "buildEnvConfig.sh").write_text(
|
||||
"EP_CHIP=CV1841CP\nEP_SDK_VER=v6.3.2\nEP_FLASH_TYPE=nand\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(uboot / "cvi_board_init.c").write_text("int cvi_board_init(void) { return 0; }\n", encoding="utf-8")
|
||||
generated = reference / "build"
|
||||
generated.mkdir()
|
||||
(generated / "artifact.bin").write_bytes(b"generated")
|
||||
(generated / "external-cache-link").symlink_to("/outside/ignored-build-cache")
|
||||
(reference / "debug.log").write_text("ignored\n", encoding="utf-8")
|
||||
(reference / "imgs").symlink_to(project_guard.expected_imgs_target(name))
|
||||
return reference
|
||||
|
||||
|
||||
def test_prepare_verify_finalize(root: Path) -> None:
|
||||
reference_name = "CV1841CP-REF_dualos"
|
||||
target_name = "CV1841CP-NEW_dualos"
|
||||
reference = make_reference(root, reference_name)
|
||||
|
||||
prepared = project_guard.prepare_project(reference_name, target_name, root=root)
|
||||
staging = root / prepared["staging_directory"]
|
||||
assert staging.is_dir()
|
||||
assert not (staging / "build").exists()
|
||||
assert not (staging / "debug.log").exists()
|
||||
assert (staging / "imgs").is_symlink()
|
||||
assert os.readlink(staging / "imgs") == project_guard.expected_imgs_target(target_name)
|
||||
manifest_path = staging / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert manifest["reference_project"] == reference_name
|
||||
assert manifest["imgs_retargeted"] is True
|
||||
|
||||
relative_config = "configs/uboot/cvi_board_init.c"
|
||||
(staging / relative_config).write_text("int cvi_board_init(void) { return 1; }\n", encoding="utf-8")
|
||||
report = staging / "doc" / "bsp_adaptation" / "adaptation_report.md"
|
||||
report.write_text("# BSP adaptation\n\nStatic validation passed.\n", encoding="utf-8")
|
||||
verified = project_guard.verify_project(
|
||||
reference_name,
|
||||
staging.name,
|
||||
target_name,
|
||||
[relative_config],
|
||||
root=root,
|
||||
)
|
||||
assert verified["passed"] is True, verified
|
||||
assert relative_config in verified["changes"]["modified"]
|
||||
assert "imgs" in verified["changes"]["modified"]
|
||||
|
||||
finalized = project_guard.finalize_project(
|
||||
reference_name,
|
||||
staging.name,
|
||||
target_name,
|
||||
[relative_config],
|
||||
root=root,
|
||||
)
|
||||
target = root / target_name
|
||||
assert finalized["target_path"] == str(target)
|
||||
assert target.is_dir() and not staging.exists()
|
||||
assert reference.is_dir()
|
||||
final_manifest = json.loads(
|
||||
(target / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert final_manifest["final_path"] == str(target)
|
||||
|
||||
handoff = target / "doc" / "bsp_adaptation" / "handoff" / "psp_handoff.json"
|
||||
handoff.parent.mkdir(parents=True)
|
||||
handoff.write_text('{"schema_version": 1}\n', encoding="utf-8")
|
||||
audited = project_guard.audit_final_project(target_name, root=root)
|
||||
assert audited["passed"] is True, audited
|
||||
assert audited["reference_drifted_since_prepare"] is False
|
||||
|
||||
(target / "buildEnvConfig.sh").write_text("EP_CHIP=CV1842HP\n", encoding="utf-8")
|
||||
violated = project_guard.audit_final_project(target_name, root=root)
|
||||
assert violated["passed"] is False
|
||||
assert violated["violations"] == ["buildEnvConfig.sh"]
|
||||
|
||||
|
||||
def test_unapproved_delta_is_rejected(root: Path) -> None:
|
||||
reference_name = "CV1841CP-REF2_dualos"
|
||||
target_name = "CV1841CP-NEW2_dualos"
|
||||
make_reference(root, reference_name)
|
||||
prepared = project_guard.prepare_project(reference_name, target_name, root=root)
|
||||
staging = root / prepared["staging_directory"]
|
||||
(staging / "buildEnvConfig.sh").write_text("EP_CHIP=CV1842HP\n", encoding="utf-8")
|
||||
(staging / "doc" / "bsp_adaptation" / "adaptation_report.md").write_text(
|
||||
"not final\n", encoding="utf-8"
|
||||
)
|
||||
verified = project_guard.verify_project(
|
||||
reference_name,
|
||||
staging.name,
|
||||
target_name,
|
||||
["configs/uboot/cvi_board_init.c"],
|
||||
root=root,
|
||||
)
|
||||
assert verified["passed"] is False
|
||||
assert verified["violations"] == ["buildEnvConfig.sh"]
|
||||
try:
|
||||
project_guard.finalize_project(
|
||||
reference_name,
|
||||
staging.name,
|
||||
target_name,
|
||||
["configs/uboot/cvi_board_init.c"],
|
||||
root=root,
|
||||
)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "未通过" in str(exc)
|
||||
else:
|
||||
raise AssertionError("finalize should reject a failed delta report")
|
||||
|
||||
|
||||
def test_external_link_and_credentials_are_rejected(root: Path) -> None:
|
||||
external = make_reference(root, "CV1815JA-EXTERNAL")
|
||||
(external / "imgs").unlink()
|
||||
(external / "other-link").symlink_to("/outside/emxres/shared")
|
||||
try:
|
||||
project_guard.prepare_project(external.name, "CV1815JA-NEW", root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "外部绝对软链接" in str(exc)
|
||||
else:
|
||||
raise AssertionError("unexpected external symlink should be rejected")
|
||||
|
||||
credential = make_reference(root, "CV1815JA-CREDENTIAL")
|
||||
(credential / ".env").write_text("TOKEN=secret\n", encoding="utf-8")
|
||||
try:
|
||||
project_guard.prepare_project(credential.name, "CV1815JA-CLEAN", root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "疑似凭据" in str(exc)
|
||||
else:
|
||||
raise AssertionError("credential-like files should be rejected")
|
||||
|
||||
|
||||
def test_changes_after_verify_are_rejected(root: Path) -> None:
|
||||
reference_name = "CV1841CP-REF3_dualos"
|
||||
target_name = "CV1841CP-NEW3_dualos"
|
||||
make_reference(root, reference_name)
|
||||
prepared = project_guard.prepare_project(reference_name, target_name, root=root)
|
||||
staging = root / prepared["staging_directory"]
|
||||
report = staging / "doc" / "bsp_adaptation" / "adaptation_report.md"
|
||||
report.write_text("# verified\n", encoding="utf-8")
|
||||
verified = project_guard.verify_project(
|
||||
reference_name,
|
||||
staging.name,
|
||||
target_name,
|
||||
[],
|
||||
root=root,
|
||||
)
|
||||
assert verified["passed"] is True
|
||||
(staging / "buildEnvConfig.sh").write_text("EP_CHIP=CV1842HP\n", encoding="utf-8")
|
||||
try:
|
||||
project_guard.finalize_project(reference_name, staging.name, target_name, [], root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "未通过" in str(exc)
|
||||
else:
|
||||
raise AssertionError("finalize should reject post-verification changes")
|
||||
|
||||
|
||||
def test_reference_drift_is_rejected(root: Path) -> None:
|
||||
reference_name = "CV1841CP-REF4_dualos"
|
||||
target_name = "CV1841CP-NEW4_dualos"
|
||||
reference = make_reference(root, reference_name)
|
||||
prepared = project_guard.prepare_project(reference_name, target_name, root=root)
|
||||
staging = root / prepared["staging_directory"]
|
||||
(reference / "buildEnvConfig.sh").write_text("EP_CHIP=CV1842HP\n", encoding="utf-8")
|
||||
try:
|
||||
project_guard.verify_project(reference_name, staging.name, target_name, [], root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "参考项目发生变化" in str(exc)
|
||||
else:
|
||||
raise AssertionError("verify should reject reference drift after prepare")
|
||||
|
||||
|
||||
def test_staging_credentials_and_excluded_outputs_are_rejected(root: Path) -> None:
|
||||
reference_name = "CV1815JA-REF5"
|
||||
target_name = "CV1815JA-NEW5"
|
||||
make_reference(root, reference_name)
|
||||
prepared = project_guard.prepare_project(reference_name, target_name, root=root)
|
||||
staging = root / prepared["staging_directory"]
|
||||
(staging / ".env").write_text("TOKEN=secret\n", encoding="utf-8")
|
||||
try:
|
||||
project_guard.verify_project(reference_name, staging.name, target_name, [], root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "疑似凭据" in str(exc)
|
||||
else:
|
||||
raise AssertionError("verify should reject credentials added during editing")
|
||||
(staging / ".env").unlink()
|
||||
(staging / "logs").mkdir()
|
||||
(staging / "logs" / "build.txt").write_text("generated\n", encoding="utf-8")
|
||||
try:
|
||||
project_guard.verify_project(reference_name, staging.name, target_name, [], root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "被排除" in str(exc)
|
||||
else:
|
||||
raise AssertionError("verify should reject excluded outputs added during editing")
|
||||
|
||||
|
||||
def test_excluded_output_added_after_verify_is_rejected(root: Path) -> None:
|
||||
reference_name = "CV1815JA-REF6"
|
||||
target_name = "CV1815JA-NEW6"
|
||||
make_reference(root, reference_name)
|
||||
prepared = project_guard.prepare_project(reference_name, target_name, root=root)
|
||||
staging = root / prepared["staging_directory"]
|
||||
(staging / "doc" / "bsp_adaptation" / "adaptation_report.md").write_text(
|
||||
"# verified\n", encoding="utf-8"
|
||||
)
|
||||
verified = project_guard.verify_project(
|
||||
reference_name, staging.name, target_name, [], root=root
|
||||
)
|
||||
assert verified["passed"] is True
|
||||
(staging / "logs").mkdir()
|
||||
(staging / "logs" / "late.txt").write_text("late\n", encoding="utf-8")
|
||||
try:
|
||||
project_guard.finalize_project(reference_name, staging.name, target_name, [], root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "被排除" in str(exc)
|
||||
else:
|
||||
raise AssertionError("finalize should reject excluded output added after verify")
|
||||
|
||||
|
||||
def test_tampered_delta_report_is_not_trusted(root: Path) -> None:
|
||||
reference_name = "CV1815JA-REF7"
|
||||
target_name = "CV1815JA-NEW7"
|
||||
make_reference(root, reference_name)
|
||||
prepared = project_guard.prepare_project(reference_name, target_name, root=root)
|
||||
staging = root / prepared["staging_directory"]
|
||||
(staging / "doc" / "bsp_adaptation" / "adaptation_report.md").write_text(
|
||||
"# report\n", encoding="utf-8"
|
||||
)
|
||||
(staging / "buildEnvConfig.sh").write_text("EP_CHIP=CV1842HP\n", encoding="utf-8")
|
||||
verified = project_guard.verify_project(
|
||||
reference_name, staging.name, target_name, [], root=root
|
||||
)
|
||||
assert verified["passed"] is False
|
||||
delta_path = staging / "doc" / "bsp_adaptation" / "validation" / "project_delta.json"
|
||||
tampered = json.loads(delta_path.read_text(encoding="utf-8"))
|
||||
tampered["passed"] = True
|
||||
tampered["violations"] = []
|
||||
project_guard.write_json(delta_path, tampered)
|
||||
try:
|
||||
project_guard.finalize_project(reference_name, staging.name, target_name, [], root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "未通过" in str(exc)
|
||||
else:
|
||||
raise AssertionError("finalize should recompute instead of trusting a tampered delta report")
|
||||
|
||||
|
||||
def test_allow_paths_must_be_exact() -> None:
|
||||
try:
|
||||
project_guard.normalize_allow_paths(["configs/**"])
|
||||
except project_guard.GuardError as exc:
|
||||
assert "不允许通配符" in str(exc)
|
||||
else:
|
||||
raise AssertionError("wildcard allow paths should be rejected")
|
||||
|
||||
|
||||
def test_audit_requires_frozen_baseline(root: Path) -> None:
|
||||
target = root / "CV1841CP-UNMANAGED_dualos"
|
||||
target.mkdir()
|
||||
try:
|
||||
project_guard.audit_final_project(target.name, root=root)
|
||||
except project_guard.GuardError as exc:
|
||||
assert "clone_manifest" in str(exc)
|
||||
else:
|
||||
raise AssertionError("audit should reject an unmanaged project")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "EmxRes"
|
||||
root.mkdir()
|
||||
test_prepare_verify_finalize(root)
|
||||
test_unapproved_delta_is_rejected(root)
|
||||
test_external_link_and_credentials_are_rejected(root)
|
||||
test_changes_after_verify_are_rejected(root)
|
||||
test_reference_drift_is_rejected(root)
|
||||
test_staging_credentials_and_excluded_outputs_are_rejected(root)
|
||||
test_excluded_output_added_after_verify_is_rejected(root)
|
||||
test_tampered_delta_report_is_not_trusted(root)
|
||||
test_audit_requires_frozen_baseline(root)
|
||||
test_allow_paths_must_be_exact()
|
||||
print("Project guard behavioral tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavioral tests for the bundled CV18 GPIO pinout catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("query_builtin_pinout.py")
|
||||
SPEC = importlib.util.spec_from_file_location("query_builtin_pinout", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def selected_id(chip: str) -> str:
|
||||
return str(MODULE.select_document(chip)["id"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
expected = {
|
||||
"CV1810": "cv181xc",
|
||||
"CV1811C": "cv181xc",
|
||||
"CV1812CPA": "cv181xc",
|
||||
"CV1812HP": "cv181xh",
|
||||
"CV1815JA": "cv1815j",
|
||||
"CV1841CP": "cv184xc",
|
||||
"CV1842CP": "cv184xc",
|
||||
"CV1842HP": "cv184xh",
|
||||
"CV1843HP": "cv184xh",
|
||||
}
|
||||
for chip, identifier in expected.items():
|
||||
assert selected_id(chip) == identifier, chip
|
||||
|
||||
try:
|
||||
MODULE.select_document("CV1801B")
|
||||
except MODULE.PinoutError as exc:
|
||||
assert "没有对应" in str(exc)
|
||||
else:
|
||||
raise AssertionError("unsupported chips must fail closed")
|
||||
|
||||
manifest = MODULE.load_manifest()
|
||||
assert len(manifest) == 5
|
||||
for document in manifest:
|
||||
sheet, rows = MODULE.load_pin_rows(document["path"])
|
||||
assert "管腳信息表" in sheet or "管脚信息表" in sheet
|
||||
assert rows
|
||||
results, unmatched = MODULE.query_rows(rows, ["SD0_CLK", "GPIOA_7"], "exact")
|
||||
assert not unmatched, document["id"]
|
||||
assert [item["query"] for item in results] == ["SD0_CLK", "GPIOA_7"]
|
||||
gpio_matches = results[1]["matches"]
|
||||
assert any(
|
||||
mux.get("function") == "XGPIOA[7]"
|
||||
for row in gpio_matches
|
||||
for mux in row["muxes"]
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
project = Path(temporary) / "CV1815JA-misleading-name"
|
||||
project.mkdir()
|
||||
config = project / "buildEnvConfig.sh"
|
||||
config.write_text("export EP_CHIP='CV1842HP'\n", encoding="utf-8")
|
||||
result = MODULE.run(project, None, ["SD0_CLK"], "exact")
|
||||
assert result["passed"] is True
|
||||
assert result["chip"] == "CV1842HP"
|
||||
assert result["pinout"]["id"] == "cv184xh"
|
||||
assert result["chip_evidence"]["source"] == "project-buildEnvConfig"
|
||||
|
||||
config.write_text("EP_CHIP=CV1811C\nEP_CHIP=CV1841CP\n", encoding="utf-8")
|
||||
try:
|
||||
MODULE.project_chip(project)
|
||||
except MODULE.PinoutError as exc:
|
||||
assert "唯一确定" in str(exc)
|
||||
else:
|
||||
raise AssertionError("ambiguous EP_CHIP must fail closed")
|
||||
|
||||
config.write_text("EP_CHIP=$CV1841CP\n", encoding="utf-8")
|
||||
try:
|
||||
MODULE.project_chip(project)
|
||||
except MODULE.PinoutError as exc:
|
||||
assert "静态芯片名称" in str(exc)
|
||||
else:
|
||||
raise AssertionError("dynamic EP_CHIP must fail closed")
|
||||
|
||||
result = MODULE.run(None, "CV1841CP", ["NOT_A_REAL_PIN"], "exact")
|
||||
assert result["passed"] is False
|
||||
assert result["unmatched"] == ["NOT_A_REAL_PIN"]
|
||||
|
||||
print("Bundled CV18 GPIO pinout behavioral tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavioral tests for validate_gpio_plan.py."""
|
||||
|
||||
import copy
|
||||
|
||||
import validate_gpio_plan
|
||||
|
||||
|
||||
def valid_plan() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"project": "CV1841CP-BOARD_dualos",
|
||||
"validation_mode": "emxres-config",
|
||||
"sources": ["schematic.pdf page 3", "GPIO List row 12"],
|
||||
"confirmation": {
|
||||
"confirmed": True,
|
||||
"confirmed_at": "2026-09-02T10:00:00+08:00",
|
||||
"summary": "用户确认整张 GPIO 实施表",
|
||||
},
|
||||
"signals": [
|
||||
{
|
||||
"function": "CAM_RST0",
|
||||
"pad": "PAD_MIPI_TXP2",
|
||||
"mux": "XGPIOC_17",
|
||||
"gpio": "C17",
|
||||
"direction": "output",
|
||||
"active_level": "low",
|
||||
"boot_value": 0,
|
||||
"runtime_owner": "camera",
|
||||
"dts_consumers": ["mipi_rx.snsr-reset"],
|
||||
"source_evidence": ["schematic.pdf page 3"],
|
||||
"decision": "confirmed",
|
||||
"note": "early reset",
|
||||
},
|
||||
{
|
||||
"function": "CAM_MCLK0",
|
||||
"pad": "PAD_MIPI_TXP0",
|
||||
"mux": "CAM_MCLK0",
|
||||
"gpio": None,
|
||||
"direction": "peripheral",
|
||||
"active_level": None,
|
||||
"boot_value": None,
|
||||
"runtime_owner": "camera",
|
||||
"dts_consumers": [],
|
||||
"source_evidence": ["schematic.pdf page 3"],
|
||||
"decision": "inherited",
|
||||
"note": "camera clock",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
result = validate_gpio_plan.validate_plan(valid_plan())
|
||||
assert result["passed"] is True, result
|
||||
|
||||
candidate = valid_plan()
|
||||
candidate["confirmation"] = {"confirmed": False}
|
||||
candidate["signals"][0]["decision"] = "provisional"
|
||||
candidate["signals"][0]["active_level"] = "unknown"
|
||||
candidate_result = validate_gpio_plan.validate_plan(candidate, allow_unconfirmed=True)
|
||||
assert candidate_result["passed"] is True, candidate_result
|
||||
assert candidate_result["warnings"]
|
||||
assert validate_gpio_plan.validate_plan(candidate)["passed"] is False
|
||||
|
||||
duplicate = copy.deepcopy(valid_plan())
|
||||
duplicate["signals"].append(copy.deepcopy(duplicate["signals"][0]))
|
||||
duplicate["signals"][-1]["function"] = "WIFI_PWREN"
|
||||
duplicate_result = validate_gpio_plan.validate_plan(duplicate)
|
||||
assert duplicate_result["passed"] is False
|
||||
assert any("重复 Pad" in error for error in duplicate_result["errors"])
|
||||
assert any("重复 GPIO" in error for error in duplicate_result["errors"])
|
||||
|
||||
invalid_input = copy.deepcopy(valid_plan())
|
||||
invalid_input["signals"][0]["direction"] = "input"
|
||||
invalid_input["signals"][0]["boot_value"] = 1
|
||||
assert validate_gpio_plan.validate_plan(invalid_input)["passed"] is False
|
||||
print("GPIO plan behavioral tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a machine-readable BSP GPIO implementation plan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DIRECTIONS = {"input", "output", "peripheral", "none"}
|
||||
ACTIVE_LEVELS = {"high", "low", "paired", "unknown", None}
|
||||
DECISIONS = {"confirmed", "inherited", "no-gpio", "provisional", "blocked"}
|
||||
IMPLEMENTABLE = {"confirmed", "inherited", "no-gpio"}
|
||||
REQUIRED_SIGNAL_FIELDS = {
|
||||
"function",
|
||||
"pad",
|
||||
"mux",
|
||||
"gpio",
|
||||
"direction",
|
||||
"active_level",
|
||||
"boot_value",
|
||||
"runtime_owner",
|
||||
"dts_consumers",
|
||||
"source_evidence",
|
||||
"decision",
|
||||
"note",
|
||||
}
|
||||
|
||||
|
||||
def validate_plan(payload: object, allow_unconfirmed: bool = False) -> dict[str, object]:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
if not isinstance(payload, dict):
|
||||
return {"passed": False, "errors": ["顶层必须是 JSON 对象"], "warnings": []}
|
||||
|
||||
if payload.get("schema_version") != 1:
|
||||
errors.append("schema_version 必须为 1")
|
||||
if not isinstance(payload.get("project"), str) or not payload.get("project", "").strip():
|
||||
errors.append("project 必须是非空字符串")
|
||||
if payload.get("validation_mode") not in {"emxres-config", "full-dts-audit"}:
|
||||
errors.append("validation_mode 必须为 emxres-config 或 full-dts-audit")
|
||||
sources = payload.get("sources")
|
||||
if not isinstance(sources, list) or not sources or not all(
|
||||
isinstance(item, str) and item.strip() for item in sources
|
||||
):
|
||||
errors.append("sources 必须是非空字符串数组")
|
||||
|
||||
confirmation = payload.get("confirmation")
|
||||
confirmed = isinstance(confirmation, dict) and confirmation.get("confirmed") is True
|
||||
if not confirmed and not allow_unconfirmed:
|
||||
errors.append("confirmation.confirmed 必须为 true")
|
||||
if confirmed:
|
||||
if not isinstance(confirmation.get("confirmed_at"), str) or not confirmation.get(
|
||||
"confirmed_at", ""
|
||||
).strip():
|
||||
errors.append("确认后必须填写 confirmation.confirmed_at")
|
||||
if not isinstance(confirmation.get("summary"), str) or not confirmation.get(
|
||||
"summary", ""
|
||||
).strip():
|
||||
errors.append("确认后必须填写 confirmation.summary")
|
||||
|
||||
signals = payload.get("signals")
|
||||
if not isinstance(signals, list) or not signals:
|
||||
errors.append("signals 必须是非空数组")
|
||||
signals = []
|
||||
|
||||
functions: set[str] = set()
|
||||
pads: set[str] = set()
|
||||
gpios: set[str] = set()
|
||||
for index, signal in enumerate(signals):
|
||||
prefix = f"signals[{index}]"
|
||||
if not isinstance(signal, dict):
|
||||
errors.append(f"{prefix} 必须是对象")
|
||||
continue
|
||||
missing = sorted(REQUIRED_SIGNAL_FIELDS - set(signal))
|
||||
if missing:
|
||||
errors.append(f"{prefix} 缺少字段: {', '.join(missing)}")
|
||||
continue
|
||||
|
||||
function = signal.get("function")
|
||||
if not isinstance(function, str) or not function.strip():
|
||||
errors.append(f"{prefix}.function 必须是非空字符串")
|
||||
elif function in functions:
|
||||
errors.append(f"重复功能名: {function}")
|
||||
else:
|
||||
functions.add(function)
|
||||
|
||||
direction = signal.get("direction")
|
||||
decision = signal.get("decision")
|
||||
active_level = signal.get("active_level")
|
||||
boot_value = signal.get("boot_value")
|
||||
pad = signal.get("pad")
|
||||
gpio = signal.get("gpio")
|
||||
|
||||
if direction not in DIRECTIONS:
|
||||
errors.append(f"{prefix}.direction 非法: {direction!r}")
|
||||
if active_level not in ACTIVE_LEVELS:
|
||||
errors.append(f"{prefix}.active_level 非法: {active_level!r}")
|
||||
if decision not in DECISIONS:
|
||||
errors.append(f"{prefix}.decision 非法: {decision!r}")
|
||||
elif decision not in IMPLEMENTABLE and not allow_unconfirmed:
|
||||
errors.append(f"{prefix} 仍为 {decision},不能进入实现")
|
||||
|
||||
if not isinstance(signal.get("runtime_owner"), str):
|
||||
errors.append(f"{prefix}.runtime_owner 必须是字符串")
|
||||
if not isinstance(signal.get("note"), str):
|
||||
errors.append(f"{prefix}.note 必须是字符串")
|
||||
if not isinstance(signal.get("dts_consumers"), list) or not all(
|
||||
isinstance(item, str) for item in signal.get("dts_consumers", [])
|
||||
):
|
||||
errors.append(f"{prefix}.dts_consumers 必须是字符串数组")
|
||||
evidence = signal.get("source_evidence")
|
||||
if not isinstance(evidence, list) or not evidence or not all(
|
||||
isinstance(item, str) and item.strip() for item in evidence
|
||||
):
|
||||
errors.append(f"{prefix}.source_evidence 必须是非空字符串数组")
|
||||
|
||||
if decision == "no-gpio":
|
||||
if direction != "none" or any(
|
||||
value is not None
|
||||
for value in (pad, signal.get("mux"), gpio, active_level, boot_value)
|
||||
):
|
||||
errors.append(
|
||||
f"{prefix} 的 no-gpio 行必须使用 direction=none 且 pad/mux/gpio/active_level/boot_value=null"
|
||||
)
|
||||
elif direction in {"input", "output"}:
|
||||
if not isinstance(pad, str) or not pad.strip():
|
||||
errors.append(f"{prefix} 的 GPIO 行必须填写 pad")
|
||||
if not isinstance(gpio, str) or not gpio.strip():
|
||||
errors.append(f"{prefix} 的 GPIO 行必须填写 gpio")
|
||||
if active_level is None:
|
||||
errors.append(f"{prefix} 的 GPIO 行不能使用 active_level=null")
|
||||
if active_level == "unknown" and not allow_unconfirmed:
|
||||
errors.append(f"{prefix} 的 active_level 仍为 unknown")
|
||||
if direction == "output" and boot_value not in {0, 1}:
|
||||
errors.append(f"{prefix} 的输出必须填写 boot_value=0 或 1")
|
||||
if direction == "input" and boot_value is not None:
|
||||
errors.append(f"{prefix} 的输入必须使用 boot_value=null")
|
||||
elif direction == "peripheral":
|
||||
if not isinstance(pad, str) or not pad.strip():
|
||||
errors.append(f"{prefix} 的外设复用必须填写 pad")
|
||||
if gpio is not None or active_level is not None or boot_value is not None:
|
||||
errors.append(f"{prefix} 的外设复用必须使用 gpio/active_level/boot_value=null")
|
||||
elif direction == "none" and decision != "no-gpio":
|
||||
errors.append(f"{prefix} 只有 no-gpio 决策可以使用 direction=none")
|
||||
|
||||
if isinstance(pad, str) and pad:
|
||||
if pad in pads:
|
||||
errors.append(f"重复 Pad: {pad}")
|
||||
pads.add(pad)
|
||||
if isinstance(gpio, str) and gpio:
|
||||
if gpio in gpios:
|
||||
errors.append(f"重复 GPIO: {gpio}")
|
||||
gpios.add(gpio)
|
||||
|
||||
if allow_unconfirmed and not confirmed:
|
||||
warnings.append("计划尚未获得用户确认,仅通过候选结构检查")
|
||||
return {
|
||||
"passed": not errors,
|
||||
"signal_count": len(signals),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("plan", type=Path)
|
||||
parser.add_argument("--allow-unconfirmed", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
payload = json.loads(args.plan.read_text(encoding="utf-8"))
|
||||
result = validate_plan(payload, allow_unconfirmed=args.allow_unconfirmed)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(json.dumps({"passed": False, "errors": [str(exc)]}, ensure_ascii=False, indent=2))
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result["passed"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user