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:
@@ -0,0 +1,3 @@
|
|||||||
|
from .pin_validator_action import STM32PinValidator
|
||||||
|
|
||||||
|
STM32PinValidator().register()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+248
@@ -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
|
||||||
Reference in New Issue
Block a user