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) <noreply@anthropic.com>
This commit is contained in:
janik
2026-04-09 15:11:06 +07:00
parent cc69b0052f
commit 5f9e0b221b
5 changed files with 767 additions and 0 deletions
+460
View File
@@ -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