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
+248
View File
@@ -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