Files
stm32PinValidator/pin_validator_action.py
T
janik 5f9e0b221b 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>
2026-04-09 15:11:46 +07:00

461 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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