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,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())