264 lines
13 KiB
Python
264 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Read-only CV181X Release/default/PSP configuration inventory and graph audit."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
|
|
def digest(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def git_provenance(root: Path | None):
|
|
if root is None:
|
|
return None
|
|
def git(*args):
|
|
return subprocess.run(["git", "-C", str(root), *args], capture_output=True,
|
|
text=True, check=True).stdout.strip()
|
|
return {"path": str(root.resolve()), "commit": git("rev-parse", "HEAD"),
|
|
"dirty_paths": git("status", "--short", "--untracked-files=all").splitlines()}
|
|
|
|
|
|
def load(path: Path):
|
|
if not path.is_file() or path.is_symlink():
|
|
raise ValueError(f"需要普通 JSON 文件: {path}")
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def leaves(value, prefix=""):
|
|
if isinstance(value, dict):
|
|
for key, item in value.items():
|
|
yield from leaves(item, f"{prefix}.{key}" if prefix else key)
|
|
elif isinstance(value, list):
|
|
for index, item in enumerate(value):
|
|
yield from leaves(item, f"{prefix}[{index}]")
|
|
else:
|
|
yield prefix, value
|
|
|
|
|
|
def config_files(profile: Path, root_name: str):
|
|
paths = [profile / root_name, *sorted((profile / "configs").glob("*.json"))]
|
|
if root_name == "profile.json":
|
|
paths.append(profile / "AiPlugin.json")
|
|
if not paths[0].is_file() or not (profile / "configs").is_dir():
|
|
raise ValueError(f"Release profile 不完整: {profile}")
|
|
return {path.relative_to(profile).as_posix(): path for path in paths if path.is_file()}
|
|
|
|
|
|
@lru_cache(maxsize=2)
|
|
def source_index(source_root: Path, profile_name: str) -> dict[str, list[str]]:
|
|
index: dict[str, list[str]] = {}
|
|
pattern = re.compile(r'\[\s*"([^"]+)"\s*\]')
|
|
chip_root = source_root / "chip/cvitek/cv181x"
|
|
build = source_root / "profile" / profile_name / "build.cmake"
|
|
selected = set()
|
|
if build.is_file():
|
|
build_text = build.read_text(encoding="utf-8", errors="replace")
|
|
for variant in re.findall(r'aux_source_directory\([^\n]*/chip/\$\{ChipManufactory\}/\$\{ChipSerial\}/([^\s)]+)', build_text):
|
|
selected.add(profile_name if variant == "${Profile}" else variant)
|
|
paths = set(chip_root.glob("*.cpp"))
|
|
for variant in selected:
|
|
paths.update((chip_root / variant).glob("*.cpp"))
|
|
for path in sorted(paths):
|
|
for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
|
for key in pattern.findall(line):
|
|
index.setdefault(key, []).append(f"{path.relative_to(source_root)}:{number}")
|
|
return index
|
|
|
|
|
|
def source_hits(source_root: Path | None, profile_name: str, key: str) -> list[str]:
|
|
return source_index(source_root, profile_name).get(key, [])[:12] if source_root is not None else []
|
|
|
|
|
|
def profile_inventory(profile: Path, root_name: str, source_profile: Path | None, source_root: Path | None):
|
|
result = {}
|
|
drift = []
|
|
for relative, path in config_files(profile, root_name).items():
|
|
source_path = source_profile / relative if source_profile and relative != "AiPlugin.json" else None
|
|
if source_path is not None and (not source_path.is_file() or digest(path) != digest(source_path)):
|
|
drift.append(relative)
|
|
rows = []
|
|
for field, default in leaves(load(path)):
|
|
key = re.split(r"\.|\[", field)[-1]
|
|
rows.append({
|
|
"field": field,
|
|
"release_default": default,
|
|
"type": type(default).__name__,
|
|
"source_hits_hint_only": source_hits(source_root, profile.name, key),
|
|
})
|
|
result[relative] = {"sha256": digest(path), "fields": rows}
|
|
return result, drift
|
|
|
|
|
|
def check_graph(media_system, media_files, ai_files):
|
|
errors = []
|
|
if not isinstance(media_system, list) or not media_system:
|
|
return ["system.json 必须包含至少一个 System 通道"]
|
|
for system_index, system in enumerate(media_system):
|
|
groups = {g["vpssGrp"]: g for g in system.get("vpss", {}).get("group", [])}
|
|
pools = len(system.get("vb", []))
|
|
for group_id, group in groups.items():
|
|
for attach in group.get("attachPool", []):
|
|
if attach.get("chn", -1) not in range(len(group.get("chn", []))):
|
|
errors.append(f"System[{system_index}] VPSS {group_id} attachPool.chn 不存在")
|
|
if attach.get("pool", -1) not in range(pools):
|
|
errors.append(f"System[{system_index}] VPSS {group_id} attachPool.pool 不存在")
|
|
for bind in group.get("bind", []):
|
|
parent = groups.get(bind.get("group"))
|
|
if parent is None or bind.get("chn", -1) not in range(len(parent.get("chn", []))):
|
|
errors.append(f"System[{system_index}] VPSS {group_id} bind 来源不存在")
|
|
for relative in ("configs/venc.json", "configs/snap.json", "configs/osd.json", "configs/draw.json"):
|
|
for index, item in enumerate(media_files.get(relative, [])):
|
|
attr = item.get("attr", item)
|
|
group_id = attr.get("VpssGrp")
|
|
channel = attr.get("VpssChn")
|
|
if group_id is not None and (group_id not in groups or channel not in range(len(groups[group_id].get("chn", [])))):
|
|
errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在")
|
|
enabled = ai_files.get("configs/config.json", {})
|
|
for relative, items in ai_files.items():
|
|
if relative == "configs/config.json" or not isinstance(items, list):
|
|
continue
|
|
module = Path(relative).stem
|
|
if enabled.get(module) is not True:
|
|
continue
|
|
for index, item in enumerate(items):
|
|
if not isinstance(item, dict) or "vpssGrp" not in item:
|
|
continue
|
|
group_id, channel = item["vpssGrp"], item.get("vpssChn")
|
|
if group_id not in groups or channel not in range(len(groups[group_id].get("chn", []))):
|
|
errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在")
|
|
return errors
|
|
|
|
|
|
def model_references(ai_files, ai_meta, psp=None):
|
|
enabled = ai_files.get("configs/config.json", {})
|
|
declared = set(ai_meta.get("runtime", {}).get("file", []))
|
|
result = []
|
|
for relative, items in ai_files.items():
|
|
if not relative.startswith("configs/") or not isinstance(items, list):
|
|
continue
|
|
module = Path(relative).stem
|
|
if enabled.get(module) is not True:
|
|
continue
|
|
for index, item in enumerate(items):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
for key, value in item.items():
|
|
if not key.endswith("ModelPath") and key != "modelPath":
|
|
continue
|
|
if not isinstance(value, str) or not value:
|
|
continue
|
|
local = (psp / "AiPlugin" / value).is_file() if psp is not None else False
|
|
result.append({"module": module, "channel": index, "field": key,
|
|
"model": value, "release_runtime_declared": value in declared,
|
|
"psp_file_present": local,
|
|
"requires_source_or_packaging_evidence": value not in declared and not local})
|
|
return result
|
|
|
|
|
|
def audit(media_profile: Path, ai_profile: Path, media_source: Path | None = None,
|
|
ai_source: Path | None = None, psp: Path | None = None):
|
|
media_profile, ai_profile = media_profile.resolve(), ai_profile.resolve()
|
|
if not media_profile.parent.name.startswith("EmxMedia-") or not ai_profile.parent.name.startswith("EmxAiPlugin-"):
|
|
raise ValueError("必须指定精确 Release 版本下的 profile,不能指定源码 profile 或 Debug")
|
|
media_root = media_profile.parent.name
|
|
ai_root = ai_profile.parent.name
|
|
media_meta = load(media_profile / "configs.json")
|
|
ai_meta = load(ai_profile / "profile.json")
|
|
ai_package = load(ai_profile / "AiPlugin.json")
|
|
if any(meta.get("ChipSerial", "").lower() != "cv181x" for meta in (media_meta, ai_meta)):
|
|
raise ValueError("当前只支持 CV181X Release")
|
|
media_source_profile = media_source / "profile" / media_profile.name if media_source else None
|
|
ai_source_profile = ai_source / "profile" / ai_profile.name if ai_source else None
|
|
media_catalog, media_drift = profile_inventory(media_profile, "configs.json", media_source_profile, media_source)
|
|
ai_catalog, ai_drift = profile_inventory(ai_profile, "profile.json", ai_source_profile, ai_source)
|
|
errors = []
|
|
for key in ("EmxSdkVersion", "ChipManufactory", "ChipSerial", "ChipSdkVersion", "Cross"):
|
|
if media_meta.get(key) != ai_meta.get(key):
|
|
errors.append(f"Media/AI {key} 不一致")
|
|
if ai_package.get("profile") != ai_profile.name or ai_package.get("version") != ai_root.removeprefix("EmxAiPlugin-"):
|
|
errors.append("AiPlugin.json 与所选 Release/profile 不一致")
|
|
media_files = {rel: load(path) for rel, path in config_files(media_profile, "configs.json").items()}
|
|
ai_files = {rel: load(path) for rel, path in config_files(ai_profile, "profile.json").items()}
|
|
errors += check_graph(media_files["configs/system.json"], media_files, ai_files)
|
|
candidate = []
|
|
model_refs = model_references(ai_files, ai_meta, psp)
|
|
if psp is not None:
|
|
psp = psp.resolve()
|
|
psp_json = load(psp / "psp.json")
|
|
for key, expected in (("EmxMediaVersion", media_root), ("EmxMediaProfile", media_profile.name),
|
|
("EmxAiPluginVersion", ai_root), ("EmxAiPluginProfile", ai_profile.name)):
|
|
if psp_json.get(key) != expected:
|
|
errors.append(f"psp.json {key} 与所选 Release 不一致")
|
|
mapping = (("media", media_files, media_catalog, psp / "configs/media/scene/default"),
|
|
("ai", ai_files, ai_catalog, psp / "AiPlugin/configs"))
|
|
effective = {"media": dict(media_files), "ai": dict(ai_files)}
|
|
for kind, defaults, catalog, target in mapping:
|
|
for relative, baseline in defaults.items():
|
|
if not relative.startswith("configs/"):
|
|
continue
|
|
path = target / Path(relative).name
|
|
if not path.is_file():
|
|
if kind == "ai":
|
|
path = psp / "AiPlugin/configs_ro" / Path(relative).name
|
|
if not path.is_file():
|
|
candidate.append({"kind": kind, "file": relative, "state": "not_in_psp_check_effective_source"})
|
|
continue
|
|
current = load(path)
|
|
effective[kind][relative] = current
|
|
baseline_fields, current_fields = dict(leaves(baseline)), dict(leaves(current))
|
|
for field in sorted(baseline_fields.keys() | current_fields.keys()):
|
|
if field not in baseline_fields or field not in current_fields or baseline_fields[field] != current_fields[field]:
|
|
candidate.append({"kind": kind, "file": str(path), "field": field,
|
|
"release_default": baseline_fields.get(field),
|
|
"candidate": current_fields.get(field),
|
|
"state": "changed_or_missing_requires_evidence"})
|
|
errors += check_graph(effective["media"]["configs/system.json"], effective["media"], effective["ai"])
|
|
model_refs = model_references(effective["ai"], ai_meta, psp)
|
|
return {
|
|
"schema_version": 1,
|
|
"scope": "read_only_static_audit_not_hardware_or_runtime_validation",
|
|
"media_release": str(media_profile), "ai_release": str(ai_profile),
|
|
"media_sha256": digest(media_profile / "configs.json"),
|
|
"ai_sha256": digest(ai_profile / "profile.json"),
|
|
"source_release_drift": {"media": media_drift, "ai": ai_drift},
|
|
"source_git": {"media": git_provenance(media_source), "ai": git_provenance(ai_source)},
|
|
"field_counts": {"media": sum(len(x["fields"]) for x in media_catalog.values()),
|
|
"ai": sum(len(x["fields"]) for x in ai_catalog.values())},
|
|
"catalog": {"media": media_catalog, "ai": ai_catalog},
|
|
"candidate_differences": candidate,
|
|
"enabled_ai_model_references": model_refs,
|
|
"errors": errors,
|
|
"passed_static_cross_checks": not errors,
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--media-release-profile", type=Path, required=True)
|
|
parser.add_argument("--ai-release-profile", type=Path, required=True)
|
|
parser.add_argument("--media-source", type=Path)
|
|
parser.add_argument("--ai-source", type=Path)
|
|
parser.add_argument("--psp", type=Path)
|
|
args = parser.parse_args()
|
|
try:
|
|
result = audit(args.media_release_profile, args.ai_release_profile,
|
|
args.media_source, args.ai_source, args.psp)
|
|
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError,
|
|
subprocess.CalledProcessError) as exc:
|
|
parser.exit(2, f"audit failed: {exc}\n")
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
if result["errors"]:
|
|
parser.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|