feat: 发布固件底层开发 Skill 套件
This commit is contained in:
commit
c7ae3b4ce1
72 files changed
+10212
No files matched your search
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Select a bundled CV18 pinout workbook and query its pin/FMUX information."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterator
|
||||
from xml.etree import ElementTree as ET
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
|
||||
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
NS = {"m": MAIN_NS}
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CATALOG_ROOT = SKILL_ROOT / "references" / "gpio-pinout"
|
||||
MANIFEST_PATH = CATALOG_ROOT / "manifest.json"
|
||||
EP_CHIP_RE = re.compile(r"^\s*(?:export\s+)?EP_CHIP\s*=\s*(.*?)\s*$")
|
||||
CELL_RE = re.compile(r"^([A-Z]+)([0-9]+)$")
|
||||
MUX_RE = re.compile(r"^\s*([0-7])\s*:\s*(.+?)\s*$")
|
||||
MAX_XLSX_SIZE = 16 * 1024 * 1024
|
||||
MAX_UNCOMPRESSED_SIZE = 64 * 1024 * 1024
|
||||
|
||||
|
||||
class PinoutError(RuntimeError):
|
||||
"""A bundled pinout cannot be selected or queried safely."""
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_json_atomic(path: Path, payload: dict[str, object]) -> None:
|
||||
path = path.expanduser().resolve(strict=False)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||
) as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
temporary = Path(handle.name)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def normalize_chip(value: str) -> str:
|
||||
normalized = re.sub(r"[^A-Za-z0-9]", "", value).upper()
|
||||
if not normalized:
|
||||
raise PinoutError("芯片名称规范化后为空")
|
||||
return normalized
|
||||
|
||||
|
||||
def project_chip(project: Path) -> tuple[str, dict[str, object]]:
|
||||
project = project.expanduser().resolve(strict=True)
|
||||
if not project.is_dir() or project.is_symlink():
|
||||
raise PinoutError(f"项目必须是普通目录: {project}")
|
||||
config = project / "buildEnvConfig.sh"
|
||||
if not config.is_file() or config.is_symlink():
|
||||
raise PinoutError(f"项目缺少普通文件 buildEnvConfig.sh: {config}")
|
||||
values: list[str] = []
|
||||
for line_number, line in enumerate(
|
||||
config.read_text(encoding="utf-8", errors="replace").splitlines(), 1
|
||||
):
|
||||
match = EP_CHIP_RE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
try:
|
||||
tokens = shlex.split(match.group(1), comments=True, posix=True)
|
||||
except ValueError as exc:
|
||||
raise PinoutError(f"buildEnvConfig.sh:{line_number} EP_CHIP 无法解析: {exc}") from exc
|
||||
if len(tokens) != 1 or not tokens[0]:
|
||||
raise PinoutError(
|
||||
f"buildEnvConfig.sh:{line_number} EP_CHIP 必须是一个静态芯片名称"
|
||||
)
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]+", tokens[0]) is None:
|
||||
raise PinoutError(
|
||||
f"buildEnvConfig.sh:{line_number} EP_CHIP 包含展开或非法字符,必须是静态芯片名称"
|
||||
)
|
||||
values.append(normalize_chip(tokens[0]))
|
||||
unique = sorted(set(values))
|
||||
if len(unique) != 1:
|
||||
raise PinoutError(f"buildEnvConfig.sh 必须唯一确定 EP_CHIP,实际 {unique}")
|
||||
return unique[0], {
|
||||
"source": "project-buildEnvConfig",
|
||||
"project": str(project),
|
||||
"build_env_config": str(config),
|
||||
"build_env_config_sha256": sha256_file(config),
|
||||
}
|
||||
|
||||
|
||||
def safe_relative_file(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise PinoutError(f"manifest {field} 必须是非空字符串")
|
||||
pure = PurePosixPath(value)
|
||||
if pure.is_absolute() or ".." in pure.parts or len(pure.parts) != 1:
|
||||
raise PinoutError(f"manifest {field} 必须是当前目录内文件名: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def load_manifest() -> list[dict[str, Any]]:
|
||||
if not MANIFEST_PATH.is_file() or MANIFEST_PATH.is_symlink():
|
||||
raise PinoutError(f"内置 GPIO manifest 缺失或不是普通文件: {MANIFEST_PATH}")
|
||||
path = MANIFEST_PATH.resolve(strict=True)
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
|
||||
raise PinoutError("gpio-pinout/manifest.json schema_version 必须为 1")
|
||||
documents = payload.get("documents")
|
||||
if not isinstance(documents, list) or not documents:
|
||||
raise PinoutError("gpio-pinout/manifest.json.documents 必须是非空数组")
|
||||
checked: list[dict[str, Any]] = []
|
||||
ids: set[str] = set()
|
||||
for index, item in enumerate(documents):
|
||||
if not isinstance(item, dict):
|
||||
raise PinoutError(f"manifest documents[{index}] 必须是对象")
|
||||
identifier = item.get("id")
|
||||
if not isinstance(identifier, str) or not identifier or identifier in ids:
|
||||
raise PinoutError(f"manifest documents[{index}].id 非法或重复")
|
||||
ids.add(identifier)
|
||||
filename = safe_relative_file(item.get("file"), f"documents[{index}].file")
|
||||
digest = item.get("sha256")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
raise PinoutError(f"manifest documents[{index}].sha256 非法")
|
||||
patterns = item.get("chip_patterns")
|
||||
aliases = item.get("chip_aliases")
|
||||
if not isinstance(patterns, list) or not patterns or not all(
|
||||
isinstance(pattern, str) and pattern for pattern in patterns
|
||||
):
|
||||
raise PinoutError(f"manifest documents[{index}].chip_patterns 非法")
|
||||
if not isinstance(aliases, list) or not all(
|
||||
isinstance(alias, str) and alias for alias in aliases
|
||||
):
|
||||
raise PinoutError(f"manifest documents[{index}].chip_aliases 非法")
|
||||
try:
|
||||
compiled = [re.compile(pattern) for pattern in patterns]
|
||||
except re.error as exc:
|
||||
raise PinoutError(f"manifest documents[{index}] 正则非法: {exc}") from exc
|
||||
workbook_candidate = CATALOG_ROOT / filename
|
||||
if not workbook_candidate.is_file() or workbook_candidate.is_symlink():
|
||||
raise PinoutError(f"内置 GPIO 表格缺失或不是普通文件: {workbook_candidate}")
|
||||
workbook = workbook_candidate.resolve(strict=True)
|
||||
if workbook.parent != CATALOG_ROOT.resolve():
|
||||
raise PinoutError(f"内置 GPIO 表格越出目录: {workbook}")
|
||||
actual_digest = sha256_file(workbook)
|
||||
if actual_digest != digest:
|
||||
raise PinoutError(
|
||||
f"内置 GPIO 表格 hash 与 manifest 不一致: {filename}: {actual_digest}"
|
||||
)
|
||||
checked.append(
|
||||
{
|
||||
**item,
|
||||
"path": workbook,
|
||||
"compiled_patterns": compiled,
|
||||
"normalized_aliases": {normalize_chip(alias) for alias in aliases},
|
||||
}
|
||||
)
|
||||
return checked
|
||||
|
||||
|
||||
def select_document(chip: str) -> dict[str, Any]:
|
||||
chip = normalize_chip(chip)
|
||||
matched = [
|
||||
item
|
||||
for item in load_manifest()
|
||||
if chip in item["normalized_aliases"]
|
||||
or any(pattern.fullmatch(chip) for pattern in item["compiled_patterns"])
|
||||
]
|
||||
if len(matched) != 1:
|
||||
identifiers = [item["id"] for item in matched]
|
||||
if not matched:
|
||||
raise PinoutError(f"芯片 {chip} 没有对应的内置 GPIO 设计表")
|
||||
raise PinoutError(f"芯片 {chip} 同时匹配多个内置 GPIO 设计表: {identifiers}")
|
||||
return matched[0]
|
||||
|
||||
|
||||
def xml_text(node: ET.Element) -> str:
|
||||
return "".join(text.text or "" for text in node.iter(f"{{{MAIN_NS}}}t"))
|
||||
|
||||
|
||||
def workbook_parts(archive: ZipFile) -> tuple[list[str], list[tuple[str, str]]]:
|
||||
names = archive.namelist()
|
||||
total_size = sum(item.file_size for item in archive.infolist())
|
||||
if total_size > MAX_UNCOMPRESSED_SIZE:
|
||||
raise PinoutError(f"XLSX 解压后超过 {MAX_UNCOMPRESSED_SIZE} 字节限制")
|
||||
shared: list[str] = []
|
||||
if "xl/sharedStrings.xml" in names:
|
||||
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
|
||||
shared = [xml_text(item) for item in root.findall(f"{{{MAIN_NS}}}si")]
|
||||
workbook = ET.fromstring(archive.read("xl/workbook.xml"))
|
||||
relationships = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
|
||||
targets = {
|
||||
item.attrib["Id"]: item.attrib["Target"]
|
||||
for item in relationships.findall(f"{{{PKG_REL_NS}}}Relationship")
|
||||
}
|
||||
sheets: list[tuple[str, str]] = []
|
||||
for sheet in workbook.findall(f".//{{{MAIN_NS}}}sheet"):
|
||||
rel_id = sheet.attrib.get(f"{{{DOC_REL_NS}}}id")
|
||||
if rel_id not in targets:
|
||||
raise PinoutError(f"工作表 {sheet.attrib.get('name')} 缺少 relationship")
|
||||
target = targets[rel_id].lstrip("/")
|
||||
if not target.startswith("xl/"):
|
||||
target = posixpath.normpath(posixpath.join("xl", target))
|
||||
if target.startswith("../") or target not in names:
|
||||
raise PinoutError(f"工作表目标路径非法或缺失: {target}")
|
||||
sheets.append((sheet.attrib.get("name", ""), target))
|
||||
return shared, sheets
|
||||
|
||||
|
||||
def column_number(cell_reference: str) -> int:
|
||||
match = CELL_RE.fullmatch(cell_reference)
|
||||
if match is None:
|
||||
raise PinoutError(f"XLSX cell reference 非法: {cell_reference!r}")
|
||||
value = 0
|
||||
for character in match.group(1):
|
||||
value = value * 26 + ord(character) - ord("A") + 1
|
||||
return value
|
||||
|
||||
|
||||
def cell_value(cell: ET.Element, shared: list[str]) -> str:
|
||||
cell_type = cell.attrib.get("t")
|
||||
if cell_type == "inlineStr":
|
||||
return xml_text(cell)
|
||||
value = cell.find(f"{{{MAIN_NS}}}v")
|
||||
raw = "" if value is None or value.text is None else value.text
|
||||
if cell_type == "s" and raw:
|
||||
try:
|
||||
return shared[int(raw)]
|
||||
except (IndexError, ValueError) as exc:
|
||||
raise PinoutError(f"XLSX shared string index 非法: {raw!r}") from exc
|
||||
if cell_type == "b":
|
||||
return "TRUE" if raw == "1" else "FALSE"
|
||||
return raw
|
||||
|
||||
|
||||
def sheet_rows(archive: ZipFile, target: str, shared: list[str]) -> Iterator[tuple[int, dict[int, str]]]:
|
||||
root = ET.fromstring(archive.read(target))
|
||||
for row in root.findall(f".//{{{MAIN_NS}}}sheetData/{{{MAIN_NS}}}row"):
|
||||
values: dict[int, str] = {}
|
||||
for cell in row.findall(f"{{{MAIN_NS}}}c"):
|
||||
reference = cell.attrib.get("r")
|
||||
if reference is None:
|
||||
continue
|
||||
value = cell_value(cell, shared).strip()
|
||||
if value:
|
||||
values[column_number(reference)] = value
|
||||
if values:
|
||||
yield int(row.attrib.get("r", "0")), values
|
||||
|
||||
|
||||
def normalized_header(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", value.casefold())
|
||||
|
||||
|
||||
def header_columns(row: dict[int, str]) -> dict[str, int]:
|
||||
aliases = {
|
||||
"pin_number": {"pinnum", "pinnumber"},
|
||||
"pin_name": {"pinname"},
|
||||
"io_type": {"iotype"},
|
||||
"io_group": {"iogroup"},
|
||||
"power_domain": {"powerdomain"},
|
||||
"io_cfg_register": {"iocfgregister"},
|
||||
"fmux_register": {"functionselectregister"},
|
||||
"fmux_default": {"fmuxdefault"},
|
||||
"description": {"description"},
|
||||
"note": {"note"},
|
||||
}
|
||||
result: dict[str, int] = {}
|
||||
for column, value in row.items():
|
||||
normalized = normalized_header(value)
|
||||
for field, candidates in aliases.items():
|
||||
if normalized in candidates and field not in result:
|
||||
result[field] = column
|
||||
return result
|
||||
|
||||
|
||||
def parse_muxes(description: str) -> list[dict[str, object]]:
|
||||
muxes: list[dict[str, object]] = []
|
||||
for line in description.replace("\r", "\n").splitlines():
|
||||
match = MUX_RE.fullmatch(line)
|
||||
if match is None:
|
||||
continue
|
||||
raw_function = match.group(2).strip()
|
||||
default = bool(re.search(r"\(\s*default\s*\)\s*$", raw_function, re.IGNORECASE))
|
||||
function = re.sub(
|
||||
r"\s*\(\s*default\s*\)\s*$", "", raw_function, flags=re.IGNORECASE
|
||||
).strip()
|
||||
muxes.append({"number": int(match.group(1)), "function": function, "default": default})
|
||||
return muxes
|
||||
|
||||
|
||||
def load_pin_rows(path: Path) -> tuple[str, list[dict[str, object]]]:
|
||||
path = path.resolve(strict=True)
|
||||
if path.stat().st_size > MAX_XLSX_SIZE:
|
||||
raise PinoutError(f"内置 XLSX 超过 {MAX_XLSX_SIZE} 字节限制: {path}")
|
||||
try:
|
||||
with ZipFile(path) as archive:
|
||||
shared, sheets = workbook_parts(archive)
|
||||
candidates = [item for item in sheets if "管腳信息表" in item[0] or "管脚信息表" in item[0]]
|
||||
if len(candidates) != 1:
|
||||
raise PinoutError(f"必须唯一定位管脚信息表,实际 {[item[0] for item in candidates]}")
|
||||
sheet_name, target = candidates[0]
|
||||
rows = iter(sheet_rows(archive, target, shared))
|
||||
try:
|
||||
_, header = next(rows)
|
||||
except StopIteration as exc:
|
||||
raise PinoutError(f"管脚信息表为空: {sheet_name}") from exc
|
||||
columns = header_columns(header)
|
||||
required = {"pin_number", "pin_name", "fmux_register", "fmux_default", "description"}
|
||||
missing = sorted(required - set(columns))
|
||||
if missing:
|
||||
raise PinoutError(f"管脚信息表缺少列: {missing}")
|
||||
parsed: list[dict[str, object]] = []
|
||||
for excel_row, values in rows:
|
||||
pin_number = values.get(columns["pin_number"], "").strip()
|
||||
pin_name = values.get(columns["pin_name"], "").strip()
|
||||
if not pin_number and not pin_name:
|
||||
continue
|
||||
item: dict[str, object] = {"excel_row": excel_row}
|
||||
for field, column in columns.items():
|
||||
item[field] = values.get(column, "").strip()
|
||||
item["muxes"] = parse_muxes(str(item.get("description", "")))
|
||||
parsed.append(item)
|
||||
if not parsed:
|
||||
raise PinoutError(f"管脚信息表未解析出数据行: {sheet_name}")
|
||||
return sheet_name, parsed
|
||||
except (BadZipFile, KeyError, ET.ParseError) as exc:
|
||||
raise PinoutError(f"内置 XLSX 结构无效: {path}: {exc}") from exc
|
||||
|
||||
|
||||
def lookup_aliases(value: object) -> set[str]:
|
||||
normalized = re.sub(r"[^A-Za-z0-9]", "", str(value)).upper()
|
||||
if not normalized:
|
||||
return set()
|
||||
aliases = {normalized}
|
||||
if normalized.startswith("XGPIO"):
|
||||
aliases.add(normalized[1:])
|
||||
elif normalized.startswith("GPIO"):
|
||||
aliases.add("X" + normalized)
|
||||
return aliases
|
||||
|
||||
|
||||
def query_rows(
|
||||
rows: list[dict[str, object]], queries: list[str], match_mode: str
|
||||
) -> tuple[list[dict[str, object]], list[str]]:
|
||||
results: list[dict[str, object]] = []
|
||||
unmatched: list[str] = []
|
||||
for query in queries:
|
||||
needles = lookup_aliases(query)
|
||||
query_matches: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
fields: list[tuple[str, object]] = [
|
||||
("pin_number", row.get("pin_number", "")),
|
||||
("pin_name", row.get("pin_name", "")),
|
||||
]
|
||||
fields.extend(
|
||||
("mux_function", mux.get("function", ""))
|
||||
for mux in row.get("muxes", [])
|
||||
if isinstance(mux, dict)
|
||||
)
|
||||
matched_by: list[dict[str, str]] = []
|
||||
for field, raw in fields:
|
||||
haystacks = lookup_aliases(raw)
|
||||
matched = bool(needles & haystacks)
|
||||
if match_mode == "contains":
|
||||
matched = any(needle in haystack for needle in needles for haystack in haystacks)
|
||||
if matched:
|
||||
matched_by.append({"field": field, "value": str(raw)})
|
||||
if matched_by:
|
||||
query_matches.append({**row, "matched_by": matched_by})
|
||||
if query_matches:
|
||||
results.append({"query": query, "matches": query_matches})
|
||||
else:
|
||||
unmatched.append(query)
|
||||
return results, unmatched
|
||||
|
||||
|
||||
def run(project: Path | None, chip_arg: str | None, queries: list[str], match_mode: str) -> dict[str, object]:
|
||||
if project is not None:
|
||||
chip, chip_evidence = project_chip(project)
|
||||
elif chip_arg is not None:
|
||||
chip = normalize_chip(chip_arg)
|
||||
chip_evidence = {"source": "explicit-chip"}
|
||||
else:
|
||||
raise PinoutError("必须提供 --project 或 --chip")
|
||||
document = select_document(chip)
|
||||
sheet, rows = load_pin_rows(document["path"])
|
||||
query_results, unmatched = query_rows(rows, queries, match_mode)
|
||||
errors = [f"内置 GPIO 表中未找到查询项: {item}" for item in unmatched]
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"passed": not errors,
|
||||
"chip": chip,
|
||||
"chip_evidence": chip_evidence,
|
||||
"pinout": {
|
||||
"id": document["id"],
|
||||
"revision": document.get("revision"),
|
||||
"path": str(document["path"]),
|
||||
"sha256": document["sha256"],
|
||||
"worksheet": sheet,
|
||||
"row_count": len(rows),
|
||||
},
|
||||
"match_mode": match_mode,
|
||||
"queries": query_results,
|
||||
"unmatched": unmatched,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--project", type=Path, help="从项目 buildEnvConfig.sh 的 EP_CHIP 选表")
|
||||
source.add_argument("--chip", help="显式芯片名称;不从项目目录名推断")
|
||||
parser.add_argument(
|
||||
"--query",
|
||||
action="append",
|
||||
default=[],
|
||||
help="查询 Pin Num、Pin Name、FMUX function;可重复,不提供时只解析并报告选表",
|
||||
)
|
||||
parser.add_argument("--match", choices=("exact", "contains"), default="exact")
|
||||
parser.add_argument("--output", type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
result = run(args.project, args.chip, args.query, args.match)
|
||||
if args.output is not None:
|
||||
write_json_atomic(args.output, result)
|
||||
except (PinoutError, OSError, json.JSONDecodeError) as exc:
|
||||
result = {
|
||||
"schema_version": 1,
|
||||
"passed": False,
|
||||
"errors": [str(exc)],
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("passed") is True else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user