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.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)
|
||||
|
||||
@ -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] = {}
|
||||
|
||||
|
||||
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))
|
||||
|
||||
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")
|
||||
|
||||
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