""" 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