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