diff --git a/src/mckicad/tools/schematic.py b/src/mckicad/tools/schematic.py index bb8684f..eb4fbcf 100644 --- a/src/mckicad/tools/schematic.py +++ b/src/mckicad/tools/schematic.py @@ -16,6 +16,11 @@ from typing import Any from mckicad.config import INLINE_RESULT_THRESHOLD from mckicad.server import mcp from mckicad.utils.file_utils import write_detail_file +from mckicad.utils.sexp_parser import ( + parse_global_labels, + parse_lib_symbol_pins, + transform_pin_to_schematic, +) logger = logging.getLogger(__name__) @@ -705,8 +710,18 @@ def get_schematic_info(schematic_path: str) -> dict[str, Any]: if "text_elements" not in stats or not isinstance(stats.get("text_elements"), dict): stats["text_elements"] = {} stats["text_elements"]["global_label"] = len(hier_labels) + + # Fallback: parse (global_label ...) nodes from raw file when + # kicad-sch-api reports 0 — it has no sch.global_labels attribute. + if isinstance(stats.get("text_elements"), dict): + if stats["text_elements"].get("global_label", 0) == 0: + raw_global = parse_global_labels(schematic_path) + if raw_global: + stats["text_elements"]["global_label"] = len(raw_global) + stats["text_elements"]["total_text_elements"] = ( - stats["text_elements"].get("label", 0) + len(hier_labels) + stats["text_elements"].get("label", 0) + + stats["text_elements"].get("global_label", 0) ) # Run validation @@ -871,6 +886,31 @@ def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]: if not raw_pins: raw_pins = list(getattr(comp, "pins", [])) + # Last resort: parse pins from the raw (lib_symbols ...) section. + # Handles custom library symbols where get_symbol_definition() fails. + if not raw_pins: + lib_id = getattr(comp, "lib_id", None) + if lib_id: + sexp_pins = parse_lib_symbol_pins(schematic_path, str(lib_id)) + if sexp_pins: + comp_pos = getattr(comp, "position", None) + comp_rot = float(getattr(comp, "rotation", 0) or 0) + comp_mirror = getattr(comp, "mirror", None) + mirror_x = comp_mirror in ("x", True) if comp_mirror else False + cx = float(comp_pos.x) if comp_pos is not None and hasattr(comp_pos, "x") else 0.0 + cy = float(comp_pos.y) if comp_pos is not None and hasattr(comp_pos, "y") else 0.0 + + for sp in sexp_pins: + sx, sy = transform_pin_to_schematic( + sp["x"], sp["y"], cx, cy, comp_rot, mirror_x + ) + pins.append({ + "number": sp["number"], + "name": sp["name"], + "type": sp["type"], + "position": {"x": sx, "y": sy}, + }) + for p in (raw_pins or []): if isinstance(p, dict): pins.append(p) diff --git a/src/mckicad/tools/schematic_analysis.py b/src/mckicad/tools/schematic_analysis.py index 4f8dbbf..7f6f799 100644 --- a/src/mckicad/tools/schematic_analysis.py +++ b/src/mckicad/tools/schematic_analysis.py @@ -18,6 +18,11 @@ from mckicad.config import INLINE_RESULT_THRESHOLD, TIMEOUT_CONSTANTS from mckicad.server import mcp from mckicad.utils.file_utils import write_detail_file from mckicad.utils.kicad_cli import find_kicad_cli +from mckicad.utils.sexp_parser import ( + parse_global_labels, + parse_lib_symbol_pins, + transform_pin_to_schematic, +) logger = logging.getLogger(__name__) @@ -104,7 +109,9 @@ def _default_output_path(schematic_path: str, filename: str) -> str: # --------------------------------------------------------------------------- -def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list[dict[str, str]]]: +def _build_connectivity( + sch: Any, schematic_path: str | None = None +) -> tuple[dict[str, list[dict[str, str]]], list[dict[str, str]]]: """Build a net connectivity graph by walking wires, pin positions, and labels. kicad-sch-api does not auto-compute nets on loaded schematics. This @@ -112,10 +119,14 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list 1. Collecting all wire start/end coordinates 2. Mapping component pin positions to coordinates - 3. Mapping label positions to coordinates + 3. Mapping label positions to coordinates (local, hierarchical, global) 4. Using union-find to group touching points into nets 5. Merging groups that share the same label text + When *schematic_path* is provided, ``(global_label ...)`` entries are + also extracted directly from the raw file via :mod:`sexp_parser`, since + kicad-sch-api does not expose them. + Returns: (net_graph, unconnected_pins) where net_graph maps net names to lists of {reference, pin} dicts, and unconnected_pins lists @@ -197,7 +208,7 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list except Exception: pass - # Hierarchical / global labels + # Hierarchical / global labels (kicad-sch-api attribute) try: for label in getattr(sch, "hierarchical_labels", []): text = getattr(label, "text", None) @@ -209,6 +220,15 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list except Exception: pass + # Global labels from raw file — kicad-sch-api has no sch.global_labels + # attribute, so (global_label ...) nodes are invisible to the API. + # Parse them directly from the S-expression when the file path is known. + if schematic_path: + for gl in parse_global_labels(schematic_path): + coord = _rc(gl["x"], gl["y"]) + _find(coord) + label_at[coord] = gl["text"] + # Power symbols — their value acts as a net name (e.g. GND, VCC) for comp in getattr(sch, "components", []): ref = getattr(comp, "reference", None) @@ -487,7 +507,7 @@ def analyze_connectivity(schematic_path: str) -> dict[str, Any]: try: sch = _ksa_load(schematic_path) - net_graph, unconnected = _build_connectivity(sch) + net_graph, unconnected = _build_connectivity(sch, schematic_path) total_connections = sum(len(pins) for pins in net_graph.values()) detail_path = write_detail_file(schematic_path, "connectivity.json", { @@ -550,7 +570,7 @@ def check_pin_connection( try: sch = _ksa_load(schematic_path) - net_graph, _unconnected = _build_connectivity(sch) + net_graph, _unconnected = _build_connectivity(sch, schematic_path) # Find which net this pin belongs to net_name = None @@ -632,7 +652,7 @@ def verify_pins_connected( try: sch = _ksa_load(schematic_path) - net_graph, _unconnected = _build_connectivity(sch) + net_graph, _unconnected = _build_connectivity(sch, schematic_path) # Find the net for each pin net_for_pin1 = None @@ -759,6 +779,34 @@ def get_component_pins( if not raw_pins: raw_pins = getattr(comp, "pins", []) + # Last resort: parse pins directly from the raw (lib_symbols ...) + # section of the .kicad_sch file. This handles custom library + # symbols (e.g. Espressif:ESP32-P4) where get_symbol_definition() + # returns None because kicad-sch-api can't resolve the library prefix. + if not raw_pins: + lib_id = getattr(comp, "lib_id", None) + if lib_id: + sexp_pins = parse_lib_symbol_pins(schematic_path, str(lib_id)) + if sexp_pins: + # Get component position and rotation for coordinate transform + comp_pos = getattr(comp, "position", None) + comp_rot = float(getattr(comp, "rotation", 0) or 0) + comp_mirror = getattr(comp, "mirror", None) + mirror_x = comp_mirror in ("x", True) if comp_mirror else False + cx = float(comp_pos.x) if comp_pos is not None and hasattr(comp_pos, "x") else 0.0 + cy = float(comp_pos.y) if comp_pos is not None and hasattr(comp_pos, "y") else 0.0 + + for sp in sexp_pins: + sx, sy = transform_pin_to_schematic( + sp["x"], sp["y"], cx, cy, comp_rot, mirror_x + ) + pins_data.append({ + "number": sp["number"], + "name": sp["name"], + "type": sp["type"], + "position": {"x": sx, "y": sy}, + }) + for p in raw_pins: pin_entry: dict[str, Any] = {} diff --git a/src/mckicad/utils/sexp_parser.py b/src/mckicad/utils/sexp_parser.py new file mode 100644 index 0000000..5307e3a --- /dev/null +++ b/src/mckicad/utils/sexp_parser.py @@ -0,0 +1,219 @@ +"""Minimal S-expression parser for extracting data kicad-sch-api doesn't parse. + +kicad-sch-api v0.5.5 has two known parsing gaps: + +1. ``sheets.data['lib_symbols']`` is always ``{}`` even though the raw + ``.kicad_sch`` file embeds full symbol definitions (including pin data) + in its ``(lib_symbols ...)`` section. + +2. ``(global_label ...)`` nodes are not exposed through any collection + attribute — ``sch.labels`` only contains local labels, and there is + no ``sch.global_labels`` attribute. + +This module provides focused parsers for these two cases, reading directly +from the raw ``.kicad_sch`` file. +""" + +import math +import re +from typing import Any + +# --------------------------------------------------------------------------- +# Global labels +# --------------------------------------------------------------------------- + +# Match: (global_label "TEXT" ... (at X Y [R]) ...) +_GLOBAL_LABEL_RE = re.compile( + r'\(global_label\s+"([^"]+)"' # capture label text + r'.*?' # skip shape, nested parens etc. (non-greedy, DOTALL) + r'\(at\s+([\d.e+-]+)\s+([\d.e+-]+)', # capture x, y + re.DOTALL, +) + + +def parse_global_labels(filepath: str) -> list[dict[str, Any]]: + """Extract ``(global_label ...)`` entries from a .kicad_sch file. + + Returns a list of ``{'text': str, 'x': float, 'y': float}`` dicts. + """ + try: + with open(filepath, encoding="utf-8") as f: + content = f.read() + except Exception: + return [] + + labels: list[dict[str, Any]] = [] + for match in _GLOBAL_LABEL_RE.finditer(content): + labels.append({ + "text": match.group(1), + "x": float(match.group(2)), + "y": float(match.group(3)), + }) + return labels + + +# --------------------------------------------------------------------------- +# lib_symbols pin extraction +# --------------------------------------------------------------------------- + +# Match pins inside a symbol section: +# (pin TYPE SHAPE (at X Y [R]) (length L) (name "NAME" ...) (number "NUM" ...)) +_PIN_RE = re.compile( + r'\(pin\s+' + r'(\w+)\s+' # pin type (passive, input, output, ...) + r'(\w+)\s*' # pin shape (line, inverted, ...) + r'\(at\s+([\d.e+-]+)\s+([\d.e+-]+)(?:\s+([\d.e+-]+))?\)' # (at X Y [R]) + r'\s*\(length\s+([\d.e+-]+)\)' # (length L) + r'.*?' # skip to name + r'\(name\s+"([^"]*)"' # (name "NAME" ...) + r'.*?' # skip to number + r'\(number\s+"([^"]*)"', # (number "NUM" ...) + re.DOTALL, +) + + +def parse_lib_symbol_pins(filepath: str, lib_id: str) -> list[dict[str, Any]]: + """Extract pin definitions for a symbol from the ``(lib_symbols ...)`` section. + + Pins are returned in **local symbol coordinates** (not transformed to + schematic space). Use :func:`transform_pin_to_schematic` to convert + them if the component's position and rotation are known. + + Args: + filepath: Path to a .kicad_sch file. + lib_id: Full library identifier (e.g. ``Espressif:ESP32-P4``). + + Returns: + List of pin dicts with ``number``, ``name``, ``type``, ``shape``, + ``x``, ``y``, ``rotation``, and ``length`` fields. + """ + try: + with open(filepath, encoding="utf-8") as f: + content = f.read() + except Exception: + return [] + + # 1. Find the (lib_symbols ...) section + lib_section = _extract_section(content, "lib_symbols") + if not lib_section: + return [] + + # 2. Find the top-level (symbol "LIB_ID" ...) within lib_symbols + symbol_section = _extract_named_section(lib_section, "symbol", lib_id) + if not symbol_section: + return [] + + # 3. Extract all (pin ...) entries + pins: list[dict[str, Any]] = [] + for match in _PIN_RE.finditer(symbol_section): + pins.append({ + "number": match.group(8), + "name": match.group(7), + "type": match.group(1), + "shape": match.group(2), + "x": float(match.group(3)), + "y": float(match.group(4)), + "rotation": float(match.group(5) or 0), + "length": float(match.group(6)), + }) + + return pins + + +# --------------------------------------------------------------------------- +# Coordinate transformation +# --------------------------------------------------------------------------- + + +def transform_pin_to_schematic( + pin_x: float, + pin_y: float, + comp_x: float, + comp_y: float, + comp_rotation_deg: float = 0, + mirror_x: bool = False, +) -> tuple[float, float]: + """Transform a pin from local symbol coordinates to schematic coordinates. + + Applies the component's rotation and position offset. + + Args: + pin_x: Pin X in local symbol coordinates. + pin_y: Pin Y in local symbol coordinates. + comp_x: Component X position in schematic coordinates. + comp_y: Component Y position in schematic coordinates. + comp_rotation_deg: Component rotation in degrees (0, 90, 180, 270). + mirror_x: Whether the component is mirrored along the X axis. + + Returns: + (schematic_x, schematic_y) tuple. + """ + px, py = pin_x, pin_y + + # Apply mirror first (before rotation) + if mirror_x: + px = -px + + # Apply rotation (standard 2D rotation matrix) + rad = math.radians(comp_rotation_deg) + cos_r = math.cos(rad) + sin_r = math.sin(rad) + rx = px * cos_r - py * sin_r + ry = px * sin_r + py * cos_r + + # Apply position offset + return (round(comp_x + rx, 3), round(comp_y + ry, 3)) + + +# --------------------------------------------------------------------------- +# Section extraction helpers +# --------------------------------------------------------------------------- + + +def _extract_section(content: str, keyword: str) -> str | None: + """Extract the text of a top-level ``(keyword ...)`` section using bracket counting.""" + marker = f"({keyword}" + start = content.find(marker) + if start == -1: + return None + + depth = 0 + for i in range(start, len(content)): + if content[i] == "(": + depth += 1 + elif content[i] == ")": + depth -= 1 + if depth == 0: + return content[start : i + 1] + return None + + +def _extract_named_section(content: str, keyword: str, name: str) -> str | None: + """Extract ``(keyword "name" ...)`` section, matching the exact name. + + Avoids matching sub-units like ``"Device:R_0_1"`` when looking for + ``"Device:R"``. + """ + marker = f'({keyword} "{name}"' + pos = 0 + while True: + start = content.find(marker, pos) + if start == -1: + return None + + # Verify exact name match (next char must be whitespace or close paren) + after = start + len(marker) + if after < len(content) and content[after] not in (" ", "\n", "\r", "\t", ")"): + pos = after + continue + + # Extract using bracket counting + depth = 0 + for i in range(start, len(content)): + if content[i] == "(": + depth += 1 + elif content[i] == ")": + depth -= 1 + if depth == 0: + return content[start : i + 1] + return None diff --git a/tests/test_power_symbols.py b/tests/test_power_symbols.py index bd8cc24..f584c11 100644 --- a/tests/test_power_symbols.py +++ b/tests/test_power_symbols.py @@ -74,6 +74,7 @@ class TestAddPowerSymbolToPin: sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 100)) pin_pos = sch.get_component_pin_position("C1", "1") + assert pin_pos is not None result = add_power_symbol_to_pin( sch=sch, pin_position=(pin_pos.x, pin_pos.y), @@ -93,6 +94,8 @@ class TestAddPowerSymbolToPin: pin1 = sch.get_component_pin_position("R1", "1") pin2 = sch.get_component_pin_position("R1", "2") + assert pin1 is not None + assert pin2 is not None r1 = add_power_symbol_to_pin(sch, (pin1.x, pin1.y), "+3V3") r2 = add_power_symbol_to_pin(sch, (pin2.x, pin2.y), "GND") diff --git a/tests/test_sexp_parser.py b/tests/test_sexp_parser.py new file mode 100644 index 0000000..d9a7e9b --- /dev/null +++ b/tests/test_sexp_parser.py @@ -0,0 +1,292 @@ +"""Tests for the S-expression parser utilities. + +These tests do NOT require kicad-sch-api — they test raw file parsing. +""" + +import os +import tempfile + +import pytest + +from mckicad.utils.sexp_parser import ( + parse_global_labels, + parse_lib_symbol_pins, + transform_pin_to_schematic, +) + +# Minimal .kicad_sch content with global labels and lib_symbols +SAMPLE_SCHEMATIC = """\ +(kicad_sch + (version 20231120) + (generator "eeschema") + (uuid "abc123") + (paper "A4") + (lib_symbols + (symbol "Device:R" + (pin_numbers hide) + (pin_names + (offset 0) + ) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (property "Reference" "R" + (at 2.032 0 90) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "Device:R_0_1" + (polyline + (pts + (xy -1.016 -2.54) + (xy -1.016 2.54) + ) + ) + ) + (symbol "Device:R_1_1" + (pin passive line + (at 0 3.81 270) + (length 2.54) + (name "~" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (number "1" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + ) + (pin passive line + (at 0 -3.81 90) + (length 2.54) + (name "~" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (number "2" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + ) + ) + ) + (symbol "Espressif:ESP32-P4" + (pin_names + (offset 1.016) + ) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (symbol "Espressif:ESP32-P4_0_1" + (pin input line + (at -25.4 22.86 0) + (length 2.54) + (name "GPIO0" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (number "1" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + ) + (pin power_in line + (at 0 30.48 270) + (length 2.54) + (name "VDD" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (number "2" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + ) + (pin output line + (at 25.4 22.86 180) + (length 2.54) + (name "TX" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (number "3" + (effects + (font + (size 1.27 1.27) + ) + ) + ) + ) + ) + ) + ) + (global_label "ESP_3V3" + (shape input) + (at 127 95.25 180) + (uuid "def456") + (effects + (font + (size 1.27 1.27) + ) + (justify right) + ) + ) + (global_label "GND" + (shape input) + (at 200.5 150.75 0) + (uuid "ghi789") + ) + (global_label "SPI_CLK" + (shape output) + (at 300 200 90) + (uuid "jkl012") + ) + (label "LOCAL_NET" + (at 100 100 0) + (uuid "mno345") + ) +) +""" + + +@pytest.fixture +def sample_schematic_file(): + """Write the sample schematic to a temp file and return its path.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".kicad_sch", delete=False, encoding="utf-8" + ) as f: + f.write(SAMPLE_SCHEMATIC) + path = f.name + + yield path + os.unlink(path) + + +class TestParseGlobalLabels: + def test_finds_all_global_labels(self, sample_schematic_file): + labels = parse_global_labels(sample_schematic_file) + assert len(labels) == 3 + + def test_extracts_text_and_position(self, sample_schematic_file): + labels = parse_global_labels(sample_schematic_file) + texts = {lbl["text"] for lbl in labels} + assert texts == {"ESP_3V3", "GND", "SPI_CLK"} + + esp = next(lbl for lbl in labels if lbl["text"] == "ESP_3V3") + assert esp["x"] == pytest.approx(127.0) + assert esp["y"] == pytest.approx(95.25) + + gnd = next(lbl for lbl in labels if lbl["text"] == "GND") + assert gnd["x"] == pytest.approx(200.5) + assert gnd["y"] == pytest.approx(150.75) + + def test_does_not_include_local_labels(self, sample_schematic_file): + labels = parse_global_labels(sample_schematic_file) + texts = {lbl["text"] for lbl in labels} + assert "LOCAL_NET" not in texts + + def test_nonexistent_file_returns_empty(self): + labels = parse_global_labels("/nonexistent/path.kicad_sch") + assert labels == [] + + +class TestParseLibSymbolPins: + def test_finds_resistor_pins(self, sample_schematic_file): + pins = parse_lib_symbol_pins(sample_schematic_file, "Device:R") + assert len(pins) == 2 + + nums = {p["number"] for p in pins} + assert nums == {"1", "2"} + + pin1 = next(p for p in pins if p["number"] == "1") + assert pin1["name"] == "~" + assert pin1["type"] == "passive" + assert pin1["x"] == pytest.approx(0.0) + assert pin1["y"] == pytest.approx(3.81) + + def test_finds_custom_ic_pins(self, sample_schematic_file): + pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4") + assert len(pins) == 3 + + names = {p["name"] for p in pins} + assert names == {"GPIO0", "VDD", "TX"} + + gpio = next(p for p in pins if p["name"] == "GPIO0") + assert gpio["number"] == "1" + assert gpio["type"] == "input" + assert gpio["x"] == pytest.approx(-25.4) + assert gpio["y"] == pytest.approx(22.86) + assert gpio["rotation"] == pytest.approx(0.0) + + vdd = next(p for p in pins if p["name"] == "VDD") + assert vdd["type"] == "power_in" + + def test_does_not_match_subunit_prefix(self, sample_schematic_file): + # "Espressif:ESP32-P4_0_1" is a sub-unit, not the top-level symbol + pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4_0") + assert len(pins) == 0 + + def test_nonexistent_lib_id_returns_empty(self, sample_schematic_file): + pins = parse_lib_symbol_pins(sample_schematic_file, "NoSuchLib:Missing") + assert pins == [] + + def test_nonexistent_file_returns_empty(self): + pins = parse_lib_symbol_pins("/nonexistent/path.kicad_sch", "Device:R") + assert pins == [] + + +class TestTransformPinToSchematic: + def test_zero_rotation(self): + sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 0) + assert sx == pytest.approx(100.0) + assert sy == pytest.approx(103.81) + + def test_90_degree_rotation(self): + sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 90) + assert sx == pytest.approx(100 - 3.81, abs=0.01) + assert sy == pytest.approx(100.0, abs=0.01) + + def test_180_degree_rotation(self): + sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 180) + assert sx == pytest.approx(100.0, abs=0.01) + assert sy == pytest.approx(100 - 3.81, abs=0.01) + + def test_270_degree_rotation(self): + sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 270) + assert sx == pytest.approx(100 + 3.81, abs=0.01) + assert sy == pytest.approx(100.0, abs=0.01) + + def test_mirror_x(self): + sx, sy = transform_pin_to_schematic(5, 0, 100, 100, 0, mirror_x=True) + assert sx == pytest.approx(95.0) + assert sy == pytest.approx(100.0)