454 lines
18 KiB
Python
454 lines
18 KiB
Python
#!/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())
|