From 5f9e0b221b01c72869af9dff2d1f8d86caa1c914 Mon Sep 17 00:00:00 2001 From: janik Date: Thu, 9 Apr 2026 15:11:06 +0700 Subject: [PATCH] initial commit: STM32 Pin Validator KiCad plugin Validates STM32 MCU pin assignments between KiCad PCB/schematic and STM32CubeMX .ioc files with colour-coded results. Co-Authored-By: Claude Opus 4.6 (1M context) --- __init__.py | 3 + icon.png | Bin 0 -> 280 bytes ioc_parser.py | 56 +++++ pin_validator_action.py | 460 ++++++++++++++++++++++++++++++++++++++++ sch_parser.py | 248 ++++++++++++++++++++++ 5 files changed, 767 insertions(+) create mode 100644 __init__.py create mode 100644 icon.png create mode 100644 ioc_parser.py create mode 100644 pin_validator_action.py create mode 100644 sch_parser.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..eefd806 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .pin_validator_action import STM32PinValidator + +STM32PinValidator().register() diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ce78eeca485ff65055705f151b21328430e3148a GIT binary patch literal 280 zcmV+z0q6dSP)v#T#vBLu0842;RnZd+BW56_MEKt^U{Ev^13!sD>a+rcZ@1!}z+Rsp& zmx98F#R)+l=<4c%@t}!=4*p)^k|`*RMMOl1Hu%k(Hw^p}S>gP1PP@QnvrsDz_>l~+ z_*l#k#;}VvK~(Xv7==$23x3yr#3@H{9Q;Ny92V|4Eg&Wie%F3vU~T5dFdRK-h|UMd z0A?V1kiaa&21v4i70Doc;Z9a@U0K(`(B4#mk(ppYLve9^_ws&5bVJd@ogxd+0k#w~ eAoT&mkOu&vGFfw|y8agc0000~&=T literal 0 HcmV?d00001 diff --git a/ioc_parser.py b/ioc_parser.py new file mode 100644 index 0000000..fe33e46 --- /dev/null +++ b/ioc_parser.py @@ -0,0 +1,56 @@ +"""Parser for STM32CubeMX .ioc files (key=value format).""" + +import re + +# Matches GPIO pin names like PA0, PB12, PC3, PD2, PF0-OSC_IN, PB8-BOOT0 +# Also handles CubeMX aliases like "PC14-OSC32_IN\ (PC14)" or "PA11\ [PA9]" +_GPIO_RE = re.compile(r"^(P[A-K]\d+(?:-[A-Z_0-9]+)?)(?:\\\s*[\[(][^\])]*[\])])?\.(.*)") + +# Extracts remap aliases from Mcu.Pin entries, e.g. "PA11 [PA9]" → base=PA11, alias=PA9 +_MCU_PIN_RE = re.compile( + r"^(P[A-K]\d+)(?:-[A-Z_0-9]+)?\s*[\[(](P[A-K]\d+)[\])]$" +) + + +def parse_ioc(ioc_path): + """ + Parse a .ioc file and return per-pin configuration and remap info. + + Returns: + (pins, remaps) + + pins: {gpio_base_name: {property: value, ...}} + remaps: {alias_gpio: base_gpio} + e.g. {'PA9': 'PA11'} means PA11 is remapped to PA9's + function — PA11 is the physical port for that pin. + """ + pins = {} + remaps = {} + + with open(ioc_path, "r", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if "=" not in line: + continue + + key, _, value = line.partition("=") + + # Mcu.PinXX entries — extract remap aliases + if key.startswith("Mcu.Pin"): + m = _MCU_PIN_RE.match(value) + if m and m.group(1) != m.group(2): + # e.g. PA11 [PA9] → remaps["PA9"] = "PA11" + remaps[m.group(2)] = m.group(1) + continue + + m = _GPIO_RE.match(key) + if not m: + continue + + full_pin = m.group(1) # e.g. "PF0-OSC_IN" + prop = m.group(2) # e.g. "Signal" + base = full_pin.split("-")[0] # e.g. "PF0" + + pins.setdefault(base, {})[prop] = value + + return pins, remaps diff --git a/pin_validator_action.py b/pin_validator_action.py new file mode 100644 index 0000000..283f4a0 --- /dev/null +++ b/pin_validator_action.py @@ -0,0 +1,460 @@ +""" +STM32 Pin Validator – KiCad pcbnew Action Plugin + +Compares pin assignments across three sources: + 1. PCB layout (pad → net, via pcbnew API) + 2. Schematic (pad → GPIO name, parsed from .kicad_sch) + 3. CubeMX .ioc (GPIO → signal / label, parsed from .ioc) + +Results are shown in a colour-coded table so mismatches are easy to spot. +""" + +import json +import os +import re +import pcbnew +import wx +import wx.grid + +from . import ioc_parser +from . import sch_parser + +# Pin names that are power/ground – skip from signal comparison +_POWER_NAMES = frozenset( + ["VDD", "VSS", "VDDA", "VSSA", "VBAT", "VREF+"] +) + +# ── history persistence ────────────────────────────────────────────── + +_HISTORY_FILE = os.path.join(os.path.dirname(__file__), "history.json") +_MAX_HISTORY = 10 + + +def _load_history(): + """Return list of previously used .ioc paths (most recent first).""" + try: + with open(_HISTORY_FILE, "r") as f: + data = json.load(f) + return [p for p in data if os.path.isfile(p)][:_MAX_HISTORY] + except (FileNotFoundError, json.JSONDecodeError, TypeError): + return [] + + +def _save_history(path, history): + """Prepend *path* to history and persist.""" + history = [p for p in history if p != path] + history.insert(0, path) + history = history[:_MAX_HISTORY] + try: + with open(_HISTORY_FILE, "w") as f: + json.dump(history, f, indent=2) + except OSError: + pass + return history + +# ── colour palette ────────────────────────────────────────────────── +_CLR_MATCH = wx.Colour(200, 255, 200) # green – all good +_CLR_REVIEW = wx.Colour(255, 255, 200) # yellow – needs manual look +_CLR_WARN = wx.Colour(255, 200, 200) # red – likely issue +_CLR_POWER = wx.Colour(220, 220, 220) # grey – power rail +_CLR_NC = wx.Colour(245, 245, 245) # light – not connected + + +# ==================================================================== +# Setup dialog – choose which MCU ↔ .ioc file to compare +# ==================================================================== + +class _SetupDialog(wx.Dialog): + """Let the user pick an MCU footprint and an .ioc file (with history).""" + + def __init__(self, parent, mcu_list, project_dir, history): + super().__init__( + parent, title="STM32 Pin Validator — Setup", + size=(560, 220), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + ) + self._project_dir = project_dir + self._history = list(history) + self.ioc_path = history[0] if history else "" + + panel = wx.Panel(self) + grid = wx.FlexGridSizer(rows=2, cols=3, hgap=8, vgap=10) + grid.AddGrowableCol(1, 1) + + # Row 1 — MCU selector + grid.Add( + wx.StaticText(panel, label="MCU footprint:"), + 0, wx.ALIGN_CENTER_VERTICAL, + ) + self._mcu_choice = wx.Choice(panel, choices=mcu_list) + self._mcu_choice.SetSelection(0) + grid.Add(self._mcu_choice, 1, wx.EXPAND) + grid.AddSpacer(0) + + # Row 2 — .ioc file picker (combo with history + browse button) + grid.Add( + wx.StaticText(panel, label=".ioc file:"), + 0, wx.ALIGN_CENTER_VERTICAL, + ) + self._ioc_combo = wx.ComboBox( + panel, choices=self._history, style=wx.CB_DROPDOWN | wx.CB_READONLY, + ) + if self._history: + self._ioc_combo.SetSelection(0) + self._ioc_combo.Bind(wx.EVT_COMBOBOX, self._on_combo) + grid.Add(self._ioc_combo, 1, wx.EXPAND) + browse = wx.Button(panel, label="Browse…") + browse.Bind(wx.EVT_BUTTON, self._on_browse) + grid.Add(browse, 0) + + vbox = wx.BoxSizer(wx.VERTICAL) + vbox.Add(grid, 0, wx.EXPAND | wx.ALL, 12) + + btns = wx.StdDialogButtonSizer() + ok = wx.Button(panel, wx.ID_OK) + ok.SetDefault() + btns.AddButton(ok) + btns.AddButton(wx.Button(panel, wx.ID_CANCEL)) + btns.Realize() + vbox.Add(btns, 0, wx.ALIGN_CENTER | wx.BOTTOM, 10) + + panel.SetSizer(vbox) + + def _on_combo(self, _evt): + idx = self._ioc_combo.GetSelection() + if 0 <= idx < len(self._history): + self.ioc_path = self._history[idx] + + def _on_browse(self, _evt): + start = self._project_dir + for _ in range(4): + up = os.path.dirname(start) + if up == start: + break + start = up + + dlg = wx.FileDialog( + self, "Select STM32CubeMX .ioc file", + defaultDir=start, + wildcard="IOC files (*.ioc)|*.ioc|All files (*.*)|*.*", + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, + ) + if dlg.ShowModal() == wx.ID_OK: + self.ioc_path = dlg.GetPath() + # add to combo if new + if self.ioc_path not in self._history: + self._ioc_combo.Insert(self.ioc_path, 0) + self._history.insert(0, self.ioc_path) + self._ioc_combo.SetValue(self.ioc_path) + dlg.Destroy() + + def GetSelection(self): + return self._mcu_choice.GetSelection(), self.ioc_path + + +# ==================================================================== +# Results dialog +# ==================================================================== + +class _ResultsDialog(wx.Dialog): + _COLS = ("Pin #", "GPIO", "Sch Label", "PCB Net", "IOC Signal", "IOC Label", "Status") + + def __init__(self, parent, title, results): + super().__init__( + parent, title=title, size=(950, 620), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + ) + self._all = results + + panel = wx.Panel(self) + vbox = wx.BoxSizer(wx.VERTICAL) + + # summary counts + counts = {} + for r in results: + counts[r["status"]] = counts.get(r["status"], 0) + 1 + parts = [] + for key in ("Match", "Review", "PCB Only", "IOC Only", "NC", "Power"): + if counts.get(key): + parts.append(f"{counts[key]} {key.lower()}") + vbox.Add( + wx.StaticText(panel, label=" " + " | ".join(parts)), + 0, wx.EXPAND | wx.ALL, 5, + ) + + # filter + self._chk = wx.CheckBox(panel, label="Show all pins (uncheck for warnings only)") + self._chk.SetValue(True) + self._chk.Bind(wx.EVT_CHECKBOX, self._on_filter) + vbox.Add(self._chk, 0, wx.LEFT | wx.BOTTOM, 5) + + # grid + self._grid = wx.grid.Grid(panel) + self._grid.CreateGrid(0, len(self._COLS)) + for i, c in enumerate(self._COLS): + self._grid.SetColLabelValue(i, c) + self._fill(results) + vbox.Add(self._grid, 1, wx.EXPAND | wx.ALL, 5) + + btn = wx.Button(panel, wx.ID_CLOSE, "Close") + btn.Bind(wx.EVT_BUTTON, lambda _: self.EndModal(wx.ID_CLOSE)) + vbox.Add(btn, 0, wx.ALIGN_CENTER | wx.ALL, 5) + + panel.SetSizer(vbox) + + # ── helpers ────────────────────────────────────────────────────── + + def _fill(self, rows): + g = self._grid + if g.GetNumberRows(): + g.DeleteRows(0, g.GetNumberRows()) + g.AppendRows(len(rows)) + + for i, r in enumerate(rows): + vals = ( + str(r["pin"]), r["gpio"], r["sch_label"], + r["pcb_net"], r["ioc_signal"], r["ioc_label"], + r["status"], + ) + clr = { + "Match": _CLR_MATCH, + "Review": _CLR_REVIEW, + "PCB Only": _CLR_WARN, + "IOC Only": _CLR_WARN, + "Power": _CLR_POWER, + }.get(r["status"], _CLR_NC) + + for j, v in enumerate(vals): + g.SetCellValue(i, j, v or "") + g.SetCellBackgroundColour(i, j, clr) + g.SetReadOnly(i, j) + + g.AutoSizeColumns() + + def _on_filter(self, _evt): + if self._chk.GetValue(): + data = self._all + else: + data = [r for r in self._all + if r["status"] in ("PCB Only", "IOC Only", "Review")] + self._fill(data) + self._grid.ForceRefresh() + + +# ==================================================================== +# Plugin +# ==================================================================== + +class STM32PinValidator(pcbnew.ActionPlugin): + + def defaults(self): + self.name = "STM32 Pin Validator" + self.category = "Verification" + self.description = ( + "Validate STM32 MCU pin assignments between " + "KiCad PCB/schematic and STM32CubeMX .ioc" + ) + self.show_toolbar_button = True + icon = os.path.join(os.path.dirname(__file__), "icon.png") + if os.path.exists(icon): + self.icon_file_name = icon + + # ── entry point ────────────────────────────────────────────────── + + def Run(self): + board = pcbnew.GetBoard() + project_dir = os.path.dirname(board.GetFileName()) + + # 1. find MCU footprints (any STM32) + mcus = [fp for fp in board.GetFootprints() + if fp.GetValue().upper().startswith("STM32")] + if not mcus: + wx.MessageBox( + "No STM32 footprints found on the board.", + "STM32 Pin Validator", wx.OK | wx.ICON_ERROR, + ) + return + + # 2. find schematic + sch_path = self._find_sch(project_dir) + if not sch_path: + wx.MessageBox( + "No .kicad_sch file found in the project directory.", + "STM32 Pin Validator", wx.OK | wx.ICON_ERROR, + ) + return + + # 3. setup dialog – user picks MCU + .ioc file (with history) + history = _load_history() + labels = [f"{fp.GetReference()} ({fp.GetValue()})" for fp in mcus] + setup = _SetupDialog(None, labels, project_dir, history) + if setup.ShowModal() != wx.ID_OK: + setup.Destroy() + return + mcu_idx, ioc_path = setup.GetSelection() + setup.Destroy() + + if not ioc_path: + wx.MessageBox( + "No .ioc file selected.", + "STM32 Pin Validator", wx.OK | wx.ICON_WARNING, + ) + return + + _save_history(ioc_path, history) + + fp = mcus[mcu_idx] + ref = fp.GetReference() + ioc_pins, ioc_remaps = ioc_parser.parse_ioc(ioc_path) + + # 4. extract PCB pads + pcb_pads = {} + for pad in fp.Pads(): + pcb_pads[pad.GetPadName()] = pad.GetNetname() + + # 5. schematic pin mapping + label tracing + pin_gpio = sch_parser.get_pin_mapping(sch_path, ref) + if not pin_gpio: + wx.MessageBox( + f"Could not resolve pin mapping for {ref} in the schematic.\n" + f"Make sure the schematic is saved and contains {ref}.", + "STM32 Pin Validator", wx.OK | wx.ICON_WARNING, + ) + return + + pin_labels = sch_parser.get_pin_labels(sch_path, ref) + + # 6. compare and show results + results = self._compare(pcb_pads, pin_gpio, pin_labels, + ioc_pins, ioc_remaps) + dlg = _ResultsDialog( + None, + f"Pin Validation — {ref} ({fp.GetValue()})", + results, + ) + dlg.ShowModal() + dlg.Destroy() + + # ── helpers ────────────────────────────────────────────────────── + + @staticmethod + def _find_sch(project_dir): + for name in os.listdir(project_dir): + if name.endswith(".kicad_sch"): + return os.path.join(project_dir, name) + return None + + # ── comparison logic ───────────────────────────────────────────── + + @staticmethod + def _resolve_gpio(gpio, ioc_pins, ioc_remaps): + """Find the correct IOC entry for a pin's GPIO name. + + For simple names like "PA0", look up directly. + For alternatives like "PA9/PA11", use IOC remap info to + identify the physical port for this pin: + - If the IOC says PA11 is remapped to PA9 (remaps["PA9"]="PA11"), + and both PA9 and PA11 appear in the alternatives, then PA11 + is the physical port for this pin. + """ + # Direct match (no alternatives) + if "/" not in gpio: + return ioc_pins.get(gpio, {}) + + parts = gpio.split("/") + + # Check remaps: if one part is an alias pointing to another + # part, use the remap target (= the physical port) + for part in parts: + if part in ioc_remaps: + base = ioc_remaps[part] + if base in parts and base in ioc_pins: + return ioc_pins[base] + + # No remap info — use first part that has IOC data + for part in parts: + if part in ioc_pins: + return ioc_pins[part] + + return {} + + def _compare(self, pcb_pads, pin_gpio, pin_labels, + ioc_pins, ioc_remaps): + rows = [] + + for pad_num in sorted(pcb_pads, key=lambda k: int(k) if k.isdigit() else 999): + net = pcb_pads[pad_num] + gpio = pin_gpio.get(pad_num, "?") + sch_label = pin_labels.get(pad_num, "") + + if gpio in _POWER_NAMES: + rows.append(self._row(pad_num, gpio, sch_label, net, + "", "", "Power")) + continue + + cfg = self._resolve_gpio(gpio, ioc_pins, ioc_remaps) + ioc_sig = cfg.get("Signal", "") + ioc_label = cfg.get("GPIO_Label", "") or ioc_sig + pcb_nc = ("unconnected" in net.lower()) or (net == "") + ioc_active = bool(ioc_sig) + + if pcb_nc and not ioc_active: + status = "NC" + elif pcb_nc and ioc_active: + status = "IOC Only" + elif not pcb_nc and not ioc_active: + # No IOC config — check if pin is used as NRST (system function) + if "NRST" in sch_label.upper() or "NRST" in net.upper(): + status = "Match" + else: + status = "PCB Only" + elif self._names_match(sch_label, net, ioc_label, ioc_sig): + status = "Match" + else: + status = "Review" + + rows.append(self._row(pad_num, gpio, sch_label, net, + ioc_sig, ioc_label, status)) + + # sort: warnings first, then pin number + rank = {"IOC Only": 0, "PCB Only": 1, "Review": 2, + "Match": 3, "NC": 4, "Power": 5} + rows.sort(key=lambda r: ( + rank.get(r["status"], 9), + int(r["pin"]) if r["pin"].isdigit() else 999, + )) + return rows + + @staticmethod + def _row(pin, gpio, sch_label, net, sig, label, status): + return dict(pin=pin, gpio=gpio, sch_label=sch_label, + pcb_net=net, ioc_signal=sig, ioc_label=label, + status=status) + + @staticmethod + def _normalize(name): + """Lowercase, strip leading '/', canonicalise +/- and P/M suffixes.""" + if not name: + return "" + s = name.lstrip("/").lower() + # Replace + with p and - with m so both conventions match + s = s.replace("+", "p").replace("-", "m") + # Strip anything that isn't a C identifier char + return re.sub(r"[^a-z0-9_]", "", s) + + @staticmethod + def _names_match(sch_label, pcb_net, ioc_label, ioc_signal): + """Case-insensitive match, ignoring leading '/' and non-C chars. + + Checks the schematic label first (most authoritative), + then falls back to the PCB net name. + """ + norm = STM32PinValidator._normalize + for name in (sch_label, pcb_net): + s = norm(name) + if not s: + continue + for candidate in (ioc_label, ioc_signal): + if norm(candidate) == s: + return True + return False diff --git a/sch_parser.py b/sch_parser.py new file mode 100644 index 0000000..bfefe59 --- /dev/null +++ b/sch_parser.py @@ -0,0 +1,248 @@ +""" +Parser for KiCad .kicad_sch S-expression files. + +Extracts: + - pin-number → GPIO-name mapping (from the symbol library definition) + - pin-number → schematic label (by tracing wires from MCU pins to labels) +""" + +import re +from collections import defaultdict + + +# ── low-level helpers ──────────────────────────────────────────────── + +def _find_matching_close(text, start, limit=200_000): + """Return index of the ')' that closes the '(' at *start*.""" + depth = 0 + for i, ch in enumerate(text[start : start + limit]): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + return start + i + return start + limit + + +def _rnd(x, y): + """Round a coordinate pair for reliable dict-key comparison.""" + return (round(x, 2), round(y, 2)) + + +# ── symbol / pin extraction ───────────────────────────────────────── + +def _extract_pins(section): + """Return {pin_number: pin_name} for every (pin ...) in *section*.""" + pins = {} + pos = 0 + while True: + idx = section.find("(pin ", pos) + if idx == -1: + break + end = _find_matching_close(section, idx) + block = section[idx : end + 1] + + nm = re.search(r'\(name\s+"([^"]+)"', block) + nu = re.search(r'\(number\s+"(\d+)"', block) + if nm and nu: + pins[nu.group(1)] = nm.group(1) + + pos = end + 1 + return pins + + +_PIN_POS_RE = re.compile( + r"\(pin\s+\w+\s+\w+\s*\n\s*" + r"\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)" + r".*?\(name\s+\"([^\"]+)\"" + r".*?\(number\s+\"(\d+)\"", + re.DOTALL, +) + + +def _extract_pin_positions(section): + """Return {pin_number: (local_x, local_y, angle, name)}.""" + pins = {} + for m in _PIN_POS_RE.finditer(section): + lx = float(m.group(1)) + ly = float(m.group(2)) + ang = int(m.group(3)) + name = m.group(4) + num = m.group(5) + pins[num] = (lx, ly, ang, name) + return pins + + +def _find_lib_id(content, reference): + """Find the lib_id string for the symbol instance with *reference*.""" + ref_re = re.compile( + r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"' + ) + for m in ref_re.finditer(content): + chunk = content[max(0, m.start() - 3000) : m.start()] + hits = list( + re.finditer(r'\(symbol\s*\n\s*\(lib_id\s+"([^"]+)"\)', chunk) + ) + if hits: + return hits[-1].group(1) + return None + + +def _find_symbol_position(content, reference): + """Return (x, y) placement of the symbol instance for *reference*.""" + ref_re = re.compile( + r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"' + ) + for m in ref_re.finditer(content): + chunk = content[max(0, m.start() - 3000) : m.start()] + hits = list(re.finditer( + r'\(symbol\s*\n\s*\(lib_id\s+"[^"]+"\)\s*\n\s*' + r'\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)', + chunk, + )) + if hits: + return float(hits[-1].group(1)), float(hits[-1].group(2)) + return None + + +def _sub_symbol_sections(content, lib_id): + """Yield content slices for each sub-symbol unit (_0_1, _1_1, …).""" + sym_name = lib_id.split(":")[-1] if ":" in lib_id else lib_id + sub_re = re.compile( + r'\(symbol\s+"' + re.escape(sym_name) + r'_\d+_1"' + ) + for hit in sub_re.finditer(content): + end = _find_matching_close(content, hit.start()) + yield content[hit.start() : end + 1] + + +# ── schematic wire / label parsing ─────────────────────────────────── + +_WIRE_RE = re.compile( + r"\(wire\s*\n\s*\(pts\s*\n\s*" + r"\(xy\s+([-\d.]+)\s+([-\d.]+)\)\s+" + r"\(xy\s+([-\d.]+)\s+([-\d.]+)\)\s*\n" +) + + +def _parse_wires(content): + """Return list of ((x1,y1), (x2,y2)) wire segments.""" + return [ + ((float(m.group(1)), float(m.group(2))), + (float(m.group(3)), float(m.group(4)))) + for m in _WIRE_RE.finditer(content) + ] + + +def _parse_labels(content): + """Return {(x,y): label_text} for all net labels and global labels.""" + labels = {} + + # normal net labels + for m in re.finditer( + r'\(label\s+"([^"]+)".*?\(at\s+([-\d.]+)\s+([-\d.]+)', + content, re.DOTALL, + ): + txt = m.group(1) + if not txt.startswith("TODO"): + labels[(float(m.group(2)), float(m.group(3)))] = txt + + # global labels + for m in re.finditer( + r'\(global_label\s+"([^"]+)".*?\(at\s+([-\d.]+)\s+([-\d.]+)', + content, re.DOTALL, + ): + labels[(float(m.group(2)), float(m.group(3)))] = m.group(1) + + return labels + + +def _build_wire_graph(wires): + """Build adjacency dict from wire segments (rounded coordinates).""" + graph = defaultdict(set) + for (x1, y1), (x2, y2) in wires: + p1, p2 = _rnd(x1, y1), _rnd(x2, y2) + graph[p1].add(p2) + graph[p2].add(p1) + return graph + + +def _bfs_labels(start, graph, label_at): + """BFS from *start* through the wire graph; return first label found.""" + visited = set() + queue = [start] + while queue: + pt = queue.pop(0) + if pt in visited: + continue + visited.add(pt) + if pt in label_at: + return label_at[pt] + for nb in graph.get(pt, ()): + if nb not in visited: + queue.append(nb) + return "" + + +# ── public API ─────────────────────────────────────────────────────── + +def get_pin_mapping(sch_path, reference): + """ + Return {pad_number_str: gpio_name_str} for *reference* (e.g. "U31"). + """ + with open(sch_path, "r", encoding="utf-8") as fh: + content = fh.read() + + lib_id = _find_lib_id(content, reference) + if not lib_id: + return {} + + all_pins = {} + for section in _sub_symbol_sections(content, lib_id): + all_pins.update(_extract_pins(section)) + + return all_pins + + +def get_pin_labels(sch_path, reference): + """ + Return {pad_number_str: schematic_label_str} for *reference*. + + Traces wires from each MCU pin to the nearest connected label + (normal or global) in the schematic. + """ + with open(sch_path, "r", encoding="utf-8") as fh: + content = fh.read() + + lib_id = _find_lib_id(content, reference) + if not lib_id: + return {} + + sym_pos = _find_symbol_position(content, reference) + if not sym_pos: + return {} + sx, sy = sym_pos + + # collect pin local positions from all sub-symbol units + pin_locals = {} + for section in _sub_symbol_sections(content, lib_id): + pin_locals.update(_extract_pin_positions(section)) + + # transform to absolute coordinates (rotation 0: abs = sym + local, Y inverted) + pin_abs = {} + for num, (lx, ly, _angle, _name) in pin_locals.items(): + pin_abs[num] = _rnd(sx + lx, sy - ly) + + # build wire connectivity graph + graph = _build_wire_graph(_parse_wires(content)) + label_at = {_rnd(*k): v for k, v in _parse_labels(content).items()} + + # trace from each pin + result = {} + for num, start in pin_abs.items(): + lbl = _bfs_labels(start, graph, label_at) + if lbl: + result[num] = lbl + + return result