feat: 发布固件底层开发 Skill 套件

This commit is contained in:
tangfahong committed 2026-09-11 15:06:55 +08:00
commit c7ae3b4ce1
72 files changed
+10212

No files matched your search

@@ -0,0 +1,48 @@
---
name: firmware-lowlevel-psp-development
description: "基于相近板卡 PSP 创建、修改和验证项目级平台配置,锁定 BSP 固件、Media 与 AIPlugin 依赖,并通过固定打包环境形成可交给应用层的精确 Git 基线。用于 PSP 配置适配、组包证据、人工设备验证和 Etunel 交接;不用于 BSP/DTS/驱动实现、应用业务、公共组件开发或未经确认的烧录、上传与发布。"
---
# 固件底层开发_PSP开发
把每个硬件项目的 PSP 作为独立、轻量的配置仓库维护。PSP 固定工作区是当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/`;项目目录由用户或团队预先在该根目录下创建。以用户指定的相近板卡 PSP 为参考,只做目标项目所需的最小差异;PSP 的唯一身份是完整 Git commit SHA。
## 进入流程
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. 读取 [PSP 主工作流](references/workflow.md),确认目标项目目录是固定工作区的直接子目录,并确认参考 PSP、芯片/板卡/系统形态、BSP handoff、差异清单和负责人。目标目录不存在时停止,请用户或团队先创建;不得代为创建目录或初始化仓库。
4. 首次填充、迁移、提交或更新 PSP 仓库时,读取[项目与 Git 规则](references/project-and-git.md)。首次填充只对预创建的空独立 Git 仓库执行:先在 staging 中适配并审计,验证通过后自动导入并创建本地候选提交,同时保留 `.git`、分支和远端;不再为导入和提交重复询问。已有业务内容的项目转入现有项目更新流程,不得按参考复制覆盖。
5. 修改 GPIO、BSP 引用、内存/分区结果、Sensor、Media 或 AIPlugin 配置时,读取[配置边界与检查](references/configuration-boundaries.md)。CV18 Sensor 默认按芯片系列使用 skill 内置枚举表,把规格书选型自动映射为配置名称或数值;无需临时查找 SDK 头文件。ISP/PQ 由用户更新。PSP 只消费 BSP 和公共组件成果,不越界修改其源码。
6. 使用 BSP 产物时,读取统一的 [BSP→PSP 交接契约](../firmware-lowlevel-development/references/bsp-psp-handoff.md),校验用户明确提供的 `psp_handoff.json`;禁止扫描目录选择最新构建。
7. 需要组包或设备验证时,读取[组包与设备验证](references/packaging-and-device-validation.md)。EMX2.1 的目录和命令按内置固定规则生成本地契约,再做打包准备检查;不用让用户重复提供已经固定的信息。
8. 需要跨角色交付或返工时,读取 [Etunel 交接](references/etunel-handoff.md);机器可读文件按[数据契约](references/schemas.md)生成。
## PSP 内容边界
PSP 仓库可以保存 `psp.json`、项目配置、运行脚本、用户提供的 ISP/PQ/标定参数、必要小型板级资源、精简结果文档和 `metadata/bsp.lock.json`。Skill 不生成或修改 ISP/PQ;保持参考 PSP 的目录和 `psp.json` schema,除非跨层变更已经确认。
PSP 仓库不保存固定打包框架、BSP 源码或 IMG、大型模型、公共运行库、烧录包、缓存、原始日志和持续变化的验证记录。HAL、驱动、Bootloader、DTS/DTSI、底层内存实现以及公共 Media/AIPlugin 代码不属于本模块。
## 不可绕过的门禁
- 新 PSP 必须有用户明确指定的参考 PSP;先核对芯片系列、系统形态、存储介质、Sensor、GPIO、媒体和 AIPlugin 能力。明显不兼容时停止,除非用户知情确认跨平台参考。
- 目标 PSP 必须位于当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/<项目名>/`,是该固定根目录的普通直接子目录和独立 Git 仓库。目录缺失、软链接、嵌套仓库或根目录外路径均停止;不运行 `mkdir` 或 `git init` 补齐。
- 默认继承参考 PSP,但必须分别展示修改项和关键继承项。能由参考兼容性、BSP handoff 和仓库证据证明的继承值不要求用户重复确认;GPIO、Sensor、BSP 固件、Media 或 AIPlugin 仍有冲突、缺项或多种合理解释时才阻止形成 `candidate`。
- CV181X 的 `sensor_cfg.ini` 中 `name` 必须填写内置 `sensor_list.h` 的 `_SAMPLE_SNS_TYPE_E` 枚举名称;CV184X 必须填写内置 `sensor_cfg.h` 的 `_SNS_TYPE_E` 对应枚举值。规格选型不明确或内置表中无匹配项时停止,不得猜测;只有已证明 SDK 枚举不同才使用显式头文件覆盖。
- 保留团队统一环境中的绝对路径;检查全部 JSON 和配置/脚本文本。允许公共构建根路径;个人目录和临时目录必须警告,正式交付仅在用户显式批准精确根路径后放行。
- `psp.json`、BSP handoff、配置接口或依赖基线不匹配时,不通过寻找近似文件、选择最新目录、静默覆盖或降低检查来继续。
- 需求和适配方案已经确认后,完整差异和打包准备检查通过即自动完成本地导入、`git add`、候选提交、应用工程内既有 `psp` 子模块更新、`./product.py` 和 `./release.py 0`,不再请求执行确认。自动范围不包括创建或更换子模块地址、提交应用仓库、Git 上传、Etunel 交接、`accepted/revoked` tag、`./release.py 1`、设备烧录和发布。
- 自动步骤出现脏工作区重叠、仓库或 commit 不一致、子模块缺失、脚本/路径变化、检查失败、命令报错、产物不唯一或需要清理重试时立即停止。先保存证据,再请用户确认处理方式;不得自行覆盖、丢弃改动、修改应用框架或改用 `./release.py 1`。
- 同一阻塞条件没有变化时,不重复发整套问题。只说明缺少的证据、它阻止哪一步,以及由谁补充。
- 输出警告时按“阻止继续”“需要确认”“暂不影响当前步骤”分组;目标机路径在主机不存在、ISP/PQ 待用户更新等已知情况不得与真实组包错误混在一起。
- `packaged`、`device_verified`、`app_integrated` 和 `accepted` 必须引用真实证据与同一个完整 PSP commit。人工设备结果标记为 `USER_REPORTED`,未执行项不得写成通过。
- 完成时按[记录规范](../firmware-lowlevel-development/references/records.md)和[统一交付契约](../firmware-lowlevel-development/references/deliverable-contract.md)报告;没有有效 Etunel 上下文时只生成交接材料,不声称已发送。
## 确定性辅助工具
- `scripts/psp/psp_project_guard.py`:用 `inspect` 只读校验固定 PSP 根目录、项目路径和独立仓库并区分空仓库/现有项目;首次填充时受控复制到 staging,检查差异白名单与旧项目残留,验证通过后自动导入内容、复核最终目录且证明 Git 分支/远端未改变;不创建目录、不初始化仓库。
- `scripts/psp/validate_bsp_handoff.py`:校验 BSP handoff、精确 imgs 路径和产物 hash。
- `scripts/psp/validate_psp.py`:校验 PSP 结构、配置语法、路径、脚本、CV18 Sensor 枚举映射和应用/PSP 覆盖关系;根据 `--expected-chip` 自动选择内置 CV181X/CV184X 头文件,通常无需 `--sensor-enum-header`。
- `scripts/psp/record_packaging_result.py`:用 `contract` 根据 EMX2.1 固定目录和命令生成本地契约;用 `preflight` 在候选提交前和正式组包前检查资源来源、DATA 工具、分区字段、BSP 分区余量与应用脚本;用 `snapshot/record` 证明本轮唯一新增的 `output/<版本>/` 及指定烧录包并记录路径、大小和 hash。工具不执行打包命令。
- `scripts/psp/render_psp_handoff.py`:把同一 commit 的验证证据汇总为供用户确认或 Etunel Hub 使用的交接文件,并可只读核对远端 commit;不发送消息。
@@ -0,0 +1,6 @@
interface:
display_name: "固件底层开发_PSP开发"
short_description: "基于参考板卡适配、组包并验证项目 PSP 配置"
default_prompt: "使用 $firmware-lowlevel-psp-development 基于相近板卡 PSP 创建并验证这个项目的 PSP 配置。"
policy:
allow_implicit_invocation: false
@@ -0,0 +1,76 @@
# PSP 配置边界与检查
## PSP 可以维护
- 参考 PSP 已有的 `psp.json` 字段和目录布局;
- `configs/`、`param/`、`scripts/` 和项目实际采用的等价目录;
- 应用可见的 IO/GPIO 语义、LED/按键/IRCUT/PWM/PTZ 等运行配置;
- Sensor、Media 和 AIPlugin 的项目级配置与 profile;
- BSP IMG 的绝对路径引用、最终内存/分区摘要和依赖锁定;
- 用户提供的 ISP/PQ、标定参数、Logo 等必要的小型平台资源;
- 精简结果文档和 sidecar metadata。
固定打包框架、BSP IMG、大型模型、公共运行库、构建缓存、烧录包及持续变化的日志/验证记录不进入 PSP 仓库。
## 不取得的所有权
PSP 不直接修改 BSP 的 `cvi_board_init.c`、DTS/DTSI、驱动、Bootloader、分区表或内存实现。CV181X 的 `memmap.py/ION_SIZE`,以及 CV184X board defconfig 中的 `CONFIG_ION_SIZE`、`CONFIG_RTOS_ION_SIZE` 由 BSP 工作流根据用户明确的媒体内存需求修改。SigmaStar 等未提供规则的平台由用户手工处理,并保留警告。
PSP 可以保存和核对最终值。Media/AIPlugin 需求要求改变 BSP 时,生成需求清单,经 Etunel Hub 路由 BSP;接收新的 BSP handoff 后再更新 PSP。
PSP 也不修改 Media/AIPlugin 公共源码、训练/转换模型、ISP/PQ 配置或伪造兼容版本;缺少上游成果时交 Hub 路由。
## GPIO 一致性
交叉核对 BSP handoff/Pinmux 结果与 PSP 中实际存在的 GPIO 配置,包括但不限于:
- `configs/bsp.json`;
- `scripts/pinmux.sh`;
- `configs/ptz/motorGpio.json`;
- 其他包含 GPIO 编号和应用语义的配置。
发现 GPIO 被 BSP、DTS/DTSI 或其他设备树模块占用时,停止 PSP 验收并提出 BSP 变更请求。没有对应 GPIO 时明确记录“无此 GPIO”,不伪造编号。PSP 只修改自己的运行语义,不越界释放 Kernel IO。
## CV18 Sensor 配置基线
CV18 系列以 PSP 内唯一的 `sensor_cfg.ini` 为主要 Sensor 配置。文件可能位于参考 PSP 的 `configs/media/sensor/`、`configs/media/isp/` 或等价目录;保持参考布局,不为统一目录而搬移。存在多个同名候选、没有候选、软链接或无法确认当前生效文件时停止。
先从原理图或 GPIO list 确认 Sensor 型号、数量、I2C bus/address、MIPI device/lane、P/N swap、MCLK、reset/power GPIO 等实际字段,再用规格书中的 Sensor 类型锁定枚举标识符。不得只凭相近命名、参考板旧值或枚举数字猜选型。每个 `[sensor]`、`[sensor2]` 等实际启用 section 都要分别给出规格书选定的枚举名称和依据。
Skill 已内置本流程确认的两份枚举基线,运行时不得再到 SDK 目录或附件目录中搜索“最新”或相近文件:
- [CV181X `sensor_list.h`](sensor-enums/cv181x/sensor_list.h),用于 `_SAMPLE_SNS_TYPE_E`;
- [CV184X `sensor_cfg.h`](sensor-enums/cv184x/sensor_cfg.h),用于 `_SNS_TYPE_E`。
`validate_psp.py` 根据 `--expected-chip` 自动选择对应内置文件并在结果中记录内置路径和 SHA-256。若规格书 Sensor 在内置表中不存在,或项目资料明确证明目标 Media SDK 使用不同枚举版本,停止自动适配并报告基线差异;不得退回目录扫描或猜测近似枚举。此时可在用户提供精确新头文件后用 `--sensor-enum-header` 显式覆盖并记录 `source=override`,随后应按独立 skill 更新流程评审是否替换内置基线。
配置规则如下:
- CV181X(CV1810、CV1811C、CV1815JA 等):读取 `sensor_list.h` 中 `typedef enum _SAMPLE_SNS_TYPE_E`,`sensor_cfg.ini` 的 `name` 填枚举名称,例如 `SMS_SC4336P_MIPI_4M_30FPS_10BIT`,不能填枚举序号或自行换算值。
- CV184X(CV1841CP、CV1842CP、CV1842HP、CV1843HP 等):读取 `sensor_cfg.h` 中 `typedef enum _SNS_TYPE_E`,先按规格确定枚举名称,再把该枚举的显式整数值写入 `sensor_cfg.ini` 的 `name`;建议保持头文件中的十六进制表达形式。结果同时记录枚举名称和值,不能只记录一个无来源数字。
调用 `validate_psp.py` 时无需提供头文件路径;只需为每个实际 Sensor section 根据规格选型生成 `--expected-sensor sensor=ENUM_NAME`、`--expected-sensor sensor2=ENUM_NAME`。工具会自动选取内置头文件,记录 `sensor_cfg.ini` 与枚举表 hash,并按芯片系列验证名称或数值表示;枚举不存在、数值不可解析、section 缺少规格期望或配置不一致均阻止 candidate。`--sensor-enum-header` 仅用于上述已确认的版本差异覆盖,不是日常必需输入。
## ISP/PQ 用户边界
ISP 和 PQ 文件由用户更新,Skill 不生成、不选择、不修改,也不把参考值自动声明为适配完成。若参考 PSP 已包含这些文件,保留其目录和内容,把状态记录为 `USER_OWNED/PENDING`;用户提供更新后记录精确文件/hash 和 `USER_REPORTED` 确认。ISP/PQ 未更新不阻止完成 PSP 的其他静态检查,但相关设备检查保持未执行或风险项,不能据此声称图像效果已经通过。
## Media 与 AIPlugin
至少核对:Media 版本、Sensor 型号/数量/总线、用户负责的 ISP/PQ 状态、AIPlugin 版本、AIPlugin profile 的 SDK、模型平台和运行库目录。版本冲突默认阻塞;只有有实际负责人确认的兼容例外才能继续,并写入最终结果。
未声明的参考配置可以继承,但必须把关键继承项列给用户确认。Media 或 AIPlugin 未提供差异不等于自动禁用,也不等于其真实兼容。
## `psp.json` 和绝对路径
应用打包代码已依赖参考 `psp.json`,因此默认保持文件名、层级和字段。新增 BSP lock、允许覆盖和治理信息使用 `metadata/` sidecar;变更 `psp.json` schema、文件名、目录、启动调用或应用可见 GPIO 语义时,先通过 Etunel 取得技术负责人和应用层确认。
应用层与底层共用 BSP 环境,所以保留 `psp.json` 的绝对路径。路径检查覆盖 PSP 中所有 JSON,以及 `.ini`、`.cfg`、`.conf`、`.sh`、`.py` 文本,不能只检查 `psp.json`。允许团队公共构建根;`/home/<用户>`、`/tmp`、`/var/tmp` 等个人/临时路径必须警告,正式交付默认失败。只有用户确认的共享根或本轮验证根才能通过重复的 `--allowed-absolute-root <精确绝对路径>` 放行,放行记录必须进入验证结果。外部构建输入不存在时失败,其他配置/脚本路径不存在时至少警告;不得自动寻找名称相近或日期更新的替代物。
## 同名配置覆盖
现有打包顺序允许 PSP 后复制并覆盖应用配置,但不能静默覆盖。比较应用 `orgConfigs` 与 PSP `configs`、应用 `orgParam` 与 PSP `param` 等真实映射,输出相对路径、双方 hash 和最终生效方。
默认保留应用工程中的 `orgConfigs`、`orgParam`,并从 PSP 候选内容中移除重复的同名文件。这样不会让参考 PSP 在组包时意外覆盖应用配置,也不需要用户逐个确认“继续保留应用值”。
只有硬件适配确实要求 PSP 成为最终生效方时,才向用户展示双方路径、hash、差异和影响,并在确认后把精确路径写入 `metadata/allowed-overrides.json`。不要为绕过门禁添加通配符;schema 见[数据契约](schemas.md)。
@@ -0,0 +1,50 @@
# PSP Etunel 交接
仅在当前 Hook、Etunel 角色契约和入站任务确认本会话是嵌入式底层角色时执行正式交接。成员结果只交给 Hub;不直接向应用层 AI 派任务或索取结论。没有有效 WORK_ID、SUBTASK_ID 或工具时只生成 handoff 文件并标记“尚未正式发送”。
## packaged candidate 交接
底层自检、固定组包和负责人确认完成后,依次处理:
1. 展示远端、分支和 candidate commit,取得 Git 上传确认;
2. push 后由 `render_psp_handoff.py --verify-remote` 使用只读 `git ls-remote` 验证团队共享地址的 heads/tags 能取得精确完整 commit;验证失败、超时或只有本地路径时不能形成 Etunel 交接;
3. 生成并展示 Etunel handoff,取得独立发送确认;
4. 向 Hub 提交本角色正式结果,由 Hub 给应用层创建集成任务。
交接至少包含:
- WORK_ID、SUBTASK_ID、阶段、波次和嵌入式底层主责;
- 项目、芯片、硬件/系统形态;
- PSP 共享仓库地址、完整 commit 和 candidate/packaged 状态;
- 参考 PSP 及来源版本;
- BSP handoff、BSP commit/build ID、Media/AIPlugin 基线;
- 烧录包绝对路径/hash;
- 静态检查、打包、设备和应用验证的真实状态/证据;
- 未执行项、警告、例外、依赖和剩余风险;
- 应用集成步骤及必须检查的项目;
- `self_check_result`、`internal_approved`、确认负责人/时间/范围/条件。
Git 地址和 SHA 是交付主体;Etunel 引用 PSP 内的 `psp.json`、`metadata/bsp.lock.json` 和精简适配结果。`--for-etunel` 只接受 `packaged` 或 `accepted`,还必须有与当前 URL/SHA 一致且通过的远端可达性证据。不要重复传输仓库、BSP IMG、大模型和公共库。
## 应用集成
Hub 交给应用层的任务至少要求:
- 在应用 `psp/` submodule 获取完整 PSP SHA;
- 生成应用侧 `psp.lock.json` 并检查 gitlink/HEAD/lock 一致;
- 完成应用编译和完整固件打包;
- 验证 BSP、Media 库、模型按配置找到并复制;
- 检查 PSP 覆盖清单;
- 返回应用 commit、PSP commit、命令、结果、证据和负责人确认。
PSP 可以在 packaged 后交付,设备验证与应用集成并行。两条证据必须对应同一 PSP commit,之后 Hub 创建独立 PSP 验收收尾任务。底层负责人确认并取得用户发布确认后,才创建/push accepted tag。
应用长期未完成时,原底层实现任务可以以 device_verified 或 packaged 的真实状态结束,PSP 不提前 accepted;收到应用结果后另建收尾任务。
## 问题返工
应用层经 Hub 返回问题,输入应包含 PSP SHA、应用仓库/commit、芯片/板卡、BSP build、复现步骤、期望/实际、日志、涉及配置、阻塞影响和应用负责人确认状态。
Hub 创建新的嵌入式底层 SUBTASK_ID。PSP 在职责内修正并产生新 candidate;旧 commit 和证据保留。问题属于 BSP/DTS/驱动/内存、应用业务、硬件、公共 Media/AIPlugin 或测试环境时,只提供事实、证据和归属建议,由 Hub 路由,不越界修复。
已经 accepted 的缺陷按[项目与 Git 规则](project-and-git.md)创建 revoked 状态、通知已知引用方,并为修复重新走完整流程。Git/Etunel 发送和状态发布分别确认。
@@ -0,0 +1,112 @@
# PSP 组包与设备验证
## EMX2.1 固定打包契约
PSP 的日常开发仓库位于 `~/work/project/EMX2.1/AppTeam/Psp/<项目名>/`。需要验证时,把同一个 candidate commit 作为独立 `psp` 子仓库加入用户指定的 EMX2.1 应用工程;打包身份是 `<应用工程>/psp` 的精确 Git commit,不是一次无版本拷贝。
应用工程、脚本和输出位置固定为:
```text
<应用工程>/
├── psp/ # 当前 candidate 的独立 PSP 子仓库
├── scripts/
│ ├── product.py
│ └── release.py
└── output/<版本>/ # 本轮组包生成的版本目录
```
打包机器契约准确记录 `project_root`、`project_root/psp`、`project_root/scripts`、`project_root/output`、适用芯片、成功退出码以及下面三条允许命令。该契约只描述稳定接口,保存在 PSP 仓库外;Skill 不解析、重构或修改工程打包框架。
```text
./product.py
./release.py 0
./release.py 1
```
用户给出应用工程路径后,使用 `record_packaging_result.py contract --project-root <应用工程> --chip <芯片> --output <仓库外契约文件>` 按上述固定规则生成契约,不再要求用户手写 JSON 或重复说明命令。候选提交前允许工程内 `psp/` 尚未加入,此时只对 staging 做准备检查;正式组包前 `psp/` 必须存在并锁定候选 commit。工程路径不一致或脚本不完整时停止;不得猜测替代命令、标记 `packaged` 或交付烧录包。
## 参数 `0/1` 的选择
`./release.py 0` 是自动执行的默认值:PSP 资源或项目配置变化,且未改变应用源码、编译配置、工具链、依赖或会使增量结果失效的输入时使用,优先保留增量构建效率。
只有下列情况使用 `./release.py 1`:应用/公共源码有较大修改,CMake/编译选项/工具链/依赖发生变化,增量产物疑似陈旧或已失败,用户明确要求 clean rebuild,或本轮无法证明增量结果可靠。它会清理已有编译状态,执行前必须向用户展示精确工程和命令并取得当次确认;不能仅凭“更稳妥”自动选择。
## 打包前检查
打包准备检查分两次执行。第一次在 staging 适配完成、候选提交创建前执行,用 `preflight --psp-source <staging>` 提前发现 PSP 自身问题;第二次在候选提交已集成到应用工程后执行,用 `preflight --psp-commit <完整 SHA> --formal` 锁定实际组包输入。
`preflight` 至少检查:
- `scripts/product.py`、`scripts/release.py` 和 `psp.json` 是普通文件;
- `EmxResToProduct` 每个 `src` 都有明确且存在的来源,`dst` 是安全的工程内相对路径且不重复;
- `data` 分区有 `file`,`release.py` 使用 DATA 组包时,`mkAiData.py` 和 `data_ubinize.cfg` 都由 PSP 显式映射,不能依赖应用工作区里的旧文件;
- `mkAiData.py` 的容量参数不超过 `data` 分区;
- PSP 的 kernel/rootfs 分区名称、文件和容量与 `metadata/bsp.lock.json` 中已经通过的 BSP 分区证据一致,余量仍不少于 512 KiB;
- PSP 是预期 commit 且工作区干净。
工具能证明的是静态关系。还要只读检查 `product.py` 使用的 Media、AIPlugin、运行库和驱动清单;空目录、空列表以及“脚本打印错误但退出码仍为 0”的路径都要在执行前说明。该问题属于应用打包框架时,未经明确授权不得修改应用脚本。
完成上述检查后自动继续:
1. 从项目已有规则读取“哪个文件是实际烧录包”的精确相对路径。无法唯一发现时视为异常,先请用户指定;`output/<版本>/` 可能包含多个 `.bin`,不能按扩展名或修改时间自行挑选。
2. 在命令前运行 `record_packaging_result.py snapshot --contract <契约> --state <PSP 仓库外证据路径>`,锁定契约 hash、PSP commit、应用工程状态和既有 `output` 版本目录。不得为了方便删除旧版本目录。
3. 命令日志写入 PSP 仓库外的受限证据目录。日志可能包含账号、口令或内部地址;不得把原始日志提交到 PSP、复制到文档或 Etunel 消息。证据只保存日志绝对路径、大小、SHA-256,以及失败/警告行号和行 hash。
## 精确执行顺序
执行目录必须是 `<应用工程>/scripts`,顺序不能颠倒或合并为无法区分结果的一条命令:
1. 自动运行 `./product.py`,记录真实退出码和独立日志。该步骤拷贝 PSP 配置/资源到工程使用位置。
2. 退出码为契约成功码后,仍要检查日志和复制结果。已知脚本中的报错打印不一定都转成非零退出码,因此只看 `$?` 不足以通过;出现红色错误输出、Traceback、明确复制/校验失败或资源缺失时停止,不运行 `release.py`。
3. `product.py` 无错误后自动运行 `./release.py 0`,记录独立退出码和日志。只有需要 clean rebuild 时才停止并请用户确认 `./release.py 1`。
4. 用 `record_packaging_result.py record` 同时提交两条命令、两个退出码、两个日志和用户/项目指定的烧录包绝对路径;工具不执行命令,只验证证据。
若实际流程未按顺序执行、两阶段日志无法区分,或在 `product.py` 已失败后仍运行 `release.py`,本轮不得标记 packaged。需要重试时重新快照并生成新的不可覆盖证据文件。
## `output` 结果判定
成功退出码不单独证明 packaged。完成后必须同时满足:
- 相对打包前快照,`<应用工程>/output/` 恰好新增一个直接子版本目录;
- 既有版本目录没有被删除或修改;
- 新版本目录内全部普通文件可枚举并记录路径、大小和 SHA-256,且不含软链接;
- 用户/项目指定的唯一烧录包是该新版本目录内的非空普通文件;
- PSP 在打包前后均为同一个完整 commit 且 worktree 干净;
- `product.py` 和 `release.py` 的退出码、日志、命令及执行目录均与契约一致。
不得选择目录中的“最新”版本。没有新目录、同时新增多个目录、复写旧目录、烧录包不在本轮目录、输出文件无法唯一确认或 hash 无法计算时均失败。最终记录既包含本轮 `output/<版本>/` 绝对路径,也包含精确烧录包路径/hash,供设备验证和后续 PSP→应用交接使用。
打包失败只收集证据并判断归属:指向 PSP 时在 staging 修正并重新生成候选提交;指向应用工程、框架、BSP 或公共库时交对应所有者处理。不得为绕过失败而放宽契约。
出现异常后不自动修改、提交或重试。先展示失败证据、归属和建议处理方式,由用户确认下一步。问题修正并重新通过 `preflight` 后,恢复正常自动流程:创建新的候选提交,再执行 `./product.py` 和 `./release.py 0`。Git 上传、Etunel 发送、设备和发布仍单独确认。
## 当前人工烧录
PSP skill 不执行烧录。packaged 后向用户展示烧录包绝对路径、SHA-256、`output/<版本>/` 路径、PSP commit、BSP build ID 和项目检查项。用户手工烧录后,记录:
- 板卡型号和硬件 revision;
- 实际烧录包路径/hash 和 PSP commit;
- 启动是否成功;
- 内存/分区、关键 GPIO、Sensor、媒体、用户更新的 ISP/PQ、AIPlugin 和异常日志;
- N/A、未执行项、异常现象、结论和确认时间;
- `evidence_source: USER_REPORTED`。
通用模板不替代项目功能清单。模糊的“正常”不能自动覆盖所有适用项;缺少反馈时保持 packaged,适用检查未执行时为 PARTIAL。
## 状态和证据失效
```text
candidate -> packaged -> device_verified
-> app_integrated
同一 commit 两项均通过 -> accepted
```
`accepted` 需要静态检查、固定工程打包、目标设备验证、目标应用集成、底层负责人确认、应用负责人确认和阻塞问题关闭。它不替代独立整机测试、产品验收或发布。
`psp.json`、GPIO、Sensor、Media、AIPlugin、脚本、小型资源、BSP 路径/build/hash 任一有效变化,使 packaged、device_verified、app_integrated 失效。ISP/PQ 由用户更新;一旦其内容变化,同样必须重打包并重新验证对应图像项。纯文档/注释变化经差异证明和负责人确认可复用技术证据,但新 commit 仍需独立 accepted 状态。
每轮烧录对应一个不可变 candidate 和烧录包 hash。失败后修正产生新 commit/新镜像;不得覆盖已验证基线。
## 后续串口能力
首版不创建串口工具或依赖。后续在取得串口设备、波特率、登录方式、启动特征、参数查询命令和设备授权规则后,单独实现启动日志采集和只读参数分析。默认不自动烧录、重启或发送改变设备状态的命令。
@@ -0,0 +1,70 @@
# PSP 项目与 Git 规则
## 仓库模型
- PSP 固定工作区是当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/`。每个硬件项目是其普通直接子目录,目录名使用团队预先创建的实际项目名,不添加 `psp-` 前缀。
- 项目目录和独立 Git 仓库由用户或团队预先创建。skill 不创建项目目录、不运行 `git init`、不更改初始分支,也不自行配置远端;目录缺失、软链接、嵌套在其他 worktree 中或不在固定根目录时停止。
- 多个应用只有在硬件、GPIO、内存/分区、BSP 固件组合、Sensor/Media/ISP/PQ 和 AIPlugin 平台配置完全一致时才共用同一 PSP。
- 保留预创建仓库的默认分支。经用户确认需要任务分支时,Etunel 任务建议使用 `candidate/<SUBTASK_ID>`,普通任务使用 `candidate/<简短任务名>`;分支名不是版本。
- 应用仓库通过 Git submodule 把 PSP 固定在 `psp/`,由 submodule gitlink 锁定精确 commit;应用侧 `psp.lock.json` 只提供可读交接和一致性检查。
## 受控复制
1. 展开当前用户的 `~`,先运行 `psp_project_guard.py inspect --target ~/work/project/EMX2.1/AppTeam/Psp/<项目名>`,确认目标 PSP 是固定根目录的普通直接子目录、其 `.git` 是普通目录,并记录分支、HEAD、远端指纹和配置指纹。首次填充要求结果是 `precreated-empty-repository`;结果是 `existing-project` 时停止本节,转入“现有项目更新”。
2. 检查参考 PSP 是普通目录、Git worktree 还是 submodule,记录精确来源。参考 worktree 脏时默认使用可追踪 HEAD;复制未提交内容必须让用户确认并标记来源不能由单一 commit 表示。
3. 使用 `psp_project_guard.py prepare --target ~/work/project/EMX2.1/AppTeam/Psp/<项目名>` 复制到同一根目录下的唯一 staging,拒绝软链接、疑似凭据、缺失目标、目标根目录逃逸及非空目标。
4. 在 staging 中修改;不接触参考 PSP 和目标 PSP。
5. 使用 `verify` 按精确路径白名单检查差异和旧标识残留。
6. 输出差异、关键继承、警告与验证结果。需求和适配方案已确认且没有阻塞项时,不再询问是否导入或提交。
7. 使用 `finalize` 自动把已验证内容导入预创建空仓库。工具必须在导入前复核目标仍为空且 Git 身份未变化,导入时不复制或替换 `.git`;再运行 `audit-final` 证明业务内容与 staging 一致、分支/HEAD/远端/配置保持 prepare 基线。
目标项目本就必须存在;这里的“空”是除 `.git` 外没有条目。prepare 后目标出现文件或 Git 元数据变化时,保留 staging 并停止,不覆盖、删除或合并。由用户处理冲突后重新 prepare。
## 现有项目更新
目标已有业务内容时,不执行参考项目整体复制。确认目标路径和仓库身份,检查 worktree 与当前分支,以明确 commit/worktree 为基线完成最小修改;继续使用差异白名单、旧标识扫描和静态验证。检查通过后自动提交本任务的精确文件;若当前内容与用户声称的项目不一致,或存在重叠改动,停止并报告证据,不自行清空、替换、迁移或重新初始化。
## 提交和远端
- 所有 PSP 必须在工作开始前就是独立 Git 仓库;skill 不初始化仓库、不切换默认分支、不添加或修改远端。
- 本地 candidate commit 前输出新增、修改、删除、关键值、继承项、风险、测试和建议说明。需求和适配方案已经确认且检查通过后,自动暂存白名单内的本任务文件并创建 candidate commit,不再询问是否提交。
- commit message 至少表达项目、适配/修复内容、BSP build ID,以及存在时的 WORK_ID/SUBTASK_ID;不写本机临时日志或烧录包路径。
- 自动提交前核对暂存区只包含本任务文件。Git 用户身份缺失、暂存区混入其他文件、提交钩子失败或 commit 结果不明确时停止,不自动修改 Git 配置、不使用 `--no-verify`、不重试,也不改写历史。
- 用户提供共享仓库地址或明确授权仓库创建流程。PSP skill 不自行选择 Git 服务、组织或远端。
- 本地路径不能作为正式 Etunel 应用交付地址。commit 可从团队地址获取并验证后,才能声称应用可消费。
- Git push、Etunel candidate 交接、accepted/revoked tag 创建与 push、设备烧录和产品发布分别确认;一次确认不自动覆盖后续动作。
- 不改写已经提供给打包、设备或应用验证的 commit。修正产生新 commit,并记录其替代的旧 candidate。
## 状态和 tag
完整 Git commit SHA 是唯一内容身份。PSP 仓库不写自身 commit 或易过期的状态文件。
- candidate、packaged、device_verified、app_integrated:记录在外部验证证据和 Etunel 任务中;
- accepted:`accepted/<12位commit前缀>` annotated tag;
- 已 accepted 后发现严重缺陷:保留 accepted tag,并创建 `revoked/<12位commit前缀>` annotated tag;
- 未 accepted candidate 被替代:只在 Etunel 中记录 superseded 关系,不创建 revoked tag。
accepted tag 至少引用完整 PSP commit、WORK_ID、应用集成 SUBTASK_ID、应用 commit、构建/打包证据、底层负责人和确认时间。revoked tag 记录问题、影响范围和 Etunel 返工任务。创建及上传 tag 前重新确认。
正式交付构建应取得最新远端状态,证明存在 accepted 且不存在 revoked。离线或 refs 过旧时,开发构建可警告,正式交付停止。
## 应用侧锁定
应用层创建 `psp.lock.json`;PSP 仓库不创建它。最小字段见[数据契约](schemas.md)。构建时检查:
- `psp/` 已初始化且是预期仓库;
- HEAD 与 lock 的完整 SHA、submodule gitlink 一致;
- PSP worktree 干净且 commit 未 revoked;
- 开发/集成构建允许 packaged candidate,正式交付要求 accepted;
- BSP IMG 路径/hash 和允许覆盖项有效。
PSP 不修改应用开发 Skill 或应用业务。需要验证 PSP 时,在精确应用工程已由任务确定后,自动把工程中既有且仓库身份一致的 `psp/` 子模块更新到候选 commit,并运行固定增量组包命令;不得顺带修改应用源码、暂存或提交应用仓库的其他变化。
更新子模块前检查应用父仓库、`.gitmodules`、gitlink 和 `psp/` worktree。正常自动流程如下:
1. `.gitmodules` 中必须唯一登记 `path=psp`,并且 URL 与目标 PSP 仓库身份一致;尚未初始化时自动运行项目已有的 `git submodule update --init -- psp`。
2. 已初始化的 `psp/` 必须干净,应用父仓库的 `.gitmodules` 和 `psp` gitlink 不能已有本任务以外的修改。
3. 从固定工作区中的本地 PSP 仓库取得完整候选 commit,再把应用工程的 `psp/` 检出到该 commit。该操作不要求先 push,也不改子模块 URL。
4. 核对 `psp/` HEAD 等于候选 commit、worktree 干净,并把父仓库显示的 gitlink 变化记入组包证据;不自动 `git add` 或提交应用父仓库。
子模块未登记、URL/仓库身份不一致、`.gitmodules` 或 gitlink 已有重叠改动、`psp/` 不干净、候选 commit 无法从本地仓库取得、初始化需要新的凭据、需要改 URL 或需要提交应用仓库时停止并请用户确认。
@@ -0,0 +1,161 @@
# PSP 数据契约
所有 SHA 使用 64 位小写 SHA-256;Git commit 使用仓库返回的完整 SHA。状态必须引用实际证据,不允许人工填写 `passed` 掩盖未执行步骤。
## `metadata/bsp.lock.json`
PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit:
```json
{
"schema_version": 1,
"handoff_path": "/absolute/BSP/doc/bsp_adaptation/handoff/psp_handoff.json",
"handoff_sha256": "<sha256>",
"bsp_project": "CV1841CP-LY-SM-YT70_dualos",
"bsp_commit": "<完整Git SHA或旧交接中的null>",
"bsp_source_dirty": false,
"chip": "CV1841CP",
"build_id": "20260901_1",
"imgs_version_path": "/epbuild/EMX2.0/EmxRes/.../imgs/20260901_1",
"image_partition_margin": {
"partition_table": "/absolute/BSP/configs/.../partition_spinand.xml",
"required_margin_bytes": 524288,
"checks": [
{
"image": "kernel.bin",
"image_size_bytes": 2621440,
"effective_partition_size_bytes": 3670016,
"actual_margin_bytes": 1048576,
"passed": true
},
{
"image": "rootfs.bin",
"image_size_bytes": 3145728,
"effective_partition_size_bytes": 4194304,
"actual_margin_bytes": 1048576,
"passed": true
}
]
},
"artifacts": [
{
"relative_path": "boot.bin",
"absolute_path": "/epbuild/.../boot.bin",
"size": 123,
"sha256": "<sha256>"
}
],
"mixed_build": null,
"warnings": []
}
```
混用构建时 `mixed_build` 记录每个文件来源、build ID、commit、hash、原因、风险、确认人和时间;默认值为 `null`。
## `metadata/allowed-overrides.json`
为保持 `psp.json` 兼容,应用同名覆盖的精确白名单放在 sidecar:
```json
{
"schema_version": 1,
"overrides": [
{
"psp_path": "configs/sdcard.json",
"application_path": "orgConfigs/sdcard.json",
"winner": "psp",
"reason": "板级存储配置由 PSP 提供",
"confirmed_by": "<负责人角色或姓名>",
"confirmed_at": "<ISO-8601>"
}
]
}
```
路径必须是仓库内精确相对路径,不允许绝对路径、`..` 或通配符。
## 应用侧 `psp.lock.json`
由应用层创建,不放进 PSP 仓库:
```json
{
"schema_version": 1,
"project_id": "LY-SM-YT70",
"psp_path": "psp",
"psp_repository": "<团队共享Git地址>",
"psp_commit": "<完整Git SHA>",
"expected_state": "candidate",
"bsp_build_id": "20260901_1",
"etunel_work_id": "<WORK_ID>",
"etunel_handoff_subtask": "<SUBTASK_ID>"
}
```
submodule gitlink 是实际锁定依据;lock 用于人和工具核对。两者不一致时停止构建。
## CV18 Sensor 静态证据
`validate_psp.py` 的 `checks.sensor_baseline` 记录 `sensor_cfg.ini` 的绝对路径/hash、枚举头文件绝对路径/hash/enum tag/`source`、芯片系列和每个 Sensor section 的规格枚举名称、期望配置值、实际值及匹配结果。默认 `source=bundled`,由芯片系列自动选择 skill 内置枚举表;只有明确传入替代头文件时为 `source=override`。CV181X 的 `expected_config_value` 是枚举名称;CV184X 是从 `_SNS_TYPE_E` 解析出的规范十六进制值。
## 本地固定打包契约和 `packaging-result.json`
用户提供应用工程路径和芯片后,`record_packaging_result.py contract` 按 EMX2.1 固定目录与命令生成打包契约。契约不放进 PSP 仓库,机器 schema 为:
```json
{
"schema_version": 2,
"contract_type": "emx21-project-packaging",
"contract_id": "<稳定且唯一的契约ID>",
"chips": ["CV1841CP"],
"project_root": "/absolute/application-project",
"psp_path": "/absolute/application-project/psp",
"working_directory": "/absolute/application-project/scripts",
"output_root": "/absolute/application-project/output",
"product_command": "./product.py",
"release_commands": ["./release.py 0", "./release.py 1"],
"success_exit_codes": [0]
}
```
四个路径必须严格满足上述父子关系,命令必须逐字匹配;应用工程路径、`psp` 子仓库或脚本无法验证时不得创建占位值或形成 `packaged`。`release.py 1` 还需要本轮用户确认 clean rebuild,不能只依赖契约中的允许列表。
`record_packaging_result.py preflight` 生成不可覆盖的 `schema_version: 1` 检查结果。候选提交前可以用 `--psp-source <staging>` 检查待导入内容;正式组包前必须使用应用工程内 `psp`、完整 commit 和 `--formal`。结果至少记录 PSP/脚本路径、资源来源映射、分区、BSP 分区比对、错误和警告。
`record_packaging_result.py snapshot` 生成不可覆盖的 `schema_version: 3` 快照,至少锁定契约路径/hash/id、打包前 PSP Git 状态、应用工程 Git 状态和既有 `output` 直接版本目录及目录树 hash。
`record_packaging_result.py record` 生成 `schema_version: 2` 的 `packaging-result.json`,至少包含 `passed`、契约路径/hash/id、芯片、PSP 仓库/commit/dirty、BSP build ID、应用工程路径/commit/dirty、执行目录、两阶段精确命令/退出码/日志证据、clean rebuild 确认状态、本轮新增版本目录绝对路径和完整文件清单,以及用户/项目指定烧录包的绝对路径、大小、mtime 和 SHA-256。日志证据只保存外部日志路径/hash/大小以及失败或警告的行号和行 hash,不复制日志正文。
本轮必须恰好新增一个 `output/<版本>/` 直接子目录,且既有版本目录未被修改或删除。handoff 生成时再次核对契约文件和烧录包 hash。
## 外部 `device-validation.json`
```json
{
"schema_version": 1,
"result": "PASS",
"evidence_source": "USER_REPORTED",
"psp_commit": "<完整Git SHA>",
"image_path": "/absolute/path/to/flash-package.img",
"image_sha256": "<sha256>",
"board": "LY-SM-YT70",
"hardware_revision": "<revision>",
"checks": [
{"name": "boot", "result": "PASS", "evidence": "<用户反馈摘要>"}
],
"unexecuted": [],
"issues": [],
"confirmed_by": "<用户或负责人>",
"confirmed_at": "<ISO-8601>"
}
```
适用检查未执行时总体结果不能为 PASS。
## 外部 `application-integration.json`
至少包含 schema、结果、PSP commit、应用仓库/commit、应用构建和打包命令/退出码、产物证据、覆盖检查、未执行项、应用负责人和确认时间。
## 外部 `psp-handoff.json`
由 `render_psp_handoff.py` 汇总,不复制 PSP 配置。至少包含项目/芯片、PSP 仓库/完整 commit/Git 状态、参考 PSP、BSP lock、Media/AIPlugin 基线、验证状态、烧录包、应用集成、Etunel 标识、自检、负责人确认、未执行项、警告、例外和风险。没有有效 Etunel 上下文时必须标记 `etunel.sent=false`。供 Etunel 使用时只允许 `packaged` 或 `accepted`,并记录 `git ls-remote` 对当前共享 URL/commit 的可达性结果;生成文件本身仍保持 `sent=false`。
@@ -0,0 +1,240 @@
/*
* Copyright (C) Cvitek Co., Ltd. 2019-2020. All rights reserved.
*
* File Name: sensor_list.h
* Description:
*/
#ifndef __SENSOR_LIST_H__
#define __SENSOR_LIST_H__
#include "cvi_comm_isp.h"
typedef enum _SAMPLE_SNS_TYPE_E {
/* ------ LINEAR BEGIN ------*/
BRIGATES_BG0808_MIPI_2M_30FPS_10BIT,
BYD_BF2253L_MIPI_1200P_30FPS_10BIT,
CHIPUP_XS9922B_MODE_720P_1CH_8BIT,
CHIPUP_XS9922B_MODE_720P_2CH_8BIT,
CHIPUP_XS9922B_MODE_720P_3CH_8BIT,
CVSENS_CV2002_MIPI_2M_1080P_30FPS_10BIT,
CVSENS_CV2003_MIPI_2M_1080P_30FPS_10BIT,
CVSENS_CV2003_1L_MIPI_2M_1080P_30FPS_10BIT,
CVSENS_CV2003_1L_SLAVE_MIPI_2M_1080P_30FPS_10BIT,
CVSENS_CV2003_1L_SLAVE1_MIPI_2M_1080P_30FPS_10BIT,
CVSENS_CV4001_MIPI_4M_1440P_25FPS_12BIT,
CVSENS_CV4002_MIPI_4M_1440P_25FPS_10BIT,
CVSENS_CV4002_MIPI_1M_720P_30FPS_10BIT,
GCORE_GC02M1_MIPI_2M_30FPS_10BIT,
GCORE_GC0308_MIPI_480P_30FPS_8BIT,
GCORE_GC0312_MIPI_480P_20FPS_8BIT,
GCORE_GC0329_MIPI_480P_10FPS_8BIT,
GCORE_GC05A2_MIPI_5M_30FPS_10BIT,
GCORE_GC1054_MIPI_1M_30FPS_10BIT,
GCORE_GC1084_MIPI_1M_30FPS_10BIT,
GCORE_GC1084_SLAVE_MIPI_1M_30FPS_10BIT,
GCORE_GC1084_SLAVE1_MIPI_1M_30FPS_10BIT,
GCORE_GC1084_SLAVE2_MIPI_1M_30FPS_10BIT,
GCORE_GC2053_MIPI_2M_30FPS_10BIT,
GCORE_GC2053_SLAVE_MIPI_2M_30FPS_10BIT,
GCORE_GC2053_1L_MIPI_2M_30FPS_10BIT,
GCORE_GC2083_MIPI_2M_30FPS_10BIT,
GCORE_GC2093_MIPI_2M_30FPS_10BIT,
GCORE_GC2093_MIPI_2M_60FPS_10BIT,
GCORE_GC2093_SLAVE_MIPI_2M_30FPS_10BIT,
GCORE_GC2145_MIPI_2M_12FPS_8BIT,
GCORE_GC2385_1L_MIPI_2M_30FPS_10BIT,
GCORE_GC4023_MIPI_4M_30FPS_10BIT,
GCORE_GC4653_MIPI_4M_30FPS_10BIT,
GCORE_GC4653_SLAVE_MIPI_4M_30FPS_10BIT,
IMGDS_MIS2008_MIPI_2M_1080P_30FPS_12BIT,
IMGDS_MIS4001_MIPI_4M_1440P_30FPS_10BIT,
NEXTCHIP_N5_1M_2CH_25FPS_8BIT,
NEXTCHIP_N5_2M_25FPS_8BIT,
NEXTCHIP_N6_2M_4CH_25FPS_8BIT,
OV_OS02D10_MIPI_2M_30FPS_10BIT,
OV_OS02D10_SLAVE_MIPI_2M_30FPS_10BIT,
OV_OS02N10_1L_MIPI_2M_15FPS_10BIT,
OV_OS02K10_SLAVE_MIPI_2M_30FPS_12BIT,
OV_OS04A10_MIPI_4M_1440P_30FPS_12BIT,
OV_OS04C10_MIPI_4M_30FPS_12BIT,
OV_OS04C10_MIPI_4M_1440P_30FPS_12BIT,
OV_OS04C10_SLAVE_MIPI_4M_30FPS_12BIT,
OV_OS08A20_MIPI_4M_30FPS_10BIT,
OV_OS08A20_SLAVE_MIPI_4M_30FPS_10BIT,
OV_OS08A20_MIPI_5M_30FPS_10BIT,
OV_OS08A20_SLAVE_MIPI_5M_30FPS_10BIT,
OV_OS08A20_MIPI_8M_30FPS_10BIT,
OV_OS08A20_SLAVE_MIPI_8M_30FPS_10BIT,
OV_OV4689_MIPI_4M_30FPS_10BIT,
OV_OV5647_MIPI_2M_30FPS_10BIT,
OV_OV6211_MIPI_400P_120FPS_10BIT,
OV_OV7251_MIPI_480P_120FPS_10BIT,
PICO384_THERMAL_384X288,
PICO640_THERMAL_479P,
PIXELPLUS_PR2000_2M_30FPS_8BIT,
PIXELPLUS_PR2020_1M_25FPS_8BIT,
PIXELPLUS_PR2020_1M_30FPS_8BIT,
PIXELPLUS_PR2020_2M_25FPS_8BIT,
PIXELPLUS_PR2020_2M_30FPS_8BIT,
PIXELPLUS_PR2100_2M_25FPS_8BIT,
PIXELPLUS_PR2100_2M_2CH_25FPS_8BIT,
PIXELPLUS_PR2100_2M_4CH_25FPS_8BIT,
SMS_SC035GS_MIPI_480P_120FPS_12BIT,
SMS_SC035GS_1L_MIPI_480P_120FPS_10BIT,
SMS_SC035HGS_MIPI_480P_120FPS_12BIT,
SMS_SC035HGS_1L_MIPI_480P_120FPS_10BIT,
SMS_SC132GS_MIPI_1080P_60FPS_10BIT,
SMS_SC132GS_SLAVE_MIPI_1080P_60FPS_10BIT,
SMS_SC1336_1L_MIPI_1M_30FPS_10BIT,
SMS_SC1336_1L_MIPI_1M_60FPS_10BIT,
SMS_SC1346_1L_MIPI_1M_30FPS_10BIT,
SMS_SC1346_1L_MIPI_1M_60FPS_10BIT,
SMS_SC1346_1L_SLAVE_MIPI_1M_30FPS_10BIT,
SMS_SC1346_1L_SLAVE_MIPI_1M_60FPS_10BIT,
SMS_SC200AI_MIPI_2M_30FPS_10BIT,
SMS_SC200AI_1L_MIPI_2M_30FPS_10BIT,
SMS_SC230AI_2L_MIPI_2M_30FPS_10BIT,
SMS_SC230AI_2L_SLAVE_MIPI_2M_30FPS_10BIT,
SMS_SC301IOT_MIPI_3M_30FPS_10BIT,
SMS_SC401AI_MIPI_3M_30FPS_10BIT,
SMS_SC401AI_MIPI_4M_30FPS_10BIT,
SMS_SC500AI_MIPI_4M_30FPS_10BIT,
SMS_SC500AI_MIPI_5M_30FPS_10BIT,
SMS_SC501AI_2L_MIPI_5M_30FPS_10BIT,
SMS_SC531AI_2L_MIPI_5M_30FPS_10BIT,
SMS_SC850SL_MIPI_8M_30FPS_12BIT,
SMS_SC3332_MIPI_3M_30FPS_10BIT,
SMS_SC3335_MIPI_3M_30FPS_10BIT,
SMS_SC3335_SLAVE_MIPI_3M_30FPS_10BIT,
SMS_SC3336_MIPI_3M_30FPS_10BIT,
SMS_SC3336_1L_MIPI_3M_20FPS_10BIT,
SMS_SC3336P_MIPI_3M_30FPS_10BIT,
SMS_SC2331_1L_MIPI_2M_30FPS_10BIT,
SMS_SC2331_1L_SLAVE_MIPI_2M_30FPS_10BIT,
SMS_SC2331_1L_SLAVE1_MIPI_2M_30FPS_10BIT,
SMS_SC2335_MIPI_2M_30FPS_10BIT,
SMS_SC2336_MIPI_2M_30FPS_10BIT,
SMS_SC2336_SLAVE_MIPI_2M_30FPS_10BIT,
SMS_SC2336_SLAVE1_MIPI_2M_30FPS_10BIT,
SMS_SC2336_1L_MIPI_2M_30FPS_10BIT,
SMS_SC2336P_MIPI_2M_30FPS_10BIT,
SMS_SC2336P_1L_MIPI_2M_30FPS_10BIT,
SMS_SC223A_1L_MIPI_2M_30FPS_10BIT,
SMS_SC4210_MIPI_4M_30FPS_12BIT,
SMS_SC4336_MIPI_4M_30FPS_10BIT,
SMS_SC4336P_MIPI_4M_30FPS_10BIT,
SMS_SC4336P_MIPI_4M_25FPS_10BIT,
SMS_SC4336P_1L_MIPI_4M_20FPS_10BIT,
SMS_SC4336P1_1L_MIPI_4M_20FPS_10BIT,
SMS_SC5336_2L_MIPI_5M_30FPS_10BIT,
SMS_SC5336P_2L_MIPI_5M_30FPS_10BIT,
SMS_SC8238_MIPI_8M_30FPS_10BIT,
SOI_F23_MIPI_2M_30FPS_10BIT,
SOI_F35_MIPI_2M_30FPS_10BIT,
SOI_F35_SLAVE_MIPI_2M_30FPS_10BIT,
SOI_F37P_MIPI_2M_30FPS_10BIT,
SOI_H65_MIPI_1M_30FPS_10BIT,
SOI_K06_MIPI_4M_25FPS_10BIT,
SOI_Q03_MIPI_3M_30FPS_10BIT,
SOI_Q03P_MIPI_3M_30FPS_10BIT,
SOI_F53_MIPI_2M_30FPS_10BIT,
SOI_F352_MIPI_2M_30FPS_10BIT,
SOI_K306_MIPI_4M_25FPS_10BIT,
SONY_IMX290_MIPI_1M_30FPS_12BIT,
SONY_IMX290_MIPI_2M_60FPS_12BIT,
SONY_IMX307_MIPI_2M_30FPS_12BIT,
SONY_IMX307_SLAVE_MIPI_2M_30FPS_12BIT,
SONY_IMX307_2L_MIPI_2M_30FPS_12BIT,
SONY_IMX307_SUBLVDS_2M_30FPS_12BIT,
SONY_IMX307_MIPI_2M_60FPS_12BIT,
SONY_IMX307_SUBLVDS_2M_60FPS_12BIT,
#ifdef FPGA_PORTING
SONY_IMX327_MIPI_1M_30FPS_10BIT,
#endif
SONY_IMX327_MIPI_2M_30FPS_12BIT,
SONY_IMX327_SLAVE_MIPI_2M_30FPS_12BIT,
SONY_IMX327_2L_MIPI_2M_30FPS_12BIT,
SONY_IMX327_SUBLVDS_2M_30FPS_12BIT,
SONY_IMX327_MIPI_2M_60FPS_12BIT,
SONY_IMX334_MIPI_8M_30FPS_12BIT,
SONY_IMX335_MIPI_4M_30FPS_12BIT,
SONY_IMX335_MIPI_4M_1600P_30FPS_12BIT,
SONY_IMX335_2L_MIPI_4M_30FPS_10BIT,
SONY_IMX335_MIPI_5M_30FPS_12BIT,
SONY_IMX335_MIPI_2M_60FPS_10BIT,
SONY_IMX335_MIPI_4M_60FPS_10BIT,
SONY_IMX335_MIPI_5M_60FPS_10BIT,
SONY_IMX347_MIPI_4M_60FPS_12BIT,
SONY_IMX385_MIPI_2M_30FPS_12BIT,
SONY_IMX675_MIPI_5M_30FPS_12BIT,
TECHPOINT_TP2850_MIPI_2M_30FPS_8BIT,
TECHPOINT_TP2850_MIPI_4M_30FPS_8BIT,
TECHPOINT_TP2825_MIPI_2M_30FPS_8BIT,
TECHPOINT_TP2863_MIPI_1M_25FPS_8BIT,
TECHPOINT_TP2863_MIPI_2M_25FPS_8BIT,
TECHPOINT_TP2863_MIPI_1M_30FPS_8BIT,
TECHPOINT_TP2863_MIPI_2M_30FPS_8BIT,
VIVO_MCS369_2M_30FPS_12BIT,
VIVO_MCS369Q_4M_30FPS_12BIT,
VIVO_MM308M2_2M_25FPS_8BIT,
LONTIUM_LT6911_2M_60FPS_8BIT,
LONTIUM_LT7911_2M_60FPS_8BIT,
/* ------ LINEAR END ------*/
SAMPLE_SNS_TYPE_LINEAR_BUTT,
/* ------ WDR 2TO1 BEGIN ------*/
BRIGATES_BG0808_MIPI_2M_30FPS_10BIT_WDR2TO1 = SAMPLE_SNS_TYPE_LINEAR_BUTT,
CVSENS_CV2002_MIPI_2M_1080P_30FPS_WDR2TO1,
CVSENS_CV4001_MIPI_4M_1440P_15FPS_WDR2TO1,
GCORE_GC2093_MIPI_2M_30FPS_10BIT_WDR2TO1,
GCORE_GC2093_SLAVE_MIPI_2M_30FPS_10BIT_WDR2TO1,
OV_OS04A10_MIPI_4M_1440P_30FPS_10BIT_WDR2TO1,
OV_OS04C10_MIPI_4M_30FPS_10BIT_WDR2TO1,
OV_OS04C10_MIPI_4M_1440P_30FPS_10BIT_WDR2TO1,
OV_OS04C10_SLAVE_MIPI_4M_30FPS_10BIT_WDR2TO1,
OV_OS08A20_MIPI_4M_30FPS_10BIT_WDR2TO1,
OV_OS08A20_SLAVE_MIPI_4M_30FPS_10BIT_WDR2TO1,
OV_OS08A20_MIPI_5M_30FPS_10BIT_WDR2TO1,
OV_OS08A20_SLAVE_MIPI_5M_30FPS_10BIT_WDR2TO1,
OV_OS08A20_MIPI_8M_30FPS_10BIT_WDR2TO1,
OV_OS08A20_SLAVE_MIPI_8M_30FPS_10BIT_WDR2TO1,
SMS_SC1336_1L_MIPI_1M_30FPS_10BIT_WDR2TO1,
SMS_SC1336_1L_MIPI_1M_60FPS_10BIT_WDR2TO1,
SMS_SC1346_1L_MIPI_1M_30FPS_10BIT_WDR2TO1,
SMS_SC1346_1L_MIPI_1M_60FPS_10BIT_WDR2TO1,
SMS_SC200AI_MIPI_2M_30FPS_10BIT_WDR2TO1,
SMS_SC500AI_MIPI_4M_30FPS_10BIT_WDR2TO1,
SMS_SC500AI_MIPI_5M_30FPS_10BIT_WDR2TO1,
SMS_SC850SL_MIPI_8M_30FPS_10BIT_WDR2TO1,
SMS_SC4210_MIPI_4M_30FPS_10BIT_WDR2TO1,
SMS_SC8238_MIPI_8M_15FPS_10BIT_WDR2TO1,
SOI_F35_MIPI_2M_30FPS_10BIT_WDR2TO1,
SOI_F35_SLAVE_MIPI_2M_30FPS_10BIT_WDR2TO1,
SOI_F352_MIPI_2M_30FPS_10BIT_WDR2TO1,
SONY_IMX307_MIPI_2M_30FPS_12BIT_WDR2TO1,
SONY_IMX307_2L_MIPI_2M_30FPS_12BIT_WDR2TO1,
SONY_IMX307_SLAVE_MIPI_2M_30FPS_12BIT_WDR2TO1,
SONY_IMX307_SUBLVDS_2M_30FPS_12BIT_WDR2TO1,
#ifdef FPGA_PORTING
SONY_IMX327_MIPI_1M_30FPS_10BIT_WDR2TO1,
#endif
SONY_IMX327_MIPI_2M_30FPS_12BIT_WDR2TO1,
SONY_IMX327_2L_MIPI_2M_30FPS_12BIT_WDR2TO1,
SONY_IMX327_SLAVE_MIPI_2M_30FPS_12BIT_WDR2TO1,
SONY_IMX327_SUBLVDS_2M_30FPS_12BIT_WDR2TO1,
SONY_IMX334_MIPI_8M_30FPS_12BIT_WDR2TO1,
SONY_IMX335_MIPI_2M_30FPS_10BIT_WDR2TO1,
SONY_IMX335_MIPI_4M_30FPS_10BIT_WDR2TO1,
SONY_IMX335_MIPI_4M_1600P_30FPS_10BIT_WDR2TO1,
SONY_IMX335_MIPI_5M_30FPS_10BIT_WDR2TO1,
SONY_IMX675_MIPI_5M_25FPS_12BIT_WDR2TO1,
SONY_IMX347_MIPI_4M_30FPS_12BIT_WDR2TO1,
SONY_IMX385_MIPI_2M_30FPS_12BIT_WDR2TO1,
/* ------ WDR 2TO1 END ------*/
SAMPLE_SNS_TYPE_BUTT,
} SAMPLE_SNS_TYPE_E;
extern const char *snsr_type_name[SAMPLE_SNS_TYPE_BUTT];
#endif
@@ -0,0 +1,524 @@
#ifndef __SENSOR_CFG_H_
#define __SENSOR_CFG_H_
#include "cvi_comm_video.h"
#include "cvi_sns_ctrl.h"
#ifdef __cplusplus
#if __cplusplus
extern "C" {
#endif
#endif
#ifndef SWITCH_GPIO_NUM
#define SWITCH_GPIO_NUM 2
#endif
#define SNS_DBG_PRT(fmt...) \
do { \
printf("[%s]-%d: ", __func__, __LINE__); \
printf(fmt); \
} while (0)
typedef enum _SNS_INTF_MODE_E {
SNS_MODE_BT656 = 0, /* ITU-R BT.656 YUV4:2:2 */
SNS_MODE_BT601, /* ITU-R BT.601 YUV4:2:2 */
SNS_MODE_DIGITAL_CAMERA, /* digatal camera mode */
SNS_MODE_BT1120_STANDARD, /* BT.1120 progressive mode */
SNS_MODE_BT1120_INTERLEAVED, /* BT.1120 interstage mode */
SNS_MODE_MIPI, /* MIPI RAW mode */
SNS_MODE_MIPI_YUV420_NORMAL, /* MIPI YUV420 normal mode */
SNS_MODE_MIPI_YUV420_LEGACY, /* MIPI YUV420 legacy mode */
SNS_MODE_MIPI_YUV422, /* MIPI YUV422 mode */
SNS_MODE_LVDS, /* LVDS mode */
SNS_MODE_HISPI, /* HiSPi mode */
SNS_MODE_SLVS, /* SLVS mode */
SNS_MODE_BUTT
} SNS_INTF_MODE_E;
typedef enum _SNS_DATA_TYPE_E {
SNS_DATA_TYPE_YUV = 0,
SNS_DATA_TYPE_RGB,
SNS_DATA_TYPE_YUV_EARLY,
SNS_DATA_TYPE_BUTT
} SNS_DATA_TYPE_E;
typedef enum _SNS_YUV_DATA_SEQ_E {
SNS_DATA_SEQ_VUVU = 0,
SNS_DATA_SEQ_UVUV,
SNS_DATA_SEQ_UYVY, /* The input sequence of YUV is UYVY */
SNS_DATA_SEQ_VYUY, /* The input sequence of YUV is VYUY */
SNS_DATA_SEQ_YUYV, /* The input sequence of YUV is YUYV */
SNS_DATA_SEQ_YVYU, /* The input sequence of YUV is YVYU */
SNS_DATA_SEQ_BUTT
} SNS_YUV_DATA_SEQ_E;
typedef enum _SNS_CHN_MODE_E {
SNS_CHN_MODE_1Multiplex = 0, /* 1 Multiplex mode */
SNS_CHN_MODE_2Multiplex, /* 2 Multiplex mode */
SNS_CHN_MODE_3Multiplex, /* 3 Multiplex mode */
SNS_CHN_MODE_4Multiplex, /* 4 Multiplex mode */
SNS_CHN_MODE_BUTT
} SNS_CHN_MODE_E;
typedef struct _SENSOR_MCLK_ATTR_S {
CVI_U8 u8Mclk;
CVI_BOOL bMclkEn;
} SENSOR_MCLK_ATTR_S;
extern ISP_SNS_OBJ_S stSnsAR2020_Obj;
extern ISP_SNS_OBJ_S stSnsBG0808_Obj;
extern ISP_SNS_OBJ_S stSnsCV2003_Obj;
extern ISP_SNS_OBJ_S stSnsCV2005_Obj;
extern ISP_SNS_OBJ_S stSnsGc02m1_Obj;
extern ISP_SNS_OBJ_S stSnsGc0308_Obj;
extern ISP_SNS_OBJ_S stSnsGc1054_Obj;
extern ISP_SNS_OBJ_S stSnsGc2053_Obj;
extern ISP_SNS_OBJ_S stSnsGc2053_Slave_Obj;
extern ISP_SNS_OBJ_S stSnsGc2053_1l_Obj;
extern ISP_SNS_OBJ_S stSnsGc2083_Obj;
extern ISP_SNS_OBJ_S stSnsGc2093_Obj;
extern ISP_SNS_OBJ_S stSnsGc2145_Obj;
extern ISP_SNS_OBJ_S stSnsGc4023_Obj;
extern ISP_SNS_OBJ_S stSnsGc4653_Obj;
extern ISP_SNS_OBJ_S stSnsGc4683_Obj;
extern ISP_SNS_OBJ_S stSnsGc8613_Obj;
extern ISP_SNS_OBJ_S stSnsMIS40C1_Obj;
extern ISP_SNS_OBJ_S stSnsN5_Obj;
extern ISP_SNS_OBJ_S stSnsN6_Obj;
extern ISP_SNS_OBJ_S stSnsNC021_Obj;
extern ISP_SNS_OBJ_S stSnsOs02g10_Obj;
extern ISP_SNS_OBJ_S stSnsOs04a10_Obj;
extern ISP_SNS_OBJ_S stSnsOs04c10_Obj;
extern ISP_SNS_OBJ_S stSnsOs04e10_Obj;
extern ISP_SNS_OBJ_S stSnsOs05a20_Obj;
extern ISP_SNS_OBJ_S stSnsOs08a20_Obj;
extern ISP_SNS_OBJ_S stSnsOs08b10_Obj;
extern ISP_SNS_OBJ_S stSnsOv2736_Obj;
extern ISP_SNS_OBJ_S stSnsOv4689_Obj;
extern ISP_SNS_OBJ_S stSnsOv48b2q_Obj;
extern ISP_SNS_OBJ_S stSnsOv6211_Obj;
extern ISP_SNS_OBJ_S stSnsOv7251_Obj;
extern ISP_SNS_OBJ_S stSnsOv9282_Obj;
extern ISP_SNS_OBJ_S stSnsPR2020_Obj;
extern ISP_SNS_OBJ_S stSnsPR2100_Obj;
extern ISP_SNS_OBJ_S stSnsSC020HGS_Obj;
extern ISP_SNS_OBJ_S stSnsSC035GS_Obj;
extern ISP_SNS_OBJ_S stSnsSC035GS_1L_Obj;
extern ISP_SNS_OBJ_S stSnsSC035HGS_Obj;
extern ISP_SNS_OBJ_S stSnsSC035HGS_1L_Obj;
extern ISP_SNS_OBJ_S stSnsSC233HGS_Obj;
extern ISP_SNS_OBJ_S stSnsSC235HAI_Obj;
extern ISP_SNS_OBJ_S stSnsSC1336_1L_Obj;
extern ISP_SNS_OBJ_S stSnsSC1346_1L_Obj;
extern ISP_SNS_OBJ_S stSnsSC1346_1L_Slave_Obj;
extern ISP_SNS_OBJ_S stSnsSC200AI_Obj;
extern ISP_SNS_OBJ_S stSnsSC200AI_1L_Obj;
extern ISP_SNS_OBJ_S stSnsSC301IOT_Obj;
extern ISP_SNS_OBJ_S stSnsSC401AI_Obj;
extern ISP_SNS_OBJ_S stSnsSC431HAI_Obj;
extern ISP_SNS_OBJ_S stSnsSC438AI_Obj;
extern ISP_SNS_OBJ_S stSnsSC500AI_Obj;
extern ISP_SNS_OBJ_S stSnsSC501AI_2L_Obj;
extern ISP_SNS_OBJ_S stSnsSC531AI_2L_Obj;
extern ISP_SNS_OBJ_S stSnsSC533HAI_Obj;
extern ISP_SNS_OBJ_S stSnsSC535HAI_Obj;
extern ISP_SNS_OBJ_S stSnsSC635AI_Obj;
extern ISP_SNS_OBJ_S stSnsSC831HAI_Obj;
extern ISP_SNS_OBJ_S stSnsSC1330_Obj;
extern ISP_SNS_OBJ_S stSnsSC3332_Obj;
extern ISP_SNS_OBJ_S stSnsSC3335_Obj;
extern ISP_SNS_OBJ_S stSnsSC3336_Obj;
extern ISP_SNS_OBJ_S stSnsSC3336_1L_Obj;
extern ISP_SNS_OBJ_S stSnsSC2335_Obj;
extern ISP_SNS_OBJ_S stSnsSC2336_Obj;
extern ISP_SNS_OBJ_S stSnsSC2336_Slave_Obj;
extern ISP_SNS_OBJ_S stSnsSC2336_Slave1_Obj;
extern ISP_SNS_OBJ_S stSnsSC2336_1L_Obj;
extern ISP_SNS_OBJ_S stSnsSC2336P_Obj;
extern ISP_SNS_OBJ_S stSnsSC2336P_1L_Obj;
extern ISP_SNS_OBJ_S stSnsSC4336_Obj;
extern ISP_SNS_OBJ_S stSnsSC4336P_Obj;
extern ISP_SNS_OBJ_S stSnsSC202L_Obj;
extern ISP_SNS_OBJ_S stSnsF23_Obj;
extern ISP_SNS_OBJ_S stSnsF35_Obj;
extern ISP_SNS_OBJ_S stSnsF37P_Obj;
extern ISP_SNS_OBJ_S stSnsK06_Obj;
extern ISP_SNS_OBJ_S stSnsQ03_Obj;
extern ISP_SNS_OBJ_S stSnsQ03P_Obj;
extern ISP_SNS_OBJ_S stSnsImx135_Obj;
extern ISP_SNS_OBJ_S stSnsImx290_2l_Obj;
extern ISP_SNS_OBJ_S stSnsImx307_Obj;
extern ISP_SNS_OBJ_S stSnsImx307_Slave_Obj;
extern ISP_SNS_OBJ_S stSnsImx307_2l_Obj;
extern ISP_SNS_OBJ_S stSnsImx327_Obj;
extern ISP_SNS_OBJ_S stSnsImx327_Slave_Obj;
extern ISP_SNS_OBJ_S stSnsImx327_2l_Obj;
extern ISP_SNS_OBJ_S stSnsImx327_fpga_Obj;
extern ISP_SNS_OBJ_S stSnsImx327_Sublvds_Obj;
extern ISP_SNS_OBJ_S stSnsImx334_Obj;
extern ISP_SNS_OBJ_S stSnsImx335_Obj;
extern ISP_SNS_OBJ_S stSnsImx376_Obj;
extern ISP_SNS_OBJ_S stSnsImx412_Obj;
extern ISP_SNS_OBJ_S stSnsImx415_Obj;
extern ISP_SNS_OBJ_S stSnsImx585_Obj;
extern ISP_SNS_OBJ_S stSnsImx662_Obj;
extern ISP_SNS_OBJ_S stSnsImx675_Obj;
extern ISP_SNS_OBJ_S stSnsImx678_Obj;
extern ISP_SNS_OBJ_S stSnsImx900_Obj;
extern ISP_SNS_OBJ_S stSnsTP2860_Obj;
extern ISP_SNS_OBJ_S stSnsTP2825_Obj;
extern ISP_SNS_OBJ_S stSnsMCS369_Obj;
extern ISP_SNS_OBJ_S stSnsMCS369Q_Obj;
extern ISP_SNS_OBJ_S stSnsMM308M2_Obj;
extern ISP_SNS_OBJ_S stSnsLT6911_Obj;
typedef enum _SNS_TYPE_E {
/*BRIGATES*/
BRIGATES_BG0808_MIPI_2M_30FPS_10BIT = 0X38003240,
BRIGATES_BG0808_MIPI_2M_30FPS_10BIT_WDR2TO1 = 0X380032C0,
/*BYD*/
/*CHIPUP*/
/*CVSENS*/
CVSENS_CV2003_1L_MASTER_SWITCH_MIPI_2M_1080P_15FPS_10BIT = 0X44003108,
CVSENS_CV2003_1L_SLAVE_SWITCH_MIPI_2M_1080P_15FPS_10BIT = 0X44003110,
CVSENS_CV2003_MIPI_2M_1080P_30FPS_10BIT = 0X44003340,
CVSENS_CV2003_1L_MIPI_2M_1080P_30FPS_10BIT = 0X44003300,
CVSENS_CV2003_1L_SLAVE_HIGH_MIPI_2M_1080P_30FPS_10BIT = 0X44003310,
CVSENS_CV2003_1L_SLAVE_LOW_MIPI_2M_1080P_30FPS_10BIT = 0X44003311,
CVSENS_CV2005_1L_MIPI_2M_1080P_15FPS_10BIT = 0X44083100,
/*GCORE*/
GCORE_GC02M1_MIPI_2M_30FPS_10BIT = 0X48183300,
GCORE_GC0308_1M_30FPS_8BIT = 0X48570B60,
GCORE_GC1054_MIPI_1M_30FPS_10BIT = 0X48342340,
GCORE_GC2053_MIPI_2M_30FPS_10BIT = 0X483C3340,
GCORE_GC2053_SLAVE_MIPI_2M_30FPS_10BIT = 0X483C3350,
GCORE_GC2053_1L_MIPI_2M_30FPS_10BIT = 0X483C3300,
GCORE_GC2083_2L_MIPI_2M_30FPS_10BIT = 0X48443320,
GCORE_GC2093_MIPI_2M_30FPS_10BIT = 0X48483340,
GCORE_GC2093_MIPI_2M_60FPS_10BIT = 0X48483440,
GCORE_GC2145_2M_12FPS_8BIT = 0X484F3020,
GCORE_GC4023_MIPI_4M_30FPS_10BIT = 0X48083B40,
GCORE_GC4653_MIPI_4M_30FPS_10BIT = 0X480C3B40,
GCORE_GC4683_MIPI_1M_120FPS_10BIT = 0X48582640,
GCORE_GC4683_MIPI_4M_60FPS_10BIT = 0X48583C20,
GCORE_GC8613_MIPI_2M_30FPS_10BIT = 0X48143340,
GCORE_GC8613_MIPI_8M_30FPS_10BIT = 0X48145B40,
GCORE_GC8613_MIPI_8M_25FPS_10BIT = 0X48145A40,
GCORE_GC8613_MIPI_8M_60FPS_10BIT = 0X48145C40,
GCORE_GC2093_MIPI_2M_30FPS_10BIT_WDR2TO1 = 0X484833C0,
GCORE_GC8613_MIPI_8M_30FPS_10BIT_WDR2TO1 = 0X48145BC0,
/*IMGDS*/
IMGDS_MIS40C1_MIPI_4M_20FPS_1L_10BIT = 0X00003A00,
IMGDS_MIS40C1_MIPI_4M_20FPS_2L_10BIT = 0X00003A20,
/*NEXTCHIP*/
NEXTCHIP_N5_1M_2CH_25FPS_8BIT = 0X04032240,
NEXTCHIP_N5_2M_25FPS_8BIT = 0X04033240,
NEXTCHIP_N6_2M_4CH_25FPS_8BIT = 0X04073240,
/*OV*/
OV_OS02G10_MIPI_2M_30FPS_10BIT = 0X10503320,
OV_OS04A10_MIPI_4M_1440P_30FPS_12BIT = 0X10083B40,
OV_OS04A10_MIPI_4M_1440P_2L_10BIT = 0X10083A20,
OV_OS04A10_MASTER_MIPI_4M_1440P_2L_10BIT = 0X10083A28,
OV_OS04A10_SLAVE_MIPI_4M_1440P_2L_10BIT = 0X10083A30,
OV_OS04C10_MIPI_4M_30FPS_12BIT = 0X100C3B40,
OV_OS04C10_MIPI_4M_1440P_30FPS_12BIT = 0X100C3B41,
OV_OS05A20_MIPI_1944P_15FPS_2L_12BIT = 0X10605120,
OV_OS08A20_MIPI_5M_30FPS_10BIT = 0X101C5340, //2592 * 1944
OV_OV4689_MIPI_4M_30FPS_10BIT = 0X102C3B40,
OV_OV48B2Q_PDAF_MIPI_8M_30FPS_10BIT = 0X10245B40,
OV_OV48B2Q_PDAF_MIPI_12M_30FPS_10BIT = 0X10246340,
OV_OV6211_MIPI_400P_120FPS_10BIT = 0X10340E40,
OV_OV7251_MIPI_480P_120FPS_10BIT = 0X10380E40,
OV_OS04E10_MIPI_4M_30FPS_12BIT = 0X10103B40,
OV_OS04E10_MIPI_4M_30FPS_2L_10BIT = 0X10103B20,
OV_OS04E10_SALVE_MIPI_4M_30FPS_2L_10BIT = 0X10103B30,
OV_OS08B10_MIPI_8M_30FPS_10BIT = 0X10205B40,
OV_OV2736_MIPI_2M_30FPS_12BIT = 0X10283340,
OV_OS04A10_MIPI_4M_1440P_30FPS_10BIT_WDR2TO1 = 0X10083BC0,
OV_OS04A10_MASTER_MIPI_4M_1440P_2L_10BIT_WDR2TO1 = 0X10083AA8,
OV_OS04A10_SLAVE_MIPI_4M_1440P_2L_10BIT_WDR2TO1 = 0X10083AB0,
OV_OS04C10_MIPI_4M_30FPS_10BIT_WDR2TO1 = 0X100C3BC0,
OV_OS04C10_MIPI_4M_1440P_30FPS_10BIT_WDR2TO1 = 0X100C3BC1,
OV_OS08A20_MIPI_4M_30FPS_10BIT_WDR2TO1 = 0X101C3BC0,
OV_OS08A20_SLAVE_MIPI_4M_30FPS_10BIT_WDR2TO1 = 0X101C3BD0,
OV_OS08A20_MIPI_5M_30FPS_10BIT_WDR2TO1 = 0X101C53C0,
OV_OS08A20_MIPI_8M_30FPS_10BIT_WDR2TO1 = 0X101C5BC0,
OV_OS04E10_MIPI_4M_30FPS_10BIT_WDR2TO1 = 0X10103BC0,
OV_OS04E10_MIPI_4M_30FPS_2L_10BIT_WDR2TO1 = 0X10103BA0,
OV_OS04E10_SLAVE_MIPI_4M_30FPS_2L_10BIT_WDR2TO1 = 0X10103BB0,
OV_OS08B10_MIPI_8M_30FPS_10BIT_WDR2TO1 = 0X10205BC0,
OV_OV2736_MIPI_2M_30FPS_12BIT_WDR2TO1 = 0X102833C0,
OV_OV9282_MASTER_MIPI_800P_15FPS_2L_10BIT = 0X105C1928,
OV_OV9282_SLAVE_MIPI_800P_15FPS_2L_10BIT = 0X105C1930,
/*PICO384*/
PICO640_THERMAL_479P = 0X64050B40,
/*PIXELPLUS*/
PIXELPLUS_PR2020_1M_25FPS_8BIT = 0X18012240,
PIXELPLUS_PR2020_1M_30FPS_8BIT = 0X18012340,
PIXELPLUS_PR2020_2M_25FPS_8BIT = 0X18013240,
PIXELPLUS_PR2020_2M_30FPS_8BIT = 0X18013340,
PIXELPLUS_PR2100_2M_25FPS_8BIT = 0X18053240,
PIXELPLUS_PR2100_2M_2CH_2L_25FPS_8BIT = 0X18053220,
PIXELPLUS_PR2100_2M_2CH_25FPS_8BIT = 0X18053241,
PIXELPLUS_PR2100_2M_4CH_25FPS_8BIT = 0X18053242,
PIXELPLUS_PR2100_2M_4CH_30FPS_8BIT = 0X18053340,
/*SMS*/
SMS_SC020HGS_MIPI_400P_120FPS_10BIT = 0X20A80E40,
SMS_SC035GS_MIPI_480P_120FPS_12BIT = 0X20300E40,
SMS_SC035GS_1L_MIPI_480P_120FPS_10BIT = 0X20300E00,
SMS_SC035HGS_MIPI_480P_120FPS_12BIT = 0X20340E40,
SMS_SC035HGS_1L_MIPI_480P_120FPS_10BIT = 0X20340E00,
SMS_SC233HGS_MIPI_2M_60FPS_10BIT = 0X20AC3440,
SMS_SC233HGS_MIPI_2M_120FPS_10BIT = 0X20AC3640,
SMS_SC235HAI_1L_MIPI_2M_15FPS_10BIT = 0X20B83100,
SMS_SC235HAI_1L_SLAVE_MIPI_2M_15FPS_10BIT = 0X20B83110,
SMS_SC235HAI_2L_MIPI_2M_15FPS_10BIT = 0X20B83120,
SMS_SC235HAI_2L_SLAVE_MIPI_2M_15FPS_10BIT = 0X20B83130,
SMS_SC235HAI_2L_MIPI_2M_30FPS_10BIT = 0X20B83320,
SMS_SC1336_1L_MIPI_600P_30FPS_10BIT = 0X20701300,
SMS_SC1336_1L_MIPI_600P_60FPS_10BIT = 0X20701400,
SMS_SC1336_1L_MIPI_1M_30FPS_10BIT = 0X20702300,
SMS_SC1336_1L_MIPI_1M_60FPS_10BIT = 0X20702400,
SMS_SC1346_1L_MIPI_1M_30FPS_10BIT = 0X20742300,
SMS_SC1346_1L_MIPI_1M_60FPS_10BIT = 0X20742400,
SMS_SC1346_1L_SLAVE_MIPI_1M_30FPS_10BIT = 0X20742310,
SMS_SC1346_1L_SLAVE_MIPI_1M_60FPS_10BIT = 0X20742410,
SMS_SC1346_1L_MIPI_600P_30FPS_10BIT = 0X20741300,
SMS_SC1346_1L_MIPI_600P_60FPS_10BIT = 0X20741400,
SMS_SC200AI_MIPI_2M_30FPS_10BIT = 0X203C3340,
SMS_SC200AI_1L_MIPI_2M_30FPS_10BIT = 0X203C3300,
SMS_SC301IOT_MIPI_3M_30FPS_10BIT = 0X204C3B40,
SMS_SC401AI_MIPI_3M_30FPS_10BIT = 0X20503B40,
SMS_SC401AI_MIPI_4M_30FPS_10BIT = 0X20503B41,
SMS_SC431HAI_2L_MIPI_4M_30FPS_10BIT = 0X20CC3B20,
SMS_SC431HAI_1L_MIPI_4M_20FPS_10BIT = 0X20CC3900,
SMS_SC500AI_MIPI_4M_30FPS_10BIT = 0X20583B40,
SMS_SC500AI_MIPI_5M_30FPS_10BIT = 0X20585340,
SMS_SC501AI_2L_MIPI_5M_30FPS_10BIT = 0X20985320,
SMS_SC531AI_2L_MIPI_5M_30FPS_10BIT = 0X20605320,
SMS_SC1330_MIPI_1M_30FPS_10BIT = 0X20A42B20,
SMS_SC1330_MIPI_1M_30FPS_10BIT_MASTER = 0X20A42B28,
SMS_SC1330_MIPI_1M_30FPS_10BIT_SLAVE = 0X20A42B30,
SMS_SC1330_1L_MIPI_1M_30FPS_12BIT = 0X20A42B00,
SMS_SC1330_1L_MIPI_1M_30FPS_12BIT_MASTER = 0X20A42B08,
SMS_SC1330_1L_MIPI_1M_30FPS_12BIT_SLAVE = 0X20A42B10,
SMS_SC3332_MIPI_3M_30FPS_10BIT = 0X20003B40,
SMS_SC3335_MIPI_3M_30FPS_10BIT = 0X20043B40,
SMS_SC3336_MIPI_3M_30FPS_10BIT = 0X20083B40,
SMS_SC3336_1L_MIPI_3M_20FPS_10BIT = 0X20083A00,
SMS_SC2335_MIPI_2M_30FPS_10BIT = 0X20883340,
SMS_SC2336_MIPI_2M_30FPS_10BIT = 0X208C3340,
SMS_SC2336_SLAVE_MIPI_2M_30FPS_10BIT = 0X208C3350,
SMS_SC2336_SLAVE1_MIPI_2M_30FPS_10BIT = 0X208C3351,
SMS_SC2336_1L_MIPI_2M_30FPS_10BIT = 0X208C3300,
SMS_SC2336P_MIPI_2M_30FPS_10BIT = 0X20903340,
SMS_SC2336P_1L_MIPI_2M_30FPS_10BIT = 0X20903300,
SMS_SC4336_MIPI_4M_30FPS_10BIT = 0X20183B40,
SMS_SC4336P_MIPI_4M_30FPS_10BIT = 0X201C3B40,
SMS_SC4336P_1L_MIPI_4M_20FPS_10BIT = 0X201C3A00,
SMS_SC4336P_SLAVE_MIPI_4M_30FPS_10BIT = 0X201C3B50,
SMS_SC233HGS_MIPI_2M_60FPS_10BIT_WDR2TO1 = 0X20AC34C0,
SMS_SC200AI_MIPI_2M_30FPS_10BIT_WDR2TO1 = 0X203C33C0,
SMS_SC438AI_MIPI_4M_30FPS_10BIT = 0X20A03B40,
SMS_SC438AI_MIPI_4M_30FPS_10BIT_MASTER = 0X20A03B48,
SMS_SC438AI_MIPI_4M_30FPS_10BIT_SLAVE = 0X20A03B50,
SMS_SC500AI_MIPI_5M_60FPS_10BIT = 0X20585440,
SMS_SC500AI_2L_MIPI_5M_30FPS_10BIT = 0X20585320,
SMS_SC533HAI_MIPI_5M_30FPS_10BIT = 0X20C84B40,
SMS_SC533HAI_MIPI_5M_30FPS_10BIT_WDR2TO1 = 0X20C84BC0,
SMS_SC533HAI_2L_MIPI_5M_30FPS_10BIT = 0X20C84B20,
SMS_SC535HAI_MIPI_5M_15FPS_2L_10BIT_MASTER = 0X20BC3928,
SMS_SC535HAI_MIPI_5M_15FPS_2L_10BIT_SLAVE = 0X20BC3930,
SMS_SC535HAI_MIPI_5M_15FPS_4L_10BIT_MASTER = 0X20BC3948,
SMS_SC535HAI_MIPI_5M_15FPS_4L_10BIT_SLAVE = 0X20BC3950,
SMS_SC535HAI_MIPI_5M_30FPS_2L_10BIT_MASTER = 0X20BC3B28,
SMS_SC535HAI_MIPI_5M_30FPS_2L_10BIT_SLAVE = 0X20BC3B30,
SMS_SC535HAI_MIPI_5M_30FPS_4L_10BIT_MASTER = 0X20BC3B48,
SMS_SC535HAI_MIPI_5M_30FPS_4L_10BIT_SLAVE = 0X20BC3B50,
SMS_SC635AI_MIPI_6M_60FPS_10BIT = 0X20B05C40,
SMS_SC500AI_MIPI_4M_30FPS_10BIT_WDR2TO1 = 0X20583BC0,
SMS_SC500AI_MIPI_5M_30FPS_10BIT_WDR2TO1 = 0X205853C0,
SMS_SC831HAI_MASTER_MIPI_8M_30FPS_10BIT = 0X20B45B48,
SMS_SC831HAI_SLAVE_MIPI_8M_30FPS_10BIT = 0X20B45B50,
SMS_SC202L_2L_MASTER_MIPI_2M_30FPS_10BIT = 0X20C03328,
SMS_SC202L_2L_SLAVE_MIPI_2M_30FPS_10BIT = 0X20C03330,
/*SOI*/
SOI_F23_MIPI_2M_30FPS_10BIT = 0X24103340,
SOI_F35_MIPI_2M_30FPS_10BIT = 0X24143340,
SOI_F37P_MIPI_2M_30FPS_10BIT = 0X24183340,
SOI_K06_MIPI_4M_25FPS_10BIT = 0X24043A40,
SOI_Q03_MIPI_3M_30FPS_10BIT = 0X24283B40,
SOI_Q03P_MIPI_3M_30FPS_10BIT = 0X242C3B40,
SOI_F23_MIPI_2M_30FPS_10BIT_WDR2TO1 = 0X241033C0,
SOI_F35_MIPI_2M_30FPS_10BIT_WDR2TO1 = 0X241433C0,
SOI_F35_SLAVE_MIPI_2M_30FPS_10BIT_WDR2TO1 = 0X241433D0,
/*SONY*/
SONY_IMX135_MIPI_8M_25FPS_10BIT = 0X28405A40,
SONY_IMX290_MIPI_1M_30FPS_12BIT = 0X282C2340,
SONY_IMX290_MIPI_2M_60FPS_12BIT = 0X282C3440,
SONY_IMX307_MIPI_2M_30FPS_12BIT = 0X28303340,
SONY_IMX307_SLAVE_MIPI_2M_30FPS_12BIT = 0X28303350,
SONY_IMX307_2L_MIPI_2M_30FPS_12BIT = 0X28303320,
SONY_IMX307_MIPI_2M_60FPS_12BIT = 0X28303440,
SONY_IMX327_MIPI_2M_30FPS_12BIT = 0X28343340,
SONY_IMX327_SLAVE_MIPI_2M_30FPS_12BIT = 0X28343350,
SONY_IMX327_2L_MIPI_2M_30FPS_12BIT = 0X28343320,
SONY_IMX327_SUBLVDS_2M_30FPS_12BIT = 0X28343341,
SONY_IMX327_MIPI_2M_60FPS_12BIT = 0X28343440,
SONY_IMX327_MIPI_1M_30FPS_10BIT = 0X28342340,
SONY_IMX334_MIPI_8M_30FPS_12BIT = 0X28005B40,
SONY_IMX334_MIPI_8M_60FPS_12BIT = 0X28005C40,
SONY_IMX335_MIPI_4M_30FPS_12BIT = 0X28043B40,
SONY_IMX335_MIPI_4M_1600P_30FPS_12BIT = 0X28044B40,
SONY_IMX335_2L_MIPI_4M_30FPS_10BIT = 0X28043B20,
SONY_IMX335_MIPI_5M_30FPS_12BIT = 0X28045340,
SONY_IMX335_MIPI_2M_60FPS_10BIT = 0X28043440,
SONY_IMX335_MIPI_4M_60FPS_10BIT = 0X28043C40,
SONY_IMX335_MIPI_5M_60FPS_10BIT = 0X28045440,
SONY_IMX376_2L_MIPI_5M_30FPS_10BIT = 0X28445320,
SONY_IMX662_MIPI_2M_30FPS_10BIT = 0X28203320,
SONY_IMX675_MIPI_4M_30FPS_12BIT = 0X28243B40,
SONY_IMX675_MIPI_5M_30FPS_12BIT = 0X28245340,
SONY_IMX678_MIPI_8M_30FPS_10BIT = 0X283C5B40,
SONY_IMX678_MIPI_5M_25FPS_12BIT_WDR2TO1 = 0X283C5240,
SONY_IMX412_MIPI_12M_30FPS_12BIT = 0X28106340,
SONY_IMX415_MIPI_2M_60FPS_12BIT = 0X28143440,
SONY_IMX415_MIPI_4M_25FPS_12BIT = 0X28143A40,
SONY_IMX415_MIPI_5M_25FPS_12BIT = 0X28145240,
SONY_IMX415_MIPI_8M_25FPS_12BIT = 0X28145A40,
SONY_IMX415_MIPI_8M_30FPS_10BIT = 0X28145B40,
SONY_IMX415_MIPI_4M_25FPS_12BIT_WDR2TO1 = 0X28143A41,
SONY_IMX585_MIPI_8M_30FPS_12BIT = 0X281C5B40,
SONY_IMX900_MIPI_3M_70FPS_12BIT_COLOR = 0X28383C40,
SONY_IMX900_MIPI_3M_70FPS_12BIT_MONO = 0X28383C41,
SONY_IMX307_MIPI_2M_30FPS_12BIT_WDR2TO1 = 0X283033C0,
SONY_IMX307_2L_MIPI_2M_30FPS_12BIT_WDR2TO1 = 0X283033A0,
SONY_IMX307_SLAVE_MIPI_2M_30FPS_12BIT_WDR2TO1 = 0X283033D0,
SONY_IMX327_MIPI_2M_30FPS_12BIT_WDR2TO1 = 0X283433C0,
SONY_IMX327_2L_MIPI_2M_30FPS_12BIT_WDR2TO1 = 0X283433A0,
SONY_IMX327_SLAVE_MIPI_2M_30FPS_12BIT_WDR2TO1 = 0X283433D0,
SONY_IMX327_SUBLVDS_2M_30FPS_12BIT_WDR2TO1 = 0X283433C1,
SONY_IMX327_MIPI_1M_30FPS_10BIT_WDR2TO1 = 0X283423C0,
SONY_IMX334_MIPI_8M_30FPS_12BIT_WDR2TO1 = 0X28005BC0,
SONY_IMX335_MIPI_2M_30FPS_10BIT_WDR2TO1 = 0X280433C0,
SONY_IMX335_MIPI_4M_30FPS_10BIT_WDR2TO1 = 0X28043BC0,
SONY_IMX335_MIPI_4M_1600P_30FPS_10BIT_WDR2TO1 = 0X28044BC0,
SONY_IMX335_MIPI_5M_30FPS_10BIT_WDR2TO1 = 0X280453C0,
SONY_IMX662_MIPI_2M_30FPS_10BIT_WDR2TO1 = 0X282033A0,
SONY_IMX675_MIPI_5M_25FPS_12BIT_WDR2TO1 = 0X282452C0,
SONY_IMX412_MIPI_8M_30FPS_12BIT_WDR2TO1 = 0X28105BC0,
SONY_IMX412_MIPI_12M_30FPS_12BIT_WDR2TO1 = 0X281063C0,
SONY_IMX585_MIPI_8M_25FPS_12BIT_WDR2TO1 = 0X281C5AC0,
/*TECHPOINT*/
TECHPOINT_TP2850_MIPI_2M_30FPS_8BIT = 0X2C043340,
TECHPOINT_TP2850_MIPI_4M_30FPS_8BIT = 0X2C043B40,
TECHPOINT_TP2825_MIPI_1M_30FPS_8BIT = 0X2C102340,
TECHPOINT_TP2825_MIPI_2M_30FPS_8BIT = 0X2C103340,
TECHPOINT_TP2825_MIPI_2M_50FPS_8BIT = 0X2C103740,
TECHPOINT_TP2825_MIPI_2M_60FPS_8BIT = 0X2C103440,
TECHPOINT_TP2825_MIPI_4M_25FPS_8BIT = 0X2C103A40,
TECHPOINT_TP2825_MIPI_4M_30FPS_8BIT = 0X2C103B40,
TECHPOINT_TP2860_MIPI_2M_25FPS_8BIT = 0X2C143240,
/*VIVO*/
VIVO_MCS369_2M_30FPS_12BIT = 0X340B3340,
VIVO_MCS369Q_4M_30FPS_12BIT = 0X34073B40,
VIVO_MM308M2_2M_25FPS_8BIT = 0X34033240,
/*LONTIUM*/
LONTIUM_MIPI_LT6911_1M_60FPS_8BIT = 0X58012440,
LONTIUM_MIPI_LT6911_2M_60FPS_8BIT = 0X58013440,
LONTIUM_MIPI_LT6911_8M_60FPS_8BIT = 0X58015C40,
/*ONSEMI*/
ONSEMI_AR2020_20M_25FPS_10BIT = 0X08106A40,
/*BOARD*/
BOARD_FULL_SIZE_MIPI_30FPS_12BIT = 0X60006B40,
BOARD_MAX_SIZE_MIPI_30FPS_12BIT = 0X60046B40,
/*NUC*/
NUC_NC021_MIPI_2M_50FPS_8BIT = 0X5C003440,
NUC_NC021_MIPI_2M_25FPS_8BIT_WDR2TO1 = 0X5C0032C0,
/*end*/
CVI_SNS_TYPE_BUTT,
} CVI_SNS_TYPE_E;
#pragma pack(1)
typedef struct _SNS_INI_CFG_S {
CVI_U8 devNum;
CVI_S32 enSnsMode;
CVI_SNS_TYPE_E enSnsType[VI_MAX_DEV_NUM];
CVI_S32 s32BusId[VI_MAX_DEV_NUM];
CVI_S32 s32SnsI2cAddr[VI_MAX_DEV_NUM];
CVI_S32 MipiDev[VI_MAX_DEV_NUM];
CVI_S16 as16LaneId[VI_MAX_DEV_NUM][MIPI_LANE_NUM + 1];
CVI_S16 as16FuncId[VI_MAX_DEV_NUM][TTL_PIN_FUNC_NUM];
CVI_S8 as8PNSwap[VI_MAX_DEV_NUM][MIPI_LANE_NUM + 1];
CVI_U8 u8HwSync[VI_MAX_DEV_NUM];
SENSOR_MCLK_ATTR_S stMclkAttr[VI_MAX_DEV_NUM];
CVI_U8 u8Hsettle[VI_MAX_DEV_NUM];
CVI_BOOL bHsettlen[VI_MAX_DEV_NUM];
CVI_U8 u8Orien[VI_MAX_DEV_NUM];
CVI_S32 s32RstPort[VI_MAX_DEV_NUM];
CVI_S32 s32RstPin[VI_MAX_DEV_NUM];
CVI_S32 s32RstPol[VI_MAX_DEV_NUM];
CVI_U8 u8AttachDev[VI_MAX_DEV_NUM];
CVI_U8 u8MuxDev[VI_MAX_DEV_NUM];
CVI_S32 s32SwitchPort[VI_MAX_DEV_NUM][SWITCH_GPIO_NUM];
CVI_S32 s32SwitchPin[VI_MAX_DEV_NUM][SWITCH_GPIO_NUM];
CVI_S32 s32SwitchPol[VI_MAX_DEV_NUM][SWITCH_GPIO_NUM];
} SNS_INI_CFG_S;
typedef struct _SNS_CFG_S {
CVI_U32 u32ImageWigth[VI_MAX_DEV_NUM];
CVI_U32 u32ImageHeight[VI_MAX_DEV_NUM];
WDR_MODE_E enWDRMode[VI_MAX_DEV_NUM];
SNS_INTF_MODE_E enInterFaceMode[VI_MAX_DEV_NUM];
SNS_DATA_TYPE_E enFormatMode[VI_MAX_DEV_NUM];
SNS_YUV_DATA_SEQ_E enYuvFormat[VI_MAX_DEV_NUM];
BAYER_FORMAT_E enBayerFormat[VI_MAX_DEV_NUM];
SNS_CHN_MODE_E enChnMode[VI_MAX_DEV_NUM];
CVI_BOOL bBypassIsp[VI_MAX_DEV_NUM];
CVI_FLOAT f32FrameRate[VI_MAX_DEV_NUM];
CVI_U8 u8LaneNumber[VI_MAX_DEV_NUM];
CVI_U8 u8EnMasterMode[VI_MAX_DEV_NUM];
ISP_SNS_OBJ_S * pstSnsObj[VI_MAX_DEV_NUM];
#ifdef __arm__
CVI_U32 pstSnsObj_padding[VI_MAX_DEV_NUM]; /* padding for keeping same size of this structure */
#endif
ISP_SENSOR_EXP_FUNC_S * pstSensorExpFunc[VI_MAX_DEV_NUM];
#ifdef __arm__
CVI_U32 pstSensorExpFunc_padding[VI_MAX_DEV_NUM]; /* padding for keeping same size of this structure */
#endif
AE_SENSOR_EXP_FUNC_S * pstExpFuncs[VI_MAX_DEV_NUM];
#ifdef __arm__
CVI_U32 pstExpFuncs_padding[VI_MAX_DEV_NUM]; /* padding for keeping same size of this structure */
#endif
SNS_AHD_OBJ_S * pstAhdFuncs[VI_MAX_DEV_NUM];
#ifdef __arm__
CVI_U32 pstAhdFuncs_padding[VI_MAX_DEV_NUM]; /* padding for keeping same size of this structure */
#endif
} SNS_CFG_S;
typedef struct _SENSOR_CFG_S {
SNS_INI_CFG_S sns_ini_cfg;
SNS_CFG_S sns_cfg;
ISP_INIT_ATTR_S pstInitAttr;
} SENSOR_CFG_S;
#pragma pack()
/****************************************************************************
* external variables and functions *
****************************************************************************/
CVI_S32 cvi_sns_getsnswdrattr(CVI_U32 dev_num, SENSOR_CFG_S *sensor_cfg);
CVI_S32 cvi_sns_getsnsobj(CVI_U32 dev_num, SENSOR_CFG_S *sensor_cfg);
CVI_S32 cvi_sns_getispattr(CVI_U32 dev_num, SENSOR_CFG_S *sensor_cfg);
CVI_S32 cvi_sns_getyuvbypass(CVI_U32 dev_num, SENSOR_CFG_S *sensor_cfg);
CVI_S32 cvi_sns_getdevattr(CVI_U32 dev_num, SENSOR_CFG_S *sensor_cfg);
CVI_S32 cvi_sns_getsize(CVI_U32 dev_num, SENSOR_CFG_S *sensor_cfg);
#ifdef __cplusplus
#if __cplusplus
}
#endif
#endif /* End of #ifdef __cplusplus */
#endif /* __SENSOR_CFG_H_ */
@@ -0,0 +1,107 @@
# PSP 主工作流
## 1. 输入和事实基线
开始前从项目和安全只读检查中确认:
- 目标应用项目路径、新项目名称,以及固定根目录 `~/work/project/EMX2.1/AppTeam/Psp/` 下由用户或团队预创建的同名 PSP 项目目录;
- 用户指定的相近板卡 PSP 路径或精确 commit;
- 芯片、硬件 revision、单/双系统、存储介质和目标 BSP build type;
- BSP `doc/bsp_adaptation/handoff/psp_handoff.json`;
- 相对参考 PSP 的已知 GPIO、内存/分区、Media、Sensor 和 AIPlugin 差异,以及由用户更新的 ISP/PQ 状态;
- CV18 项目的 Sensor 规格选型和原理图/GPIO list;枚举表由 skill 按芯片系列从内置基线自动选择,不要求用户另行提供头文件;
- PSP、应用集成、设备验证和批准负责人;
- 禁止修改的目录及可用的 Git/Etunel 上下文。
- 用户指定的 EMX2.1 应用工程绝对路径;工程已有固定 `scripts/product.py`、`scripts/release.py` 和 `output/` 约定时,Skill 自行读取,不再向用户追问命令。
缺失可从仓库发现的事实时先调查,不要求用户重复。参考 PSP、目标差异、负责人决定或跨平台风险属于用户决定,不能推断。同一缺项连续出现时只更新缺项列表,不重复完整问卷,也不在没有新输入时反复生成同一结论。
把 `~` 展开为运行 skill 的当前用户目录,并记录规范化后的绝对 PSP 根路径。目标项目必须是该根目录的普通直接子目录和独立 Git 仓库;保留已有分支、远端和仓库配置。目标目录不存在时停止并请用户或团队创建,禁止由 skill 建目录、运行 `git init` 或替用户配置远端。预创建目录已有业务内容时视为现有 PSP,只走基于 Git 差异的更新流程,禁止再次从参考 PSP 整体导入。
## 2. 参考兼容性
比较参考和目标的芯片系列、板卡形态、单/双系统、存储介质、Sensor 数量/型号、GPIO/Pinmux 结构、媒体能力和 AIPlugin 能力。列出:
- 可直接继承;
- 需要修改;
- 无法确认;
- 明显不兼容。
明显不兼容时默认停止。用户明确接受跨平台参考风险后仍须把每个未验证项留在结果中。
## 3. 受控适配
对于尚无业务内容的预创建项目仓库,按[项目与 Git 规则](project-and-git.md)在同一固定根目录的唯一 staging 中复制参考 PSP,排除参考仓库身份、构建输出、缓存、日志、临时文件、本机生成物和旧的应用侧 `psp.lock.json`。保持参考 PSP 的有效目录、配置、脚本、小型资源和精简文档。最终仅把已验证业务内容导入目标,目标自身 `.git` 不进入 staging、也不被替换。
对于已有业务内容的目标项目,先证明它是固定根目录下的预期独立仓库,记录 HEAD、分支、远端和工作区状态;只修改任务明确授权的文件,并以修改前 commit/worktree 为差异基线。不得为了套用参考项目而清空、覆盖或重新初始化目标。
只修改确认的目标差异。适配后生成两张清单:
1. 修改项:文件、关键字段、旧值、新值、依据和影响;
2. 继承项:GPIO、Sensor、BSP、Media、AIPlugin 等关键配置及确认状态。
扫描旧项目名、旧芯片/板卡、旧 BSP build ID、旧绝对路径、旧 Sensor、旧 SDK/Media/AIPlugin 版本、脚本硬编码及“临时修改/记得修改”等残留。每个命中必须归类为已替换、确认继承或无法判断;无法判断项阻止 accepted。
## 4. 先处理 BSP 阻塞
使用 `validate_bsp_handoff.py` 校验精确 BSP 输入,并把稳定摘要写入 `metadata/bsp.lock.json`;不要修改 BSP handoff。调用 `validate_psp.py` 时必须显式提供目标 PSP 项目名,以及期望的 BSP 项目、芯片、SDK、build type 和单/双系统形态,任一身份缺失或不一致都阻止 candidate。关键 IMG 缺失或 hash 不一致时同样阻止 candidate。
如果 BSP handoff 缺失、镜像 hash 不符或 kernel/rootfs 的 512 KiB 余量检查失败,只报告这一组 BSP 问题和精确失败值,先交 BSP 处理。不要同时要求用户补 Sensor、ISP/PQ 或应用打包信息。能从现有 BSP 产物重新生成的证据应先做只读检查,再只申请一次明确的 BSP 操作确认。
按[配置边界与检查](configuration-boundaries.md)核对 GPIO、内存/分区、Sensor、Media、AIPlugin 及应用覆盖关系。CV18 根据规格书确定枚举名称后调用 `validate_psp.py --expected-sensor sensor=ENUM_NAME`,工具根据芯片自动使用内置枚举表:CV181X 验证配置中的枚举名称,CV184X 验证该名称对应的枚举数值。无需搜索或传入 SDK 头文件;只有已确认的枚举版本差异才显式覆盖。ISP/PQ 仅记录用户负责的状态,不由 Skill 修改。`psp.json` 保持参考格式;新增治理数据写入 sidecar metadata,避免未经协调改变应用打包接口。
## 5. 静态验证和打包准备检查
最低静态检查包括:
- 受控复制和差异白名单;
- PSP 目录、JSON/INI 和 shell 语法;
- 旧项目残留;
- BSP handoff、路径、大小和 hash;
- GPIO、Media、Sensor 枚举映射、AIPlugin、SDK 和模型引用的一致性,以及 ISP/PQ 的用户负责状态;
- 应用与 PSP 的同名配置覆盖清单;
- 未决、警告和批准例外。
在导入目标 PSP 和创建候选提交前,还要读取用户指定应用工程的固定打包入口,并运行:
1. `record_packaging_result.py contract`,根据 `<应用工程>/psp`、`scripts/`、`output/` 和目标芯片生成仓库外的本地契约;
2. `record_packaging_result.py preflight --psp-source <staging>`,提前核对 `EmxResToProduct` 的每个资源来源、重复目标、`data.file`、DATA 组包工具映射、`mkAiData.py` 参数、PSP 分区与 BSP `bsp.lock.json`;
3. 只读检查 `product.py` 依赖的 Media、AIPlugin、公共运行库和驱动配置。空数组、缺目录或脚本只打印错误但仍返回 0 的情况必须列为阻塞;PSP Skill 不自行修改应用脚本。
打包准备检查不执行 `product.py` 或 `release.py`。它失败时先在 staging 修正 PSP;问题属于应用框架、BSP 或公共组件时只给出文件、字段和影响,由对应所有者处理。不要先提交一个已知无法组包的候选版本。
警告分三组输出:阻止继续、需要用户确认、暂不影响当前步骤。ISP/PQ 的 `USER_OWNED/PENDING`、目标机运行路径在当前主机不可见等已知状态,单独列出,不计入静态错误总数。
## 6. 自动提交候选版本
需求和适配方案已经确认后,先输出完整差异、继承清单、静态验证、打包准备检查、风险、commit message,以及下一阶段的精确路径和命令。所有检查通过后直接完成本地导入、`git add` 和候选提交,不再询问是否提交。提交前后核对工作区内容一致,完整 SHA 是唯一 PSP 身份。
Git 身份未配置、工作区有非本任务改动、暂存区包含白名单外文件、验证结果在提交前发生变化或提交失败时停止。不要替用户配置 Git 身份、丢弃改动或改写历史;说明异常后请用户决定。`./release.py 1`、Git 上传、Etunel 发送、tag、设备和发布不在自动范围内。
## 7. 组包和设备/应用验证
候选提交创建后,自动把应用工程中已经登记且仓库身份一致的 `psp` 子模块更新到该完整 commit;不自动新建子模块、不更换 URL,也不提交应用仓库。随后用同一工具以 `--formal` 再做一次打包准备检查,确认 commit 和工作区未变。在 `<应用工程>/scripts` 自动运行 `./product.py`;退出码和日志都没有错误后,自动运行默认增量的 `./release.py 0`。按[组包与设备验证](packaging-and-device-validation.md)证明本轮唯一新增的 `output/<版本>/`,再记录项目规则明确指定的唯一烧录包。
子模块不存在、URL 或仓库身份不一致、父仓库的 `.gitmodules`/`psp` 已有重叠改动、`product.py` 报错、`release.py 0` 报错或产物检查失败时停止并请用户决定。需要 clean rebuild 时也停止,展示原因和 `./release.py 1` 精确命令,取得确认后才执行。
达到 packaged 后可以经 Etunel 提供给应用层,人工设备验证与应用集成并行:
```text
candidate -> packaged -> device_verified
-> app_integrated
同一 commit 两项均通过 -> accepted
```
配置、脚本、小型资源或依赖变化使受影响证据失效;纯文档变化经差异证明和负责人确认后可以复用技术证据。
## 8. 交接和返工
按 [Etunel 交接](etunel-handoff.md)把 packaged candidate 提供给 Hub。应用问题必须携带 PSP commit、应用 commit、BSP build、复现步骤、期望/实际、日志和阻塞影响,经 Hub 创建新的底层任务。
职责内问题形成新 candidate commit;跨 BSP、应用、硬件或公共组件的问题只提交证据和归属建议,由 Hub 路由。不得 force-push 已供打包、设备或应用验证的 commit。
## 9. 完成状态
输出真实阶段,并同时给出中文解释:候选版本(`candidate`)、已组包(`packaged`)、设备验证通过(`device_verified`)、应用集成通过(`app_integrated`)、已验收(`accepted`)、部分完成(`partial`)或已阻塞(`blocked`)。完整成果引用 PSP 仓库/commit、BSP lock、静态检查、烧录包绝对路径/hash、人工设备反馈、应用集成、Etunel 交接、未执行项和剩余风险。
accepted 仅表示 PSP 可作为正式应用集成基线,不代表整机测试、所有硬件行为、媒体/算法效果或产品发布已经通过。
@@ -0,0 +1,838 @@
#!/usr/bin/env python3
"""Safely adapt a reference PSP into a pre-created project repository."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
EXCLUDED_DIR_NAMES = {
".git",
".cache",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"__pycache__",
"build",
"logs",
"out",
"output",
"temp",
"tmp",
}
EXCLUDED_FILE_NAMES = {".git", ".DS_Store", "psp.lock.json"}
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"}
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
DEFAULT_PSP_ROOT = Path.home() / "work/project/EMX2.1/AppTeam/Psp"
class GuardError(RuntimeError):
"""A safety or validation gate failed."""
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_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 load_json(path: Path) -> dict[str, object]:
payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise GuardError(f"JSON 根节点必须是对象: {path}")
return payload
def ordinary_directory(path: Path, field: str, must_exist: bool = True) -> Path:
path = path.expanduser().absolute()
if must_exist:
if not path.is_dir() or path.is_symlink():
raise GuardError(f"{field} 必须是存在的普通目录: {path}")
return path.resolve()
if path.exists() or path.is_symlink():
raise GuardError(f"{field} 已存在,禁止覆盖: {path}")
if not path.parent.is_dir() or path.parent.is_symlink():
raise GuardError(f"{field} 的父目录不存在或不是普通目录: {path.parent}")
return path
def psp_root_directory(path: Path) -> Path:
return ordinary_directory(path, "psp-root")
def project_directory(path: Path, root: Path, field: str) -> Path:
project = ordinary_directory(path, field)
if not NAME_RE.fullmatch(project.name):
raise GuardError(f"{field} 目录名非法: {project.name!r}")
if project.parent != root:
raise GuardError(f"{field} 必须是 PSP 固定根目录的直接子目录: {root}")
return project
def target_payload_entries(path: Path) -> list[str]:
"""Return every top-level entry other than the repository metadata."""
return sorted(entry.name for entry in path.iterdir() if entry.name != ".git")
def is_excluded(path: Path, is_dir: bool) -> bool:
if is_dir:
return path.name in EXCLUDED_DIR_NAMES
return path.name in EXCLUDED_FILE_NAMES or path.suffix.lower() in EXCLUDED_FILE_SUFFIXES
def copy_ignore(directory: str, names: list[str]) -> set[str]:
base = Path(directory)
return {name for name in names if is_excluded(base / name, (base / name).is_dir())}
def excluded_paths(root: Path) -> list[str]:
hits: list[str] = []
for current, dirs, files in os.walk(root, followlinks=False):
base = Path(current)
kept: list[str] = []
for name in sorted(dirs):
path = base / name
if is_excluded(path, True):
hits.append(path.relative_to(root).as_posix() + "/")
elif path.is_symlink():
kept.append(name)
else:
kept.append(name)
dirs[:] = kept
for name in sorted(files):
path = base / name
if is_excluded(path, False):
hits.append(path.relative_to(root).as_posix())
return sorted(hits)
def scan_symlinks(root: Path) -> list[str]:
links: list[str] = []
for current, dirs, files in os.walk(root, followlinks=False):
base = Path(current)
kept: list[str] = []
for name in sorted(dirs):
path = base / name
if is_excluded(path, True):
continue
if path.is_symlink():
links.append(path.relative_to(root).as_posix())
else:
kept.append(name)
dirs[:] = kept
for name in sorted(files):
path = base / name
if path.is_symlink() and not is_excluded(path, False):
links.append(path.relative_to(root).as_posix())
return sorted(links)
def scan_credentials(root: Path) -> list[str]:
hits: list[str] = []
for current, dirs, files in os.walk(root, followlinks=False):
base = Path(current)
dirs[:] = [
name
for name in dirs
if not is_excluded(base / name, True) and not (base / name).is_symlink()
]
for name in files:
path = base / name
if path.is_symlink() or is_excluded(path, False):
continue
if name in CREDENTIAL_FILE_NAMES or path.suffix.lower() in CREDENTIAL_FILE_SUFFIXES:
hits.append(path.relative_to(root).as_posix())
return sorted(hits)
def inventory(root: Path) -> dict[str, dict[str, object]]:
entries: dict[str, dict[str, object]] = {}
for current, dirs, files in os.walk(root, followlinks=False):
base = Path(current)
kept: list[str] = []
for name in sorted(dirs):
path = base / name
if is_excluded(path, True):
continue
if path.is_symlink():
rel = path.relative_to(root).as_posix()
entries[rel] = {"kind": "symlink", "target": os.readlink(path)}
else:
kept.append(name)
dirs[:] = kept
for name in sorted(files):
path = base / name
if is_excluded(path, False):
continue
rel = path.relative_to(root).as_posix()
if path.is_symlink():
entries[rel] = {"kind": "symlink", "target": os.readlink(path)}
elif path.is_file():
item = path.stat()
entries[rel] = {
"kind": "file",
"mode": stat.S_IMODE(item.st_mode),
"size": item.st_size,
"sha256": sha256_file(path),
}
return dict(sorted(entries.items()))
def run_git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def excluded_git_status_line(line: str) -> bool:
value = line[3:] if len(line) > 3 else line
if " -> " in value:
value = value.split(" -> ", 1)[1]
value = value.strip().strip('"')
pure = PurePosixPath(value)
return any(part in EXCLUDED_DIR_NAMES for part in pure.parts) or (
pure.name in EXCLUDED_FILE_NAMES
or PurePosixPath(pure.name).suffix.lower() in EXCLUDED_FILE_SUFFIXES
)
def git_state(path: Path) -> dict[str, object]:
top = run_git(path, "rev-parse", "--show-toplevel")
if top.returncode != 0:
return {"tracked": False, "repository_root": None, "commit": None, "dirty": None}
status = run_git(path, "status", "--porcelain", "--untracked-files=all", "--", ".")
if status.returncode != 0:
raise GuardError(f"无法读取 PSP Git 状态: {status.stderr}")
commit = run_git(path, "rev-parse", "--verify", "HEAD")
branch = run_git(path, "symbolic-ref", "--quiet", "--short", "HEAD")
git_dir = run_git(path, "rev-parse", "--absolute-git-dir")
if git_dir.returncode != 0:
raise GuardError(f"无法读取 PSP Git 元数据目录: {git_dir.stderr}")
all_lines = [line for line in status.stdout.splitlines() if line.strip()]
lines = [line for line in all_lines if not excluded_git_status_line(line)]
return {
"tracked": True,
"repository_root": str(Path(top.stdout.strip()).resolve()),
"git_dir": str(Path(git_dir.stdout.strip()).resolve()),
"commit": commit.stdout.strip() if commit.returncode == 0 else None,
"unborn": commit.returncode != 0,
"branch": branch.stdout.strip() if branch.returncode == 0 else None,
"dirty": bool(lines),
"status": lines,
"ignored_generated_status": [
line for line in all_lines if excluded_git_status_line(line)
],
}
def repository_identity(path: Path, state: dict[str, object] | None = None) -> dict[str, object]:
state = git_state(path) if state is None else state
if state.get("tracked") is not True:
raise GuardError(f"目标 PSP 必须是预先创建的独立 Git 仓库: {path}")
expected_git_dir = path / ".git"
git_dir = Path(str(state.get("git_dir"))).resolve()
if state.get("repository_root") != str(path) or git_dir != expected_git_dir:
raise GuardError(f"目标 PSP 必须以自身目录和 .git 作为独立 Git 仓库: {path}")
if not expected_git_dir.is_dir() or expected_git_dir.is_symlink():
raise GuardError(f"目标 PSP 的 .git 必须是普通目录: {expected_git_dir}")
config = expected_git_dir / "config"
head = expected_git_dir / "HEAD"
if not config.is_file() or config.is_symlink() or not head.is_file() or head.is_symlink():
raise GuardError(f"目标 PSP Git config/HEAD 缺失或不是普通文件: {path}")
remotes: dict[str, list[dict[str, str]]] = {}
remote_names = run_git(path, "remote")
if remote_names.returncode != 0:
raise GuardError(f"无法读取目标 PSP Git 远端: {remote_names.stderr}")
for name in sorted(line for line in remote_names.stdout.splitlines() if line):
urls = run_git(path, "remote", "get-url", "--all", name)
if urls.returncode != 0:
raise GuardError(f"无法读取目标 PSP Git 远端 {name}: {urls.stderr}")
remotes[name] = [
{"sha256": hashlib.sha256(url.encode("utf-8")).hexdigest()}
for url in urls.stdout.splitlines()
]
return {
"repository_root": str(path),
"git_dir": str(expected_git_dir),
"commit": state.get("commit"),
"unborn": state.get("unborn"),
"branch": state.get("branch"),
"config_sha256": sha256_file(config),
"head_sha256": sha256_file(head),
"remotes": remotes,
}
def copy_committed_head(reference: Path, staging: Path, state: dict[str, object]) -> None:
repository_value = state.get("repository_root")
commit = state.get("commit")
if not isinstance(repository_value, str) or not isinstance(commit, str):
raise GuardError("参考 PSP 缺少可追踪的 Git HEAD")
repository = Path(repository_value)
try:
prefix = reference.relative_to(repository)
except ValueError as exc:
raise GuardError("参考 PSP 不在其 Git repository_root 内") from exc
command = ["git", "-C", str(repository), "ls-tree", "-r", "-z", commit]
if prefix.parts:
command.extend(["--", prefix.as_posix()])
listing = subprocess.run(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
)
if listing.returncode != 0:
raise GuardError(f"无法读取参考 PSP HEAD 清单: {listing.stderr.decode(errors='replace')}")
staging.mkdir()
entries = [item for item in listing.stdout.split(b"\0") if item]
for raw in entries:
try:
metadata, raw_path = raw.split(b"\t", 1)
mode, kind, _object_id = metadata.decode("ascii").split(" ", 2)
repository_path = PurePosixPath(raw_path.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise GuardError("参考 PSP Git tree 含无法解析的条目") from exc
if kind != "blob" or mode not in {"100644", "100755"}:
raise GuardError(
f"参考 PSP HEAD 含不支持的 symlink/submodule/特殊条目: {repository_path}"
)
if prefix.parts:
try:
relative = repository_path.relative_to(PurePosixPath(prefix.as_posix()))
except ValueError as exc:
raise GuardError(f"Git tree 条目逃逸参考 PSP: {repository_path}") from exc
else:
relative = repository_path
relative_path = Path(relative.as_posix())
if any(part in EXCLUDED_DIR_NAMES for part in relative.parts) or is_excluded(
relative_path, False
):
continue
destination = staging / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
content = subprocess.run(
["git", "-C", str(repository), "show", f"{commit}:{repository_path.as_posix()}"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if content.returncode != 0:
raise GuardError(
f"无法读取参考 PSP HEAD 文件 {repository_path}: "
f"{content.stderr.decode(errors='replace')}"
)
destination.write_bytes(content.stdout)
destination.chmod(0o755 if mode == "100755" else 0o644)
def reference_matches_manifest(reference: Path, manifest: dict[str, object]) -> bool:
mode = manifest.get("reference_baseline")
if mode == "committed-head":
expected = manifest.get("reference_git")
if not isinstance(expected, dict):
return False
current = git_state(reference)
return current.get("commit") == expected.get("commit")
source = manifest.get("source_inventory")
return isinstance(source, dict) and inventory(reference) == source
def normalize_paths(values: list[str], field: str) -> set[str]:
normalized: set[str] = set()
for value in values:
pure = PurePosixPath(value)
if pure.is_absolute() or ".." in pure.parts or value in {"", "."}:
raise GuardError(f"{field} 必须是目录内精确相对路径: {value!r}")
if any(character in value for character in "*?[]"):
raise GuardError(f"{field} 不允许通配符: {value!r}")
normalized.add(pure.as_posix())
return normalized
def compare(
source: dict[str, dict[str, object]], candidate: dict[str, dict[str, object]]
) -> dict[str, list[str]]:
source_paths = set(source)
candidate_paths = set(candidate)
return {
"added": sorted(candidate_paths - source_paths),
"removed": sorted(source_paths - candidate_paths),
"modified": sorted(
path for path in source_paths & candidate_paths if source[path] != candidate[path]
),
}
def read_text_for_scan(path: Path) -> str | None:
if path.stat().st_size > 4 * 1024 * 1024:
return None
data = path.read_bytes()
if b"\x00" in data:
return None
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return None
def scan_residues(root: Path, tokens: list[str], allowed_paths: set[str]) -> list[dict[str, object]]:
hits: list[dict[str, object]] = []
lowered = [(token, token.casefold()) for token in tokens if token]
if not lowered:
return hits
for relative, item in inventory(root).items():
if item.get("kind") != "file" or relative in allowed_paths:
continue
path = root / relative
text = read_text_for_scan(path)
if text is None:
continue
folded = text.casefold()
for token, token_folded in lowered:
count = folded.count(token_folded)
if count:
hits.append({"path": relative, "token": token, "count": count})
return hits
def inspect_project(target: Path, psp_root: Path = DEFAULT_PSP_ROOT) -> dict[str, object]:
psp_root = psp_root_directory(psp_root)
target = project_directory(target, psp_root, "target")
state = git_state(target)
identity = repository_identity(target, state)
entries = target_payload_entries(target)
return {
"passed": True,
"checked_at": now_utc(),
"psp_root": str(psp_root),
"target": str(target),
"project": target.name,
"mode": "existing-project" if entries else "precreated-empty-repository",
"payload_entries": entries,
"git": state,
"repository_identity": identity,
}
def prepare(
reference: Path,
target: Path,
manifest_path: Path,
old_tokens: list[str],
include_dirty: bool,
psp_root: Path = DEFAULT_PSP_ROOT,
) -> dict[str, object]:
psp_root = psp_root_directory(psp_root)
reference = ordinary_directory(reference, "reference")
target = project_directory(target, psp_root, "target")
if reference == target or reference in target.parents or target in reference.parents:
raise GuardError("reference、target 不能互相包含")
target_git = git_state(target)
target_repository = repository_identity(target, target_git)
payload_entries = target_payload_entries(target)
if payload_entries:
raise GuardError(
"目标 PSP 已有业务内容,禁止按新项目参考复制流程覆盖;"
f"请转入现有项目更新流程: {', '.join(payload_entries)}"
)
if target_git.get("dirty") is True:
raise GuardError("目标 PSP 工作区不干净,禁止导入参考内容")
manifest_path = manifest_path.expanduser().absolute()
if manifest_path.exists() or manifest_path.is_symlink():
raise GuardError(f"manifest 已存在,禁止覆盖: {manifest_path}")
links = scan_symlinks(reference)
if links:
raise GuardError("参考 PSP 包含软链接,未复制: " + ", ".join(links))
credentials = scan_credentials(reference)
if credentials:
raise GuardError("参考 PSP 包含疑似凭据文件,未复制: " + ", ".join(credentials))
source_git = git_state(reference)
staging = target.parent / f".{target.name}.psp-staging-{uuid.uuid4().hex[:12]}"
if staging.exists() or staging.is_symlink():
raise GuardError(f"随机 staging 已存在: {staging}")
if source_git.get("dirty") is True and not include_dirty:
copy_committed_head(reference, staging, source_git)
baseline_mode = "committed-head"
warnings = ["参考 PSP 工作区有未提交内容;staging 使用已提交 HEAD,不包含这些修改"]
else:
source_before_copy = inventory(reference)
shutil.copytree(reference, staging, copy_function=shutil.copy2, ignore=copy_ignore)
if source_before_copy != inventory(reference):
raise GuardError(f"复制期间参考 PSP 发生变化;staging 保留: {staging}")
baseline_mode = "worktree"
warnings = []
staging_inventory = inventory(staging)
if baseline_mode == "worktree" and staging_inventory != source_before_copy:
raise GuardError(f"staging 与过滤后的参考清单不一致;staging 保留: {staging}")
source_before = staging_inventory
payload: dict[str, object] = {
"schema_version": 2,
"created_at": now_utc(),
"psp_root": str(psp_root),
"reference": str(reference),
"target": str(target),
"staging": str(staging),
"target_mode": "precreated-empty-repository",
"target_repository_before_import": target_repository,
"reference_git": source_git,
"reference_baseline": baseline_mode,
"include_dirty_reference": include_dirty,
"old_tokens": sorted(set(token for token in old_tokens if token)),
"excluded_paths": excluded_paths(reference),
"source_inventory": source_before,
"staging_inventory_before_edits": staging_inventory,
"warnings": warnings,
}
write_json_atomic(manifest_path, payload)
return {
"passed": True,
"reference": str(reference),
"target": str(target),
"staging": str(staging),
"psp_root": str(psp_root),
"target_mode": payload["target_mode"],
"target_repository": target_repository,
"manifest": str(manifest_path),
"reference_git": source_git,
"excluded_paths": payload["excluded_paths"],
"warnings": warnings,
}
def verify(
manifest_path: Path,
allow_paths: list[str],
allowed_residues: list[str],
report_path: Path | None,
) -> dict[str, object]:
manifest_path = manifest_path.expanduser().resolve(strict=True)
manifest = load_json(manifest_path)
reference = ordinary_directory(Path(str(manifest.get("reference"))), "reference")
staging = ordinary_directory(Path(str(manifest.get("staging"))), "staging")
source = manifest.get("source_inventory")
if not isinstance(source, dict):
raise GuardError("manifest 缺少 source_inventory")
if not reference_matches_manifest(reference, manifest):
raise GuardError("prepare 后参考 PSP 发生变化;必须重新 prepare")
links = scan_symlinks(staging)
credentials = scan_credentials(staging)
unexpected_excluded = excluded_paths(staging)
allowed = normalize_paths(allow_paths, "allow-path")
residue_allow = normalize_paths(allowed_residues, "allow-residue")
candidate_inventory = inventory(staging)
changes = compare(source, candidate_inventory)
violations = sorted(
path for group in changes.values() for path in group if path not in allowed
)
old_tokens = manifest.get("old_tokens")
if not isinstance(old_tokens, list) or not all(isinstance(item, str) for item in old_tokens):
raise GuardError("manifest.old_tokens 非法")
residues = scan_residues(staging, old_tokens, residue_allow)
errors: list[str] = []
if links:
errors.append("staging 包含软链接")
if credentials:
errors.append("staging 包含疑似凭据")
if unexpected_excluded:
errors.append("staging 包含缓存、构建输出、日志或临时文件")
if violations:
errors.append("存在未列入精确差异白名单的修改")
if residues:
errors.append("存在未确认的参考项目残留")
report: dict[str, object] = {
"schema_version": 1,
"checked_at": now_utc(),
"manifest": str(manifest_path),
"reference": str(reference),
"staging": str(staging),
"target": manifest.get("target"),
"allowed_paths": sorted(allowed),
"allowed_residue_paths": sorted(residue_allow),
"changes": changes,
"violations": violations,
"residues": residues,
"symlinks": links,
"credential_files": credentials,
"unexpected_excluded_paths": unexpected_excluded,
"verified_inventory": candidate_inventory,
"errors": errors,
"passed": not errors,
}
if report_path is not None:
write_json_atomic(report_path, report)
return report
def populate_precreated_repository(staging: Path, target: Path) -> None:
children = sorted(staging.iterdir(), key=lambda path: path.name)
collisions = [child.name for child in children if (target / child.name).exists()]
collisions.extend(
child.name for child in children if (target / child.name).is_symlink()
)
if collisions:
raise GuardError(
"目标 PSP 与 staging 存在路径冲突: "
+ ", ".join(sorted(set(collisions)))
)
created: list[Path] = []
try:
for source in children:
destination = target / source.name
if source.is_dir():
shutil.copytree(source, destination, copy_function=shutil.copy2)
else:
shutil.copy2(source, destination)
created.append(destination)
except OSError:
for path in reversed(created):
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
elif path.exists() or path.is_symlink():
path.unlink()
raise
def finalize(manifest_path: Path, verify_report_path: Path) -> dict[str, object]:
manifest_path = manifest_path.expanduser().resolve(strict=True)
verify_report_path = verify_report_path.expanduser().resolve(strict=True)
manifest = load_json(manifest_path)
report = load_json(verify_report_path)
if report.get("passed") is not True:
raise GuardError("verify 报告未通过,禁止 finalize")
if report.get("manifest") != str(manifest_path):
raise GuardError("verify 报告不属于当前 manifest")
staging = ordinary_directory(Path(str(manifest.get("staging"))), "staging")
root_value = manifest.get("psp_root")
if not isinstance(root_value, str):
raise GuardError("manifest 缺少 psp_root")
psp_root = psp_root_directory(Path(root_value))
target = project_directory(Path(str(manifest.get("target"))), psp_root, "target")
expected_repository = manifest.get("target_repository_before_import")
if not isinstance(expected_repository, dict):
raise GuardError("manifest 缺少 target_repository_before_import")
current_repository = repository_identity(target)
if current_repository != expected_repository:
raise GuardError("prepare 后目标 PSP 的 Git 分支、HEAD、远端或配置已变化")
payload_entries = target_payload_entries(target)
if payload_entries:
raise GuardError(
"prepare 后目标 PSP 出现业务内容,禁止覆盖: " + ", ".join(payload_entries)
)
verified_inventory = report.get("verified_inventory")
if not isinstance(verified_inventory, dict) or inventory(staging) != verified_inventory:
raise GuardError("verify 后 staging 已变化;必须重新 verify")
populate_precreated_repository(staging, target)
if repository_identity(target) != expected_repository:
raise GuardError("导入内容时目标 PSP Git 元数据发生变化")
shutil.rmtree(staging)
return {
"passed": True,
"finalized_at": now_utc(),
"target": str(target),
"target_mode": manifest.get("target_mode"),
"repository_preserved": True,
"target_repository": expected_repository,
"manifest": str(manifest_path),
"verify_report": str(verify_report_path),
}
def audit_final(
manifest_path: Path,
allow_paths: list[str],
allowed_residues: list[str],
report_path: Path | None,
) -> dict[str, object]:
manifest_path = manifest_path.expanduser().resolve(strict=True)
manifest = load_json(manifest_path)
root_value = manifest.get("psp_root")
if not isinstance(root_value, str):
raise GuardError("manifest 缺少 psp_root")
psp_root = psp_root_directory(Path(root_value))
target = project_directory(Path(str(manifest.get("target"))), psp_root, "target")
reference = ordinary_directory(Path(str(manifest.get("reference"))), "reference")
source = manifest.get("source_inventory")
if not isinstance(source, dict):
raise GuardError("manifest 缺少 source_inventory")
allowed = normalize_paths(allow_paths, "allow-path")
residue_allow = normalize_paths(allowed_residues, "allow-residue")
target_inventory = inventory(target)
changes = compare(source, target_inventory)
violations = sorted(
path for group in changes.values() for path in group if path not in allowed
)
old_tokens = manifest.get("old_tokens")
if not isinstance(old_tokens, list) or not all(isinstance(item, str) for item in old_tokens):
raise GuardError("manifest.old_tokens 非法")
residues = scan_residues(target, old_tokens, residue_allow)
links = scan_symlinks(target)
credentials = scan_credentials(target)
unexpected_excluded = [path for path in excluded_paths(target) if path != ".git/"]
expected_repository = manifest.get("target_repository_before_import")
repository_unchanged = isinstance(expected_repository, dict) and (
repository_identity(target) == expected_repository
)
errors: list[str] = []
if violations:
errors.append("正式 PSP 存在未列入精确差异白名单的修改")
if residues:
errors.append("正式 PSP 存在未确认的参考项目残留")
if links:
errors.append("正式 PSP 包含软链接")
if credentials:
errors.append("正式 PSP 包含疑似凭据")
if unexpected_excluded:
errors.append("正式 PSP 包含缓存、构建输出、日志或临时文件")
if not repository_unchanged:
errors.append("正式 PSP 的 Git 分支、HEAD、远端或配置偏离 prepare 基线")
report: dict[str, object] = {
"schema_version": 1,
"checked_at": now_utc(),
"manifest": str(manifest_path),
"reference": str(reference),
"reference_drifted_since_prepare": not reference_matches_manifest(reference, manifest),
"target": str(target),
"allowed_paths": sorted(allowed),
"allowed_residue_paths": sorted(residue_allow),
"changes": changes,
"violations": violations,
"residues": residues,
"symlinks": links,
"credential_files": credentials,
"unexpected_excluded_paths": unexpected_excluded,
"repository_unchanged": repository_unchanged,
"target_repository": expected_repository,
"errors": errors,
"passed": not errors,
"audited_inventory": target_inventory,
}
if report_path is not None:
write_json_atomic(report_path, report)
return report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
inspect_parser = subparsers.add_parser(
"inspect", help="只读校验固定根目录下的预创建项目仓库"
)
inspect_parser.add_argument(
"--psp-root",
type=Path,
default=DEFAULT_PSP_ROOT,
help=f"PSP 固定根目录(默认: {DEFAULT_PSP_ROOT})",
)
inspect_parser.add_argument("--target", type=Path, required=True)
prepare_parser = subparsers.add_parser("prepare", help="复制参考 PSP 到唯一 staging")
prepare_parser.add_argument(
"--psp-root",
type=Path,
default=DEFAULT_PSP_ROOT,
help=f"PSP 固定根目录(默认: {DEFAULT_PSP_ROOT})",
)
prepare_parser.add_argument("--reference", type=Path, required=True)
prepare_parser.add_argument("--target", type=Path, required=True)
prepare_parser.add_argument("--manifest", type=Path, required=True)
prepare_parser.add_argument("--old-token", action="append", default=[])
prepare_parser.add_argument("--include-dirty", action="store_true")
verify_parser = subparsers.add_parser("verify", help="审计 staging 的精确差异与残留")
verify_parser.add_argument("--manifest", type=Path, required=True)
verify_parser.add_argument("--allow-path", action="append", default=[])
verify_parser.add_argument("--allow-residue", action="append", default=[])
verify_parser.add_argument("--report", type=Path)
finalize_parser = subparsers.add_parser(
"finalize", help="把已验证 staging 导入预创建的空项目仓库"
)
finalize_parser.add_argument("--manifest", type=Path, required=True)
finalize_parser.add_argument("--verify-report", type=Path, required=True)
audit_parser = subparsers.add_parser("audit-final", help="按 prepare 基线复核正式 PSP")
audit_parser.add_argument("--manifest", type=Path, required=True)
audit_parser.add_argument("--allow-path", action="append", default=[])
audit_parser.add_argument("--allow-residue", action="append", default=[])
audit_parser.add_argument("--report", type=Path)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "inspect":
result = inspect_project(args.target, args.psp_root)
elif args.command == "prepare":
result = prepare(
args.reference,
args.target,
args.manifest,
args.old_token,
args.include_dirty,
args.psp_root,
)
elif args.command == "verify":
result = verify(args.manifest, args.allow_path, args.allow_residue, args.report)
elif args.command == "finalize":
result = finalize(args.manifest, args.verify_report)
elif args.command == "audit-final":
result = audit_final(
args.manifest, args.allow_path, args.allow_residue, args.report
)
else:
raise GuardError(f"未知命令: {args.command}")
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("passed") is True else 2
except (GuardError, OSError, json.JSONDecodeError) as exc:
print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2))
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
"""检查 EMX2.1 PSP 固定组包流程并记录精确产物。"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from pathlib import PurePosixPath
from typing import Any
PRODUCT_COMMAND = "./product.py"
RELEASE_COMMANDS = {"./release.py 0", "./release.py 1"}
MAX_LOG_SIZE = 64 * 1024 * 1024
ANSI_RED_RE = re.compile(r"\x1b\[(?:1;)?91m")
ANSI_YELLOW_RE = re.compile(r"\x1b\[(?:1;)?93m")
FAILURE_RE = re.compile(
r"(?:^|\s)(?:Traceback|FATAL|FAILED|ERROR:|Error:|No such file|cannot stat)(?:\s|$)",
re.IGNORECASE,
)
WARNING_RE = re.compile(r"(?:^|\s)(?:WARNING:|Warning:|WARN:)(?:\s|$)", re.IGNORECASE)
class PackagingError(RuntimeError):
"""组包证据缺失、过期或不一致。"""
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_json_new(path: Path, payload: dict[str, object]) -> None:
path = path.expanduser().resolve(strict=False)
if path.exists() or path.is_symlink():
raise PackagingError(f"证据文件已存在,禁止覆盖: {path}")
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 load_json(path: Path) -> dict[str, Any]:
payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise PackagingError(f"JSON 根节点必须是对象: {path}")
return payload
def exact_absolute_path(value: object, field: str) -> Path:
if not isinstance(value, str) or not value.strip():
raise PackagingError(f"打包契约 {field} 必须是非空绝对路径")
path = Path(value).expanduser()
if not path.is_absolute():
raise PackagingError(f"打包契约 {field} 必须是绝对路径: {value!r}")
return path.resolve(strict=False)
def exact_directory(path: Path, field: str) -> Path:
expanded = path.expanduser()
if expanded.is_symlink():
raise PackagingError(f"{field} 不允许使用软链接: {expanded}")
resolved = expanded.resolve(strict=True)
if not resolved.is_dir():
raise PackagingError(f"{field} 必须是存在的普通目录: {resolved}")
return resolved
def exact_file(path: Path, field: str) -> Path:
expanded = path.expanduser()
if expanded.is_symlink():
raise PackagingError(f"{field} 不允许使用软链接: {expanded}")
resolved = expanded.resolve(strict=True)
if not resolved.is_file():
raise PackagingError(f"{field} 必须是存在的普通文件: {resolved}")
return resolved
def require_string_list(payload: dict[str, Any], field: str) -> list[str]:
value = payload.get(field)
if (
not isinstance(value, list)
or not value
or not all(isinstance(item, str) and item.strip() for item in value)
):
raise PackagingError(f"打包契约 {field} 必须是非空字符串数组")
return list(value)
def load_contract(path: Path) -> tuple[Path, dict[str, Any], str]:
path = path.expanduser().resolve(strict=True)
if not path.is_file() or path.is_symlink():
raise PackagingError(f"打包契约必须是普通文件: {path}")
payload = load_json(path)
if payload.get("schema_version") != 2:
raise PackagingError("打包契约 schema_version 必须为 2")
if payload.get("contract_type") != "emx21-project-packaging":
raise PackagingError(
"打包契约 contract_type 必须为 emx21-project-packaging"
)
if not isinstance(payload.get("contract_id"), str) or not payload["contract_id"].strip():
raise PackagingError("打包契约 contract_id 不能为空")
require_string_list(payload, "chips")
if payload.get("product_command") != PRODUCT_COMMAND:
raise PackagingError(f"product_command 必须精确为 {PRODUCT_COMMAND}")
release_commands = require_string_list(payload, "release_commands")
if set(release_commands) != RELEASE_COMMANDS:
raise PackagingError("release_commands 必须精确包含 ./release.py 0 和 ./release.py 1")
exit_codes = payload.get("success_exit_codes")
if (
not isinstance(exit_codes, list)
or not exit_codes
or not all(isinstance(item, int) and not isinstance(item, bool) for item in exit_codes)
):
raise PackagingError("打包契约 success_exit_codes 必须是非空整数数组")
paths = {
field: exact_absolute_path(payload.get(field), field)
for field in ("project_root", "psp_path", "working_directory", "output_root")
}
if paths["working_directory"] != paths["project_root"] / "scripts":
raise PackagingError("working_directory 必须精确为 <project_root>/scripts")
if paths["psp_path"] != paths["project_root"] / "psp":
raise PackagingError("psp_path 必须精确为 <project_root>/psp")
if paths["output_root"] != paths["project_root"] / "output":
raise PackagingError("output_root 必须精确为 <project_root>/output")
return path, payload, sha256_file(path)
def run_git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def git_state(path: Path) -> dict[str, object]:
path = exact_directory(path, "Git 路径")
top = run_git(path, "rev-parse", "--show-toplevel")
if top.returncode != 0:
return {"tracked": False, "path": str(path), "commit": None, "dirty": None}
commit = run_git(path, "rev-parse", "HEAD")
status = run_git(path, "status", "--porcelain", "--untracked-files=all", "--", ".")
if commit.returncode != 0 or status.returncode != 0:
raise PackagingError(f"无法读取 Git 状态: {commit.stderr or status.stderr}")
lines = [line for line in status.stdout.splitlines() if line.strip()]
return {
"tracked": True,
"path": str(path),
"repository_root": str(Path(top.stdout.strip()).resolve()),
"commit": commit.stdout.strip(),
"dirty": bool(lines),
"status": lines,
}
def version_state(path: Path) -> dict[str, object]:
if not path.is_dir() or path.is_symlink():
raise PackagingError(f"output 的直接子项必须是普通版本目录: {path}")
files: list[dict[str, object]] = []
tree = hashlib.sha256()
for item in sorted(path.rglob("*")):
if item.is_symlink():
raise PackagingError(f"版本输出中不允许软链接: {item}")
if not item.is_file():
continue
relative = item.relative_to(path).as_posix()
digest = sha256_file(item)
size = item.stat().st_size
files.append(
{"relative_path": relative, "absolute_path": str(item), "size": size, "sha256": digest}
)
tree.update(relative.encode("utf-8"))
tree.update(b"\0")
tree.update(str(size).encode("ascii"))
tree.update(b"\0")
tree.update(digest.encode("ascii"))
tree.update(b"\n")
return {
"name": path.name,
"path": str(path),
"tree_sha256": tree.hexdigest(),
"file_count": len(files),
"total_size": sum(int(item["size"]) for item in files),
"files": files,
}
def output_state(output_root: Path) -> dict[str, object]:
output_root = output_root.expanduser().resolve(strict=False)
if not output_root.exists():
return {"path": str(output_root), "exists": False, "versions": []}
if not output_root.is_dir() or output_root.is_symlink():
raise PackagingError(f"output_root 必须是普通目录: {output_root}")
versions = [version_state(item) for item in sorted(output_root.iterdir())]
return {"path": str(output_root), "exists": True, "versions": versions}
def contract_evidence(path: Path, payload: dict[str, Any], digest: str) -> dict[str, object]:
return {"path": str(path), "sha256": digest, "contract_id": payload["contract_id"]}
def create_contract(project_root: Path, chip: str, destination: Path) -> dict[str, object]:
"""根据团队固定目录和命令生成本地机器契约。"""
project = exact_directory(project_root, "应用工程")
psp = (project / "psp").resolve(strict=False)
if psp.exists() or psp.is_symlink():
psp = exact_directory(psp, "应用工程 psp 子仓库")
scripts = exact_directory(project / "scripts", "应用工程 scripts 目录")
exact_file(scripts / "product.py", "product.py")
exact_file(scripts / "release.py", "release.py")
if not chip.strip():
raise PackagingError("芯片名称不能为空")
payload: dict[str, object] = {
"schema_version": 2,
"contract_type": "emx21-project-packaging",
"contract_id": f"emx21:{project.name}:{chip.strip()}",
"chips": [chip.strip()],
"project_root": str(project),
"psp_path": str(psp),
"working_directory": str(scripts),
"output_root": str((project / "output").resolve(strict=False)),
"product_command": PRODUCT_COMMAND,
"release_commands": sorted(RELEASE_COMMANDS),
"success_exit_codes": [0],
}
write_json_new(destination, payload)
return payload
SIZE_RE = re.compile(r"^(\d+)([KMG])$", re.IGNORECASE)
SIZE_MULTIPLIERS = {"K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024}
UBI_LEB_SIZE = 126976
def parse_size(value: object, field: str) -> int | None:
if value == "-":
return None
if not isinstance(value, str):
raise PackagingError(f"{field} 必须使用 K、M、G 或 - 表示")
match = SIZE_RE.fullmatch(value.strip())
if match is None:
raise PackagingError(f"{field} 格式非法: {value!r}")
return int(match.group(1)) * SIZE_MULTIPLIERS[match.group(2).upper()]
def safe_destination(value: object, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise PackagingError(f"{field} 不能为空")
path = PurePosixPath(value)
if path.is_absolute() or ".." in path.parts:
raise PackagingError(f"{field} 必须是工程内安全相对路径: {value!r}")
return path.as_posix()
def packaging_preflight(
contract_path: Path,
destination: Path,
psp_source: Path | None = None,
psp_commit: str | None = None,
formal: bool = False,
) -> dict[str, object]:
"""在 product.py 前检查 PSP 资源、分区和 BSP 证据是否一致。"""
errors: list[str] = []
warnings: list[str] = []
contract_path, contract, digest = load_contract(contract_path)
project = exact_directory(exact_absolute_path(contract["project_root"], "project_root"), "应用工程")
scripts = exact_directory(exact_absolute_path(contract["working_directory"], "working_directory"), "打包脚本目录")
product_script = exact_file(scripts / "product.py", "product.py")
release_script = exact_file(scripts / "release.py", "release.py")
contract_psp = exact_absolute_path(contract["psp_path"], "psp_path")
source = exact_directory(
psp_source or exact_directory(contract_psp, "契约 PSP"), "待检查 PSP"
)
psp_git = git_state(source)
if formal:
if source != contract_psp:
errors.append("正式组包前检查必须使用应用工程中的 psp 子仓库")
if psp_git.get("tracked") is not True or psp_git.get("repository_root") != psp_git.get("path"):
errors.append("正式组包前 PSP 必须是独立 Git 仓库根目录")
if not psp_commit:
errors.append("正式组包前必须提供完整 PSP commit")
elif psp_git.get("commit") != psp_commit:
errors.append(
f"PSP commit 不匹配:期望 {psp_commit!r},实际 {psp_git.get('commit')!r}"
)
if psp_git.get("dirty") is not False:
errors.append("正式组包前 PSP 工作区必须干净")
psp_json_path = exact_file(source / "psp.json", "psp.json")
psp_json = load_json(psp_json_path)
mappings = psp_json.get("EmxResToProduct")
if not isinstance(mappings, list):
errors.append("psp.json.EmxResToProduct 必须是数组")
mappings = []
resolved_mappings: list[dict[str, object]] = []
destinations: dict[str, int] = {}
public_emxres = project / "Public" / "EmxRes"
for index, item in enumerate(mappings):
if not isinstance(item, dict):
errors.append(f"EmxResToProduct[{index}] 必须是对象")
continue
try:
dst = safe_destination(item.get("dst"), f"EmxResToProduct[{index}].dst")
except PackagingError as exc:
errors.append(str(exc))
continue
raw_src = item.get("src")
if not isinstance(raw_src, str) or not raw_src.strip():
errors.append(f"EmxResToProduct[{index}].src 不能为空")
continue
raw_path = Path(raw_src).expanduser()
resolved_src = (
raw_path.resolve(strict=False)
if raw_path.is_absolute()
else (public_emxres / raw_path).resolve(strict=False)
)
if not resolved_src.is_file() or resolved_src.is_symlink():
errors.append(
f"组包输入不存在或不是普通文件 EmxResToProduct[{index}].src: {resolved_src}"
)
if dst in destinations:
errors.append(
f"EmxResToProduct 目标重复: {dst},索引 {destinations[dst]} 和 {index}"
)
else:
destinations[dst] = index
resolved_mappings.append(
{"index": index, "src": raw_src, "resolved_src": str(resolved_src), "dst": dst}
)
partition = psp_json.get("partition")
partition_rows: dict[str, dict[str, object]] = {}
flash_size = None
if not isinstance(partition, dict):
errors.append("psp.json.partition 必须是对象")
else:
try:
flash_size = parse_size(partition.get("flashImgSize"), "partition.flashImgSize")
if flash_size is None:
errors.append("partition.flashImgSize 不能使用 -")
except PackagingError as exc:
errors.append(str(exc))
table = partition.get("partitionTable")
if not isinstance(table, list) or not table:
errors.append("partition.partitionTable 必须是非空数组")
table = []
allocated = 0
remainder_count = 0
for index, item in enumerate(table):
if not isinstance(item, dict):
errors.append(f"partitionTable[{index}] 必须是对象")
continue
name = item.get("name")
if not isinstance(name, str) or not name.strip():
errors.append(f"partitionTable[{index}].name 不能为空")
continue
if name in partition_rows:
errors.append(f"分区名称重复: {name}")
continue
try:
size_bytes = parse_size(item.get("size"), f"partitionTable[{index}].size")
except PackagingError as exc:
errors.append(str(exc))
size_bytes = None
if item.get("size") == "-":
remainder_count += 1
elif size_bytes is not None:
allocated += size_bytes
partition_rows[name] = {
"index": index,
"name": name,
"file": item.get("file"),
"size": item.get("size"),
"size_bytes": size_bytes,
}
if remainder_count > 1:
errors.append("分区表最多只能有一个 size=- 的剩余空间分区")
if flash_size is not None and allocated > flash_size:
errors.append(
f"已分配分区总量 {allocated} 字节超过 flashImgSize {flash_size} 字节"
)
release_text = release_script.read_text(encoding="utf-8", errors="replace")
data_row = partition_rows.get("data")
if data_row is not None:
data_file = data_row.get("file")
if not isinstance(data_file, str) or not data_file.strip():
errors.append("data 分区缺少 file,release.py 组包时会失败")
if "mkAiData.py" in release_text:
for required_dst in ("imgs/mkAiData.py", "imgs/data_ubinize.cfg"):
if required_dst not in destinations:
errors.append(
f"DATA 组包资源没有明确来源映射: EmxResToProduct 缺少 {required_dst}"
)
counts = {int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", release_text)}
if len(counts) != 1:
errors.append("无法从 release.py 唯一确定 mkAiData.py 的容量参数")
elif isinstance(data_row.get("size_bytes"), int):
count = next(iter(counts))
if count <= 0:
errors.append("mkAiData.py 的容量参数必须大于 0")
requested = count * UBI_LEB_SIZE
if requested > int(data_row["size_bytes"]):
errors.append(
f"mkAiData.py 参数需要约 {requested} 字节,超过 data 分区 {data_row['size_bytes']} 字节"
)
bsp_lock_path = source / "metadata" / "bsp.lock.json"
bsp_checks: list[dict[str, object]] = []
if not bsp_lock_path.is_file() or bsp_lock_path.is_symlink():
errors.append("缺少普通文件 metadata/bsp.lock.json")
else:
bsp_lock = load_json(bsp_lock_path)
margin = bsp_lock.get("image_partition_margin")
checks = margin.get("checks") if isinstance(margin, dict) else None
if not isinstance(margin, dict) or margin.get("passed") is not True or not isinstance(checks, list):
errors.append("bsp.lock.json 缺少已通过的 kernel/rootfs 分区余量证据")
else:
required_margin = margin.get("required_margin_bytes")
checked_aliases: set[str] = set()
for check in checks:
if not isinstance(check, dict) or not isinstance(check.get("partitions"), list):
continue
image = check.get("image")
image_size = check.get("image_size_bytes")
for bsp_partition in check["partitions"]:
if not isinstance(bsp_partition, dict):
continue
alias = bsp_partition.get("alias")
if not isinstance(alias, str) or alias not in partition_rows:
errors.append(f"PSP 分区表缺少 BSP 已验证分区: {alias!r}")
continue
checked_aliases.add(alias)
psp_partition = partition_rows[alias]
bsp_size = bsp_partition.get("size_bytes")
if psp_partition.get("size_bytes") != bsp_size:
errors.append(
f"分区 {alias} 与 BSP 不一致:PSP {psp_partition.get('size_bytes')} 字节,BSP {bsp_size} 字节"
)
if psp_partition.get("file") != image:
errors.append(
f"分区 {alias} 的 file 与 BSP 不一致:PSP {psp_partition.get('file')!r},BSP {image!r}"
)
if (
isinstance(image_size, int)
and isinstance(psp_partition.get("size_bytes"), int)
and isinstance(required_margin, int)
and int(psp_partition["size_bytes"]) - image_size < required_margin
):
errors.append(f"分区 {alias} 相对 {image} 的余量不足 {required_margin} 字节")
bsp_checks.append(
{
"partition": alias,
"image": image,
"psp_size_bytes": psp_partition.get("size_bytes"),
"bsp_size_bytes": bsp_size,
}
)
expected_aliases = {
name
for name in partition_rows
if name == "kernel"
or name == "rootfs"
or name.startswith("kernel.")
or name.startswith("rootfs.")
}
missing_aliases = sorted(expected_aliases - checked_aliases)
if missing_aliases:
errors.append(f"BSP 分区余量证据未覆盖 PSP 分区: {missing_aliases}")
result: dict[str, object] = {
"schema_version": 1,
"passed": not errors,
"mode": "formal" if formal else "pre-candidate",
"checked_at": now_utc(),
"contract": contract_evidence(contract_path, contract, digest),
"project_root": str(project),
"psp_source": str(source),
"psp_git": psp_git,
"scripts": {"product": str(product_script), "release": str(release_script)},
"mappings": resolved_mappings,
"partitions": list(partition_rows.values()),
"bsp_partition_checks": bsp_checks,
"errors": errors,
"warnings": warnings,
}
write_json_new(destination, result)
return result
def log_evidence(path: Path) -> dict[str, object]:
path = path.expanduser().resolve(strict=True)
if not path.is_file() or path.is_symlink():
raise PackagingError(f"打包日志必须是存在的普通文件: {path}")
if path.stat().st_size > MAX_LOG_SIZE:
raise PackagingError("打包日志超过 64 MiB,拒绝无界读取")
text = path.read_text(encoding="utf-8", errors="replace")
failures: list[dict[str, object]] = []
warnings: list[dict[str, object]] = []
for line_number, line in enumerate(text.splitlines(), 1):
kind = None
target = None
if ANSI_RED_RE.search(line) or FAILURE_RE.search(line):
kind = "failure"
target = failures
elif ANSI_YELLOW_RE.search(line) or WARNING_RE.search(line):
kind = "warning"
target = warnings
if target is not None:
target.append(
{
"line": line_number,
"kind": kind,
"line_sha256": hashlib.sha256(line.encode("utf-8")).hexdigest(),
}
)
return {
"path": str(path),
"size": path.stat().st_size,
"sha256": sha256_file(path),
"failure_markers": failures,
"warning_markers": warnings,
}
def snapshot(contract_path: Path, destination: Path) -> dict[str, object]:
contract_path, contract, digest = load_contract(contract_path)
psp = git_state(exact_absolute_path(contract["psp_path"], "psp_path"))
if psp.get("tracked") is not True or psp.get("repository_root") != psp.get("path"):
raise PackagingError("打包环境中的 PSP 必须是独立 Git 仓库根目录")
if psp.get("dirty") is not False:
raise PackagingError("打包前 PSP 工作区必须干净")
result = {
"schema_version": 3,
"snapshot_at": now_utc(),
"contract": contract_evidence(contract_path, contract, digest),
"psp": psp,
"project": git_state(exact_absolute_path(contract["project_root"], "project_root")),
"output": output_state(exact_absolute_path(contract["output_root"], "output_root")),
}
write_json_new(destination, result)
return result
def record(
before_path: Path,
contract_path: Path,
destination: Path,
chip: str,
product_exit_code: int,
release_exit_code: int,
product_command: str,
release_command: str,
psp_repo: Path,
psp_commit: str,
bsp_build_id: str,
project_root: Path,
working_directory: Path,
product_log_path: Path,
release_log_path: Path,
image_path: Path,
formal: bool,
clean_rebuild_confirmed: bool = False,
) -> dict[str, object]:
errors: list[str] = []
warnings: list[str] = []
before_path = before_path.expanduser().resolve(strict=True)
before = load_json(before_path)
old_output = before.get("output")
old_psp = before.get("psp")
before_contract = before.get("contract")
if (
before.get("schema_version") != 3
or not isinstance(old_output, dict)
or not isinstance(old_psp, dict)
or not isinstance(before_contract, dict)
):
raise PackagingError("before snapshot schema 非法或缺少打包契约/PSP/output")
contract_path, contract, digest = load_contract(contract_path)
contract_info = contract_evidence(contract_path, contract, digest)
if before_contract != contract_info:
raise PackagingError("打包契约在 snapshot 后发生变化或不是同一文件")
actual_psp = exact_directory(psp_repo, "PSP 仓库")
actual_project = exact_directory(project_root, "应用工程")
actual_working = exact_directory(working_directory, "打包工作目录")
expected_psp = exact_absolute_path(contract["psp_path"], "psp_path")
expected_project = exact_absolute_path(contract["project_root"], "project_root")
expected_working = exact_absolute_path(contract["working_directory"], "working_directory")
expected_output = exact_absolute_path(contract["output_root"], "output_root")
if chip not in contract["chips"]:
errors.append(f"芯片 {chip!r} 不在打包契约 chips 中")
if actual_psp != expected_psp:
errors.append(f"PSP 路径不符合契约:期望 {expected_psp},实际 {actual_psp}")
if actual_project != expected_project:
errors.append(f"应用工程路径不符合契约:期望 {expected_project},实际 {actual_project}")
if actual_working != expected_working:
errors.append(f"打包工作目录不符合契约:期望 {expected_working},实际 {actual_working}")
if product_command != contract["product_command"]:
errors.append(f"资源复制命令必须精确为 {PRODUCT_COMMAND}")
if release_command not in contract["release_commands"]:
errors.append("实际 release 命令未被当前契约精确批准")
if release_command == "./release.py 1" and not clean_rebuild_confirmed:
errors.append("clean rebuild 使用 ./release.py 1 前缺少用户确认")
if product_exit_code not in contract["success_exit_codes"]:
errors.append(f"product.py 退出码不在契约成功码中: {product_exit_code}")
if release_exit_code not in contract["success_exit_codes"]:
errors.append(f"release.py 退出码不在契约成功码中: {release_exit_code}")
product_log = log_evidence(product_log_path)
release_log = log_evidence(release_log_path)
if product_log["failure_markers"]:
errors.append("product.py 日志检测到失败标记,禁止继续认定 release 结果")
if release_log["failure_markers"]:
errors.append("release.py 日志检测到失败标记")
if product_log["warning_markers"]:
warnings.append("product.py 日志包含警告标记;仅记录行号和行 hash")
if release_log["warning_markers"]:
warnings.append("release.py 日志包含警告标记;仅记录行号和行 hash")
current_output = output_state(expected_output)
if old_output.get("path") != current_output.get("path"):
raise PackagingError("契约 output_root 与 before snapshot 不一致")
old_versions = {
str(item.get("name")): item
for item in old_output.get("versions", [])
if isinstance(item, dict)
}
new_versions = {
str(item.get("name")): item
for item in current_output.get("versions", [])
if isinstance(item, dict)
}
added_names = sorted(set(new_versions) - set(old_versions))
removed_names = sorted(set(old_versions) - set(new_versions))
modified_names = sorted(
name
for name in set(old_versions) & set(new_versions)
if old_versions[name].get("tree_sha256") != new_versions[name].get("tree_sha256")
)
if len(added_names) != 1:
errors.append(f"本轮必须唯一新增一个 output 版本目录,实际新增 {added_names}")
if removed_names:
errors.append(f"本轮打包删除了既有 output 版本目录: {removed_names}")
if modified_names:
errors.append(f"本轮打包修改了既有 output 版本目录: {modified_names}")
version = new_versions[added_names[0]] if len(added_names) == 1 else None
image = image_path.expanduser()
if not image.is_absolute():
raise PackagingError(f"烧录包必须使用唯一绝对路径: {image}")
image = image.resolve(strict=False)
image_state: dict[str, object] = {"path": str(image), "exists": image.exists()}
if not image.is_file() or image.is_symlink() or image.stat().st_size <= 0:
errors.append("用户/项目契约指定的烧录包不是非空普通文件")
else:
image_state.update(
{
"ordinary_file": True,
"size": image.stat().st_size,
"mtime_ns": image.stat().st_mtime_ns,
"sha256": sha256_file(image),
}
)
if version is None:
errors.append("无法把烧录包绑定到本轮唯一 output 版本目录")
else:
version_path = Path(str(version["path"])).resolve(strict=True)
try:
image.relative_to(version_path)
except ValueError:
errors.append("烧录包不在本轮新生成的 output 版本目录内")
psp = git_state(actual_psp)
if psp.get("tracked") is not True or psp.get("repository_root") != psp.get("path"):
errors.append("打包环境中的 PSP 不是独立 Git 仓库根目录")
if old_psp.get("commit") != psp_commit or psp.get("commit") != psp_commit:
errors.append(
f"PSP commit 不匹配:期望 {psp_commit!r},打包前 {old_psp.get('commit')!r},打包后 {psp.get('commit')!r}"
)
if old_psp.get("dirty") is not False or psp.get("dirty") is not False:
errors.append("打包前后 PSP 工作区都必须干净")
project = git_state(actual_project)
if project.get("tracked") is not True:
message = "应用工程不是可追踪 Git worktree"
(errors if formal else warnings).append(message)
elif project.get("dirty") is True:
warnings.append("打包后应用工程存在修改;已记录状态,需归因 product/release 输出")
result: dict[str, object] = {
"schema_version": 2,
"passed": not errors,
"recorded_at": now_utc(),
"mode": "formal" if formal else "local",
"before_snapshot": str(before_path),
"contract": contract_info,
"chip": chip,
"commands": {
"product": {"command": product_command, "exit_code": product_exit_code, "log": product_log},
"release": {"command": release_command, "exit_code": release_exit_code, "log": release_log},
"clean_rebuild_confirmed": clean_rebuild_confirmed,
},
"working_directory": str(actual_working),
"psp": psp,
"expected_psp_commit": psp_commit,
"bsp_build_id": bsp_build_id,
"project": project,
"framework": project,
"output": {
"root": str(expected_output),
"added_versions": added_names,
"removed_versions": removed_names,
"modified_versions": modified_names,
"version": version,
},
"image": image_state,
"fresh_output": len(added_names) == 1 and not removed_names and not modified_names,
"errors": errors,
"warnings": warnings,
}
write_json_new(destination, result)
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="subcommand", required=True)
contract_parser = subparsers.add_parser(
"contract", help="根据 EMX2.1 固定目录和命令生成本地打包契约"
)
contract_parser.add_argument("--project-root", type=Path, required=True)
contract_parser.add_argument("--chip", required=True)
contract_parser.add_argument("--output", type=Path, required=True)
preflight_parser = subparsers.add_parser(
"preflight", help="在 product.py 前检查资源来源、分区和 BSP 证据"
)
preflight_parser.add_argument("--contract", type=Path, required=True)
preflight_parser.add_argument("--state", type=Path, required=True)
preflight_parser.add_argument(
"--psp-source",
type=Path,
help="候选提交前可指定 staging;正式组包时省略并使用工程内 psp",
)
preflight_parser.add_argument("--psp-commit")
preflight_parser.add_argument("--formal", action="store_true")
snapshot_parser = subparsers.add_parser("snapshot", help="记录 product/release 前的 output 与 PSP 状态")
snapshot_parser.add_argument("--contract", type=Path, required=True)
snapshot_parser.add_argument("--state", type=Path, required=True)
record_parser = subparsers.add_parser("record", help="验证 product/release 结果、版本目录和烧录包")
record_parser.add_argument("--before", type=Path, required=True)
record_parser.add_argument("--contract", type=Path, required=True)
record_parser.add_argument("--result", type=Path, required=True)
record_parser.add_argument("--chip", required=True)
record_parser.add_argument("--product-exit-code", type=int, required=True)
record_parser.add_argument("--release-exit-code", type=int, required=True)
record_parser.add_argument("--product-command", default=PRODUCT_COMMAND)
record_parser.add_argument("--release-command", required=True, choices=sorted(RELEASE_COMMANDS))
record_parser.add_argument("--clean-rebuild-confirmed", action="store_true")
record_parser.add_argument("--psp-repo", type=Path, required=True)
record_parser.add_argument("--psp-commit", required=True)
record_parser.add_argument("--bsp-build-id", required=True)
record_parser.add_argument("--project-root", type=Path, required=True)
record_parser.add_argument("--working-directory", type=Path, required=True)
record_parser.add_argument("--product-log", type=Path, required=True)
record_parser.add_argument("--release-log", type=Path, required=True)
record_parser.add_argument("--image", type=Path, required=True)
record_parser.add_argument("--formal", action="store_true")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
if args.subcommand == "contract":
result = create_contract(args.project_root, args.chip, args.output)
elif args.subcommand == "preflight":
result = packaging_preflight(
args.contract,
args.state,
args.psp_source,
args.psp_commit,
args.formal,
)
elif args.subcommand == "snapshot":
result = snapshot(args.contract, args.state)
else:
result = record(
args.before,
args.contract,
args.result,
args.chip,
args.product_exit_code,
args.release_exit_code,
args.product_command,
args.release_command,
args.psp_repo,
args.psp_commit,
args.bsp_build_id,
args.project_root,
args.working_directory,
args.product_log,
args.release_log,
args.image,
args.formal,
args.clean_rebuild_confirmed,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if args.subcommand in {"contract", "snapshot"} or result.get("passed") is True else 2
except (PackagingError, OSError, json.JSONDecodeError) as exc:
print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2))
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""Render a PSP evidence handoff for user review or Etunel Hub; never send it."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
STATUSES = ("candidate", "packaged", "device_verified", "app_integrated", "accepted")
class HandoffError(RuntimeError):
"""Handoff evidence is incomplete or inconsistent."""
def now_utc() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def load_object(path: Path, name: str) -> dict[str, Any]:
payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise HandoffError(f"{name} JSON 根节点必须是对象: {path}")
return payload
def write_json_new(path: Path, payload: dict[str, object]) -> None:
path = path.expanduser().resolve(strict=False)
if path.exists() or path.is_symlink():
raise HandoffError(f"交接文件已存在,禁止覆盖: {path}")
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 run_git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def psp_git_state(path: Path) -> dict[str, object]:
path = path.expanduser().resolve(strict=True)
if not path.is_dir() or path.is_symlink():
raise HandoffError(f"PSP 仓库必须是普通目录: {path}")
top = run_git(path, "rev-parse", "--show-toplevel")
commit = run_git(path, "rev-parse", "HEAD")
status = run_git(path, "status", "--porcelain", "--untracked-files=all", "--", ".")
if top.returncode or commit.returncode or status.returncode:
raise HandoffError("PSP 必须是可读取 HEAD 和工作区状态的 Git worktree")
lines = [line for line in status.stdout.splitlines() if line.strip()]
commit_value = commit.stdout.strip()
tags = run_git(path, "tag", "--points-at", commit_value)
if tags.returncode != 0:
raise HandoffError(f"无法读取 PSP 状态 tag: {tags.stderr.strip()}")
return {
"path": str(path),
"repository_root": str(Path(top.stdout.strip()).resolve()),
"commit": commit_value,
"dirty": bool(lines),
"status": lines,
"tags": sorted(line for line in tags.stdout.splitlines() if line),
}
def annotated_tag(path: Path, name: str) -> bool:
result = run_git(path, "cat-file", "-t", f"refs/tags/{name}")
return result.returncode == 0 and result.stdout.strip() == "tag"
def shared_repository_url(value: str) -> bool:
stripped = value.strip()
return bool(stripped) and not stripped.startswith(("/", "./", "../", "~", "file:"))
def verify_remote(repository_url: str, commit: str) -> dict[str, object]:
checked_at = now_utc()
environment = os.environ.copy()
environment["GIT_TERMINAL_PROMPT"] = "0"
try:
result = subprocess.run(
["git", "ls-remote", "--heads", "--tags", repository_url],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=30,
env=environment,
)
except subprocess.TimeoutExpired:
return {
"schema_version": 1,
"passed": False,
"checked_at": checked_at,
"repository_url": repository_url,
"commit": commit,
"refs": [],
"errors": ["git ls-remote 超时"],
}
except OSError as exc:
return {
"schema_version": 1,
"passed": False,
"checked_at": checked_at,
"repository_url": repository_url,
"commit": commit,
"refs": [],
"errors": [f"无法执行 git ls-remote: {exc}"],
}
refs: list[str] = []
for line in result.stdout.splitlines():
fields = line.split("\t", 1)
if len(fields) == 2 and fields[0] == commit:
refs.append(fields[1])
errors: list[str] = []
if result.returncode != 0:
errors.append(f"git ls-remote 失败: {result.stderr.strip() or result.returncode}")
elif not refs:
errors.append("共享远端 heads/tags 中不存在当前完整 PSP commit")
return {
"schema_version": 1,
"passed": not errors,
"checked_at": checked_at,
"repository_url": repository_url,
"commit": commit,
"refs": sorted(refs),
"errors": errors,
}
def require_passed(payload: dict[str, Any] | None, name: str, errors: list[str]) -> None:
if payload is None:
errors.append(f"缺少 {name} 证据")
elif payload.get("passed") is not True and payload.get("result") != "PASS":
errors.append(f"{name} 结果不是 PASS")
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 validate_packaging_record(payload: dict[str, Any] | None, errors: list[str]) -> None:
require_passed(payload, "PSP 打包", errors)
if payload is None:
return
if payload.get("schema_version") != 2:
errors.append("PSP 打包证据必须由契约版工具生成(schema_version=2)")
contract = payload.get("contract")
if not isinstance(contract, dict):
errors.append("PSP 打包证据缺少固定契约 path/hash/id")
else:
path_value = contract.get("path")
expected_hash = contract.get("sha256")
if not isinstance(path_value, str) or not isinstance(expected_hash, str):
errors.append("PSP 打包契约 path/hash 非法")
else:
contract_path = Path(path_value)
if not contract_path.is_file() or contract_path.is_symlink():
errors.append("PSP 打包契约文件当前不可读")
elif sha256_file(contract_path) != expected_hash:
errors.append("PSP 打包契约文件 hash 已变化")
image = payload.get("image")
if not isinstance(image, dict):
errors.append("PSP 打包证据缺少 image")
else:
path_value = image.get("path")
expected_hash = image.get("sha256")
if not isinstance(path_value, str) or not isinstance(expected_hash, str):
errors.append("PSP 烧录包 path/hash 非法")
else:
image_path = Path(path_value)
if not image_path.is_file() or image_path.is_symlink():
errors.append("PSP 烧录包当前不可读")
elif sha256_file(image_path) != expected_hash:
errors.append("PSP 烧录包 hash 与打包证据不一致")
def evidence_commit(payload: dict[str, Any] | None) -> str | None:
if payload is None:
return None
direct = payload.get("psp_commit")
if isinstance(direct, str):
return direct
expected = payload.get("expected_psp_commit")
if isinstance(expected, str):
return expected
psp = payload.get("psp")
if isinstance(psp, dict) and isinstance(psp.get("commit"), str):
return psp["commit"]
return None
def validate_device(
payload: dict[str, Any] | None,
packaging: dict[str, Any] | None,
commit: str,
errors: list[str],
) -> None:
if payload is None:
errors.append("缺少设备验证证据")
return
if payload.get("result") != "PASS":
errors.append("设备验证结果不是 PASS")
if payload.get("evidence_source") != "USER_REPORTED":
errors.append("当前人工烧录阶段的 evidence_source 必须为 USER_REPORTED")
if payload.get("psp_commit") != commit:
errors.append("设备验证的 PSP commit 与当前仓库不一致")
unexecuted = payload.get("unexecuted")
if not isinstance(unexecuted, list) or unexecuted:
errors.append("设备验证仍有未执行的适用检查")
checks = payload.get("checks")
if not isinstance(checks, list) or not checks:
errors.append("设备验证缺少非空 checks")
elif any(
not isinstance(item, dict) or item.get("result") not in {"PASS", "N/A"}
for item in checks
):
errors.append("设备验证 checks 含失败或非法结果")
if packaging:
image = packaging.get("image")
if not isinstance(image, dict):
errors.append("打包证据缺少 image")
else:
if payload.get("image_path") != image.get("path"):
errors.append("设备验证镜像路径与打包结果不一致")
if payload.get("image_sha256") != image.get("sha256"):
errors.append("设备验证镜像 hash 与打包结果不一致")
def validate_application(
payload: dict[str, Any] | None, commit: str, errors: list[str]
) -> None:
if payload is None:
errors.append("缺少应用集成证据")
return
if payload.get("result") != "PASS":
errors.append("应用集成结果不是 PASS")
if payload.get("psp_commit") != commit:
errors.append("应用集成的 PSP commit 与当前仓库不一致")
if not isinstance(payload.get("application_commit"), str) or not payload.get(
"application_commit"
):
errors.append("应用集成缺少 application_commit")
unexecuted = payload.get("unexecuted")
if not isinstance(unexecuted, list) or unexecuted:
errors.append("应用集成仍有未执行的适用检查")
approval = payload.get("internal_approved")
if approval is not True:
errors.append("应用集成缺少应用负责人确认")
def validate_approval(payload: dict[str, Any] | None, errors: list[str]) -> None:
if payload is None:
errors.append("缺少嵌入式底层负责人确认")
return
if payload.get("internal_approved") is not True:
errors.append("internal_approved 不是 true")
for field in ("confirmed_by", "confirmed_at", "confirmed_scope"):
if not isinstance(payload.get(field), str) or not payload.get(field):
errors.append(f"负责人确认缺少 {field}")
def render(
project: str,
chip: str,
status: str,
psp_repo: Path,
repository_url: str | None,
reference_psp: str,
reference_commit: str | None,
static_path: Path,
bsp_lock_path: Path,
packaging_path: Path | None,
device_path: Path | None,
application_path: Path | None,
etunel_context_path: Path | None,
approval_path: Path | None,
media_version: str | None,
ai_plugin_version: str | None,
risks: list[str],
warnings: list[str],
unexecuted: list[str],
remote_verification: dict[str, object] | None,
for_etunel: bool,
) -> dict[str, object]:
errors: list[str] = []
git = psp_git_state(psp_repo)
commit = str(git["commit"])
if git["repository_root"] != git["path"]:
errors.append("PSP 必须是独立 Git 仓库根目录")
if git["dirty"] is True:
errors.append("PSP 工作区不干净,不能形成不可变交接")
static = load_object(static_path, "static validation")
bsp_lock = load_object(bsp_lock_path, "BSP lock")
expected_bsp_lock = Path(str(git["path"])) / "metadata" / "bsp.lock.json"
if bsp_lock_path.expanduser().resolve(strict=True) != expected_bsp_lock:
errors.append("BSP lock 必须是当前 PSP 仓库的 metadata/bsp.lock.json")
if isinstance(static.get("psp_root"), str) and Path(static["psp_root"]).resolve(
strict=False
) != Path(str(git["path"])):
errors.append("静态验证结果不属于当前 PSP 仓库")
if isinstance(static.get("project"), str) and static["project"] != project:
errors.append("静态验证结果的项目名与交接项目不一致")
packaging = load_object(packaging_path, "packaging") if packaging_path else None
device = load_object(device_path, "device validation") if device_path else None
application = (
load_object(application_path, "application integration") if application_path else None
)
etunel_context = (
load_object(etunel_context_path, "Etunel context") if etunel_context_path else None
)
approval = load_object(approval_path, "owner approval") if approval_path else None
require_passed(static, "PSP 静态验证", errors)
if bsp_lock.get("schema_version") != 1:
errors.append("BSP lock schema_version 必须为 1")
if status in {"packaged", "device_verified", "app_integrated", "accepted"}:
validate_packaging_record(packaging, errors)
if packaging and evidence_commit(packaging) != commit:
errors.append("打包证据的 PSP commit 与当前仓库不一致")
if status in {"device_verified", "accepted"}:
validate_device(device, packaging, commit, errors)
if status in {"app_integrated", "accepted"}:
validate_application(application, commit, errors)
if for_etunel or status == "accepted":
validate_approval(approval, errors)
if for_etunel:
if status not in {"packaged", "accepted"}:
errors.append("Etunel 应用交接只允许 packaged 或 accepted 状态")
if not repository_url or not shared_repository_url(repository_url):
errors.append("Etunel 交接必须提供团队共享 PSP 仓库地址")
if remote_verification is None or remote_verification.get("passed") is not True:
errors.append("Etunel 交接缺少通过的共享远端 commit 可达性验证")
elif (
remote_verification.get("repository_url") != repository_url
or remote_verification.get("commit") != commit
):
errors.append("远端可达性验证与当前仓库地址或 PSP commit 不一致")
if etunel_context is None:
errors.append("Etunel 交接缺少有效上下文")
else:
for field in ("work_id", "subtask_id", "phase", "wave", "role"):
if not isinstance(etunel_context.get(field), str) or not etunel_context.get(field):
errors.append(f"Etunel 上下文缺少 {field}")
if status == "accepted":
accepted_tag = f"accepted/{commit[:12]}"
if accepted_tag not in git["tags"] or not annotated_tag(Path(str(git["path"])), accepted_tag):
errors.append(f"当前 commit 缺少 annotated accepted 状态 tag: {accepted_tag}")
revoked_tag = f"revoked/{commit[:12]}"
if revoked_tag in git["tags"]:
errors.append(f"当前 PSP commit 已被 revoked: {revoked_tag}")
evidence = {
"static_validation": static,
"packaging": packaging,
"device_validation": device,
"application_integration": application,
}
result: dict[str, object] = {
"schema_version": 1,
"handoff_type": "psp-to-application",
"generated_at": now_utc(),
"passed": not errors,
"project": project,
"chip": chip,
"status": status,
"psp": {
"repository_url": repository_url,
"remote_verification": remote_verification,
**git,
},
"reference_psp": {
"source": reference_psp,
"commit": reference_commit,
},
"bsp_lock": bsp_lock,
"component_baseline": {
"media_version": media_version,
"ai_plugin_version": ai_plugin_version,
},
"evidence": evidence,
"etunel": {
"context": etunel_context,
"remote_commit_reachable": bool(
remote_verification and remote_verification.get("passed") is True
),
"prepared_for_send": for_etunel and not errors,
"sent": False,
},
"owner_approval": approval,
"self_check_result": "PASS" if not errors else "FAIL",
"unexecuted": unexecuted,
"warnings": warnings,
"risks": risks,
"errors": errors,
}
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", required=True)
parser.add_argument("--chip", required=True)
parser.add_argument("--status", required=True, choices=STATUSES)
parser.add_argument("--psp-repo", type=Path, required=True)
parser.add_argument("--repository-url")
parser.add_argument("--reference-psp", required=True)
parser.add_argument("--reference-commit")
parser.add_argument("--static-validation", type=Path, required=True)
parser.add_argument("--bsp-lock", type=Path, required=True)
parser.add_argument("--packaging-result", type=Path)
parser.add_argument("--device-validation", type=Path)
parser.add_argument("--application-integration", type=Path)
parser.add_argument("--etunel-context", type=Path)
parser.add_argument("--owner-approval", type=Path)
parser.add_argument("--media-version")
parser.add_argument("--ai-plugin-version")
parser.add_argument("--risk", action="append", default=[])
parser.add_argument("--warning", action="append", default=[])
parser.add_argument("--unexecuted", action="append", default=[])
parser.add_argument("--for-etunel", action="store_true")
parser.add_argument(
"--verify-remote",
action="store_true",
help="用 git ls-remote 证明当前 commit 可从 repository-url 获取",
)
parser.add_argument("--output", type=Path, required=True)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
remote_verification = None
if args.verify_remote:
if not args.repository_url:
raise HandoffError("--verify-remote 必须同时提供 --repository-url")
current_commit = str(psp_git_state(args.psp_repo)["commit"])
remote_verification = verify_remote(args.repository_url, current_commit)
result = render(
args.project,
args.chip,
args.status,
args.psp_repo,
args.repository_url,
args.reference_psp,
args.reference_commit,
args.static_validation,
args.bsp_lock,
args.packaging_result,
args.device_validation,
args.application_integration,
args.etunel_context,
args.owner_approval,
args.media_version,
args.ai_plugin_version,
args.risk,
args.warning,
args.unexecuted,
remote_verification,
args.for_etunel,
)
write_json_new(args.output, result)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("passed") is True else 2
except (HandoffError, OSError, json.JSONDecodeError) as exc:
print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2))
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Behavioral tests for psp_project_guard.py."""
import json
import subprocess
import tempfile
from pathlib import Path
import psp_project_guard
def git(path: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout.strip()
def create_empty_project(psp_root: Path, name: str) -> Path:
project = psp_root / name
project.mkdir()
git(project, "init", "-q")
git(project, "remote", "add", "origin", f"ssh://git.example/Psp/{name}.git")
return project
def expect_guard_error(action, message: str) -> None:
try:
action()
except psp_project_guard.GuardError:
return
raise AssertionError(message)
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
psp_root = root / "Psp"
psp_root.mkdir()
reference = psp_root / "REF"
reference.mkdir()
(reference / "configs").mkdir()
(reference / "configs" / "board.json").write_text(
json.dumps({"project": "REF"}), encoding="utf-8"
)
(reference / "psp.json").write_text(
json.dumps({"project": "REF"}), encoding="utf-8"
)
(reference / "build").mkdir()
(reference / "build" / "generated.bin").write_bytes(b"generated")
git(reference, "init", "-q")
git(reference, "add", "configs/board.json", "psp.json")
git(
reference,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"commit",
"-qm",
"reference",
)
target = create_empty_project(psp_root, "LY-SM-YT42-AI")
original_branch = git(target, "symbolic-ref", "--short", "HEAD")
original_remote = git(target, "remote", "get-url", "origin")
inspected = psp_project_guard.inspect_project(target, psp_root)
assert inspected["mode"] == "precreated-empty-repository"
assert inspected["project"] == "LY-SM-YT42-AI"
manifest = root / "evidence" / "clone-manifest.json"
prepared = psp_project_guard.prepare(
reference, target, manifest, ["REF"], include_dirty=False, psp_root=psp_root
)
staging = Path(prepared["staging"])
assert prepared["target_mode"] == "precreated-empty-repository"
assert prepared["psp_root"] == str(psp_root)
assert not (staging / ".git").exists()
assert not (staging / "build").exists()
(staging / "psp.json").write_text(
json.dumps({"project": "NEW"}), encoding="utf-8"
)
failing = psp_project_guard.verify(manifest, ["psp.json"], [], None)
assert failing["passed"] is False
assert failing["residues"]
(staging / "configs" / "board.json").write_text(
json.dumps({"project": "NEW"}), encoding="utf-8"
)
report_path = root / "evidence" / "verify.json"
verified = psp_project_guard.verify(
manifest, ["psp.json", "configs/board.json"], [], report_path
)
assert verified["passed"] is True, verified
finalized = psp_project_guard.finalize(manifest, report_path)
assert finalized["target"] == str(target)
assert finalized["repository_preserved"] is True
assert not staging.exists()
assert (target / ".git").is_dir()
assert git(target, "symbolic-ref", "--short", "HEAD") == original_branch
assert git(target, "remote", "get-url", "origin") == original_remote
assert not (target / ".git" / "refs" / "heads" / original_branch).exists()
audit_path = root / "evidence" / "final-audit.json"
audited = psp_project_guard.audit_final(
manifest, ["psp.json", "configs/board.json"], [], audit_path
)
assert audited["passed"] is True, audited
assert audited["repository_unchanged"] is True
missing = psp_root / "MISSING"
expect_guard_error(
lambda: psp_project_guard.prepare(
reference, missing, root / "missing-manifest.json", [], False, psp_root
),
"missing target must be rejected",
)
outside = create_empty_project(root, "OUTSIDE")
expect_guard_error(
lambda: psp_project_guard.prepare(
reference, outside, root / "outside-manifest.json", [], False, psp_root
),
"target outside the fixed PSP root must be rejected",
)
populated = create_empty_project(psp_root, "POPULATED")
(populated / "README.md").write_text("existing\n", encoding="utf-8")
assert psp_project_guard.inspect_project(populated, psp_root)["mode"] == "existing-project"
expect_guard_error(
lambda: psp_project_guard.prepare(
reference, populated, root / "populated-manifest.json", [], False, psp_root
),
"populated target must use the existing-project update flow",
)
changed = create_empty_project(psp_root, "CHANGED")
changed_manifest = root / "evidence" / "changed-manifest.json"
changed_prepared = psp_project_guard.prepare(
reference, changed, changed_manifest, [], False, psp_root
)
changed_report = root / "evidence" / "changed-verify.json"
changed_verified = psp_project_guard.verify(changed_manifest, [], [], changed_report)
assert changed_verified["passed"] is True
git(changed, "remote", "set-url", "origin", "ssh://git.example/changed.git")
expect_guard_error(
lambda: psp_project_guard.finalize(changed_manifest, changed_report),
"Git metadata drift after prepare must block finalization",
)
assert Path(changed_prepared["staging"]).is_dir()
assert not (changed / "psp.json").exists()
(reference / "psp.json").write_text(
json.dumps({"project": "UNCOMMITTED"}), encoding="utf-8"
)
head_target = create_empty_project(psp_root, "FROM-HEAD")
head_manifest = root / "evidence" / "head-manifest.json"
from_head = psp_project_guard.prepare(
reference, head_target, head_manifest, ["REF"], False, psp_root
)
assert from_head["warnings"]
assert json.loads((Path(from_head["staging"]) / "psp.json").read_text())["project"] == "REF"
unsafe = root / "unsafe"
unsafe.mkdir()
(unsafe / "psp.json").write_text("{}", encoding="utf-8")
(unsafe / ".env").write_text("TOKEN=secret", encoding="utf-8")
unsafe_target = create_empty_project(psp_root, "UNSAFE")
expect_guard_error(
lambda: psp_project_guard.prepare(
unsafe, unsafe_target, root / "unsafe-manifest.json", [], False, psp_root
),
"credential-like files must be rejected",
)
print("PSP project guard tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""检查 PSP 组包契约、前置检查和结果记录。"""
import json
import subprocess
import tempfile
from pathlib import Path
import record_packaging_result
def git(path: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout.strip()
def init_repo(path: Path, filename: str) -> str:
path.mkdir(parents=True, exist_ok=True)
(path / filename).write_text("fixture", encoding="utf-8")
git(path, "init", "-q")
git(path, "add", filename)
git(
path,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"commit",
"-qm",
"fixture",
)
return git(path, "rev-parse", "HEAD")
def write_contract(path: Path, project: Path) -> None:
path.write_text(
json.dumps(
{
"schema_version": 2,
"contract_type": "emx21-project-packaging",
"contract_id": "fixture-cv1841cp-v2",
"chips": ["CV1841CP"],
"project_root": str(project),
"psp_path": str(project / "psp"),
"working_directory": str(project / "scripts"),
"output_root": str(project / "output"),
"product_command": "./product.py",
"release_commands": ["./release.py 0", "./release.py 1"],
"success_exit_codes": [0],
}
),
encoding="utf-8",
)
def record(
root: Path,
before: Path,
contract: Path,
result_name: str,
commit: str,
project: Path,
image: Path,
product_log: Path,
release_log: Path,
release_command: str = "./release.py 0",
clean_confirmed: bool = False,
) -> dict[str, object]:
return record_packaging_result.record(
before,
contract,
root / "evidence" / result_name,
"CV1841CP",
0,
0,
"./product.py",
release_command,
project / "psp",
commit,
"20260903_1",
project,
project / "scripts",
product_log,
release_log,
image,
formal=True,
clean_rebuild_confirmed=clean_confirmed,
)
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
project = root / "application"
init_repo(project, "product.json")
(project / "scripts").mkdir()
(project / "scripts" / "product.py").write_text("# fixture\n", encoding="utf-8")
(project / "scripts" / "release.py").write_text("# fixture\n", encoding="utf-8")
psp = project / "psp"
commit = init_repo(psp, "psp.json")
output = project / "output"
output.mkdir()
old_version = output / "20260902_1"
old_version.mkdir()
(old_version / "old.bin").write_bytes(b"old")
contract = root / "packaging-contract.json"
write_contract(contract, project)
product_log = root / "product.log"
release_log = root / "release.log"
product_log.write_text("resources copied\n", encoding="utf-8")
release_log.write_text("package complete\n", encoding="utf-8")
before = root / "evidence" / "before.json"
snapshot = record_packaging_result.snapshot(contract, before)
assert snapshot["schema_version"] == 3
version = output / "20260903_1"
version.mkdir()
image = version / "ep_flash_sd.bin"
image.write_bytes(b"image-v1")
result = record(
root,
before,
contract,
"result.json",
commit,
project,
image,
product_log,
release_log,
)
assert result["passed"] is True, result
assert result["fresh_output"] is True
assert result["output"]["version"]["path"] == str(version)
assert result["image"]["sha256"]
stale_before = root / "evidence" / "stale-before.json"
record_packaging_result.snapshot(contract, stale_before)
stale = record(
root,
stale_before,
contract,
"stale-result.json",
commit,
project,
image,
product_log,
release_log,
)
assert stale["passed"] is False
assert any("唯一新增" in error for error in stale["errors"])
clean_before = root / "evidence" / "clean-before.json"
record_packaging_result.snapshot(contract, clean_before)
clean_version = output / "20260903_2"
clean_version.mkdir()
clean_image = clean_version / "ep_flash_sd.bin"
clean_image.write_bytes(b"image-v2")
unconfirmed_clean = record(
root,
clean_before,
contract,
"unconfirmed-clean.json",
commit,
project,
clean_image,
product_log,
release_log,
release_command="./release.py 1",
)
assert unconfirmed_clean["passed"] is False
assert any("用户确认" in error for error in unconfirmed_clean["errors"])
failure_before = root / "evidence" / "failure-before.json"
record_packaging_result.snapshot(contract, failure_before)
failed_version = output / "20260903_3"
failed_version.mkdir()
failed_image = failed_version / "ep_flash_sd.bin"
failed_image.write_bytes(b"image-v3")
product_log.write_text("\x1b[91mcopy failed\x1b[0m\n", encoding="utf-8")
failed = record(
root,
failure_before,
contract,
"failed-product.json",
commit,
project,
failed_image,
product_log,
release_log,
)
assert failed["passed"] is False
assert any("product.py 日志" in error for error in failed["errors"])
assert "copy failed" not in json.dumps(failed, ensure_ascii=False)
product_log.write_text("resources copied\n", encoding="utf-8")
dirty_before = root / "evidence" / "dirty-before.json"
record_packaging_result.snapshot(contract, dirty_before)
dirty_version = output / "20260903_4"
dirty_version.mkdir()
dirty_image = dirty_version / "ep_flash_sd.bin"
dirty_image.write_bytes(b"image-v4")
(psp / "psp.json").write_text("dirty", encoding="utf-8")
dirty = record(
root,
dirty_before,
contract,
"dirty-result.json",
commit,
project,
dirty_image,
product_log,
release_log,
)
assert dirty["passed"] is False
assert any("必须干净" in error for error in dirty["errors"])
try:
record_packaging_result.snapshot(
root / "missing-contract.json", root / "evidence" / "must-not-exist.json"
)
except (record_packaging_result.PackagingError, FileNotFoundError):
pass
else:
raise AssertionError("missing packaging contract must fail closed")
contract_only_project = root / "contract-only-application"
contract_only_project.mkdir()
contract_only_scripts = contract_only_project / "scripts"
contract_only_scripts.mkdir()
(contract_only_scripts / "product.py").write_text("# 复制资源\n", encoding="utf-8")
(contract_only_scripts / "release.py").write_text("# 组包\n", encoding="utf-8")
contract_only_path = root / "contract-before-psp.json"
contract_only = record_packaging_result.create_contract(
contract_only_project, "CV1841CP", contract_only_path
)
assert contract_only["psp_path"] == str(contract_only_project / "psp")
ready_project = root / "ready-application"
init_repo(ready_project, "product.json")
ready_scripts = ready_project / "scripts"
ready_scripts.mkdir()
(ready_scripts / "product.py").write_text("# 复制资源\n", encoding="utf-8")
(ready_scripts / "release.py").write_text(
'os.system("./mkAiData.py 4 none")\n', encoding="utf-8"
)
resource_scripts = ready_project / "Public" / "EmxRes" / "imgs" / "scripts"
resource_scripts.mkdir(parents=True)
(resource_scripts / "mkAiData.py").write_text("# 工具\n", encoding="utf-8")
(resource_scripts / "data_ubinize.cfg").write_text("# 配置\n", encoding="utf-8")
ready_psp = ready_project / "psp"
init_repo(ready_psp, "README.md")
ready_payload = {
"partition": {
"flashImgSize": "16M",
"partitionTable": [
{"name": "kernel.A", "file": "kernel.bin", "size": "2M"},
{"name": "rootfs.A", "file": "rootfs.bin", "size": "2M"},
{"name": "data", "file": "data.bin", "size": "2M"},
{"name": "configs", "size": "-"},
],
},
"EmxResToProduct": [
{"src": "imgs/scripts/mkAiData.py", "dst": "imgs/mkAiData.py"},
{
"src": "imgs/scripts/data_ubinize.cfg",
"dst": "imgs/data_ubinize.cfg",
},
],
}
(ready_psp / "psp.json").write_text(
json.dumps(ready_payload), encoding="utf-8"
)
metadata = ready_psp / "metadata"
metadata.mkdir()
(metadata / "bsp.lock.json").write_text(
json.dumps(
{
"schema_version": 1,
"image_partition_margin": {
"passed": True,
"required_margin_bytes": 524288,
"checks": [
{
"image": "kernel.bin",
"image_size_bytes": 1048576,
"partitions": [
{"alias": "kernel.A", "size_bytes": 2097152}
],
},
{
"image": "rootfs.bin",
"image_size_bytes": 1048576,
"partitions": [
{"alias": "rootfs.A", "size_bytes": 2097152}
],
},
],
},
}
),
encoding="utf-8",
)
git(ready_psp, "add", "psp.json", "metadata/bsp.lock.json")
git(
ready_psp,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"commit",
"-qm",
"ready fixture",
)
ready_commit = git(ready_psp, "rev-parse", "HEAD")
generated_contract = root / "generated-contract.json"
generated = record_packaging_result.create_contract(
ready_project, "CV1841CP", generated_contract
)
assert generated["product_command"] == "./product.py"
assert generated["release_commands"] == ["./release.py 0", "./release.py 1"]
ready_state = root / "evidence" / "ready-state.json"
preflight = record_packaging_result.packaging_preflight(
generated_contract,
ready_state,
psp_commit=ready_commit,
formal=True,
)
assert preflight["passed"] is True, preflight
del ready_payload["partition"]["partitionTable"][2]["file"]
(ready_psp / "psp.json").write_text(
json.dumps(ready_payload), encoding="utf-8"
)
failed_state = root / "evidence" / "failed-preflight.json"
failed_preflight = record_packaging_result.packaging_preflight(
generated_contract,
failed_state,
psp_source=ready_psp,
)
assert failed_preflight["passed"] is False
assert any("data 分区缺少 file" in error for error in failed_preflight["errors"])
print("PSP 组包证据测试通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Behavioral tests for render_psp_handoff.py."""
import json
import subprocess
import tempfile
from pathlib import Path
import render_psp_handoff
def git(path: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout.strip()
def write(path: Path, payload: dict) -> Path:
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
psp = root / "psp"
psp.mkdir()
(psp / "psp.json").write_text("{}", encoding="utf-8")
(psp / "metadata").mkdir()
bsp = write(
psp / "metadata" / "bsp.lock.json", {"schema_version": 1, "build_id": "B1"}
)
git(psp, "init", "-q")
git(psp, "add", "psp.json", "metadata/bsp.lock.json")
git(
psp,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"commit",
"-qm",
"candidate",
)
commit = git(psp, "rev-parse", "HEAD")
image = root / "flash.img"
image.write_bytes(b"flash")
image_hash = render_psp_handoff.subprocess.run(
["sha256sum", str(image)], text=True, stdout=subprocess.PIPE, check=True
).stdout.split()[0]
contract = write(
root / "packaging-contract.json",
{
"schema_version": 2,
"contract_type": "emx21-project-packaging",
},
)
contract_hash = render_psp_handoff.sha256_file(contract)
static = write(root / "static.json", {"passed": True})
packaging = write(
root / "packaging.json",
{
"schema_version": 2,
"passed": True,
"contract": {
"path": str(contract),
"sha256": contract_hash,
"contract_id": "fixture",
},
"expected_psp_commit": commit,
"image": {"path": str(image), "sha256": image_hash},
},
)
device = write(
root / "device.json",
{
"result": "PASS",
"evidence_source": "USER_REPORTED",
"psp_commit": commit,
"image_path": str(image),
"image_sha256": image_hash,
"checks": [{"name": "boot", "result": "PASS"}],
"unexecuted": [],
},
)
application = write(
root / "application.json",
{
"result": "PASS",
"psp_commit": commit,
"application_commit": "a" * 40,
"unexecuted": [],
"internal_approved": True,
},
)
approval = write(
root / "approval.json",
{
"internal_approved": True,
"confirmed_by": "embedded-lowlevel owner",
"confirmed_at": "2026-09-02T00:00:00+00:00",
"confirmed_scope": "packaged candidate",
},
)
context = write(
root / "context.json",
{
"work_id": "WORK-1",
"subtask_id": "SUB-1",
"phase": "implementation",
"wave": "wave-1",
"role": "embedded-lowlevel",
},
)
repository_url = "ssh://git.example/psp-NEW.git"
remote = {
"schema_version": 1,
"passed": True,
"checked_at": "2026-09-02T00:00:00+00:00",
"repository_url": repository_url,
"commit": commit,
"refs": ["refs/heads/candidate/SUB-1"],
"errors": [],
}
candidate = render_psp_handoff.render(
"NEW",
"CV1841CP",
"candidate",
psp,
None,
"psp-REF",
None,
static,
bsp,
None,
None,
None,
None,
None,
None,
None,
[],
[],
[],
None,
False,
)
assert candidate["passed"] is True, candidate
assert candidate["etunel"]["sent"] is False
packaged = render_psp_handoff.render(
"NEW",
"CV1841CP",
"packaged",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
packaging,
None,
None,
context,
approval,
"Media-1",
"AI-1",
[],
[],
[],
remote,
True,
)
assert packaged["passed"] is True, packaged
assert packaged["etunel"]["prepared_for_send"] is True
assert packaged["etunel"]["sent"] is False
before_tag = render_psp_handoff.render(
"NEW",
"CV1841CP",
"accepted",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
packaging,
device,
application,
context,
approval,
"Media-1",
"AI-1",
[],
[],
[],
remote,
True,
)
assert before_tag["passed"] is False
assert any("accepted" in error for error in before_tag["errors"])
git(
psp,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"tag",
"-a",
f"accepted/{commit[:12]}",
"-m",
"accepted fixture",
)
accepted = render_psp_handoff.render(
"NEW",
"CV1841CP",
"accepted",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
packaging,
device,
application,
context,
approval,
"Media-1",
"AI-1",
[],
[],
[],
remote,
True,
)
assert accepted["passed"] is True, accepted
no_remote = render_psp_handoff.render(
"NEW",
"CV1841CP",
"packaged",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
packaging,
None,
None,
context,
approval,
"Media-1",
"AI-1",
[],
[],
[],
None,
True,
)
assert no_remote["passed"] is False
assert any("远端" in error for error in no_remote["errors"])
candidate_etunel = render_psp_handoff.render(
"NEW",
"CV1841CP",
"candidate",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
None,
None,
None,
context,
approval,
None,
None,
[],
[],
[],
remote,
True,
)
assert candidate_etunel["passed"] is False
assert any("packaged" in error for error in candidate_etunel["errors"])
bare = root / "remote.git"
subprocess.run(["git", "init", "--bare", "-q", str(bare)], check=True)
git(psp, "remote", "add", "fixture", str(bare))
git(psp, "push", "-q", "fixture", f"{commit}:refs/heads/candidate")
reachable = render_psp_handoff.verify_remote(str(bare), commit)
assert reachable["passed"] is True, reachable
print("PSP handoff renderer tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Behavioral tests for validate_bsp_handoff.py."""
import hashlib
import json
import tempfile
from pathlib import Path
import validate_bsp_handoff
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
imgs = root / "imgs" / "20260902_1"
imgs.mkdir(parents=True)
artifact = imgs / "boot.bin"
artifact.write_bytes(b"boot")
kernel = imgs / "kernel.bin"
kernel.write_bytes(b"kernel")
rootfs = imgs / "rootfs.bin"
rootfs.write_bytes(b"rootfs")
project = root / "CV1841CP-BOARD_dualos"
partition = project / "configs" / "board" / "partition" / "partition_spinand.xml"
partition.parent.mkdir(parents=True)
partition.write_text(
"""<physical_partition type="spinand">
<partition label="BOOT" alias="kernel.A" size_in_kb="1024" file="kernel.bin"/>
<partition label="ROOTFS" alias="rootfs.A" size_in_kb="1024" file="rootfs.bin"/>
</physical_partition>
""",
encoding="utf-8",
)
checks = []
for image in (kernel, rootfs):
size = image.stat().st_size
checks.append(
{
"image": image.name,
"image_path": str(image),
"image_size_bytes": size,
"partitions": [
{
"label": "BOOT" if image.name == "kernel.bin" else "ROOTFS",
"alias": "kernel.A" if image.name == "kernel.bin" else "rootfs.A",
"file": image.name,
"size_in_kb": 1024,
"size_bytes": 1024 * 1024,
}
],
"effective_partition_size_bytes": 1024 * 1024,
"required_margin_bytes": 512 * 1024,
"actual_margin_bytes": 1024 * 1024 - size,
"passed": True,
}
)
margin = {
"schema_version": 1,
"passed": True,
"checked_at": "2026-09-02T00:00:00+00:00",
"partition_table": str(partition),
"partition_table_sha256": hashlib.sha256(partition.read_bytes()).hexdigest(),
"required_margin_bytes": 512 * 1024,
"checks": checks,
}
margin_path = project / "doc" / "bsp_adaptation" / "validation" / "image_partition_margin_20260902_1.json"
margin_path.parent.mkdir(parents=True)
margin_path.write_text(json.dumps(margin), encoding="utf-8")
manifest = root / "psp_handoff.json"
payload = {
"schema_version": 1,
"handoff_type": "bsp-to-psp",
"producer": {
"project": "CV1841CP-BOARD_dualos",
"project_path": str(project),
},
"baseline": {
"chip": "CV1841CP",
"sdk": "cv184x_v6.3.3",
"board_build_type": "cv1841cp_wevb_0015a_spinand",
},
"build": {
"version": "20260902_1",
"status": "build_passed",
"exit_code": 0,
"completion_marker_seen": True,
"imgs_version_path": str(imgs),
"image_partition_margin_path": str(margin_path),
},
"artifacts": [
{
"relative_path": item.name,
"absolute_path": str(item),
"size": item.stat().st_size,
"sha256": hashlib.sha256(item.read_bytes()).hexdigest(),
}
for item in (artifact, kernel, rootfs)
],
"validation": {
"image_partition_margin": margin,
"hardware": "pending",
"owner_acceptance": "pending",
"release": "not-authorized",
},
}
manifest.write_text(json.dumps(payload), encoding="utf-8")
result = validate_bsp_handoff.validate_handoff(
manifest,
"CV1841CP-BOARD_dualos",
"CV1841CP",
"cv184x_v6.3.3",
"cv1841cp_wevb_0015a_spinand",
)
assert result["passed"] is True, result
assert result["dependency"]["verified_artifact_count"] == 3
assert result["dependency"]["build_id"] == "20260902_1"
assert result["dependency"]["chip"] == "CV1841CP"
assert result["dependency"]["image_partition_margin"]["passed"] is True
assert result["dependency"]["artifacts"][0]["sha256"]
assert result["warnings"]
missing_margin_payload = json.loads(json.dumps(payload))
missing_margin_payload["validation"].pop("image_partition_margin")
missing_margin_payload["build"].pop("image_partition_margin_path")
missing_margin_manifest = root / "missing_margin_handoff.json"
missing_margin_manifest.write_text(
json.dumps(missing_margin_payload), encoding="utf-8"
)
missing_margin = validate_bsp_handoff.validate_handoff(missing_margin_manifest)
assert missing_margin["passed"] is False
assert any("分区余量证据" in error for error in missing_margin["errors"])
artifact.write_bytes(b"tampered")
tampered = validate_bsp_handoff.validate_handoff(manifest)
assert tampered["passed"] is False
assert any("大小不匹配" in error for error in tampered["errors"])
print("BSP handoff consumer tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""Behavioral tests for validate_psp.py."""
import hashlib
import json
import tempfile
from pathlib import Path
import validate_psp
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
psp = root / "psp"
(psp / "configs").mkdir(parents=True)
(psp / "scripts").mkdir()
(psp / "AiPlugin").mkdir()
artifact_root = root / "imgs" / "20260902_1"
artifact_root.mkdir(parents=True)
artifact = artifact_root / "boot.bin"
artifact.write_bytes(b"boot")
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
kernel = artifact_root / "kernel.bin"
kernel.write_bytes(b"kernel")
rootfs = artifact_root / "rootfs.bin"
rootfs.write_bytes(b"rootfs")
(psp / "psp.json").write_text(
json.dumps(
{
"EmxMediaVersion": "EmxMedia-1",
"EmxAiPluginVersion": "EmxAiPlugin-1",
"EmxResToProduct": [{"src": str(artifact), "dst": "imgs/boot.bin"}],
}
),
encoding="utf-8",
)
(psp / "AiPlugin" / "profile.json").write_text(
json.dumps({"EmxSdkVersion": "EmxSdk-1"}), encoding="utf-8"
)
(psp / "configs" / "same.json").write_text(
json.dumps(
{
"source": "psp",
"tool_cache": "/var/tmp/psp-tool-only",
"endpoint": "https://git.example/psp",
}
),
encoding="utf-8",
)
sensor_dir = psp / "configs" / "media" / "sensor"
sensor_dir.mkdir(parents=True)
(sensor_dir / "sensor_cfg.ini").write_text(
"[sensor]\nname = 0X201C3B40\n", encoding="utf-8"
)
expected_sensors = [("sensor", "SMS_SC4336P_MIPI_4M_30FPS_10BIT")]
(psp / "scripts" / "start.sh").write_text("#!/bin/sh\ntrue\n", encoding="utf-8")
app = root / "orgConfigs"
app.mkdir()
(app / "same.json").write_text('{"source":"app"}', encoding="utf-8")
(psp / "metadata").mkdir()
(psp / "metadata" / "allowed-overrides.json").write_text(
json.dumps(
{
"schema_version": 1,
"overrides": [
{
"psp_path": "configs/same.json",
"application_path": "orgConfigs/same.json",
"winner": "psp",
"reason": "board config",
"confirmed_by": "PSP owner",
"confirmed_at": "2026-09-02T00:00:00+00:00",
}
],
}
),
encoding="utf-8",
)
handoff = root / "psp_handoff.json"
bsp_project = root / "CV1841CP-NEW_dualos"
partition = bsp_project / "configs" / "board" / "partition" / "partition_spinand.xml"
partition.parent.mkdir(parents=True)
partition.write_text(
"""<physical_partition type="spinand">
<partition label="BOOT" alias="kernel.A" size_in_kb="1024" file="kernel.bin"/>
<partition label="ROOTFS" alias="rootfs.A" size_in_kb="1024" file="rootfs.bin"/>
</physical_partition>
""",
encoding="utf-8",
)
margin_checks = []
for image in (kernel, rootfs):
size = image.stat().st_size
margin_checks.append(
{
"image": image.name,
"image_path": str(image),
"image_size_bytes": size,
"partitions": [
{
"label": "BOOT" if image.name == "kernel.bin" else "ROOTFS",
"alias": "kernel.A" if image.name == "kernel.bin" else "rootfs.A",
"file": image.name,
"size_in_kb": 1024,
"size_bytes": 1024 * 1024,
}
],
"effective_partition_size_bytes": 1024 * 1024,
"required_margin_bytes": 512 * 1024,
"actual_margin_bytes": 1024 * 1024 - size,
"passed": True,
}
)
margin = {
"schema_version": 1,
"passed": True,
"checked_at": "2026-09-02T00:00:00+00:00",
"partition_table": str(partition),
"partition_table_sha256": hashlib.sha256(partition.read_bytes()).hexdigest(),
"required_margin_bytes": 512 * 1024,
"checks": margin_checks,
}
margin_path = bsp_project / "doc" / "bsp_adaptation" / "validation" / "image_partition_margin_20260902_1.json"
margin_path.parent.mkdir(parents=True)
margin_path.write_text(json.dumps(margin), encoding="utf-8")
handoff.write_text(
json.dumps(
{
"schema_version": 1,
"handoff_type": "bsp-to-psp",
"producer": {
"project": "CV1841CP-NEW_dualos",
"project_path": str(bsp_project),
},
"baseline": {
"chip": "CV1841CP",
"sdk": "cv184x-v1",
"board_build_type": "cv1841cp_wevb_0015a_spinand",
"system_form": "dual-os",
},
"build": {
"version": "20260902_1",
"status": "build_passed",
"exit_code": 0,
"completion_marker_seen": True,
"imgs_version_path": str(artifact_root),
"image_partition_margin_path": str(margin_path),
},
"artifacts": [
{
"relative_path": item.name,
"absolute_path": str(item),
"size": item.stat().st_size,
"sha256": hashlib.sha256(item.read_bytes()).hexdigest(),
}
for item in (artifact, kernel, rootfs)
],
"validation": {
"image_partition_margin": margin,
"hardware": "pending",
"owner_acceptance": "pending",
"release": "not-authorized",
},
}
),
encoding="utf-8",
)
lock = psp / "metadata" / "bsp.lock.json"
result = validate_psp.validate_psp(
psp,
"NEW",
"EmxSdk-1",
"EmxMedia-1",
"EmxAiPlugin-1",
handoff,
lock,
"CV1841CP-NEW_dualos",
"CV1841CP",
"cv184x-v1",
"cv1841cp_wevb_0015a_spinand",
"dual-os",
[],
[(app, "configs")],
formal=False,
expected_sensors=expected_sensors,
)
assert result["passed"] is True, result
assert lock.is_file()
lock_payload = json.loads(lock.read_text(encoding="utf-8"))
assert lock_payload["image_partition_margin"]["required_margin_bytes"] == 512 * 1024
assert result["checks"]["configuration_overrides"][0]["allowed"] is True
assert any("个人或临时目录" in warning for warning in result["warnings"])
assert any(
item["source"] == "configs/same.json"
for item in result["checks"]["absolute_paths"]
)
assert all(
"git.example" not in item["path"]
for item in result["checks"]["absolute_paths"]
)
assert result["checks"]["sensor_baseline"]["sensors"][0]["matched"] is True
assert result["checks"]["sensor_baseline"]["enum_header"]["source"] == "bundled"
assert result["checks"]["sensor_baseline"]["enum_header"]["path"] == str(
validate_psp.bundled_sensor_header("cv184x")
)
cv184_items = validate_psp.parse_sensor_enum(
validate_psp.bundled_sensor_header("cv184x"), "_SNS_TYPE_E"
)
assert len(cv184_items) == 244
assert cv184_items["SMS_SC4336P_MIPI_4M_30FPS_10BIT"] == 0X201C3B40
assert cv184_items["IMGDS_MIS40C1_MIPI_4M_20FPS_1L_10BIT"] == 0X00003A00
assert "BYD_BF2253L_MIPI_1200P_30FPS_10BIT" not in cv184_items
formal = validate_psp.validate_psp(
psp,
"NEW",
"EmxSdk-1",
"EmxMedia-1",
"EmxAiPlugin-1",
handoff,
None,
"CV1841CP-NEW_dualos",
"CV1841CP",
"cv184x-v1",
"cv1841cp_wevb_0015a_spinand",
"dual-os",
[artifact_root, handoff],
[(app, "configs")],
formal=True,
expected_sensors=expected_sensors,
)
assert formal["passed"] is False
assert any(
"configs/same.json" in error and "个人或临时目录" in error
for error in formal["errors"]
)
artifact.write_bytes(b"tampered")
tampered = validate_psp.validate_psp(
psp,
"NEW",
None,
None,
None,
handoff,
None,
"CV1841CP-NEW_dualos",
"CV1841CP",
"cv184x-v1",
"cv1841cp_wevb_0015a_spinand",
"dual-os",
[],
[],
formal=False,
expected_sensors=expected_sensors,
)
assert tampered["passed"] is False
assert any("BSP handoff" in error for error in tampered["errors"])
missing_identity = validate_psp.validate_psp(
psp,
"NEW",
None,
None,
None,
handoff,
None,
None,
None,
None,
None,
None,
[],
[],
formal=False,
expected_sensors=expected_sensors,
)
assert missing_identity["passed"] is False
assert any("必需身份字段" in error for error in missing_identity["errors"])
(sensor_dir / "sensor_cfg.ini").write_text(
"[sensor]\nname = SMS_SC4336P_MIPI_4M_30FPS_10BIT\n", encoding="utf-8"
)
sensor_errors: list[str] = []
validate_psp.validate_sensor_baseline(
psp,
"CV1841CP",
None,
expected_sensors,
sensor_errors,
)
assert any("必须填写" in error and "枚举值" in error for error in sensor_errors)
cv181_psp = root / "cv181-psp"
cv181_sensor_dir = cv181_psp / "configs" / "media" / "isp"
cv181_sensor_dir.mkdir(parents=True)
(cv181_sensor_dir / "sensor_cfg.ini").write_text(
"[sensor]\nname = SMS_SC4336P_MIPI_4M_30FPS_10BIT\n", encoding="utf-8"
)
cv181_errors: list[str] = []
cv181_result = validate_psp.validate_sensor_baseline(
cv181_psp,
"CV1815J",
None,
expected_sensors,
cv181_errors,
)
assert cv181_errors == [], cv181_errors
assert cv181_result["sensors"][0]["matched"] is True
assert cv181_result["enum_header"]["source"] == "bundled"
cv181_items = validate_psp.parse_sensor_enum(
validate_psp.bundled_sensor_header("cv181x"), "_SAMPLE_SNS_TYPE_E"
)
assert len(cv181_items) == 213
assert "SMS_SC4336P_MIPI_4M_30FPS_10BIT" in cv181_items
print("PSP validator tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Validate a BSP-to-PSP handoff and its immutable artifact hashes."""
from __future__ import annotations
import argparse
import hashlib
import json
import xml.etree.ElementTree as ET
from pathlib import Path
REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024
REQUIRED_PARTITION_IMAGES = {"kernel.bin", "rootfs.bin"}
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 within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def validate_image_partition_margin(
payload: object,
build: dict[str, object],
producer: dict[str, object],
verified_artifacts: list[dict[str, object]],
errors: list[str],
) -> dict[str, object] | None:
if not isinstance(payload, dict) or payload.get("passed") is not True:
errors.append("BSP handoff 缺少通过的 kernel/rootfs 分区余量证据")
return None
if payload.get("schema_version") != 1:
errors.append("kernel/rootfs 分区余量证据 schema_version 必须为 1")
if payload.get("required_margin_bytes") != REQUIRED_IMAGE_MARGIN_BYTES:
errors.append("kernel/rootfs 分区余量阈值必须精确为 524288 字节")
project_value = producer.get("project_path")
table_value = payload.get("partition_table")
table_hash = payload.get("partition_table_sha256")
table_path: Path | None = None
if (
not isinstance(project_value, str)
or not Path(project_value).is_absolute()
or not isinstance(table_value, str)
or not Path(table_value).is_absolute()
):
errors.append("分区余量证据缺少绝对 BSP project_path/partition_table")
else:
project_path = Path(project_value).resolve(strict=False)
table_path = Path(table_value).resolve(strict=False)
if not within(table_path, project_path / "configs"):
errors.append("分区余量证据的 partition_table 不在 BSP 项目 configs/ 内")
if not table_path.is_file() or table_path.is_symlink():
errors.append(f"分区余量证据的 partition_table 不可读: {table_path}")
table_path = None
elif not isinstance(table_hash, str) or sha256_file(table_path) != table_hash:
errors.append("分区余量证据的 partition_table SHA-256 不匹配")
evidence_value = build.get("image_partition_margin_path")
if not isinstance(evidence_value, str) or not Path(evidence_value).is_absolute():
errors.append("build.image_partition_margin_path 必须是绝对路径")
else:
evidence_path = Path(evidence_value).resolve(strict=False)
if not evidence_path.is_file() or evidence_path.is_symlink():
errors.append(f"镜像分区余量证据文件不可读: {evidence_path}")
else:
try:
external_payload = json.loads(evidence_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"镜像分区余量证据文件解析失败: {exc}")
else:
if external_payload != payload:
errors.append("handoff 内嵌分区余量证据与外部证据文件不一致")
partitions_by_image: dict[str, list[int]] = {}
if table_path is not None:
try:
table_root = ET.parse(table_path).getroot()
except ET.ParseError as exc:
errors.append(f"分区表 XML 解析失败: {exc}")
else:
for element in table_root.iter("partition"):
image_name = Path(element.attrib.get("file", "")).name
if image_name not in REQUIRED_PARTITION_IMAGES:
continue
raw_size = element.attrib.get("size_in_kb")
try:
size_bytes = int(raw_size or "", 10) * 1024
except ValueError:
errors.append(f"分区表中 {image_name} 的 size_in_kb 非法")
continue
partitions_by_image.setdefault(image_name, []).append(size_bytes)
artifacts_by_name: dict[str, list[dict[str, object]]] = {}
for artifact in verified_artifacts:
name = Path(str(artifact.get("relative_path", ""))).name
artifacts_by_name.setdefault(name, []).append(artifact)
checks = payload.get("checks")
if not isinstance(checks, list):
errors.append("kernel/rootfs 分区余量证据 checks 必须是数组")
return dict(payload)
checks_by_image = {
item.get("image"): item for item in checks if isinstance(item, dict)
}
if set(checks_by_image) != REQUIRED_PARTITION_IMAGES or len(checks) != 2:
errors.append("分区余量证据必须且只能包含 kernel.bin、rootfs.bin 两项")
for image_name in sorted(REQUIRED_PARTITION_IMAGES):
check = checks_by_image.get(image_name)
artifacts_for_image = artifacts_by_name.get(image_name, [])
if not isinstance(check, dict):
continue
if len(artifacts_for_image) != 1:
errors.append(f"已验证 artifacts 中 {image_name} 必须唯一")
continue
artifact = artifacts_for_image[0]
sizes = partitions_by_image.get(image_name, [])
if not sizes:
errors.append(f"当前分区表未找到 {image_name} 的有效分区")
continue
image_size = artifact.get("size")
effective_size = min(sizes)
actual_margin = effective_size - int(image_size) if isinstance(image_size, int) else None
if check.get("passed") is not True:
errors.append(f"{image_name} 分区余量检查未通过")
if check.get("image_path") != artifact.get("absolute_path"):
errors.append(f"{image_name} 分区余量证据的镜像路径不一致")
if check.get("image_size_bytes") != image_size:
errors.append(f"{image_name} 分区余量证据的镜像大小不一致")
if check.get("effective_partition_size_bytes") != effective_size:
errors.append(f"{image_name} 分区余量证据的最小分区大小不一致")
if check.get("required_margin_bytes") != REQUIRED_IMAGE_MARGIN_BYTES:
errors.append(f"{image_name} 要求余量不是 524288 字节")
if check.get("actual_margin_bytes") != actual_margin:
errors.append(f"{image_name} 分区实际余量计算不一致")
if actual_margin is None or actual_margin < REQUIRED_IMAGE_MARGIN_BYTES:
errors.append(f"{image_name} 相对最小对应分区的余量不足 512 KiB")
return dict(payload)
def validate_handoff(
manifest_path: Path,
expected_project: str | None = None,
expected_chip: str | None = None,
expected_sdk: str | None = None,
expected_build_type: str | None = None,
expected_system_form: str | None = None,
) -> dict[str, object]:
errors: list[str] = []
warnings: list[str] = []
manifest_path = manifest_path.expanduser().resolve(strict=True)
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
if payload.get("schema_version") != 1:
errors.append("schema_version 必须为 1")
if payload.get("handoff_type") != "bsp-to-psp":
errors.append("handoff_type 必须为 bsp-to-psp")
producer = payload.get("producer")
baseline = payload.get("baseline")
build = payload.get("build")
validation = payload.get("validation")
artifacts = payload.get("artifacts")
if not isinstance(producer, dict):
errors.append("缺少 producer 对象")
producer = {}
if not isinstance(baseline, dict):
errors.append("缺少 baseline 对象")
baseline = {}
if not isinstance(build, dict):
errors.append("缺少 build 对象")
build = {}
if not isinstance(validation, dict):
errors.append("缺少 validation 对象")
validation = {}
if not isinstance(artifacts, list) or not artifacts:
errors.append("artifacts 必须是非空数组")
artifacts = []
expected_values = {
"project": (producer.get("project"), expected_project),
"chip": (baseline.get("chip"), expected_chip),
"sdk": (baseline.get("sdk"), expected_sdk),
"build_type": (baseline.get("board_build_type"), expected_build_type),
"system_form": (baseline.get("system_form"), expected_system_form),
}
for field, (actual, expected) in expected_values.items():
if expected is not None and actual != expected:
errors.append(f"{field} 不匹配:期望 {expected!r},实际 {actual!r}")
if build.get("status") != "build_passed" or build.get("exit_code") != 0:
errors.append("BSP 构建状态不是 build_passed/exit_code=0")
if build.get("completion_marker_seen") is not True:
errors.append("BSP 构建未记录正常完成标记")
imgs_value = build.get("imgs_version_path")
imgs_path: Path | None = None
if not isinstance(imgs_value, str) or not Path(imgs_value).is_absolute():
errors.append("imgs_version_path 必须是精确的绝对构建版本目录")
else:
imgs_path = Path(imgs_value).resolve(strict=False)
if not imgs_path.is_dir() or imgs_path.is_symlink():
errors.append(f"imgs_version_path 不存在或不是普通目录: {imgs_path}")
verified = 0
verified_artifacts: list[dict[str, object]] = []
seen_relative_paths: set[str] = set()
for index, artifact in enumerate(artifacts):
prefix = f"artifacts[{index}]"
if not isinstance(artifact, dict):
errors.append(f"{prefix} 必须是对象")
continue
relative = artifact.get("relative_path")
absolute = artifact.get("absolute_path")
size = artifact.get("size")
digest = artifact.get("sha256")
if not isinstance(relative, str) or not relative or Path(relative).is_absolute():
errors.append(f"{prefix}.relative_path 非法")
continue
if relative in seen_relative_paths:
errors.append(f"{prefix}.relative_path 重复: {relative}")
continue
seen_relative_paths.add(relative)
if not isinstance(absolute, str) or not Path(absolute).is_absolute():
errors.append(f"{prefix}.absolute_path 非法")
continue
path = Path(absolute).resolve(strict=False)
if imgs_path is None or not within(path, imgs_path):
errors.append(f"{prefix} 不在 imgs_version_path 内")
continue
if path != imgs_path / relative:
errors.append(f"{prefix} 的绝对/相对路径不一致")
continue
if not path.is_file() or path.is_symlink():
errors.append(f"{prefix} 的产物不存在或不是普通文件: {path}")
continue
if not isinstance(size, int) or size <= 0 or path.stat().st_size != size:
errors.append(f"{prefix} 的大小不匹配")
continue
if not isinstance(digest, str) or sha256_file(path) != digest:
errors.append(f"{prefix} 的 SHA-256 不匹配")
continue
verified += 1
verified_artifacts.append(
{
"relative_path": relative,
"absolute_path": str(path),
"size": size,
"sha256": digest,
}
)
image_partition_margin = validate_image_partition_margin(
validation.get("image_partition_margin"),
build,
producer,
verified_artifacts,
errors,
)
if validation.get("hardware") != "passed":
warnings.append(f"继承 BSP 硬件验证状态: {validation.get('hardware')!r}")
if validation.get("release") != "authorized":
warnings.append(f"继承 BSP 发布状态: {validation.get('release')!r}")
producer_git = producer.get("git")
if not isinstance(producer_git, dict) or not isinstance(producer_git.get("commit"), str):
warnings.append("BSP handoff 未记录可追踪的源码 commit")
return {
"passed": not errors,
"errors": errors,
"warnings": warnings,
"dependency": {
"module": "BSP",
"project": producer.get("project"),
"reference_project": producer.get("reference_project"),
"bsp_commit": producer_git.get("commit") if isinstance(producer_git, dict) else None,
"bsp_source_dirty": producer_git.get("dirty") if isinstance(producer_git, dict) else None,
"manifest_path": str(manifest_path),
"manifest_sha256": sha256_file(manifest_path),
"chip": baseline.get("chip"),
"sdk": baseline.get("sdk"),
"architecture": baseline.get("architecture"),
"flash": baseline.get("flash"),
"build_type": baseline.get("board_build_type"),
"system_form": baseline.get("system_form"),
"build_id": build.get("version"),
"imgs_version_path": str(imgs_path) if imgs_path else imgs_value,
"artifact_count": len(artifacts),
"verified_artifact_count": verified,
"artifacts": verified_artifacts,
"image_partition_margin": image_partition_margin,
"hardware_validation": validation.get("hardware"),
"owner_acceptance": validation.get("owner_acceptance"),
"release": validation.get("release"),
},
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("manifest", type=Path)
parser.add_argument("--project")
parser.add_argument("--chip")
parser.add_argument("--sdk")
parser.add_argument("--build-type")
parser.add_argument("--system-form", choices=("single-os", "dual-os"))
args = parser.parse_args()
try:
result = validate_handoff(
args.manifest,
args.project,
args.chip,
args.sdk,
args.build_type,
args.system_form,
)
except (OSError, json.JSONDecodeError) as exc:
result = {"passed": False, "errors": [str(exc)], "warnings": []}
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,873 @@
#!/usr/bin/env python3
"""Validate a project PSP without changing BSP, application, or packaging inputs."""
from __future__ import annotations
import argparse
import configparser
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any, Iterator
import validate_bsp_handoff
PERSONAL_OR_TEMP_ROOTS = (Path("/home"), Path("/tmp"), Path("/var/tmp"))
TEXT_PATH_SUFFIXES = {".cfg", ".conf", ".ini", ".py", ".sh"}
ABSOLUTE_PATH_RE = re.compile(r"(?<![A-Za-z0-9_.-])(/[A-Za-z0-9_@%+=:,./-]+)")
BUILD_INPUT_JSONS = {"psp.json", "metadata/bsp.lock.json"}
SENSOR_SECTION_RE = re.compile(r"^sensor(?:\d+)?$", re.IGNORECASE)
C_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
SKILL_ROOT = Path(__file__).resolve().parents[2]
BUNDLED_SENSOR_HEADERS = {
"cv181x": SKILL_ROOT / "references" / "sensor-enums" / "cv181x" / "sensor_list.h",
"cv184x": SKILL_ROOT / "references" / "sensor-enums" / "cv184x" / "sensor_cfg.h",
}
class ValidationError(RuntimeError):
"""The requested validation cannot be performed 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 load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def normalize_relative(value: str, field: str) -> str:
pure = PurePosixPath(value)
if pure.is_absolute() or ".." in pure.parts or value in {"", "."}:
raise ValidationError(f"{field} 必须是仓库内精确相对路径: {value!r}")
if any(character in value for character in "*?[]"):
raise ValidationError(f"{field} 不允许通配符: {value!r}")
return pure.as_posix()
def iter_strings(value: Any, location: str = "$") -> Iterator[tuple[str, str]]:
if isinstance(value, dict):
for key, child in value.items():
yield from iter_strings(child, f"{location}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
yield from iter_strings(child, f"{location}[{index}]")
elif isinstance(value, str):
yield location, value
def under(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def collect_json(root: Path, errors: list[str]) -> dict[str, Any]:
payloads: dict[str, Any] = {}
for path in sorted(root.rglob("*.json")):
if ".git" in path.parts:
continue
relative = path.relative_to(root).as_posix()
try:
payloads[relative] = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"JSON 解析失败 {relative}: {exc}")
return payloads
def validate_ini(root: Path, errors: list[str]) -> int:
count = 0
for path in sorted(root.rglob("*.ini")):
if ".git" in path.parts:
continue
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
with path.open("r", encoding="utf-8") as handle:
parser.read_file(handle)
count += 1
except (OSError, UnicodeError, configparser.Error) as exc:
errors.append(f"INI 解析失败 {path.relative_to(root).as_posix()}: {exc}")
return count
def parse_expected_sensor(value: str) -> tuple[str, str]:
if "=" not in value:
raise argparse.ArgumentTypeError(
"expected-sensor 必须为 INI_SECTION=ENUM_NAME"
)
section, enum_name = (item.strip() for item in value.split("=", 1))
if not SENSOR_SECTION_RE.fullmatch(section):
raise argparse.ArgumentTypeError(f"Sensor section 非法: {section!r}")
if not C_IDENTIFIER_RE.fullmatch(enum_name):
raise argparse.ArgumentTypeError(f"Sensor 枚举名称非法: {enum_name!r}")
return section.casefold(), enum_name
def cv18_sensor_family(chip: str | None) -> str | None:
normalized = re.sub(r"[^A-Za-z0-9]", "", chip or "").upper()
if normalized.startswith("CV181"):
return "cv181x"
if normalized.startswith("CV184"):
return "cv184x"
return None
def bundled_sensor_header(family: str) -> Path:
path = BUNDLED_SENSOR_HEADERS.get(family)
if path is None:
raise ValidationError(f"没有 {family!r} 对应的内置 Sensor 枚举基线")
path = path.resolve(strict=True)
if not path.is_file() or path.is_symlink():
raise ValidationError(f"内置 Sensor 枚举基线缺失或不是普通文件: {path}")
return path
def parse_sensor_enum(header: Path, enum_tag: str) -> dict[str, int | None]:
header = header.expanduser().resolve(strict=True)
if not header.is_file() or header.is_symlink():
raise ValidationError(f"Sensor 枚举头文件必须是普通文件: {header}")
text = header.read_text(encoding="utf-8", errors="replace")
match = re.search(
rf"typedef\s+enum\s+{re.escape(enum_tag)}\s*\{{(.*?)\}}\s*"
r"[A-Za-z_][A-Za-z0-9_]*\s*;",
text,
flags=re.DOTALL,
)
if match is None:
raise ValidationError(f"头文件中未找到 {enum_tag}: {header}")
body = re.sub(r"/\*.*?\*/", "", match.group(1), flags=re.DOTALL)
body = "\n".join(
line.split("//", 1)[0]
for line in body.splitlines()
if not line.lstrip().startswith("#")
)
result: dict[str, int | None] = {}
for raw in body.split(","):
item = raw.strip()
if not item:
continue
name, separator, expression = item.partition("=")
name = name.strip()
if not C_IDENTIFIER_RE.fullmatch(name):
continue
numeric: int | None = None
if separator:
literal = expression.strip().rstrip("uUlL")
if re.fullmatch(r"(?:0[xX][0-9A-Fa-f]+|[0-9]+)", literal):
numeric = int(literal, 0)
result[name] = numeric
if not result:
raise ValidationError(f"{enum_tag} 未解析出任何枚举项: {header}")
return result
def validate_sensor_baseline(
psp_root: Path,
expected_chip: str | None,
sensor_enum_header: Path | None,
expected_sensors: list[tuple[str, str]],
errors: list[str],
) -> dict[str, object]:
family = cv18_sensor_family(expected_chip)
result: dict[str, object] = {"applicable": family is not None, "family": family}
if family is None:
if sensor_enum_header is not None or expected_sensors:
errors.append("非 CV181X/CV184X 项目不应提供 CV18 Sensor 枚举校验参数")
return result
candidates = sorted(
path
for path in psp_root.rglob("sensor_cfg.ini")
if ".git" not in path.parts and path.is_file() and not path.is_symlink()
)
if len(candidates) != 1:
errors.append(
f"CV18 PSP 必须唯一定位一个 sensor_cfg.ini,实际 {len(candidates)} 个"
)
result["config_candidates"] = [str(path) for path in candidates]
return result
config_path = candidates[0]
result["config_path"] = str(config_path)
result["config_sha256"] = sha256_file(config_path)
header_source = "override" if sensor_enum_header is not None else "bundled"
header = (
sensor_enum_header.expanduser().resolve(strict=True)
if sensor_enum_header is not None
else bundled_sensor_header(family)
)
expected_filename = "sensor_list.h" if family == "cv181x" else "sensor_cfg.h"
enum_tag = "_SAMPLE_SNS_TYPE_E" if family == "cv181x" else "_SNS_TYPE_E"
if header.name != expected_filename:
errors.append(
f"{family.upper()} 必须使用 {expected_filename},实际 {header.name}"
)
enum_items = parse_sensor_enum(header, enum_tag)
result["enum_header"] = {
"path": str(header),
"sha256": sha256_file(header),
"enum_tag": enum_tag,
"representation": "enum-name" if family == "cv181x" else "enum-value",
"source": header_source,
}
expected: dict[str, str] = {}
for section, enum_name in expected_sensors:
if section in expected:
errors.append(f"expected-sensor 重复指定 section: {section}")
expected[section] = enum_name
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
with config_path.open("r", encoding="utf-8") as handle:
parser.read_file(handle)
except (OSError, UnicodeError, configparser.Error) as exc:
errors.append(f"sensor_cfg.ini 解析失败: {exc}")
return result
configured = {
section.casefold(): parser.get(section, "name").strip()
for section in parser.sections()
if SENSOR_SECTION_RE.fullmatch(section) and parser.has_option(section, "name")
}
if not configured:
errors.append("sensor_cfg.ini 未找到带 name 的 [sensor*] section")
if not expected_sensors:
errors.append("CV18 Sensor 校验必须提供规格书选定的 expected-sensor")
missing_expectations = sorted(set(configured) - set(expected))
unknown_sections = sorted(set(expected) - set(configured))
if missing_expectations:
errors.append(
"以下 Sensor section 缺少规格书选定的枚举名称: "
+ ", ".join(missing_expectations)
)
if unknown_sections:
errors.append(
"expected-sensor 指向不存在或缺少 name 的 section: "
+ ", ".join(unknown_sections)
)
checks: list[dict[str, object]] = []
for section in sorted(configured):
configured_name = configured[section]
enum_name = expected.get(section)
item: dict[str, object] = {
"section": section,
"configured_name": configured_name,
"expected_enum_name": enum_name,
"matched": False,
}
if enum_name is None:
checks.append(item)
continue
if enum_name not in enum_items:
errors.append(f"{section} 规格枚举名称不在 {enum_tag} 中: {enum_name}")
checks.append(item)
continue
if family == "cv181x":
item["expected_config_value"] = enum_name
item["matched"] = configured_name == enum_name
if configured_name != enum_name:
errors.append(
f"{section}.name 必须填写枚举名称 {enum_name},实际 {configured_name}"
)
else:
expected_value = enum_items[enum_name]
item["expected_config_value"] = (
f"0X{expected_value:08X}" if expected_value is not None else None
)
try:
actual_value = int(configured_name, 0)
except ValueError:
actual_value = None
item["configured_numeric_value"] = actual_value
item["matched"] = expected_value is not None and actual_value == expected_value
if expected_value is None:
errors.append(f"{enum_name} 在 {enum_tag} 中不是可解析的整数枚举值")
elif actual_value != expected_value:
errors.append(
f"{section}.name 必须填写 {enum_name} 的枚举值 "
f"0X{expected_value:08X},实际 {configured_name}"
)
checks.append(item)
result["sensors"] = checks
return result
def validate_shell(root: Path, errors: list[str]) -> int:
count = 0
for path in sorted(root.rglob("*.sh")):
if ".git" in path.parts:
continue
result = subprocess.run(
["bash", "-n", str(path)],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
count += 1
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip()
errors.append(f"Shell 语法失败 {path.relative_to(root).as_posix()}: {message}")
return count
def path_risk(path: Path) -> str | None:
resolved = path.resolve(strict=False)
for root in PERSONAL_OR_TEMP_ROOTS:
if under(resolved, root):
return str(root)
return None
def text_absolute_paths(root: Path) -> Iterator[tuple[str, str, str]]:
for path in sorted(root.rglob("*")):
if (
not path.is_file()
or path.is_symlink()
or ".git" in path.parts
or path.suffix.lower() not in TEXT_PATH_SUFFIXES
or path.stat().st_size > 4 * 1024 * 1024
):
continue
data = path.read_bytes()
if b"\x00" in data:
continue
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
continue
source = path.relative_to(root).as_posix()
for line_number, line in enumerate(text.splitlines(), 1):
for match in ABSOLUTE_PATH_RE.finditer(line):
if (
match.start() >= 1
and line[match.start() - 1] == ":"
and match.group(1).startswith("//")
):
continue
value = match.group(1).rstrip(".,;:)]}'\"")
if value != "/":
yield source, f"line:{line_number}", value
def normalize_allowed_roots(values: list[Path]) -> list[Path]:
roots: list[Path] = []
for value in values:
expanded = value.expanduser()
if not expanded.is_absolute():
raise ValidationError(f"allowed-absolute-root 必须是绝对路径: {value}")
roots.append(expanded.resolve(strict=False))
return roots
def validate_absolute_paths(
root: Path,
payloads: dict[str, Any],
allowed_roots: list[Path],
formal: bool,
errors: list[str],
warnings: list[str],
) -> list[dict[str, object]]:
entries: list[dict[str, object]] = []
normalized_allowed = normalize_allowed_roots(allowed_roots)
references: list[tuple[str, str, str]] = []
for source, payload in payloads.items():
for location, value in iter_strings(payload):
if value.startswith("/"):
references.append((source, location, value))
references.extend(text_absolute_paths(root))
seen: set[tuple[str, str, str]] = set()
for source, location, value in references:
key = (source, location, value)
if key in seen:
continue
seen.add(key)
path = Path(value).resolve(strict=False)
exists = path.exists()
allowed_by = next((item for item in normalized_allowed if under(path, item)), None)
risk_root = None if allowed_by is not None else path_risk(path)
build_input = source in BUILD_INPUT_JSONS
entry = {
"source": source,
"location": location,
"path": value,
"resolved_path": str(path),
"exists": exists,
"build_input": build_input,
"allowed_by": str(allowed_by) if allowed_by else None,
"personal_or_temporary_root": risk_root,
}
entries.append(entry)
if not exists:
message = f"外部绝对路径不存在 {source}:{location}: {value}"
if build_input:
errors.append(f"外部绝对路径不存在 {source}:{location}: {value}")
else:
warnings.append(message)
if risk_root:
message = f"绝对路径位于个人或临时目录 {source}:{location}: {value}"
(errors if formal else warnings).append(message)
return entries
def load_allowed_overrides(psp_root: Path, errors: list[str]) -> set[tuple[str, str]]:
path = psp_root / "metadata" / "allowed-overrides.json"
if not path.exists():
return set()
try:
payload = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"allowed-overrides.json 解析失败: {exc}")
return set()
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
errors.append("metadata/allowed-overrides.json schema_version 必须为 1")
return set()
items = payload.get("overrides")
if not isinstance(items, list):
errors.append("metadata/allowed-overrides.json.overrides 必须是数组")
return set()
allowed: set[tuple[str, str]] = set()
for index, item in enumerate(items):
if not isinstance(item, dict):
errors.append(f"allowed-overrides.json.overrides[{index}] 必须是对象")
continue
try:
psp_path = normalize_relative(str(item.get("psp_path", "")), "psp_path")
app_path = normalize_relative(
str(item.get("application_path", "")), "application_path"
)
except ValidationError as exc:
errors.append(str(exc))
continue
if item.get("winner") != "psp":
errors.append(f"allowed-overrides.json.overrides[{index}].winner 必须为 psp")
for field in ("reason", "confirmed_by", "confirmed_at"):
if not isinstance(item.get(field), str) or not str(item.get(field)).strip():
errors.append(f"allowed-overrides.json.overrides[{index}].{field} 不能为空")
allowed.add((app_path, psp_path))
return allowed
def parse_override_map(value: str) -> tuple[Path, str]:
if "=" not in value:
raise argparse.ArgumentTypeError("override-map 必须为 APP_DIR=PSP_SUBDIR")
app, psp_subdir = value.split("=", 1)
try:
relative = normalize_relative(psp_subdir, "PSP_SUBDIR")
except ValidationError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
return Path(app).expanduser(), relative
def scan_overrides(
psp_root: Path,
mappings: list[tuple[Path, str]],
allowed: set[tuple[str, str]],
formal: bool,
errors: list[str],
warnings: list[str],
) -> list[dict[str, object]]:
conflicts: list[dict[str, object]] = []
for app_dir_raw, psp_subdir in mappings:
app_dir = app_dir_raw.resolve(strict=False)
psp_dir = psp_root / psp_subdir
if not app_dir.is_dir():
errors.append(f"应用配置目录不存在: {app_dir}")
continue
if not psp_dir.is_dir():
errors.append(f"PSP 配置目录不存在: {psp_dir}")
continue
for app_path in sorted(path for path in app_dir.rglob("*") if path.is_file()):
relative = app_path.relative_to(app_dir)
psp_path = psp_dir / relative
if not psp_path.is_file():
continue
app_relative = app_path.as_posix()
if app_dir_raw.is_absolute():
app_key = f"{app_dir_raw.name}/{relative.as_posix()}"
else:
app_key = (app_dir_raw / relative).as_posix()
psp_key = (PurePosixPath(psp_subdir) / relative.as_posix()).as_posix()
is_allowed = (app_key, psp_key) in allowed
conflict = {
"application_path": app_relative,
"application_key": app_key,
"application_sha256": sha256_file(app_path),
"psp_path": psp_path.as_posix(),
"psp_key": psp_key,
"psp_sha256": sha256_file(psp_path),
"winner": "psp",
"allowed": is_allowed,
}
conflicts.append(conflict)
if not is_allowed:
message = f"未确认的 PSP 覆盖: {app_key} -> {psp_key}"
(errors if formal else warnings).append(message)
return conflicts
def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[str, object]:
return {
"schema_version": 1,
"handoff_path": dependency.get("manifest_path"),
"handoff_sha256": dependency.get("manifest_sha256"),
"bsp_project": dependency.get("project"),
"bsp_commit": dependency.get("bsp_commit"),
"bsp_source_dirty": dependency.get("bsp_source_dirty"),
"chip": dependency.get("chip"),
"sdk": dependency.get("sdk"),
"build_type": dependency.get("build_type"),
"system_form": dependency.get("system_form"),
"build_id": dependency.get("build_id"),
"imgs_version_path": dependency.get("imgs_version_path"),
"artifacts": dependency.get("artifacts", []),
"image_partition_margin": dependency.get("image_partition_margin"),
"mixed_build": None,
"warnings": warnings,
}
def mixed_artifact_map(psp_root: Path, errors: list[str]) -> dict[str, str]:
path = psp_root / "metadata" / "bsp.lock.json"
if not path.is_file():
return {}
try:
payload = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"metadata/bsp.lock.json 解析失败: {exc}")
return {}
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
errors.append("metadata/bsp.lock.json schema_version 必须为 1")
return {}
mixed = payload.get("mixed_build")
if mixed is None:
return {}
if not isinstance(mixed, dict) or not isinstance(mixed.get("artifacts"), list):
errors.append("metadata/bsp.lock.json.mixed_build.artifacts 必须是数组")
return {}
result: dict[str, str] = {}
for index, item in enumerate(mixed["artifacts"]):
if not isinstance(item, dict):
errors.append(f"mixed_build.artifacts[{index}] 必须是对象")
continue
absolute = item.get("absolute_path")
digest = item.get("sha256")
required = ("build_id", "bsp_commit", "reason", "confirmed_by", "confirmed_at")
if not isinstance(absolute, str) or not Path(absolute).is_absolute():
errors.append(f"mixed_build.artifacts[{index}].absolute_path 非法")
continue
if not isinstance(digest, str) or len(digest) != 64:
errors.append(f"mixed_build.artifacts[{index}].sha256 非法")
continue
if any(not isinstance(item.get(field), str) or not item.get(field) for field in required):
errors.append(f"mixed_build.artifacts[{index}] 缺少来源/确认字段")
continue
result[str(Path(absolute).resolve(strict=False))] = digest
return result
def validate_bsp_sources(
psp_root: Path,
psp_json: dict[str, Any],
dependency: dict[str, object] | None,
errors: list[str],
) -> list[dict[str, object]]:
copied = psp_json.get("EmxResToProduct")
if not isinstance(copied, list):
return []
primary: dict[str, str] = {}
if dependency:
for item in dependency.get("artifacts", []):
if isinstance(item, dict) and isinstance(item.get("absolute_path"), str):
primary[str(Path(item["absolute_path"]).resolve(strict=False))] = str(
item.get("sha256", "")
)
mixed = mixed_artifact_map(psp_root, errors)
results: list[dict[str, object]] = []
for index, item in enumerate(copied):
if not isinstance(item, dict) or not isinstance(item.get("src"), str):
continue
value = item["src"]
if not value.startswith("/"):
continue
path = Path(value).resolve(strict=False)
source = "primary" if str(path) in primary else "mixed" if str(path) in mixed else None
expected_hash = primary.get(str(path)) or mixed.get(str(path))
actual_hash = sha256_file(path) if path.is_file() and not path.is_symlink() else None
if source is None:
errors.append(
f"psp.json.EmxResToProduct[{index}].src 未被 BSP handoff 或 mixed_build 锁定: {value}"
)
elif actual_hash != expected_hash:
errors.append(f"psp.json.EmxResToProduct[{index}].src hash 与 BSP 锁定不一致")
results.append(
{
"index": index,
"path": str(path),
"source": source,
"expected_sha256": expected_hash,
"actual_sha256": actual_hash,
}
)
return results
def find_key_values(payloads: dict[str, Any], normalized_key: str) -> list[dict[str, str]]:
found: list[dict[str, str]] = []
def visit(source: str, value: Any, location: str) -> None:
if isinstance(value, dict):
for key, child in value.items():
child_location = f"{location}.{key}"
if key.casefold().replace("_", "") == normalized_key and isinstance(child, str):
found.append({"source": source, "location": child_location, "value": child})
visit(source, child, child_location)
elif isinstance(value, list):
for index, child in enumerate(value):
visit(source, child, f"{location}[{index}]")
for source, payload in payloads.items():
visit(source, payload, "$")
return found
def validate_psp(
psp_root: Path,
project: str | None,
expected_sdk: str | None,
expected_media: str | None,
expected_ai_plugin: str | None,
bsp_handoff_path: Path | None,
bsp_lock_output: Path | None,
expected_bsp_project: str | None,
expected_chip: str | None,
expected_bsp_sdk: str | None,
expected_build_type: str | None,
expected_system_form: str | None,
allowed_absolute_roots: list[Path],
override_maps: list[tuple[Path, str]],
formal: bool,
sensor_enum_header: Path | None = None,
expected_sensors: list[tuple[str, str]] | None = None,
) -> dict[str, object]:
errors: list[str] = []
warnings: list[str] = []
psp_root = psp_root.expanduser().resolve(strict=True)
if not psp_root.is_dir() or psp_root.is_symlink():
raise ValidationError(f"PSP 根目录必须是普通目录: {psp_root}")
psp_json_path = psp_root / "psp.json"
if not psp_json_path.is_file() or psp_json_path.is_symlink():
raise ValidationError(f"缺少普通文件 psp.json: {psp_json_path}")
payloads = collect_json(psp_root, errors)
psp_json = payloads.get("psp.json")
if not isinstance(psp_json, dict):
errors.append("psp.json 根节点必须是对象")
psp_json = {}
ini_count = validate_ini(psp_root, errors)
shell_count = validate_shell(psp_root, errors)
paths = validate_absolute_paths(
psp_root, payloads, allowed_absolute_roots, formal, errors, warnings
)
required_identity = {
"project": project,
"expected_bsp_project": expected_bsp_project,
"expected_chip": expected_chip,
"expected_bsp_sdk": expected_bsp_sdk,
"expected_build_type": expected_build_type,
"expected_system_form": expected_system_form,
}
for field, value in required_identity.items():
if not isinstance(value, str) or not value.strip():
errors.append(f"缺少候选 PSP 必需身份字段: {field}")
version_checks: dict[str, object] = {}
checks = (
("emxsdkversion", expected_sdk, "SDK"),
("emxmediaversion", expected_media, "Media"),
("emxaipluginversion", expected_ai_plugin, "AIPlugin"),
)
for key, expected, label in checks:
values = find_key_values(payloads, key)
version_checks[label] = {"expected": expected, "found": values}
if expected is not None:
if not values:
errors.append(f"未找到可核对的 {label} 版本字段")
for item in values:
if item["value"] != expected:
errors.append(
f"{label} 版本不匹配 {item['source']}:{item['location']}: "
f"期望 {expected!r},实际 {item['value']!r}"
)
dependency: dict[str, object] | None = None
if bsp_handoff_path is not None:
bsp_result = validate_bsp_handoff.validate_handoff(
bsp_handoff_path,
expected_project=expected_bsp_project,
expected_chip=expected_chip,
expected_sdk=expected_bsp_sdk,
expected_build_type=expected_build_type,
expected_system_form=expected_system_form,
)
if bsp_result.get("passed") is not True:
errors.extend(f"BSP handoff: {item}" for item in bsp_result.get("errors", []))
warnings.extend(f"BSP handoff: {item}" for item in bsp_result.get("warnings", []))
raw_dependency = bsp_result.get("dependency")
if isinstance(raw_dependency, dict):
dependency = raw_dependency
if dependency and bsp_lock_output is not None and bsp_result.get("passed") is True:
output = bsp_lock_output.expanduser().resolve(strict=False)
if output != psp_root / "metadata" / "bsp.lock.json":
raise ValidationError("bsp-lock-output 必须精确为 PSP/metadata/bsp.lock.json")
write_json_atomic(output, build_bsp_lock(dependency, list(bsp_result["warnings"])))
elif bsp_lock_output is not None:
raise ValidationError("写 bsp.lock.json 时必须同时提供 --bsp-handoff")
else:
errors.append("未提供 BSP handoff;不能形成可交付 candidate")
bsp_sources = validate_bsp_sources(psp_root, psp_json, dependency, errors)
sensor_baseline = validate_sensor_baseline(
psp_root,
expected_chip,
sensor_enum_header,
expected_sensors or [],
errors,
)
allowed = load_allowed_overrides(psp_root, errors)
conflicts = scan_overrides(
psp_root, override_maps, allowed, formal, errors, warnings
)
result: dict[str, object] = {
"schema_version": 1,
"passed": not errors,
"mode": "formal" if formal else "local",
"project": project,
"psp_root": str(psp_root),
"checks": {
"json_files": len(payloads),
"ini_files": ini_count,
"shell_files": shell_count,
"absolute_paths": paths,
"versions": version_checks,
"bsp_dependency": dependency,
"bsp_sources": bsp_sources,
"sensor_baseline": sensor_baseline,
"configuration_overrides": conflicts,
},
"errors": errors,
"warnings": warnings,
}
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("psp_root", type=Path)
parser.add_argument("--project", required=True)
parser.add_argument("--expected-sdk")
parser.add_argument("--expected-media-version")
parser.add_argument("--expected-ai-plugin-version")
parser.add_argument("--bsp-handoff", type=Path, required=True)
parser.add_argument("--bsp-lock-output", type=Path)
parser.add_argument("--expected-bsp-project", required=True)
parser.add_argument("--expected-chip", required=True)
parser.add_argument("--expected-bsp-sdk", required=True)
parser.add_argument("--expected-build-type", required=True)
parser.add_argument(
"--expected-system-form", required=True, choices=("single-os", "dual-os")
)
parser.add_argument(
"--sensor-enum-header",
type=Path,
help=(
"可选覆盖:CV181X 的 sensor_list.h 或 CV184X 的 sensor_cfg.h;"
"默认按芯片使用 skill 内置基线"
),
)
parser.add_argument(
"--expected-sensor",
action="append",
default=[],
type=parse_expected_sensor,
metavar="INI_SECTION=ENUM_NAME",
help="规格书选定的 Sensor 枚举名称;每个 [sensor*] section 重复一次",
)
parser.add_argument(
"--allowed-absolute-root",
action="append",
default=[],
type=Path,
help="显式批准的团队共享或当前验证根;可重复",
)
parser.add_argument(
"--override-map",
action="append",
default=[],
type=parse_override_map,
metavar="APP_DIR=PSP_SUBDIR",
)
parser.add_argument("--formal", action="store_true")
parser.add_argument("--output", type=Path)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
result = validate_psp(
args.psp_root,
args.project,
args.expected_sdk,
args.expected_media_version,
args.expected_ai_plugin_version,
args.bsp_handoff,
args.bsp_lock_output,
args.expected_bsp_project,
args.expected_chip,
args.expected_bsp_sdk,
args.expected_build_type,
args.expected_system_form,
args.allowed_absolute_root,
args.override_map,
args.formal,
args.sensor_enum_header,
args.expected_sensor,
)
if args.output is not None:
write_json_atomic(args.output, result)
except (ValidationError, OSError, json.JSONDecodeError) as exc:
result = {"schema_version": 1, "passed": False, "errors": [str(exc)], "warnings": []}
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())