Add S-expression parser for custom library pins and global labels
kicad-sch-api has two parsing gaps: get_symbol_definition() returns None for non-standard library prefixes (e.g. Espressif:ESP32-P4), and there is no sch.global_labels attribute for (global_label ...) nodes. This adds a focused parser that reads directly from the raw .kicad_sch file as a fallback, integrated into the connectivity engine, pin extraction, and label counting tools.
This commit is contained in:
parent
b7e4fc6859
commit
e610bf3871
@ -16,6 +16,11 @@ from typing import Any
|
|||||||
from mckicad.config import INLINE_RESULT_THRESHOLD
|
from mckicad.config import INLINE_RESULT_THRESHOLD
|
||||||
from mckicad.server import mcp
|
from mckicad.server import mcp
|
||||||
from mckicad.utils.file_utils import write_detail_file
|
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__)
|
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):
|
if "text_elements" not in stats or not isinstance(stats.get("text_elements"), dict):
|
||||||
stats["text_elements"] = {}
|
stats["text_elements"] = {}
|
||||||
stats["text_elements"]["global_label"] = len(hier_labels)
|
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"]["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
|
# Run validation
|
||||||
@ -871,6 +886,31 @@ def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]:
|
|||||||
if not raw_pins:
|
if not raw_pins:
|
||||||
raw_pins = list(getattr(comp, "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 []):
|
for p in (raw_pins or []):
|
||||||
if isinstance(p, dict):
|
if isinstance(p, dict):
|
||||||
pins.append(p)
|
pins.append(p)
|
||||||
|
|||||||
@ -18,6 +18,11 @@ from mckicad.config import INLINE_RESULT_THRESHOLD, TIMEOUT_CONSTANTS
|
|||||||
from mckicad.server import mcp
|
from mckicad.server import mcp
|
||||||
from mckicad.utils.file_utils import write_detail_file
|
from mckicad.utils.file_utils import write_detail_file
|
||||||
from mckicad.utils.kicad_cli import find_kicad_cli
|
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__)
|
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.
|
"""Build a net connectivity graph by walking wires, pin positions, and labels.
|
||||||
|
|
||||||
kicad-sch-api does not auto-compute nets on loaded schematics. This
|
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
|
1. Collecting all wire start/end coordinates
|
||||||
2. Mapping component pin positions to 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
|
4. Using union-find to group touching points into nets
|
||||||
5. Merging groups that share the same label text
|
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:
|
Returns:
|
||||||
(net_graph, unconnected_pins) where net_graph maps net names to
|
(net_graph, unconnected_pins) where net_graph maps net names to
|
||||||
lists of {reference, pin} dicts, and unconnected_pins lists
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Hierarchical / global labels
|
# Hierarchical / global labels (kicad-sch-api attribute)
|
||||||
try:
|
try:
|
||||||
for label in getattr(sch, "hierarchical_labels", []):
|
for label in getattr(sch, "hierarchical_labels", []):
|
||||||
text = getattr(label, "text", None)
|
text = getattr(label, "text", None)
|
||||||
@ -209,6 +220,15 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
# Power symbols — their value acts as a net name (e.g. GND, VCC)
|
||||||
for comp in getattr(sch, "components", []):
|
for comp in getattr(sch, "components", []):
|
||||||
ref = getattr(comp, "reference", None)
|
ref = getattr(comp, "reference", None)
|
||||||
@ -487,7 +507,7 @@ def analyze_connectivity(schematic_path: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
sch = _ksa_load(schematic_path)
|
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())
|
total_connections = sum(len(pins) for pins in net_graph.values())
|
||||||
|
|
||||||
detail_path = write_detail_file(schematic_path, "connectivity.json", {
|
detail_path = write_detail_file(schematic_path, "connectivity.json", {
|
||||||
@ -550,7 +570,7 @@ def check_pin_connection(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
sch = _ksa_load(schematic_path)
|
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
|
# Find which net this pin belongs to
|
||||||
net_name = None
|
net_name = None
|
||||||
@ -632,7 +652,7 @@ def verify_pins_connected(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
sch = _ksa_load(schematic_path)
|
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
|
# Find the net for each pin
|
||||||
net_for_pin1 = None
|
net_for_pin1 = None
|
||||||
@ -759,6 +779,34 @@ def get_component_pins(
|
|||||||
if not raw_pins:
|
if not raw_pins:
|
||||||
raw_pins = getattr(comp, "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:
|
for p in raw_pins:
|
||||||
pin_entry: dict[str, Any] = {}
|
pin_entry: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|||||||
219
src/mckicad/utils/sexp_parser.py
Normal file
219
src/mckicad/utils/sexp_parser.py
Normal file
@ -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
|
||||||
@ -74,6 +74,7 @@ class TestAddPowerSymbolToPin:
|
|||||||
sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 100))
|
sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 100))
|
||||||
|
|
||||||
pin_pos = sch.get_component_pin_position("C1", "1")
|
pin_pos = sch.get_component_pin_position("C1", "1")
|
||||||
|
assert pin_pos is not None
|
||||||
result = add_power_symbol_to_pin(
|
result = add_power_symbol_to_pin(
|
||||||
sch=sch,
|
sch=sch,
|
||||||
pin_position=(pin_pos.x, pin_pos.y),
|
pin_position=(pin_pos.x, pin_pos.y),
|
||||||
@ -93,6 +94,8 @@ class TestAddPowerSymbolToPin:
|
|||||||
|
|
||||||
pin1 = sch.get_component_pin_position("R1", "1")
|
pin1 = sch.get_component_pin_position("R1", "1")
|
||||||
pin2 = sch.get_component_pin_position("R1", "2")
|
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")
|
r1 = add_power_symbol_to_pin(sch, (pin1.x, pin1.y), "+3V3")
|
||||||
r2 = add_power_symbol_to_pin(sch, (pin2.x, pin2.y), "GND")
|
r2 = add_power_symbol_to_pin(sch, (pin2.x, pin2.y), "GND")
|
||||||
|
|||||||
292
tests/test_sexp_parser.py
Normal file
292
tests/test_sexp_parser.py
Normal file
@ -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)
|
||||||
Loading…
x
Reference in New Issue
Block a user